diff --git a/desktop/README.md b/desktop/README.md index 70ebe34d..61cf8ea9 100644 --- a/desktop/README.md +++ b/desktop/README.md @@ -40,11 +40,20 @@ for the JVM) and mirrors the mobile UI and permission model. or `gdbus` on Linux — so they work on Hyprland/Wayland — `osascript` on macOS, and the AWT tray notification on Windows - Mandatory passphrase lock (see Key storage below) -- Optional start-on-boot (Settings → Desktop): installs and enables a - hardened systemd **user** service that starts Amber with the desktop - session — always locked, passphrase required before anything signs. No - `MemoryDenyWriteExecute` (the JVM's JIT cannot run under it); the unit - still gets `ProtectSystem=strict`, seccomp, `NoNewPrivileges` and friends +- Optional start-on-boot (Settings → Desktop), always starting locked — + passphrase required before anything signs. Only offered for installed + builds (not `:desktop:run`): + - Windows: a per-user `HKCU\Software\Microsoft\Windows\CurrentVersion\Run` + entry (no admin rights; also listed under Task Manager → Startup apps) + - Linux: installs and enables a hardened systemd **user** service that + starts Amber with the desktop session. No `MemoryDenyWriteExecute` (the + JVM's JIT cannot run under it); the unit still gets + `ProtectSystem=strict`, seccomp, `NoNewPrivileges` and friends +- Windows installer (MSI/EXE) adds a Start Menu entry and a desktop shortcut +- The window opens fitted to the screen's usable area (never under the + taskbar) and remembers its size and maximized state +- Pending requests expire after 10 minutes (or at the request's NIP-40 + `expiration`, if sooner), since NIP-46 clients stop waiting long before - Native desktop layout: sidebar navigation with an account switcher, dense list views, and keyboard shortcuts - Light/dark theme using the Amber palette diff --git a/desktop/build.gradle.kts b/desktop/build.gradle.kts index d0df1dc9..5799921f 100644 --- a/desktop/build.gradle.kts +++ b/desktop/build.gradle.kts @@ -47,6 +47,8 @@ dependencies { // freedesktop StatusNotifierItem / AppIndicator protocol, so a tray icon // shows on Wayland compositors (Hyprland, Sway, GNOME) via waybar etc. implementation(libs.dorkbox.systemtray) + // Windows registry access for start-on-boot (HKCU\...\Run). + implementation(libs.jna.platform) runtimeOnly(libs.slf4j.nop) // Argon2id for the optional passphrase lock. @@ -76,6 +78,14 @@ compose.desktop { iconFile.set(rootProject.file("assets/android-icon-hires.png")) menuGroup = "Network" } + windows { + iconFile.set(project.file("icons/amber.ico")) + // Start Menu entry + desktop shortcut; without these the MSI + // only adds an uninstall entry and Amber.exe is hard to find. + menu = true + menuGroup = "Amber" + shortcut = true + } macOS { bundleID = "com.greenart7c3.nostrsigner" } diff --git a/desktop/icons/amber.ico b/desktop/icons/amber.ico new file mode 100644 index 00000000..79beabce Binary files /dev/null and b/desktop/icons/amber.ico differ diff --git a/desktop/src/main/kotlin/com/greenart7c3/nostrsigner/desktop/Main.kt b/desktop/src/main/kotlin/com/greenart7c3/nostrsigner/desktop/Main.kt index aece64b8..5f6574f5 100644 --- a/desktop/src/main/kotlin/com/greenart7c3/nostrsigner/desktop/Main.kt +++ b/desktop/src/main/kotlin/com/greenart7c3/nostrsigner/desktop/Main.kt @@ -7,16 +7,18 @@ import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.setValue +import androidx.compose.runtime.snapshotFlow +import androidx.compose.ui.Alignment import androidx.compose.ui.res.painterResource -import androidx.compose.ui.unit.DpSize -import androidx.compose.ui.unit.dp import androidx.compose.ui.window.Notification import androidx.compose.ui.window.Tray import androidx.compose.ui.window.Window +import androidx.compose.ui.window.WindowPlacement +import androidx.compose.ui.window.WindowPosition +import androidx.compose.ui.window.WindowState import androidx.compose.ui.window.application import androidx.compose.ui.window.isTraySupported import androidx.compose.ui.window.rememberTrayState -import androidx.compose.ui.window.rememberWindowState import com.greenart7c3.nostrsigner.desktop.core.AccountManager import com.greenart7c3.nostrsigner.desktop.core.AccountsStore import com.greenart7c3.nostrsigner.desktop.core.AmberDesktop @@ -32,8 +34,12 @@ import com.greenart7c3.nostrsigner.desktop.core.describe import com.greenart7c3.nostrsigner.desktop.ui.App import com.greenart7c3.nostrsigner.desktop.ui.NostrSignerTheme import com.greenart7c3.nostrsigner.desktop.ui.handleShortcut +import com.greenart7c3.nostrsigner.desktop.ui.initialWindowSize import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.delay import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.collectLatest +import kotlinx.coroutines.flow.drop import kotlinx.coroutines.launch import kotlinx.coroutines.withContext @@ -142,7 +148,7 @@ fun main(args: Array) { } if (launchUri != null) UriLaunch.pending.value = launchUri - // Keep the autostart unit fresh (binary path can change between builds). + // Keep the autostart entry fresh (binary path can change between builds/installs). if (SettingsStore.settings.value.startOnBoot) { AutoStart.setEnabled(true) } @@ -169,7 +175,14 @@ fun main(args: Array) { Session.boot() application { - val windowState = rememberWindowState(size = DpSize(1100.dp, 780.dp)) + val windowState = remember { + val saved = SettingsStore.settings.value + WindowState( + placement = if (saved.windowMaximized) WindowPlacement.Maximized else WindowPlacement.Floating, + position = WindowPosition(Alignment.Center), + size = initialWindowSize(saved.windowWidth, saved.windowHeight), + ) + } val pending by AmberDesktop.engine.pending.collectAsState() val settings by SettingsStore.settings.collectAsState() val language by Strings.currentLanguage.collectAsState() @@ -187,6 +200,29 @@ fun main(args: Array) { !isLinux && isTraySupported && runCatching { java.awt.SystemTray.getSystemTray() }.isSuccess } + // Remember maximized state and the floating size across launches. The + // size is only saved while floating, so un-maximizing restores it. + LaunchedEffect(windowState) { + snapshotFlow { windowState.placement to windowState.size } + .drop(1) + .collectLatest { (placement, size) -> + delay(500) + SettingsStore.update { + if (placement == WindowPlacement.Floating) { + it.copy(windowMaximized = false, windowWidth = size.width.value.toInt(), windowHeight = size.height.value.toInt()) + } else { + it.copy(windowMaximized = placement == WindowPlacement.Maximized) + } + } + } + } + // Drop requests whose client has most likely given up (see PendingBunkerRequest.expiresAt). + LaunchedEffect(Unit) { + while (true) { + delay(15_000) + AmberDesktop.engine.pruneExpired() + } + } // The tray's Quit routes here so we can exit the Compose app cleanly. LaunchedEffect(quitRequested) { if (quitRequested) exitApplication() } // A nostrconnect:// link explicitly targets Amber: raise the window, diff --git a/desktop/src/main/kotlin/com/greenart7c3/nostrsigner/desktop/core/AutoStart.kt b/desktop/src/main/kotlin/com/greenart7c3/nostrsigner/desktop/core/AutoStart.kt index 60171002..e9406696 100644 --- a/desktop/src/main/kotlin/com/greenart7c3/nostrsigner/desktop/core/AutoStart.kt +++ b/desktop/src/main/kotlin/com/greenart7c3/nostrsigner/desktop/core/AutoStart.kt @@ -1,11 +1,18 @@ package com.greenart7c3.nostrsigner.desktop.core +import com.sun.jna.platform.win32.Advapi32Util +import com.sun.jna.platform.win32.WinReg import java.io.File /** - * Optional start-on-boot: installs and enables a hardened systemd user unit - * (Opal-style) that starts Amber with the desktop session. Amber always - * comes up locked — the passphrase is still required before anything signs. + * Optional start-on-boot. Amber always comes up locked — the passphrase is + * still required before anything signs. + * + * - Windows: a per-user `HKCU\Software\Microsoft\Windows\CurrentVersion\Run` + * entry pointing at the installed `Amber.exe` (no admin rights needed; shows + * up under Task Manager → Startup apps, where the user can also disable it). + * - Linux: installs and enables a hardened systemd user unit (Opal-style) + * that starts Amber with the desktop session. * * Hardening mirrors Opal's unit with one deliberate exception: no * MemoryDenyWriteExecute, which the JVM cannot survive (the JIT needs @@ -18,22 +25,29 @@ import java.io.File */ object AutoStart { private const val UNIT_NAME = "amber.service" + private const val RUN_KEY = "Software\\Microsoft\\Windows\\CurrentVersion\\Run" + private const val RUN_VALUE = "Amber" val isLinux: Boolean = System.getProperty("os.name").lowercase().let { it.contains("linux") || it.contains("nix") || it.contains("nux") } + val isWindows: Boolean = System.getProperty("os.name").lowercase().contains("win") + + /** Set by the jpackage launcher to the installed binary; absent in dev (gradle) runs. */ + private fun packagedExecutable(): String? = System.getProperty("jpackage.app-path")?.takeIf { it.isNotBlank() } + private fun currentExecutable(): String? = runCatching { String(java.nio.file.Files.readAllBytes(java.nio.file.Path.of("/proc/self/cmdline")), Charsets.UTF_8) .split('\u0000') .firstOrNull { it.isNotBlank() } }.getOrNull() - /** True when the current launch can be supervised by systemd. */ - fun isSupported(): Boolean { - if (!isLinux) return false - val exe = currentExecutable() ?: return false - return File(exe).name != "java" + /** True when the current launch has a stable binary the OS can start at login. */ + fun isSupported(): Boolean = when { + isWindows -> packagedExecutable() != null + isLinux -> currentExecutable()?.let { File(it).name != "java" } ?: false + else -> false } private fun unitDir(): File = File(System.getProperty("user.home"), ".config/systemd/user") @@ -47,6 +61,10 @@ object AutoStart { */ fun setEnabled(enabled: Boolean) { if (!isSupported()) return + if (isWindows) { + setWindowsRunEntry(enabled) + return + } runCatching { // systemd requires an absolute ExecStart: /proc/self/cmdline // records the path exactly as invoked (it can be relative). @@ -67,6 +85,24 @@ object AutoStart { } } + /** + * Writes (or removes) the Run entry. Like the systemd path, enabling never + * launches a second instance and disabling never kills the running one. + */ + private fun setWindowsRunEntry(enabled: Boolean) { + runCatching { + if (enabled) { + val exe = packagedExecutable()?.let { File(it).canonicalFile.path } ?: return + Advapi32Util.registrySetStringValue(WinReg.HKEY_CURRENT_USER, RUN_KEY, RUN_VALUE, windowsRunCommand(exe)) + } else if (Advapi32Util.registryValueExists(WinReg.HKEY_CURRENT_USER, RUN_KEY, RUN_VALUE)) { + Advapi32Util.registryDeleteValue(WinReg.HKEY_CURRENT_USER, RUN_KEY, RUN_VALUE) + } + }.onFailure { AmberLogger.e("AutoStart", "Failed to update the Windows Run entry", it) } + } + + /** The Run value is a command line: always quote the path (e.g. `C:\Program Files\...`). */ + internal fun windowsRunCommand(exePath: String): String = "\"$exePath\"" + private fun systemctl(vararg args: String): Boolean = runCatching { ProcessBuilder("systemctl", "--user", *args).start().waitFor() == 0 }.getOrDefault(false) diff --git a/desktop/src/main/kotlin/com/greenart7c3/nostrsigner/desktop/core/BunkerEngine.kt b/desktop/src/main/kotlin/com/greenart7c3/nostrsigner/desktop/core/BunkerEngine.kt index f866175c..2a2e4115 100644 --- a/desktop/src/main/kotlin/com/greenart7c3/nostrsigner/desktop/core/BunkerEngine.kt +++ b/desktop/src/main/kotlin/com/greenart7c3/nostrsigner/desktop/core/BunkerEngine.kt @@ -20,6 +20,7 @@ import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerInternal import com.vitorpamplona.quartz.nip01Core.tags.people.taggedUsers import com.vitorpamplona.quartz.nip04Dm.crypto.EncryptedInfo import com.vitorpamplona.quartz.nip19Bech32.toNpub +import com.vitorpamplona.quartz.nip40Expiration.expiration import com.vitorpamplona.quartz.nip46RemoteSigner.BunkerRequest import com.vitorpamplona.quartz.nip46RemoteSigner.BunkerRequestConnect import com.vitorpamplona.quartz.nip46RemoteSigner.BunkerRequestSign @@ -37,6 +38,7 @@ import kotlin.time.Duration.Companion.seconds import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Job import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch import kotlinx.coroutines.sync.Mutex import kotlinx.coroutines.sync.withLock @@ -67,6 +69,13 @@ data class PendingBunkerRequest( val encryptionType: EncryptionType = EncryptionType.NIP44, val isNostrConnectUri: Boolean = false, val signerPrivKey: String = "", + /** + * Unix seconds after which the request is dropped from the queue, or null + * to keep it until answered. NIP-46 gives no signal when a client stops + * waiting, so relay requests get a generous TTL (or their NIP-40 + * expiration, if sooner) instead of lingering forever. + */ + val expiresAt: Long? = null, ) /** @@ -284,6 +293,12 @@ class BunkerEngine( if (event.kind != NostrConnectEvent.KIND) return if (!event.verify()) return if (event.content.isEmpty()) return + // Mirrors EventNotificationConsumer: drop NIP-40-expired requests. + val expiration = event.expiration() + if (expiration != null && expiration < TimeUtils.now()) { + AmberLogger.d("BunkerEngine", "Event ${event.id} has expired") + return + } val alreadySeen = synchronized(seenEvents) { if (seenEvents.containsKey(event.id)) { @@ -535,6 +550,7 @@ class BunkerEngine( result = computed.result, encryptionType = encryptionType, signerPrivKey = effectivePrivKey, + expiresAt = minOf(TimeUtils.now() + PENDING_TTL_SECONDS, event.expiration() ?: Long.MAX_VALUE), ), ) } @@ -629,6 +645,11 @@ class BunkerEngine( pending.value = pending.value.filter { it.request.id != id } } + /** Drops requests whose [PendingBunkerRequest.expiresAt] has passed; the UI calls this periodically. */ + fun pruneExpired(now: Long = TimeUtils.now()) { + pending.update { list -> list.filter { it.expiresAt == null || it.expiresAt > now } } + } + /** * Mirrors `BunkerRequestUtils.sendResult`. Runs on the engine's * application scope (a SupervisorJob), never the caller's — approving @@ -1049,6 +1070,9 @@ class BunkerEngine( } companion object { + /** How long a relay request waits for a decision before it is dropped. */ + const val PENDING_TTL_SECONDS = 10 * 60L + fun typeFromMethod(method: String): SignerType = when (method) { "connect" -> SignerType.CONNECT "sign_event" -> SignerType.SIGN_EVENT diff --git a/desktop/src/main/kotlin/com/greenart7c3/nostrsigner/desktop/core/Models.kt b/desktop/src/main/kotlin/com/greenart7c3/nostrsigner/desktop/core/Models.kt index fe6d70e8..3e31a755 100644 --- a/desktop/src/main/kotlin/com/greenart7c3/nostrsigner/desktop/core/Models.kt +++ b/desktop/src/main/kotlin/com/greenart7c3/nostrsigner/desktop/core/Models.kt @@ -181,10 +181,15 @@ data class DesktopSettings( val closeToTray: Boolean = true, /** Show a system notification when a request needs approval. */ val showNotifications: Boolean = true, - /** Start automatically with the desktop session (systemd user service). */ + /** Start automatically at login (systemd user service on Linux, HKCU Run entry on Windows). */ val startOnBoot: Boolean = false, /** UI language tag (matches Strings.supportedLanguages); null = follow the OS. */ val language: String? = null, + /** Last floating window size in dp; null = default (fitted to the screen). */ + val windowWidth: Int? = null, + val windowHeight: Int? = null, + /** Reopen maximized when the window was maximized at last change. */ + val windowMaximized: Boolean = false, ) { fun normalizedDefaultRelays(): List = defaultRelays.mapNotNull { RelayUrlNormalizer.normalizeOrNull(it) } } diff --git a/desktop/src/main/kotlin/com/greenart7c3/nostrsigner/desktop/ui/WindowGeometry.kt b/desktop/src/main/kotlin/com/greenart7c3/nostrsigner/desktop/ui/WindowGeometry.kt new file mode 100644 index 00000000..b875af92 --- /dev/null +++ b/desktop/src/main/kotlin/com/greenart7c3/nostrsigner/desktop/ui/WindowGeometry.kt @@ -0,0 +1,37 @@ +package com.greenart7c3.nostrsigner.desktop.ui + +import androidx.compose.ui.unit.DpSize +import androidx.compose.ui.unit.dp +import java.awt.GraphicsEnvironment + +/** Preferred window size when nothing was saved yet. */ +val DEFAULT_WINDOW_SIZE = DpSize(1100.dp, 780.dp) + +/** Smallest size the layout (sidebar + content) stays usable at. */ +private val MIN_WINDOW_SIZE = DpSize(720.dp, 480.dp) + +/** Space kept free around the window so its edges and title bar stay reachable. */ +private const val SCREEN_MARGIN_DP = 32 + +/** + * Clamps [desired] to the usable screen area ([usableWidth] x [usableHeight], + * in dp — i.e. AWT logical pixels, which already exclude the taskbar/dock), so + * the window never opens with its bottom (and the account switcher) hidden + * under the Windows taskbar on small or scaled displays. + */ +fun fitWindowSize(desired: DpSize, usableWidth: Int?, usableHeight: Int?): DpSize { + if (usableWidth == null || usableHeight == null) return desired + val maxWidth = (usableWidth - SCREEN_MARGIN_DP).coerceAtLeast(MIN_WINDOW_SIZE.width.value.toInt()) + val maxHeight = (usableHeight - SCREEN_MARGIN_DP).coerceAtLeast(MIN_WINDOW_SIZE.height.value.toInt()) + return DpSize( + desired.width.value.toInt().coerceIn(MIN_WINDOW_SIZE.width.value.toInt(), maxWidth).dp, + desired.height.value.toInt().coerceIn(MIN_WINDOW_SIZE.height.value.toInt(), maxHeight).dp, + ) +} + +/** Initial window size: the saved one (or the default), fitted to the primary screen's usable area. */ +fun initialWindowSize(savedWidth: Int?, savedHeight: Int?): DpSize { + val desired = if (savedWidth != null && savedHeight != null) DpSize(savedWidth.dp, savedHeight.dp) else DEFAULT_WINDOW_SIZE + val usable = runCatching { GraphicsEnvironment.getLocalGraphicsEnvironment().maximumWindowBounds }.getOrNull() + return fitWindowSize(desired, usable?.width, usable?.height) +} diff --git a/desktop/src/test/kotlin/com/greenart7c3/nostrsigner/desktop/AutoStartTest.kt b/desktop/src/test/kotlin/com/greenart7c3/nostrsigner/desktop/AutoStartTest.kt index 46507d91..d8c78d0f 100644 --- a/desktop/src/test/kotlin/com/greenart7c3/nostrsigner/desktop/AutoStartTest.kt +++ b/desktop/src/test/kotlin/com/greenart7c3/nostrsigner/desktop/AutoStartTest.kt @@ -1,6 +1,7 @@ package com.greenart7c3.nostrsigner.desktop import com.greenart7c3.nostrsigner.desktop.core.AutoStart +import org.junit.Assert.assertEquals import org.junit.Assert.assertFalse import org.junit.Assert.assertTrue import org.junit.Test @@ -31,4 +32,11 @@ class AutoStartTest { val unit = AutoStart.unitContent("/opt/My Apps/Amber/bin/Amber") assertTrue(unit.contains("ExecStart=\"/opt/My Apps/Amber/bin/Amber\"")) } + + @Test + fun windowsRunCommandQuotesPath() { + // The Run value is parsed as a command line: an unquoted + // "C:\Program Files\..." would try to launch "C:\Program". + assertEquals("\"C:\\Program Files\\Amber\\Amber.exe\"", AutoStart.windowsRunCommand("C:\\Program Files\\Amber\\Amber.exe")) + } } diff --git a/desktop/src/test/kotlin/com/greenart7c3/nostrsigner/desktop/UiStateTest.kt b/desktop/src/test/kotlin/com/greenart7c3/nostrsigner/desktop/UiStateTest.kt index 6b688986..508a8b21 100644 --- a/desktop/src/test/kotlin/com/greenart7c3/nostrsigner/desktop/UiStateTest.kt +++ b/desktop/src/test/kotlin/com/greenart7c3/nostrsigner/desktop/UiStateTest.kt @@ -36,6 +36,27 @@ class UiStateTest { UiState.rememberChoices.value = emptyMap() } + @Test + fun pruneExpiredDropsOnlyStaleRequests() = runBlocking { + val account = com.greenart7c3.nostrsigner.desktop.core.AccountManager.addAccount( + com.vitorpamplona.quartz.nip01Core.crypto.KeyPair(), + ) + fun req(id: String, expiresAt: Long?) = PendingBunkerRequest( + request = BunkerRequest(id, "sign_event", arrayOf()), + type = SignerType.SIGN_EVENT, + account = account, + localKey = "k$id", + relays = emptyList(), + expiresAt = expiresAt, + ) + AmberDesktop.engine.pending.value = listOf(req("stale", 100), req("fresh", 200), req("uri", null)) + + AmberDesktop.engine.pruneExpired(now = 150) + + // Expired relay request goes; unexpired and never-expiring (nostrconnect://) stay. + assertEquals(listOf("fresh", "uri"), AmberDesktop.engine.pending.value.map { it.request.id }) + } + @Test fun upDownSelectionClampsAtEnds() = runBlocking { // Needs a real account for PendingBunkerRequest; create one. diff --git a/desktop/src/test/kotlin/com/greenart7c3/nostrsigner/desktop/ui/WindowGeometryTest.kt b/desktop/src/test/kotlin/com/greenart7c3/nostrsigner/desktop/ui/WindowGeometryTest.kt new file mode 100644 index 00000000..e93373df --- /dev/null +++ b/desktop/src/test/kotlin/com/greenart7c3/nostrsigner/desktop/ui/WindowGeometryTest.kt @@ -0,0 +1,31 @@ +package com.greenart7c3.nostrsigner.desktop.ui + +import androidx.compose.ui.unit.DpSize +import androidx.compose.ui.unit.dp +import org.junit.Assert.assertEquals +import org.junit.Test + +class WindowGeometryTest { + @Test + fun defaultSizeFitsLargeScreenUnchanged() { + assertEquals(DEFAULT_WINDOW_SIZE, fitWindowSize(DEFAULT_WINDOW_SIZE, 1920, 1040)) + } + + @Test + fun shrinksToUsableAreaAboveTaskbar() { + // 1280x800 display with a 48px Windows taskbar: the 780dp default used + // to hide the window's bottom (and the account switcher) under it. + assertEquals(DpSize(1100.dp, 720.dp), fitWindowSize(DEFAULT_WINDOW_SIZE, 1280, 752)) + } + + @Test + fun neverGoesBelowMinimumUsableSize() { + assertEquals(DpSize(720.dp, 480.dp), fitWindowSize(DEFAULT_WINDOW_SIZE, 640, 400)) + assertEquals(DpSize(720.dp, 480.dp), fitWindowSize(DpSize(100.dp, 100.dp), 1920, 1040)) + } + + @Test + fun unknownScreenKeepsDesiredSize() { + assertEquals(DpSize(900.dp, 600.dp), fitWindowSize(DpSize(900.dp, 600.dp), null, null)) + } +} diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 354eca0f..61d35d13 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -40,6 +40,7 @@ composeMultiplatform = "1.11.1" [libraries] datastore-preferences = { module = "androidx.datastore:datastore-preferences", version.ref = "datastorePreferences" } jna = { module = "net.java.dev.jna:jna", version.ref = "jna" } +jna-platform = { module = "net.java.dev.jna:jna-platform", version.ref = "jna" } activity-compose = { module = "androidx.activity:activity-compose", version.ref = "activityCompose" } appcompat = { module = "androidx.appcompat:appcompat", version.ref = "appcompat" } biometric-ktx = { module = "androidx.biometric:biometric-ktx", version.ref = "biometricKtx" }