Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 14 additions & 1 deletion app/src/main/AndroidManifest.xml
Original file line number Diff line number Diff line change
Expand Up @@ -125,7 +125,6 @@
<data android:scheme="lnurlw" />
<data android:scheme="lnurlc" />
<data android:scheme="lnurlp" />
<data android:scheme="pubkyauth" />
</intent-filter>

<!-- NFC -->
Expand Down Expand Up @@ -164,6 +163,20 @@
android:resource="@xml/shortcuts" />
</activity>

<!-- Enabled only while Bitkit can authorize pubkyauth requests locally. -->
<activity-alias
android:name=".ui.MainActivityPubkyAuth"
android:targetActivity=".ui.MainActivity"
android:enabled="false"
android:exported="true">
<intent-filter>
<action android:name="android.intent.action.VIEW" />
<category android:name="android.intent.category.DEFAULT" />
<category android:name="android.intent.category.BROWSABLE" />
<data android:scheme="pubkyauth" />
</intent-filter>
</activity-alias>

<service
android:name=".fcm.FcmService"
android:exported="false">
Expand Down
5 changes: 5 additions & 0 deletions app/src/main/java/to/bitkit/App.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand All @@ -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)
Expand All @@ -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() {
Expand Down
95 changes: 95 additions & 0 deletions app/src/main/java/to/bitkit/services/PubkyAuthHandlerRegistrar.kt
Original file line number Diff line number Diff line change
@@ -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
49 changes: 49 additions & 0 deletions app/src/test/java/to/bitkit/build/PubkyAuthManifestTest.kt
Original file line number Diff line number Diff line change
@@ -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<Element> =
(0 until length).map { item(it) as Element }

private fun Element.handlesScheme(scheme: String): Boolean =
getElementsByTagName("data").elements().any { it.getAttribute("android:scheme") == scheme }
}
173 changes: 173 additions & 0 deletions app/src/test/java/to/bitkit/services/PubkyAuthHandlerRegistrarTest.kt
Original file line number Diff line number Diff line change
@@ -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<String?>(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"
}
}
1 change: 1 addition & 0 deletions changelog.d/next/1162.fixed.md
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
Fixed Pubky authorization links opening Bitkit when the feature is unavailable or no local identity can approve them.
Loading