From 51820c2ba6d4d02497611d7ee1e679a0a8dd1266 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 10 Apr 2026 13:01:29 +0000 Subject: [PATCH 1/4] Move intents state from MainViewModel to IntentUtils _intents/intents StateFlow and addAll/removeAll/clear are now owned by IntentUtils (singleton), so intent state is co-located with intent parsing logic. MainViewModel retains getAccount/showBunkerRequests/ onNewIntent and reads IntentUtils.intents.value directly. AccountScreen, MainActivity, and SignerActivity updated to call IntentUtils directly. The getIntentData duplicate-check no longer needs to reach back into the activity to find the ViewModel. https://claude.ai/code/session_01XqUZoaXbSH51ckFqQYBfgh --- .../greenart7c3/nostrsigner/MainActivity.kt | 3 +- .../greenart7c3/nostrsigner/MainViewModel.kt | 26 ++------------ .../greenart7c3/nostrsigner/SignerActivity.kt | 3 +- .../nostrsigner/service/IntentUtils.kt | 36 ++++++++++++------- .../nostrsigner/ui/AccountScreen.kt | 8 ++--- 5 files changed, 34 insertions(+), 42 deletions(-) diff --git a/app/src/main/java/com/greenart7c3/nostrsigner/MainActivity.kt b/app/src/main/java/com/greenart7c3/nostrsigner/MainActivity.kt index 8841b402..ff67cf3e 100644 --- a/app/src/main/java/com/greenart7c3/nostrsigner/MainActivity.kt +++ b/app/src/main/java/com/greenart7c3/nostrsigner/MainActivity.kt @@ -28,6 +28,7 @@ import androidx.lifecycle.viewmodel.compose.viewModel import androidx.navigation.compose.rememberNavController import com.greenart7c3.nostrsigner.okhttp.HttpClientManager import com.greenart7c3.nostrsigner.service.BunkerRequestUtils +import com.greenart7c3.nostrsigner.service.IntentUtils import com.greenart7c3.nostrsigner.ui.AccountScreen import com.greenart7c3.nostrsigner.ui.AccountStateViewModel import com.greenart7c3.nostrsigner.ui.CenterCircularProgressIndicator @@ -188,7 +189,7 @@ class MainActivity : AppCompatActivity() { } override fun onDestroy() { - mainViewModel.clear() + IntentUtils.clear() super.onDestroy() } diff --git a/app/src/main/java/com/greenart7c3/nostrsigner/MainViewModel.kt b/app/src/main/java/com/greenart7c3/nostrsigner/MainViewModel.kt index 40889e8a..88f50187 100644 --- a/app/src/main/java/com/greenart7c3/nostrsigner/MainViewModel.kt +++ b/app/src/main/java/com/greenart7c3/nostrsigner/MainViewModel.kt @@ -8,7 +8,6 @@ import androidx.compose.runtime.Stable import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import androidx.navigation.NavHostController -import com.greenart7c3.nostrsigner.models.IntentData import com.greenart7c3.nostrsigner.service.BunkerRequestUtils import com.greenart7c3.nostrsigner.service.IntentUtils import com.greenart7c3.nostrsigner.ui.AccountStateViewModel @@ -17,36 +16,15 @@ import com.vitorpamplona.quartz.nip19Bech32.Nip19Parser import com.vitorpamplona.quartz.nip19Bech32.entities.NPub import com.vitorpamplona.quartz.nip19Bech32.toNpub import com.vitorpamplona.quartz.utils.Hex -import kotlinx.collections.immutable.ImmutableList -import kotlinx.collections.immutable.persistentListOf -import kotlinx.collections.immutable.toPersistentList import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.delay -import kotlinx.coroutines.flow.MutableStateFlow -import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.launch @Stable @SuppressLint("StaticFieldLeak") class MainViewModel(val context: Context) : ViewModel() { - private val _intents = MutableStateFlow>(persistentListOf()) - val intents = _intents.asStateFlow() var navController: NavHostController? = null - fun addAll(list: List) { - val existingIds = intents.value.mapTo(mutableSetOf()) { it.id } - val newList = list.filter { existingIds.add(it.id) } - _intents.value = (_intents.value + newList).toPersistentList() - } - - fun removeAll(intents: List) { - _intents.value = (_intents.value - intents.toSet()).toPersistentList() - } - - fun clear() { - _intents.value = persistentListOf() - } - fun getAccount(userFromIntent: String?): String? { val currentAccount = LocalPreferences.currentAccount(context) try { @@ -66,7 +44,7 @@ class MainViewModel(val context: Context) : ViewModel() { } val pubKeys = - intents.value.mapNotNull { + IntentUtils.intents.value.mapNotNull { it.event?.pubKey }.filter { it.isNotBlank() } + BunkerRequestUtils.getBunkerRequests().mapNotNull { when (val parsed = Nip19Parser.uriToRoute(it.currentAccount)?.entity) { @@ -149,7 +127,7 @@ class MainViewModel(val context: Context) : ViewModel() { account?.let { acc -> val intentData = IntentUtils.getIntentData(context, intent, callingPackage, intent.getStringExtra("route"), acc) if (intentData != null) { - addAll(listOf(intentData)) + IntentUtils.addAll(listOf(intentData)) } intent.getStringExtra("route")?.let { route -> diff --git a/app/src/main/java/com/greenart7c3/nostrsigner/SignerActivity.kt b/app/src/main/java/com/greenart7c3/nostrsigner/SignerActivity.kt index 8208ba77..ef687781 100644 --- a/app/src/main/java/com/greenart7c3/nostrsigner/SignerActivity.kt +++ b/app/src/main/java/com/greenart7c3/nostrsigner/SignerActivity.kt @@ -35,6 +35,7 @@ import androidx.lifecycle.viewmodel.compose.viewModel import androidx.navigation.compose.rememberNavController import com.greenart7c3.nostrsigner.okhttp.HttpClientManager import com.greenart7c3.nostrsigner.service.BunkerRequestUtils +import com.greenart7c3.nostrsigner.service.IntentUtils import com.greenart7c3.nostrsigner.ui.AccountScreen import com.greenart7c3.nostrsigner.ui.AccountStateViewModel import com.greenart7c3.nostrsigner.ui.CenterCircularProgressIndicator @@ -206,7 +207,7 @@ class SignerActivity : AppCompatActivity() { } override fun onDestroy() { - mainViewModel.clear() + IntentUtils.clear() super.onDestroy() } diff --git a/app/src/main/java/com/greenart7c3/nostrsigner/service/IntentUtils.kt b/app/src/main/java/com/greenart7c3/nostrsigner/service/IntentUtils.kt index f3803ae8..964897bd 100644 --- a/app/src/main/java/com/greenart7c3/nostrsigner/service/IntentUtils.kt +++ b/app/src/main/java/com/greenart7c3/nostrsigner/service/IntentUtils.kt @@ -16,9 +16,7 @@ import com.greenart7c3.nostrsigner.Amber import com.greenart7c3.nostrsigner.BuildConfig import com.greenart7c3.nostrsigner.FailedMigrationException import com.greenart7c3.nostrsigner.LocalPreferences -import com.greenart7c3.nostrsigner.MainActivity import com.greenart7c3.nostrsigner.R -import com.greenart7c3.nostrsigner.SignerActivity import com.greenart7c3.nostrsigner.database.ApplicationEntity import com.greenart7c3.nostrsigner.database.ApplicationPermissionsEntity import com.greenart7c3.nostrsigner.database.ApplicationWithPermissions @@ -52,10 +50,32 @@ import java.net.URLDecoder import java.util.Base64 import java.util.UUID import java.util.zip.GZIPOutputStream +import kotlinx.collections.immutable.ImmutableList +import kotlinx.collections.immutable.persistentListOf +import kotlinx.collections.immutable.toPersistentList import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.launch object IntentUtils { + private val _intents = MutableStateFlow>(persistentListOf()) + val intents = _intents.asStateFlow() + + fun addAll(list: List) { + val existingIds = _intents.value.mapTo(mutableSetOf()) { it.id } + val newList = list.filter { existingIds.add(it.id) } + _intents.value = (_intents.value + newList).toPersistentList() + } + + fun removeAll(list: List) { + _intents.value = (_intents.value - list.toSet()).toPersistentList() + } + + fun clear() { + _intents.value = persistentListOf() + } + // Compiled once at class load time; avoids recompiling the pattern on every isUrlEncoded() call. private val URL_ENCODED_REGEX = Regex("%[0-9a-fA-F]{2}") @@ -561,16 +581,8 @@ object IntentUtils { } val id = intent.extras?.getString("id") ?: "" - val mainViewModel = when (val mainActivity = Amber.instance.getMainActivity()) { - is MainActivity -> mainActivity.mainViewModel - is SignerActivity -> mainActivity.mainViewModel - else -> null - } - - mainViewModel?.let { - if (it.intents.value.any { intent -> intent.id == id }) { - return null - } + if (intents.value.any { it.id == id }) { + return null } var localAccount = currentLoggedInAccount diff --git a/app/src/main/java/com/greenart7c3/nostrsigner/ui/AccountScreen.kt b/app/src/main/java/com/greenart7c3/nostrsigner/ui/AccountScreen.kt index e8398f3b..7955e214 100644 --- a/app/src/main/java/com/greenart7c3/nostrsigner/ui/AccountScreen.kt +++ b/app/src/main/java/com/greenart7c3/nostrsigner/ui/AccountScreen.kt @@ -71,7 +71,7 @@ fun AccountScreen( MainLoginPage(accountStateViewModel, newNavController) } is AccountState.LoggedIn -> { - val intents by mainViewModel.intents.collectAsState(initial = persistentListOf()) + val intents by IntentUtils.intents.collectAsState(initial = persistentListOf()) LaunchedEffect(intent) { intent.intent?.let { IntentUtils.getIntentData( @@ -81,7 +81,7 @@ fun AccountScreen( it.getStringExtra("route"), state.account, )?.let { intentData -> - mainViewModel.addAll(listOf(intentData)) + IntentUtils.addAll(listOf(intentData)) } } } @@ -107,11 +107,11 @@ fun AccountScreen( onRemoveIntentData = { results, type -> when (type) { IntentResultType.ADD -> { - mainViewModel.addAll(results) + IntentUtils.addAll(results) } IntentResultType.REMOVE -> { - mainViewModel.removeAll(results) + IntentUtils.removeAll(results) } } }, From d09ed9e90a508f61f26cf7c320a847736b9c9aa8 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 10 Apr 2026 13:03:14 +0000 Subject: [PATCH 2/4] Update MainViewModelTest to use IntentUtils directly Tests now call IntentUtils.addAll/intents/clear since the intents state was moved from MainViewModel to IntentUtils. Added @After tearDown to reset singleton state between tests. https://claude.ai/code/session_01XqUZoaXbSH51ckFqQYBfgh --- .../nostrsigner/MainViewModelTest.kt | 51 +++++++++---------- 1 file changed, 24 insertions(+), 27 deletions(-) diff --git a/app/src/test/java/com/greenart7c3/nostrsigner/MainViewModelTest.kt b/app/src/test/java/com/greenart7c3/nostrsigner/MainViewModelTest.kt index 02865270..38a5162e 100644 --- a/app/src/test/java/com/greenart7c3/nostrsigner/MainViewModelTest.kt +++ b/app/src/test/java/com/greenart7c3/nostrsigner/MainViewModelTest.kt @@ -1,22 +1,19 @@ package com.greenart7c3.nostrsigner -import android.content.Context import com.greenart7c3.nostrsigner.models.CompressionType import com.greenart7c3.nostrsigner.models.IntentData import com.greenart7c3.nostrsigner.models.ReturnType import com.greenart7c3.nostrsigner.models.SignerType -import io.mockk.mockk +import com.greenart7c3.nostrsigner.service.IntentUtils +import org.junit.After import org.junit.Assert.assertEquals -import org.junit.Before import org.junit.Test class MainViewModelTest { - private lateinit var viewModel: MainViewModel - - @Before - fun setUp() { - viewModel = MainViewModel(mockk(relaxed = true)) + @After + fun tearDown() { + IntentUtils.clear() } private fun intentData(id: String, data: String = "data") = IntentData( @@ -37,48 +34,48 @@ class MainViewModelTest { @Test fun `addAll adds new intents`() { - viewModel.addAll(listOf(intentData("id1"), intentData("id2"))) - assertEquals(2, viewModel.intents.value.size) + IntentUtils.addAll(listOf(intentData("id1"), intentData("id2"))) + assertEquals(2, IntentUtils.intents.value.size) } @Test fun `addAll ignores intents with duplicate id already in state`() { - viewModel.addAll(listOf(intentData("id1"))) - viewModel.addAll(listOf(intentData("id1"))) - assertEquals(1, viewModel.intents.value.size) + IntentUtils.addAll(listOf(intentData("id1"))) + IntentUtils.addAll(listOf(intentData("id1"))) + assertEquals(1, IntentUtils.intents.value.size) } @Test fun `addAll ignores intents with duplicate id even when other fields differ`() { - viewModel.addAll(listOf(intentData("id1", data = "original"))) - viewModel.addAll(listOf(intentData("id1", data = "different"))) - assertEquals(1, viewModel.intents.value.size) - assertEquals("original", viewModel.intents.value.first().data) + IntentUtils.addAll(listOf(intentData("id1", data = "original"))) + IntentUtils.addAll(listOf(intentData("id1", data = "different"))) + assertEquals(1, IntentUtils.intents.value.size) + assertEquals("original", IntentUtils.intents.value.first().data) } @Test fun `addAll deduplicates within a single batch`() { - viewModel.addAll(listOf(intentData("id1"), intentData("id1"))) - assertEquals(1, viewModel.intents.value.size) + IntentUtils.addAll(listOf(intentData("id1"), intentData("id1"))) + assertEquals(1, IntentUtils.intents.value.size) } @Test fun `addAll allows intents with different ids`() { - viewModel.addAll(listOf(intentData("id1"), intentData("id2"), intentData("id3"))) - assertEquals(3, viewModel.intents.value.size) + IntentUtils.addAll(listOf(intentData("id1"), intentData("id2"), intentData("id3"))) + assertEquals(3, IntentUtils.intents.value.size) } @Test fun `addAll appends to existing intents`() { - viewModel.addAll(listOf(intentData("id1"))) - viewModel.addAll(listOf(intentData("id2"))) - assertEquals(2, viewModel.intents.value.size) + IntentUtils.addAll(listOf(intentData("id1"))) + IntentUtils.addAll(listOf(intentData("id2"))) + assertEquals(2, IntentUtils.intents.value.size) } @Test fun `addAll with empty list leaves state unchanged`() { - viewModel.addAll(listOf(intentData("id1"))) - viewModel.addAll(emptyList()) - assertEquals(1, viewModel.intents.value.size) + IntentUtils.addAll(listOf(intentData("id1"))) + IntentUtils.addAll(emptyList()) + assertEquals(1, IntentUtils.intents.value.size) } } From 9502534a248f10c4c3d1bfccfcb519b3f7f9ed71 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 10 Apr 2026 13:23:02 +0000 Subject: [PATCH 3/4] Fix race condition in IntentUtils.addAll/removeAll MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The old read-modify-write pattern on MutableStateFlow was not atomic: two concurrent addAll calls both read the same current value, computed their additions independently, and the second write clobbered the first, silently dropping one intent. Switching to StateFlow.update{} fixes this — it uses compareAndSet internally and retries the lambda if the value was changed by another thread between the read and the write. https://claude.ai/code/session_01XqUZoaXbSH51ckFqQYBfgh --- .../greenart7c3/nostrsigner/service/IntentUtils.kt | 11 +++++++---- 1 file changed, 7 insertions(+), 4 deletions(-) diff --git a/app/src/main/java/com/greenart7c3/nostrsigner/service/IntentUtils.kt b/app/src/main/java/com/greenart7c3/nostrsigner/service/IntentUtils.kt index 964897bd..b5552c29 100644 --- a/app/src/main/java/com/greenart7c3/nostrsigner/service/IntentUtils.kt +++ b/app/src/main/java/com/greenart7c3/nostrsigner/service/IntentUtils.kt @@ -56,6 +56,7 @@ import kotlinx.collections.immutable.toPersistentList import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch object IntentUtils { @@ -63,13 +64,15 @@ object IntentUtils { val intents = _intents.asStateFlow() fun addAll(list: List) { - val existingIds = _intents.value.mapTo(mutableSetOf()) { it.id } - val newList = list.filter { existingIds.add(it.id) } - _intents.value = (_intents.value + newList).toPersistentList() + _intents.update { current -> + val existingIds = current.mapTo(mutableSetOf()) { it.id } + val newList = list.filter { existingIds.add(it.id) } + (current + newList).toPersistentList() + } } fun removeAll(list: List) { - _intents.value = (_intents.value - list.toSet()).toPersistentList() + _intents.update { current -> (current - list.toSet()).toPersistentList() } } fun clear() { From 66654a13679754bd3b7de7563945b7ec98301e66 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 10 Apr 2026 13:29:53 +0000 Subject: [PATCH 4/4] Process initial intent in onCreate before setContent If a second intent arrives while the activity is still initialising, onNewIntent calls setIntent() which replaces this.intent before the composable renders. AccountScreen's LaunchedEffect then only sees the second intent, silently dropping the first. Fix: call mainViewModel.onNewIntent in onCreate immediately after creating the ViewModel, so the initial intent is queued into IntentUtils.intents before any recomposition. The existing ID-dedup check in getIntentData prevents double-processing if AccountScreen's LaunchedEffect also fires for the same intent. https://claude.ai/code/session_01XqUZoaXbSH51ckFqQYBfgh --- app/src/main/java/com/greenart7c3/nostrsigner/MainActivity.kt | 1 + app/src/main/java/com/greenart7c3/nostrsigner/SignerActivity.kt | 1 + 2 files changed, 2 insertions(+) diff --git a/app/src/main/java/com/greenart7c3/nostrsigner/MainActivity.kt b/app/src/main/java/com/greenart7c3/nostrsigner/MainActivity.kt index ff67cf3e..cf309616 100644 --- a/app/src/main/java/com/greenart7c3/nostrsigner/MainActivity.kt +++ b/app/src/main/java/com/greenart7c3/nostrsigner/MainActivity.kt @@ -57,6 +57,7 @@ class MainActivity : AppCompatActivity() { super.onCreate(savedInstanceState) Amber.instance.setMainActivity(this) mainViewModel = MainViewModel(applicationContext) + intent?.let { mainViewModel.onNewIntent(it, callingPackage) } setContent { val isStartingApp = Amber.instance.isStartingAppState.collectAsStateWithLifecycle() if (isStartingApp.value) { diff --git a/app/src/main/java/com/greenart7c3/nostrsigner/SignerActivity.kt b/app/src/main/java/com/greenart7c3/nostrsigner/SignerActivity.kt index ef687781..6ecc46e7 100644 --- a/app/src/main/java/com/greenart7c3/nostrsigner/SignerActivity.kt +++ b/app/src/main/java/com/greenart7c3/nostrsigner/SignerActivity.kt @@ -61,6 +61,7 @@ class SignerActivity : AppCompatActivity() { super.onCreate(savedInstanceState) Amber.instance.setMainActivity(this) mainViewModel = MainViewModel(applicationContext) + intent?.let { mainViewModel.onNewIntent(it, callingPackage) } setContent { val isStartingApp = Amber.instance.isStartingAppState.collectAsStateWithLifecycle() NostrSignerTheme {