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.