diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 466b64fdb..c20fd33b3 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -125,7 +125,6 @@ - @@ -164,6 +163,20 @@ android:resource="@xml/shortcuts" /> + + + + + + + + + + diff --git a/app/src/main/java/to/bitkit/App.kt b/app/src/main/java/to/bitkit/App.kt index 0e3b60f05..c127a13a6 100644 --- a/app/src/main/java/to/bitkit/App.kt +++ b/app/src/main/java/to/bitkit/App.kt @@ -14,6 +14,7 @@ import to.bitkit.appwidget.AppWidgetRefreshReason import to.bitkit.appwidget.AppWidgetRefreshScheduler import to.bitkit.env.Env import to.bitkit.services.BluetoothInit +import to.bitkit.services.PubkyAuthHandlerRegistrar import to.bitkit.utils.Logger import javax.inject.Inject @@ -28,6 +29,9 @@ internal open class App : Application(), Configuration.Provider { @Inject lateinit var appWidgetRefreshScheduler: AppWidgetRefreshScheduler + @Inject + lateinit var pubkyAuthHandlerRegistrar: PubkyAuthHandlerRegistrar + override val workManagerConfiguration get() = Configuration.Builder() .setWorkerFactory(workerFactory) @@ -42,6 +46,7 @@ internal open class App : Application(), Configuration.Provider { appWidgetRefreshScheduler.ensureScheduled(AppWidgetRefreshReason.APP_START) // Initialize btleplug for Bluetooth support (required before any BLE usage) BluetoothInit.ensureInitialized() + pubkyAuthHandlerRegistrar.start() } private fun installUncaughtExceptionLogger() { diff --git a/app/src/main/java/to/bitkit/services/PubkyAuthHandlerRegistrar.kt b/app/src/main/java/to/bitkit/services/PubkyAuthHandlerRegistrar.kt new file mode 100644 index 000000000..d0a4df8d1 --- /dev/null +++ b/app/src/main/java/to/bitkit/services/PubkyAuthHandlerRegistrar.kt @@ -0,0 +1,95 @@ +package to.bitkit.services + +import android.content.ComponentName +import android.content.Context +import android.content.pm.PackageManager +import dagger.hilt.android.qualifiers.ApplicationContext +import kotlinx.coroutines.CoroutineDispatcher +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.flow.collectLatest +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.distinctUntilChanged +import kotlinx.coroutines.launch +import to.bitkit.async.appScope +import to.bitkit.data.SettingsStore +import to.bitkit.di.IoDispatcher +import to.bitkit.flags.PaykitFeatureFlags +import to.bitkit.repositories.PubkyRepo +import to.bitkit.utils.Logger +import java.util.concurrent.atomic.AtomicBoolean +import javax.inject.Inject +import javax.inject.Singleton + +/** Advertises Bitkit as a `pubkyauth` handler only while it can authorize requests locally. */ +@Singleton +internal class PubkyAuthHandlerRegistrar @Inject constructor( + @ApplicationContext private val context: Context, + private val pubkyRepo: PubkyRepo, + private val settingsStore: SettingsStore, + @IoDispatcher ioDispatcher: CoroutineDispatcher, +) { + private val scope: CoroutineScope = appScope(ioDispatcher, TAG) + private val aliasComponent = ComponentName(context.packageName, PUBKY_AUTH_ALIAS_CLASS) + private val started = AtomicBoolean() + + fun start() = start(scope) + + internal fun start(collectionScope: CoroutineScope) { + if (!started.compareAndSet(false, true)) return + + collectionScope.launch { + combine(settingsStore.isPaykitEnabled, pubkyRepo.publicKey) { localFlagEnabled, publicKey -> + localFlagEnabled to publicKey + } + .distinctUntilChanged() + .collectLatest { (localFlagEnabled, publicKey) -> + val isPaykitUiEnabled = PaykitFeatureFlags.isUiEnabled(localFlagEnabled) + val hasIdentity = publicKey != null + val hasSecretKey = isPaykitUiEnabled && hasIdentity && pubkyRepo.hasSecretKey() + + setAliasEnabled( + canHandlePubkyAuth( + isPaykitUiEnabled = isPaykitUiEnabled, + hasIdentity = hasIdentity, + hasSecretKey = hasSecretKey, + ), + ) + } + } + } + + private fun setAliasEnabled(enabled: Boolean) { + val state = + if (enabled) { + PackageManager.COMPONENT_ENABLED_STATE_ENABLED + } else { + PackageManager.COMPONENT_ENABLED_STATE_DISABLED + } + + runCatching { + context.packageManager.setComponentEnabledSetting( + aliasComponent, + state, + PackageManager.DONT_KILL_APP, + ) + }.onSuccess { + Logger.info( + "Updated pubkyauth handler to '${if (enabled) "enabled" else "disabled"}'", + context = TAG, + ) + }.onFailure { + Logger.error("Failed to update pubkyauth handler", it, context = TAG) + } + } + + companion object { + private const val TAG = "PubkyAuthHandlerRegistrar" + private const val PUBKY_AUTH_ALIAS_CLASS = "to.bitkit.ui.MainActivityPubkyAuth" + } +} + +internal fun canHandlePubkyAuth( + isPaykitUiEnabled: Boolean, + hasIdentity: Boolean, + hasSecretKey: Boolean, +): Boolean = isPaykitUiEnabled && hasIdentity && hasSecretKey diff --git a/app/src/test/java/to/bitkit/build/PubkyAuthManifestTest.kt b/app/src/test/java/to/bitkit/build/PubkyAuthManifestTest.kt new file mode 100644 index 000000000..f67dfc878 --- /dev/null +++ b/app/src/test/java/to/bitkit/build/PubkyAuthManifestTest.kt @@ -0,0 +1,49 @@ +package to.bitkit.build + +import org.w3c.dom.Element +import java.nio.file.Path +import javax.xml.parsers.DocumentBuilderFactory +import kotlin.io.path.Path +import kotlin.io.path.exists +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertTrue + +class PubkyAuthManifestTest { + private val repoRoot = generateSequence( + Path(requireNotNull(System.getProperty("user.dir")) { "user.dir is required" }), + ) { it.parent } + .first { it.resolve("gradle/libs.versions.toml").exists() } + + private val manifest by lazy { parseManifest(repoRoot.resolve("app/src/main/AndroidManifest.xml")) } + + @Test + fun `main activity does not handle pubkyauth`() { + val mainActivity = manifest.getElementsByTagName("activity").elements() + .single { it.getAttribute("android:name") == ".ui.MainActivity" } + + assertFalse(mainActivity.handlesScheme("pubkyauth")) + } + + @Test + fun `pubkyauth alias is disabled by default`() { + val alias = manifest.getElementsByTagName("activity-alias").elements() + .single { it.getAttribute("android:name") == ".ui.MainActivityPubkyAuth" } + + assertEquals(".ui.MainActivity", alias.getAttribute("android:targetActivity")) + assertEquals("false", alias.getAttribute("android:enabled")) + assertEquals("true", alias.getAttribute("android:exported")) + assertTrue(alias.handlesScheme("pubkyauth")) + } + + private fun parseManifest(path: Path) = DocumentBuilderFactory.newInstance() + .newDocumentBuilder() + .parse(path.toFile()) + + private fun org.w3c.dom.NodeList.elements(): List = + (0 until length).map { item(it) as Element } + + private fun Element.handlesScheme(scheme: String): Boolean = + getElementsByTagName("data").elements().any { it.getAttribute("android:scheme") == scheme } +} diff --git a/app/src/test/java/to/bitkit/services/PubkyAuthHandlerRegistrarTest.kt b/app/src/test/java/to/bitkit/services/PubkyAuthHandlerRegistrarTest.kt new file mode 100644 index 000000000..a663b8bac --- /dev/null +++ b/app/src/test/java/to/bitkit/services/PubkyAuthHandlerRegistrarTest.kt @@ -0,0 +1,173 @@ +package to.bitkit.services + +import android.content.Context +import android.content.pm.PackageManager +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.test.runCurrent +import org.junit.Before +import org.junit.Test +import org.mockito.kotlin.any +import org.mockito.kotlin.clearInvocations +import org.mockito.kotlin.doNothing +import org.mockito.kotlin.doThrow +import org.mockito.kotlin.eq +import org.mockito.kotlin.mock +import org.mockito.kotlin.never +import org.mockito.kotlin.verify +import org.mockito.kotlin.whenever +import to.bitkit.data.SettingsStore +import to.bitkit.repositories.PubkyRepo +import to.bitkit.test.BaseUnitTest +import kotlin.test.assertFalse +import kotlin.test.assertTrue + +@OptIn(ExperimentalCoroutinesApi::class) +class PubkyAuthHandlerRegistrarTest : BaseUnitTest() { + private val context: Context = mock() + private val packageManager: PackageManager = mock() + private val pubkyRepo: PubkyRepo = mock() + private val settingsStore: SettingsStore = mock() + private val isPaykitEnabled = MutableStateFlow(false) + private val publicKey = MutableStateFlow(null) + + @Before + fun setUp() { + whenever(context.packageName).thenReturn(PACKAGE_NAME) + whenever(context.packageManager).thenReturn(packageManager) + whenever(settingsStore.isPaykitEnabled).thenReturn(isPaykitEnabled) + whenever(pubkyRepo.publicKey).thenReturn(publicKey) + } + + @Test + fun `handler is enabled for an available locally managed identity`() = test { + isPaykitEnabled.value = true + publicKey.value = "pubkylocal" + whenever(pubkyRepo.hasSecretKey()).thenReturn(true) + + createSut().start(backgroundScope) + runCurrent() + + verifyComponentState(PackageManager.COMPONENT_ENABLED_STATE_ENABLED) + assertTrue( + canHandlePubkyAuth( + isPaykitUiEnabled = true, + hasIdentity = true, + hasSecretKey = true, + ), + ) + } + + @Test + fun `handler is disabled with an unavailable Paykit UI`() { + assertFalse( + canHandlePubkyAuth( + isPaykitUiEnabled = false, + hasIdentity = true, + hasSecretKey = true, + ), + ) + } + + @Test + fun `handler is disabled without an identity`() = test { + isPaykitEnabled.value = true + + createSut().start(backgroundScope) + runCurrent() + + verifyComponentState(PackageManager.COMPONENT_ENABLED_STATE_DISABLED) + verify(pubkyRepo, never()).hasSecretKey() + } + + @Test + fun `handler is disabled for a Ring managed identity`() = test { + isPaykitEnabled.value = true + publicKey.value = "pubkyring" + whenever(pubkyRepo.hasSecretKey()).thenReturn(false) + + createSut().start(backgroundScope) + runCurrent() + + verifyComponentState(PackageManager.COMPONENT_ENABLED_STATE_DISABLED) + } + + @Test + fun `handler is disabled when the local identity is removed`() = test { + isPaykitEnabled.value = true + publicKey.value = "pubkylocal" + whenever(pubkyRepo.hasSecretKey()).thenReturn(true) + createSut().start(backgroundScope) + runCurrent() + clearInvocations(packageManager) + + publicKey.value = null + runCurrent() + + verifyComponentState(PackageManager.COMPONENT_ENABLED_STATE_DISABLED) + } + + @Test + fun `handler is disabled when the Paykit UI is turned off`() = test { + isPaykitEnabled.value = true + publicKey.value = "pubkylocal" + whenever(pubkyRepo.hasSecretKey()).thenReturn(true) + createSut().start(backgroundScope) + runCurrent() + clearInvocations(packageManager) + + isPaykitEnabled.value = false + runCurrent() + + verifyComponentState(PackageManager.COMPONENT_ENABLED_STATE_DISABLED) + } + + @Test + fun `handler collection starts once`() = test { + val sut = createSut() + + sut.start(backgroundScope) + sut.start(backgroundScope) + runCurrent() + + verifyComponentState(PackageManager.COMPONENT_ENABLED_STATE_DISABLED) + } + + @Test + fun `handler keeps observing state after a package manager failure`() = test { + isPaykitEnabled.value = true + publicKey.value = "pubkylocal" + whenever(pubkyRepo.hasSecretKey()).thenReturn(true) + doThrow(IllegalStateException("component update failed")) + .doNothing() + .whenever(packageManager) + .setComponentEnabledSetting(any(), any(), any()) + + createSut().start(backgroundScope) + runCurrent() + + isPaykitEnabled.value = false + runCurrent() + + verifyComponentState(PackageManager.COMPONENT_ENABLED_STATE_DISABLED) + } + + private fun createSut() = PubkyAuthHandlerRegistrar( + context = context, + pubkyRepo = pubkyRepo, + settingsStore = settingsStore, + ioDispatcher = testDispatcher, + ) + + private fun verifyComponentState(state: Int) { + verify(packageManager).setComponentEnabledSetting( + any(), + eq(state), + eq(PackageManager.DONT_KILL_APP), + ) + } + + private companion object { + const val PACKAGE_NAME = "to.bitkit" + } +} diff --git a/changelog.d/next/1162.fixed.md b/changelog.d/next/1162.fixed.md new file mode 100644 index 000000000..24b2cb4df --- /dev/null +++ b/changelog.d/next/1162.fixed.md @@ -0,0 +1 @@ +Fixed Pubky authorization links opening Bitkit when the feature is unavailable or no local identity can approve them.