diff --git a/app/src/main/java/com/greenart7c3/nostrsigner/ui/components/BunkerMultiEventHomeScreen.kt b/app/src/main/java/com/greenart7c3/nostrsigner/ui/components/BunkerMultiEventHomeScreen.kt index 67aab33e..939bed41 100644 --- a/app/src/main/java/com/greenart7c3/nostrsigner/ui/components/BunkerMultiEventHomeScreen.kt +++ b/app/src/main/java/com/greenart7c3/nostrsigner/ui/components/BunkerMultiEventHomeScreen.kt @@ -151,19 +151,49 @@ fun BunkerMultiEventHomeScreen( Text(stringResource(R.string.select_deselect_all)) } + val groups = remember(bunkerRequests) { + groupRequests(bunkerRequests) { + requestGroupKey( + type = BunkerRequestUtils.getTypeFromBunker(it.request), + eventKind = (it.request as? BunkerRequestSign)?.event?.kind, + encryptedData = it.encryptedData, + nip44v3Kind = BunkerRequestUtils.getNip44v3Kind(it.request), + ) + } + } LazyColumn( Modifier.weight(1f), ) { - items(bunkerRequests, key = { it.request.id }) { bunkerRequest -> - BunkerRequestCard( - context = context, - bunkerRequest = bunkerRequest, - checked = MultiEventScreenIntents.checkedStates[bunkerRequest.request.id] ?: true, - onToggleChecked = { - val current = MultiEventScreenIntents.checkedStates[bunkerRequest.request.id] ?: true - MultiEventScreenIntents.checkedStates[bunkerRequest.request.id] = !current - }, - ) + groups.forEach { (groupKey, groupItems) -> + if (groups.size > 1) { + item(key = "group-header:${groupKey.type.name}:${groupKey.payload?.name ?: ""}:${groupKey.kind ?: ""}") { + val groupState = when { + groupItems.all { MultiEventScreenIntents.checkedStates[it.request.id] ?: true } -> ToggleableState.On + groupItems.none { MultiEventScreenIntents.checkedStates[it.request.id] ?: true } -> ToggleableState.Off + else -> ToggleableState.Indeterminate + } + RequestGroupHeader( + label = groupKey.toLabel(context), + count = groupItems.size, + state = groupState, + onToggle = { + val newValue = groupState != ToggleableState.On + MultiEventScreenIntents.checkedStates.putAll(groupItems.associate { it.request.id to newValue }) + }, + ) + } + } + items(groupItems, key = { it.request.id }) { bunkerRequest -> + BunkerRequestCard( + context = context, + bunkerRequest = bunkerRequest, + checked = MultiEventScreenIntents.checkedStates[bunkerRequest.request.id] ?: true, + onToggleChecked = { + val current = MultiEventScreenIntents.checkedStates[bunkerRequest.request.id] ?: true + MultiEventScreenIntents.checkedStates[bunkerRequest.request.id] = !current + }, + ) + } } } diff --git a/app/src/main/java/com/greenart7c3/nostrsigner/ui/components/IntentMultiEventHomeScreen.kt b/app/src/main/java/com/greenart7c3/nostrsigner/ui/components/IntentMultiEventHomeScreen.kt index 8c64b095..7e01e16c 100644 --- a/app/src/main/java/com/greenart7c3/nostrsigner/ui/components/IntentMultiEventHomeScreen.kt +++ b/app/src/main/java/com/greenart7c3/nostrsigner/ui/components/IntentMultiEventHomeScreen.kt @@ -122,19 +122,44 @@ fun IntentMultiEventHomeScreen( Text(stringResource(R.string.select_deselect_all)) } + val groups = remember(intents) { + groupRequests(intents) { + requestGroupKey(it.type, it.event?.kind, it.encryptedData, it.nip44v3Kind) + } + } LazyColumn( Modifier.weight(1f), ) { - items(intents, key = { it.id }) { intent -> - IntentRequestCard( - context = context, - intent = intent, - checked = MultiEventScreenIntents.checkedStates[intent.id] ?: true, - onToggleChecked = { - val current = MultiEventScreenIntents.checkedStates[intent.id] ?: true - MultiEventScreenIntents.checkedStates[intent.id] = !current - }, - ) + groups.forEach { (groupKey, groupIntents) -> + if (groups.size > 1) { + item(key = "group-header:${groupKey.type.name}:${groupKey.payload?.name ?: ""}:${groupKey.kind ?: ""}") { + val groupState = when { + groupIntents.all { MultiEventScreenIntents.checkedStates[it.id] ?: true } -> ToggleableState.On + groupIntents.none { MultiEventScreenIntents.checkedStates[it.id] ?: true } -> ToggleableState.Off + else -> ToggleableState.Indeterminate + } + RequestGroupHeader( + label = groupKey.toLabel(context), + count = groupIntents.size, + state = groupState, + onToggle = { + val newValue = groupState != ToggleableState.On + MultiEventScreenIntents.checkedStates.putAll(groupIntents.associate { it.id to newValue }) + }, + ) + } + } + items(groupIntents, key = { it.id }) { intent -> + IntentRequestCard( + context = context, + intent = intent, + checked = MultiEventScreenIntents.checkedStates[intent.id] ?: true, + onToggleChecked = { + val current = MultiEventScreenIntents.checkedStates[intent.id] ?: true + MultiEventScreenIntents.checkedStates[intent.id] = !current + }, + ) + } } } diff --git a/app/src/main/java/com/greenart7c3/nostrsigner/ui/components/RequestGroupUtils.kt b/app/src/main/java/com/greenart7c3/nostrsigner/ui/components/RequestGroupUtils.kt new file mode 100644 index 00000000..a1e5844b --- /dev/null +++ b/app/src/main/java/com/greenart7c3/nostrsigner/ui/components/RequestGroupUtils.kt @@ -0,0 +1,126 @@ +package com.greenart7c3.nostrsigner.ui.components + +import android.content.Context +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.material3.TriStateCheckbox +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.state.ToggleableState +import com.greenart7c3.nostrsigner.R +import com.greenart7c3.nostrsigner.models.ClearTextEncryptedDataKind +import com.greenart7c3.nostrsigner.models.EncryptedDataKind +import com.greenart7c3.nostrsigner.models.EventEncryptedDataKind +import com.greenart7c3.nostrsigner.models.Permission +import com.greenart7c3.nostrsigner.models.PrivateZapEncryptedDataKind +import com.greenart7c3.nostrsigner.models.SignerType +import com.greenart7c3.nostrsigner.models.TagArrayEncryptedDataKind +import com.greenart7c3.nostrsigner.models.encryptDecryptSignerTypes + +enum class RequestPayloadShape { + EVENT, + TAG_ARRAY, + CLEAR_TEXT, + PRIVATE_ZAP, +} + +data class RequestGroupKey( + val type: SignerType, + val kind: Int?, + val payload: RequestPayloadShape?, +) + +fun requestGroupKey( + type: SignerType, + eventKind: Int?, + encryptedData: EncryptedDataKind?, + nip44v3Kind: Int?, +): RequestGroupKey = when { + type == SignerType.SIGN_EVENT -> RequestGroupKey(type, eventKind, null) + type == SignerType.NIP44_V3_ENCRYPT || type == SignerType.NIP44_V3_DECRYPT -> RequestGroupKey(type, nip44v3Kind, null) + type in encryptDecryptSignerTypes -> when (encryptedData) { + is EventEncryptedDataKind -> RequestGroupKey(type, encryptedData.event.kind, RequestPayloadShape.EVENT) + is TagArrayEncryptedDataKind -> RequestGroupKey(type, null, RequestPayloadShape.TAG_ARRAY) + is ClearTextEncryptedDataKind -> RequestGroupKey(type, null, RequestPayloadShape.CLEAR_TEXT) + is PrivateZapEncryptedDataKind -> RequestGroupKey(type, null, RequestPayloadShape.PRIVATE_ZAP) + else -> RequestGroupKey(type, null, null) + } + else -> RequestGroupKey(type, null, null) +} + +fun groupRequests( + items: List, + keyOf: (T) -> RequestGroupKey, +): List>> = items.groupBy(keyOf).toList() + .sortedWith( + compareBy( + { it.first.type.ordinal }, + { it.first.payload?.ordinal ?: -1 }, + { it.first.kind ?: -1 }, + ), + ) + +fun RequestGroupKey.toLabel(context: Context): String { + val isEncrypt = type.name.contains("ENCRYPT") + val nip = type.name.split("_").first() + return when { + type == SignerType.CONNECT -> context.getString(R.string.connect) + type == SignerType.SIGN_EVENT -> Permission("sign_event", kind).toLocalizedString(context) + payload == RequestPayloadShape.EVENT -> { + val kindLabel = Permission("sign_event", kind).toLocalizedString(context) + if (isEncrypt) { + context.getString(R.string.encrypt_with, kindLabel, nip) + } else { + context.getString(R.string.read_from_encrypted_content, kindLabel, nip) + } + } + payload == RequestPayloadShape.TAG_ARRAY -> if (isEncrypt) { + context.getString(R.string.encrypt_this_list_of_tags_with, nip) + } else { + context.getString(R.string.read_this_list_of_tags_from_encrypted_content, nip) + } + payload == RequestPayloadShape.CLEAR_TEXT -> if (isEncrypt) { + context.getString(R.string.encrypt_this_text_with, nip) + } else { + context.getString(R.string.read_this_text_from_encrypted_content, nip) + } + payload == RequestPayloadShape.PRIVATE_ZAP -> context.getString(R.string.decrypt_zap_event).replaceFirstChar { it.uppercase() } + (type == SignerType.NIP44_V3_ENCRYPT || type == SignerType.NIP44_V3_DECRYPT) && kind != null -> { + val kindLabel = Permission("sign_event", kind).toLocalizedString(context) + if (isEncrypt) { + context.getString(R.string.encrypt_with, kindLabel, nip) + } else { + context.getString(R.string.read_from_encrypted_content, kindLabel, nip) + } + } + else -> Permission(type.name.lowercase(), null).toLocalizedString(context) + } +} + +@Composable +fun RequestGroupHeader( + label: String, + count: Int, + state: ToggleableState, + onToggle: () -> Unit, +) { + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier + .fillMaxWidth() + .clickable { onToggle() }, + ) { + TriStateCheckbox( + state = state, + onClick = onToggle, + ) + Text( + text = "$label ($count)", + style = MaterialTheme.typography.titleSmall, + ) + } +} diff --git a/app/src/test/java/com/greenart7c3/nostrsigner/ui/components/RequestGroupUtilsTest.kt b/app/src/test/java/com/greenart7c3/nostrsigner/ui/components/RequestGroupUtilsTest.kt new file mode 100644 index 00000000..a40f10e1 --- /dev/null +++ b/app/src/test/java/com/greenart7c3/nostrsigner/ui/components/RequestGroupUtilsTest.kt @@ -0,0 +1,118 @@ +package com.greenart7c3.nostrsigner.ui.components + +import com.greenart7c3.nostrsigner.models.ClearTextEncryptedDataKind +import com.greenart7c3.nostrsigner.models.EventEncryptedDataKind +import com.greenart7c3.nostrsigner.models.PrivateZapEncryptedDataKind +import com.greenart7c3.nostrsigner.models.SignerType +import com.greenart7c3.nostrsigner.models.TagArrayEncryptedDataKind +import com.greenart7c3.nostrsigner.service.model.AmberEvent +import org.junit.Assert.assertEquals +import org.junit.Test + +class RequestGroupUtilsTest { + + private fun amberEvent(kind: Int) = AmberEvent("id", "pubkey", 0L, kind, arrayOf(), "", "") + + private fun eventData(kind: Int) = EventEncryptedDataKind(amberEvent(kind), null, "result") + + @Test + fun `sign event groups by event kind`() { + val key1 = requestGroupKey(SignerType.SIGN_EVENT, 1, null, null) + val key1b = requestGroupKey(SignerType.SIGN_EVENT, 1, null, null) + val key22242 = requestGroupKey(SignerType.SIGN_EVENT, 22242, null, null) + + assertEquals(key1, key1b) + assertEquals(1, key1.kind) + assertEquals(22242, key22242.kind) + assertEquals(SignerType.SIGN_EVENT, key1.type) + assertEquals(null, key1.payload) + } + + @Test + fun `encrypt decrypt separates by payload shape`() { + val eventKey = requestGroupKey(SignerType.NIP44_DECRYPT, null, eventData(4), null) + val tagKey = requestGroupKey(SignerType.NIP44_DECRYPT, null, TagArrayEncryptedDataKind(arrayOf(), "r"), null) + val textKey = requestGroupKey(SignerType.NIP44_DECRYPT, null, ClearTextEncryptedDataKind("t", "r"), null) + val zapKey = requestGroupKey(SignerType.DECRYPT_ZAP_EVENT, null, PrivateZapEncryptedDataKind("r"), null) + + assertEquals(RequestPayloadShape.EVENT, eventKey.payload) + assertEquals(4, eventKey.kind) + assertEquals(RequestPayloadShape.TAG_ARRAY, tagKey.payload) + assertEquals(null, tagKey.kind) + assertEquals(RequestPayloadShape.CLEAR_TEXT, textKey.payload) + assertEquals(RequestPayloadShape.PRIVATE_ZAP, zapKey.payload) + } + + @Test + fun `encrypt decrypt event payloads separate by embedded event kind`() { + val kind1 = requestGroupKey(SignerType.NIP44_ENCRYPT, null, eventData(1), null) + val kind1b = requestGroupKey(SignerType.NIP44_ENCRYPT, null, eventData(1), null) + val kind7 = requestGroupKey(SignerType.NIP44_ENCRYPT, null, eventData(7), null) + + assertEquals(kind1, kind1b) + assertEquals(1, kind1.kind) + assertEquals(7, kind7.kind) + } + + @Test + fun `nip44 v3 groups by explicit kind`() { + val enc9 = requestGroupKey(SignerType.NIP44_V3_ENCRYPT, null, null, 9) + val dec9 = requestGroupKey(SignerType.NIP44_V3_DECRYPT, null, null, 9) + val decNull = requestGroupKey(SignerType.NIP44_V3_DECRYPT, null, null, null) + + assertEquals(9, enc9.kind) + assertEquals(9, dec9.kind) + assertEquals(null, decNull.kind) + assertEquals(null, enc9.payload) + } + + @Test + fun `other types group by type alone`() { + val connect = requestGroupKey(SignerType.CONNECT, null, null, null) + val pubKey = requestGroupKey(SignerType.GET_PUBLIC_KEY, null, null, null) + + assertEquals(RequestGroupKey(SignerType.CONNECT, null, null), connect) + assertEquals(RequestGroupKey(SignerType.GET_PUBLIC_KEY, null, null), pubKey) + } + + @Test + fun `groupRequests sorts by type ordinal then payload then kind`() { + val items = listOf( + RequestGroupKey(SignerType.SIGN_EVENT, 22242, null), + RequestGroupKey(SignerType.NIP44_DECRYPT, null, RequestPayloadShape.CLEAR_TEXT), + RequestGroupKey(SignerType.CONNECT, null, null), + RequestGroupKey(SignerType.SIGN_EVENT, 1, null), + RequestGroupKey(SignerType.NIP44_DECRYPT, 4, RequestPayloadShape.EVENT), + ) + + val groups = groupRequests(items) { it } + + assertEquals( + listOf( + RequestGroupKey(SignerType.CONNECT, null, null), + RequestGroupKey(SignerType.SIGN_EVENT, 1, null), + RequestGroupKey(SignerType.SIGN_EVENT, 22242, null), + RequestGroupKey(SignerType.NIP44_DECRYPT, 4, RequestPayloadShape.EVENT), + RequestGroupKey(SignerType.NIP44_DECRYPT, null, RequestPayloadShape.CLEAR_TEXT), + ), + groups.map { it.first }, + ) + } + + @Test + fun `groupRequests preserves input order within a group`() { + data class Item(val id: String, val kind: Int) + + val items = listOf( + Item("a", 1), + Item("b", 7), + Item("c", 1), + Item("d", 1), + ) + + val groups = groupRequests(items) { RequestGroupKey(SignerType.SIGN_EVENT, it.kind, null) } + + assertEquals(listOf("a", "c", "d"), groups.first { it.first.kind == 1 }.second.map { it.id }) + assertEquals(listOf("b"), groups.first { it.first.kind == 7 }.second.map { it.id }) + } +}