10 Commits

Author SHA1 Message Date
26f9e577f4 chore: bump version 2026-06-27 10:26:55 +07:00
dd416711e2 fix: app crash when message id is null (#28)
Reviewed-on: #28
2026-06-27 03:26:07 +00:00
b90279e174 chore: improve qr scan result handling (#27)
Reviewed-on: #27
2026-06-27 01:14:26 +00:00
25ba03562a chore: improve handling of loading states (#26)
Reviewed-on: #26
2026-06-26 09:23:03 +00:00
010a8b4060 feat: wipe database on logout (#25)
Reviewed-on: #25
2026-06-25 08:12:27 +00:00
750d46bc9e chore: update zapstore.yaml 2026-06-25 10:14:32 +07:00
790d24c24b chore: update metadata 2026-06-25 09:53:38 +07:00
4fbc8446d3 fix: release source endpoint 2026-06-25 09:26:15 +07:00
b4518ce360 chore: add app screenshots 2026-06-25 09:19:16 +07:00
0f4bb71a39 chore: prepare to publish 2026-06-24 17:36:04 +07:00
18 changed files with 343 additions and 125 deletions

93
CHANGELOG.md Normal file
View File

@@ -0,0 +1,93 @@
### [2026-06-20] v0.2.0 (Stable)
**What's Changed:**
* Fixed the issue that cause app crashing
* Added screener for new message requests
* Added request messages screen
* Added contact list screen
---
### [2026-06-12] v0.1.9 (Pre-Release)
**What's Changed:**
* Fixed the issue that cause app crashing
* Added relay management
---
### [2026-06-09] v0.1.8 (Pre-Release)
**What's Changed:**
* Added support for NIP-55 (use external signer like Amber)
---
### [2026-06-07] v0.1.7 (Pre-Release)
**What's Changed:**
* Added a screen for update profile
* Fixed occasional splash screen hang
* Improved the battery usage in background
---
### [2026-06-04] v0.1.6 (Pre-Release)
**What's Changed:**
* Added self chat
* Fixed empty chat rooms on refreshes
* Fixed crash on startup
---
### [2026-06-03] v0.1.5 (Pre-Release)
**What's Changed:**
* Added crash report screen
* Added notification permission banner
* Fixed navigation issues after create or import identity
---
### [2026-06-01] v0.1.4 (Pre-Release)
**What's Changed:**
* Added the deep link to allow user open chat screen directly via notification
* Fixed the issue cause app crash if room has no member
* Improve stability and performance
---
### [2026-05-29] v0.1.3 (Pre-Release)
**What's Changed:**
* Added basic notification when the app in background
* Redesign the splash screen
* Redesign the onboarding screen
* Improved UX for the new identity creation and import screen
* Fixed some issues cause app crashes
---
### [2026-05-24] v0.1.2 (Pre-Release)
**What's Changed:**
* Fixed the issue that cause app crash when open new chat room via QR
---
### [2026-05-23] v0.1.1 (Pre-Release)
**What's Changed:**
* Fixed the issue that cause app crash on startup

BIN
assets/icon.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

BIN
assets/screenshot1.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 296 KiB

BIN
assets/screenshot2.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 444 KiB

BIN
assets/screenshot3.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 212 KiB

BIN
assets/screenshot4.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 111 KiB

BIN
assets/screenshot5.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 185 KiB

View File

@@ -69,7 +69,7 @@ android {
minSdk = libs.versions.android.minSdk.get().toInt()
targetSdk = libs.versions.android.targetSdk.get().toInt()
versionCode = 1
versionName = "0.2.0"
versionName = "0.2.1"
}
packaging {
resources {

View File

@@ -65,8 +65,10 @@ class NostrForegroundService : Service() {
} catch (e: Exception) {
throw IllegalStateException("Failed to initialize Nostr Client", e)
}
// Connect to bootstrap relays
nostr.connectBootstrapRelays()
// Handle notifications
nostr.handleNotifications(
onMetadataUpdate = { pubkey, metadata ->
@@ -75,9 +77,6 @@ class NostrForegroundService : Service() {
onContactListUpdate = { contacts ->
serviceScope.launch { nostr.emitContactListUpdate(contacts) }
},
onSubscriptionClose = {
serviceScope.launch { nostr.emitSubscriptionClosed() }
},
onNewMessage = { event ->
serviceScope.launch {
if (!isUserInApp()) {

View File

@@ -4,15 +4,11 @@ import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.IntrinsicSize
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.aspectRatio
import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.widthIn
@@ -238,9 +234,10 @@ fun ChatScreen(id: Long, screening: Boolean = false) {
) {
groupedMessages.forEach { (dateHeader, messagesInGroup) ->
items(
messagesInGroup,
key = { it.id()?.toBech32()!! }) { event ->
ChatMessage(event)
items = messagesInGroup,
key = { it.id()?.toBech32() ?: it.hashCode() }
) {
ChatMessage(it)
}
item {
DateSeparator(dateHeader)
@@ -526,9 +523,7 @@ fun ChatInput(
) {
Surface(modifier = Modifier.fillMaxWidth()) {
Row(
modifier = Modifier
.padding(horizontal = 16.dp, vertical = 8.dp)
.height(IntrinsicSize.Min),
modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp),
verticalAlignment = Alignment.Bottom
) {
TextField(
@@ -545,9 +540,7 @@ fun ChatInput(
Spacer(modifier = Modifier.size(8.dp))
FilledTonalIconButton(
onClick = onSend,
modifier = Modifier
.fillMaxHeight()
.aspectRatio(1f),
modifier = Modifier.size(56.dp),
colors = IconButtonDefaults.filledTonalIconButtonColors(
containerColor = MaterialTheme.colorScheme.surfaceVariant,
contentColor = MaterialTheme.colorScheme.onSurfaceVariant

View File

@@ -127,7 +127,8 @@ fun HomeScreen() {
val userProfile by currentUserProfile.collectAsStateWithLifecycle()
val chatRooms by viewModel.chatRooms.collectAsStateWithLifecycle()
val isRelayListEmpty by viewModel.isRelayListEmpty.collectAsStateWithLifecycle()
val isPartialProcessedGiftWrap by viewModel.isPartialProcessedGiftWrap.collectAsState(initial = false)
val isSyncing by viewModel.isSyncing.collectAsStateWithLifecycle()
val isPartialProcessedGiftWrap by viewModel.isPartialProcessedGiftWrap.collectAsStateWithLifecycle()
val isBannerDismissed by viewModel.isNotificationBannerDismissed.collectAsState()
val expandedFab by remember { derivedStateOf { listState.firstVisibleItemIndex == 0 } }
@@ -185,10 +186,18 @@ fun HomeScreen() {
containerColor = MaterialTheme.colorScheme.surfaceContainer,
),
title = {
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(8.dp)
) {
Text(
text = "Coop",
style = MaterialTheme.typography.titleMediumEmphasized
)
if (isSyncing) {
LoadingIndicator(modifier = Modifier.size(24.dp))
}
}
},
actions = {
// QR Scanner
@@ -781,7 +790,7 @@ fun BottomMenuList(
}
Spacer(modifier = Modifier.size(16.dp))
FilledTonalButton(
onClick = { viewModel.logout() },
onClick = { onDismiss { viewModel.logout() } },
colors = ButtonDefaults.filledTonalButtonColors(
containerColor = MaterialTheme.colorScheme.error,
contentColor = MaterialTheme.colorScheme.onError

View File

@@ -94,17 +94,18 @@ fun ImportScreen() {
LaunchedEffect(qrScanResult.content) {
qrScanResult.content?.let { result ->
runCatching {
if (result.startsWith("nsec")) {
if (result.startsWith("nsec1")) {
Keys.parse(result)
} else if (result.startsWith("bunker://")) {
NostrConnectUri.parse(result)
} else {
throw IllegalArgumentException("Invalid secret: $result")
throw IllegalArgumentException("Unsupported secret format")
}
}.onSuccess {
secret = result
}.onFailure { e ->
snackbarHostState.showSnackbar("Invalid secret: ${e.message}")
}
.onSuccess { it -> secret = result }
.onFailure { e -> println("Failed to parse QR: ${e.message}") }
// Clear the nav state
qrScanResult.clear()
}

View File

@@ -109,14 +109,13 @@ fun NewChatScreen() {
LaunchedEffect(qrScanResult.content) {
qrScanResult.content?.let { result ->
// Verify the content
runCatching { PublicKey.parse(result) }
.onSuccess { pubkey ->
runCatching {
PublicKey.parse(result)
}.onSuccess { pubkey ->
selectedReceivers.add(pubkey)
}.onFailure { e ->
snackbarHostState.showSnackbar("Invalid address: ${e.message}")
}
.onFailure { e ->
println("Failed to parse QR: ${e.message}")
}
// Clear the nav state
qrScanResult.clear()
}

View File

@@ -5,16 +5,15 @@ import androidx.compose.foundation.Canvas
import androidx.compose.foundation.border
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.CenterAlignedTopAppBar
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Scaffold
import androidx.compose.material3.SnackbarHost
import androidx.compose.material3.Text
import androidx.compose.material3.TopAppBar
import androidx.compose.material3.TopAppBarDefaults
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
@@ -44,10 +43,10 @@ fun ScanScreen() {
Scaffold(
snackbarHost = { SnackbarHost(snackbarHostState) },
topBar = {
TopAppBar(
CenterAlignedTopAppBar(
title = {
Text(
text = "Scan QR",
text = "Scan a Nostr Address",
style = MaterialTheme.typography.titleMediumEmphasized
)
},
@@ -97,14 +96,6 @@ fun ScanScreen() {
.align(Alignment.Center)
.border(2.dp, Color.White, RoundedCornerShape(12.dp))
)
Text(
text = "Scan a Nostr address",
style = MaterialTheme.typography.titleSmallEmphasized,
color = Color.White,
modifier = Modifier
.align(Alignment.BottomCenter)
.padding(bottom = 64.dp)
)
}
}
}

View File

@@ -18,8 +18,8 @@ fun Room.nameFlow(viewModel: NostrViewModel): Flow<String> {
return combine(displayMembers.map { viewModel.getMetadata(it) }) { metadataArray ->
val names = metadataArray.mapIndexed { i, metadata ->
val profile = metadata?.asRecord()
profile?.name?.takeIf { it.isNotBlank() }
?: profile?.displayName?.takeIf { it.isNotBlank() }
profile?.displayName?.takeIf { it.isNotBlank() }
?: profile?.name?.takeIf { it.isNotBlank() }
?: displayMembers[i].short()
}

View File

@@ -4,12 +4,15 @@ import io.ktor.client.HttpClient
import io.ktor.client.call.body
import io.ktor.client.request.get
import io.ktor.client.statement.HttpResponse
import kotlinx.coroutines.Job
import kotlinx.coroutines.delay
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.channels.Channel
import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.asSharedFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
import kotlinx.coroutines.supervisorScope
import rust.nostr.sdk.AckPolicy
@@ -56,7 +59,6 @@ import rust.nostr.sdk.nip17ExtractRelayList
import rust.nostr.sdk.nip59MakeGiftWrapAsync
import kotlin.coroutines.cancellation.CancellationException
import kotlin.time.Duration
import kotlin.time.Duration.Companion.milliseconds
object NostrManager {
val instance = Nostr()
@@ -95,18 +97,20 @@ class Nostr {
private val _contactListUpdates = MutableSharedFlow<List<PublicKey>>(extraBufferCapacity = 100)
val contactListUpdates = _contactListUpdates.asSharedFlow()
private val _subscriptionClosed = MutableSharedFlow<Unit>(extraBufferCapacity = 10)
val subscriptionClosed = _subscriptionClosed.asSharedFlow()
private val _messageSyncState = MutableStateFlow(MessageSyncState())
val messageSyncState = _messageSyncState.asStateFlow()
suspend fun emitNewEvent(event: UnsignedEvent) = _newEvents.emit(event)
suspend fun emitNewEvent(event: UnsignedEvent) {
_newEvents.emit(event)
}
suspend fun emitSubscriptionClosed() = _subscriptionClosed.emit(Unit)
suspend fun emitMetadataUpdate(pubkey: PublicKey, metadata: Metadata) =
suspend fun emitMetadataUpdate(pubkey: PublicKey, metadata: Metadata) {
_metadataUpdates.emit(pubkey to metadata)
}
suspend fun emitContactListUpdate(contacts: List<PublicKey>) =
suspend fun emitContactListUpdate(contacts: List<PublicKey>) {
_contactListUpdates.emit(contacts)
}
suspend fun init(
dbPath: String,
@@ -188,6 +192,14 @@ class Nostr {
}
}
suspend fun prune() {
try {
client?.database()?.wipe()
} catch (e: Exception) {
throw IllegalStateException("Failed to prune database: ${e.message}", e)
}
}
suspend fun setSigner(new: AsyncNostrSigner) {
try {
signer.switch(new)
@@ -258,17 +270,39 @@ class Nostr {
}
}
@OptIn(ExperimentalCoroutinesApi::class)
suspend fun handleNotifications(
onMetadataUpdate: (PublicKey, Metadata) -> Unit,
onContactListUpdate: (List<PublicKey>) -> Unit,
onNewMessage: (UnsignedEvent) -> Unit,
onSubscriptionClose: () -> Unit,
) = supervisorScope {
val now = Timestamp.now()
val processedEvent = mutableSetOf<EventId>()
val notifications = client?.notifications() ?: return@supervisorScope
var eoseTrackerJob: Job? = null
val giftWrapQueue = Channel<Event>(Channel.UNLIMITED)
var processedCount = 0
var eoseReceived = false
launch(Dispatchers.Default) {
for (event in giftWrapQueue) {
val rumor = extractRumor(event) ?: continue
processedCount++
// Trigger new message notification
if (rumor.createdAt().asSecs() >= now.asSecs()) {
onNewMessage(rumor)
}
// Update sync state
_messageSyncState.update {
it.copy(
processedCount = processedCount,
isSyncing = !eoseReceived || !giftWrapQueue.isEmpty
)
}
}
}
while (true) {
val notification = notifications.next() ?: continue
@@ -285,7 +319,8 @@ class Nostr {
if (processedEvent.contains(event.id())) continue
processedEvent.add(event.id())
if (event.kind().asStd()?.equals(KindStandard.METADATA) == true) {
when (event.kind().asStd()) {
KindStandard.METADATA -> {
try {
val metadata = Metadata.fromJson(event.content())
onMetadataUpdate(event.author(), metadata)
@@ -294,7 +329,7 @@ class Nostr {
}
}
if (event.kind().asStd()?.equals(KindStandard.CONTACT_LIST) == true) {
KindStandard.CONTACT_LIST -> {
if (isSignedByUser(event = event)) {
val pubkeys = event.tags().publicKeys()
// Get mutual contacts
@@ -304,40 +339,27 @@ class Nostr {
}
}
if (event.kind().asStd()?.equals(KindStandard.INBOX_RELAYS) == true) {
KindStandard.INBOX_RELAYS -> {
// Get all gift wrap events for the current user
if (isSignedByUser(event = event)) {
getUserMessages(msgRelayList = event)
}
}
if (event.kind().asStd()?.equals(KindStandard.GIFT_WRAP) == true) {
val rumor = extractRumor(event)
// Logic to notify UI after processing
// Cancel previous tracker if it exists
eoseTrackerJob?.cancel()
// Start a new tracker
eoseTrackerJob = launch {
delay(10000.milliseconds) // Wait for 10 seconds
onSubscriptionClose()
KindStandard.GIFT_WRAP -> {
giftWrapQueue.send(event)
}
// Handle new message
rumor?.createdAt()?.asSecs()?.let {
if (it >= now.asSecs()) {
onNewMessage(rumor)
}
}
else -> {}
}
}
is RelayMessageEnum.EndOfStoredEvents -> {
val subscriptionId = message.subscriptionId
if (subscriptionId == "gift-wraps") {
onSubscriptionClose()
if (message.subscriptionId == "gift-wraps") {
eoseReceived = true
if (giftWrapQueue.isEmpty) {
_messageSyncState.update { it.copy(isSyncing = false) }
}
}
}
@@ -451,7 +473,7 @@ class Nostr {
// Decrypt the rumor
val rumor = signer.nip44DecryptAsync(sealEvent.author(), sealEvent.content())
val unsignedEvent = UnsignedEvent.fromJson(rumor)
val unsignedEvent = UnsignedEvent.fromJson(rumor).ensureId()
// Ensure the rumor author matches the seal
if (unsignedEvent.author() != sealEvent.author()) {
@@ -791,7 +813,7 @@ class Nostr {
// Merge the events
return events
?.toVec()
?.map { UnsignedEvent.fromJson(it.content()) }
?.map { UnsignedEvent.fromJson(it.content()).ensureId() }
// Filter out events without public keys (receivers)
?.filter { it.tags().publicKeys().isNotEmpty() }
?.sortedByDescending { it.createdAt().asSecs() } ?: emptyList()
@@ -1028,3 +1050,8 @@ class Nostr {
}
}
}
data class MessageSyncState(
val processedCount: Int = 0,
val isSyncing: Boolean = false
)

View File

@@ -13,10 +13,13 @@ import kotlinx.coroutines.coroutineScope
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asSharedFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.receiveAsFlow
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
@@ -79,9 +82,14 @@ class NostrViewModel(
val errorEvents = _errorEvents.receiveAsFlow()
private val _metadataStore = mutableMapOf<PublicKey, MutableStateFlow<Metadata?>>()
private val metadataRequestChannel = Channel<PublicKey>(Channel.UNLIMITED)
private val seenPublicKeys = mutableSetOf<PublicKey>()
val isSyncing = nostr.messageSyncState.map { it.isSyncing }
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), false)
init {
// Skip the splash screen if a user is already logged in
if (nostr.signer.currentUser != null) {
@@ -108,7 +116,6 @@ class NostrViewModel(
viewModelScope.launch {
lifecycle.repeatOnLifecycle(Lifecycle.State.STARTED) {
coroutineScope {
launch { refreshChatRooms() }
launch { runObserver() }
launch { runMetadataBatching() }
}
@@ -148,6 +155,21 @@ class NostrViewModel(
}
private suspend fun runObserver() = coroutineScope {
// Observe message sync progress
launch {
nostr.messageSyncState.collect { state ->
// When at least some messages are processed, allow UI to show the list
if (state.processedCount > 0) {
_isPartialProcessedGiftWrap.value = true
}
// Refresh UI every 10 messages OR when sync is fully done
if (state.processedCount % 10 == 0 || !state.isSyncing) {
refreshChatRooms()
}
}
}
// Observe new messages
launch {
nostr.newEvents.collect { event ->
@@ -181,14 +203,6 @@ class NostrViewModel(
updateMetadata(pubkey, metadata)
}
}
// Observes subscription close
launch {
nostr.subscriptionClosed.collect {
getChatRooms()
_isPartialProcessedGiftWrap.value = true
}
}
}
private suspend fun runMetadataBatching() = coroutineScope {
@@ -325,11 +339,33 @@ class NostrViewModel(
fun logout() {
viewModelScope.launch {
secretStore.clear("user_signer")
try {
_isBusy.value = true
// Reset the nostr signer and prune the database
nostr.signer.switch(Keys.generate())
nostr.prune()
} catch (e: Exception) {
showError("Logout encountered an error: ${e.message}")
} finally {
// Clear credentials from persistent storage
secretStore.clear("user_signer")
// Reset all UI states
resetInternalState()
_isBusy.value = false
_signerRequired.value = true
}
}
}
private fun resetInternalState() {
_chatRooms.value = emptySet()
_contactList.value = emptySet()
_isPartialProcessedGiftWrap.value = false
_isRelayListEmpty.value = false
_isNotificationBannerDismissed.value = false
}
fun dismissNotificationBanner() {
viewModelScope.launch {

70
zapstore.yaml Normal file
View File

@@ -0,0 +1,70 @@
# ═══════════════════════════════════════════════════════════════════
# SOURCE CONFIGURATION
# ═══════════════════════════════════════════════════════════════════
repository: https://git.reya.su/reya/coop-mobile
release_source:
url: https://git.reya.su/reya/coop-mobile
type: gitea
# ═══════════════════════════════════════════════════════════════════
# APP METADATA
# ═══════════════════════════════════════════════════════════════════
name: Coop
summary: Chat and Co-op Privately
license: GPLv3
website: https://reya.su/coop
description: |
Coop is a simple, cozy chat app built for Nostr. Its designed for folks who want to stay connected without sacrificing their privacy or control over their data.
**Features**:
- Private & Secure: Enjoy end-to-end encrypted messaging that keeps your private chats truly private.
- Friendly Socializing: Connect with friends across the open Nostr network in a clean, easy-to-use interface.
- No Identity Required: Get started without a phone number. Stay anonymous, use a pseudonym, or even your real name.
tags:
- messaging
- privacy
- material3
- nostr
# ═══════════════════════════════════════════════════════════════════
# MEDIA
# ═══════════════════════════════════════════════════════════════════
icon: ./assets/icon.png
images:
- ./assets/screenshot1.png
- ./assets/screenshot2.png
- ./assets/screenshot3.png
- ./assets/screenshot4.png
- ./assets/screenshot5.png
# ═══════════════════════════════════════════════════════════════════
# RELEASE CONFIGURATION
# ═══════════════════════════════════════════════════════════════════
release_notes: ./CHANGELOG.md
# ═══════════════════════════════════════════════════════════════════
# NOSTR-SPECIFIC
# ═══════════════════════════════════════════════════════════════════
supported_nips:
- "01"
- "17"
- "46"
- "55"
- "59"
min_allowed_version_code: 100
# ═══════════════════════════════════════════════════════════════════
# METADATA SOURCES
# ═══════════════════════════════════════════════════════════════════
metadata_sources:
- playstore