From ff383a7c6a2a88e5804b40b09bd4078716a6b973 Mon Sep 17 00:00:00 2001 From: Ren Amamiya Date: Tue, 2 Jun 2026 02:19:13 +0000 Subject: [PATCH] feat: add crash screen (#10) Reviewed-on: https://git.reya.su/reya/coop-mobile/pulls/10 --- .../src/androidMain/AndroidManifest.xml | 6 + .../kotlin/su/reya/coop/CrashActivity.kt | 108 ++++++++++++++++++ .../kotlin/su/reya/coop/MainActivity.kt | 21 ++++ .../su/reya/coop/NostrForegroundService.kt | 25 +++- gradle/libs.versions.toml | 4 +- .../kotlin/su/reya/coop/CoopWebSocket.kt | 75 ------------ .../commonMain/kotlin/su/reya/coop/Nostr.kt | 8 +- 7 files changed, 159 insertions(+), 88 deletions(-) create mode 100644 composeApp/src/androidMain/kotlin/su/reya/coop/CrashActivity.kt delete mode 100644 shared/src/commonMain/kotlin/su/reya/coop/CoopWebSocket.kt diff --git a/composeApp/src/androidMain/AndroidManifest.xml b/composeApp/src/androidMain/AndroidManifest.xml index 89f75cb..c4d8237 100644 --- a/composeApp/src/androidMain/AndroidManifest.xml +++ b/composeApp/src/androidMain/AndroidManifest.xml @@ -19,6 +19,12 @@ android:supportsRtl="true" android:theme="@android:style/Theme.Material.Light.NoActionBar"> + + + Surface( + modifier = Modifier + .fillMaxSize() + .padding(innerPadding), + color = MaterialTheme.colorScheme.background + ) { + Column( + modifier = Modifier + .fillMaxSize() + .padding(16.dp), + verticalArrangement = Arrangement.spacedBy(16.dp) + ) { + Column { + Text( + "App Crashed", + style = MaterialTheme.typography.titleMediumEmphasized, + color = MaterialTheme.colorScheme.error + ) + Text( + "Please copy the log below and send it to the developer.", + style = MaterialTheme.typography.bodySmall + ) + } + Box( + modifier = Modifier + .weight(1f) + .fillMaxWidth() + .verticalScroll(rememberScrollState()) + ) { + Text( + text = errorText, + fontFamily = FontFamily.Monospace, + fontSize = 12.sp + ) + } + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(8.dp) + ) { + FilledTonalButton( + onClick = { + finish(); + exitProcess(0) + }, + modifier = Modifier.weight(1f) + ) { + Text("Exit") + } + Button( + onClick = { + val clipboard = + getSystemService(CLIPBOARD_SERVICE) as ClipboardManager + val data = ClipData.newPlainText("Crash Log", errorText) + clipboard.setPrimaryClip(data) + }, + modifier = Modifier.weight(1f) + ) { + Text("Copy") + } + } + } + } + } + ) + } + } + } +} \ No newline at end of file diff --git a/composeApp/src/androidMain/kotlin/su/reya/coop/MainActivity.kt b/composeApp/src/androidMain/kotlin/su/reya/coop/MainActivity.kt index 0f7dffc..58a9af1 100644 --- a/composeApp/src/androidMain/kotlin/su/reya/coop/MainActivity.kt +++ b/composeApp/src/androidMain/kotlin/su/reya/coop/MainActivity.kt @@ -11,6 +11,7 @@ import androidx.core.splashscreen.SplashScreen.Companion.installSplashScreen import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModelProvider import su.reya.coop.coop.storage.SecretStore +import kotlin.system.exitProcess class MainActivity : ComponentActivity() { private val viewModel: NostrViewModel by viewModels { @@ -23,6 +24,26 @@ class MainActivity : ComponentActivity() { } override fun onCreate(savedInstanceState: Bundle?) { + Thread.setDefaultUncaughtExceptionHandler { thread, throwable -> + throwable.printStackTrace() + android.util.Log.e( + "CoopCrash", + "Uncaught exception in thread ${thread.name}", + throwable + ) + + // Start the Crash Activity + val intent = Intent(this, CrashActivity::class.java).apply { + putExtra("error", throwable.stackTraceToString()) + addFlags(Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TASK) + } + startActivity(intent) + + // Exit + android.os.Process.killProcess(android.os.Process.myPid()) + exitProcess(1) + } + val splashScreen = installSplashScreen() enableEdgeToEdge() diff --git a/composeApp/src/androidMain/kotlin/su/reya/coop/NostrForegroundService.kt b/composeApp/src/androidMain/kotlin/su/reya/coop/NostrForegroundService.kt index 41e99f8..03a45ee 100644 --- a/composeApp/src/androidMain/kotlin/su/reya/coop/NostrForegroundService.kt +++ b/composeApp/src/androidMain/kotlin/su/reya/coop/NostrForegroundService.kt @@ -6,8 +6,10 @@ import android.app.NotificationManager import android.app.PendingIntent import android.app.Service import android.content.Intent +import android.content.pm.ServiceInfo import android.os.Build import android.os.IBinder +import android.util.Log import androidx.annotation.RequiresApi import androidx.core.app.NotificationCompat import androidx.core.net.toUri @@ -22,7 +24,7 @@ import java.io.File class NostrForegroundService : Service() { private val serviceScope = CoroutineScope(Dispatchers.IO + SupervisorJob()) - private val nostr = NostrManager.instance + private val nostr by lazy { NostrManager.instance } override fun onBind(intent: Intent?): IBinder? = null @@ -30,18 +32,30 @@ class NostrForegroundService : Service() { return ProcessLifecycleOwner.get().lifecycle.currentState.isAtLeast(Lifecycle.State.STARTED) } - override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int { + override fun onCreate() { + super.onCreate() + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { createNotificationChannel() } - val notification = createNotification() - startForeground(1, notification) + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE) { + startForeground(1, notification, ServiceInfo.FOREGROUND_SERVICE_TYPE_DATA_SYNC) + } else { + startForeground(1, notification) + } + } + + override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int { serviceScope.launch { try { + Log.d("Coop", "Starting Nostr in background") + + // Create a database directory val dbDir = File(filesDir, "nostr") dbDir.mkdirs() + // Initialize Nostr client nostr.init(dbDir.absolutePath) // Connect to bootstrap relays @@ -67,10 +81,9 @@ class NostrForegroundService : Service() { } ) } catch (e: Exception) { - println("Failed to start Nostr in background: ${e.message}") + Log.e("Coop", "Failed to start Nostr", e) } } - return START_STICKY } diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index a11816a..177bed6 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -1,8 +1,8 @@ [versions] agp = "9.2.1" -android-compileSdk = "36" +android-compileSdk = "37" android-minSdk = "24" -android-targetSdk = "36" +android-targetSdk = "37" androidx-activity = "1.13.0" androidx-appcompat = "1.7.1" androidx-core = "1.18.0" diff --git a/shared/src/commonMain/kotlin/su/reya/coop/CoopWebSocket.kt b/shared/src/commonMain/kotlin/su/reya/coop/CoopWebSocket.kt deleted file mode 100644 index ff2a658..0000000 --- a/shared/src/commonMain/kotlin/su/reya/coop/CoopWebSocket.kt +++ /dev/null @@ -1,75 +0,0 @@ -package su.reya.coop - -import io.ktor.client.HttpClient -import io.ktor.client.plugins.websocket.DefaultClientWebSocketSession -import io.ktor.client.plugins.websocket.webSocketSession -import io.ktor.client.request.url -import io.ktor.websocket.Frame -import io.ktor.websocket.close -import io.ktor.websocket.readBytes -import io.ktor.websocket.readText -import kotlinx.coroutines.cancel -import kotlinx.coroutines.channels.ClosedReceiveChannelException -import rust.nostr.sdk.ConnectionMode -import rust.nostr.sdk.CustomWebSocketTransport -import rust.nostr.sdk.WebSocketAdapter -import rust.nostr.sdk.WebSocketAdapterWrapper -import rust.nostr.sdk.WebSocketMessage - -class KtorWebSocketAdapter( - private val client: HttpClient, - private val session: DefaultClientWebSocketSession -) : WebSocketAdapter { - - override suspend fun send(msg: WebSocketMessage) { - try { - when (msg) { - is WebSocketMessage.Text -> session.send(Frame.Text(msg.text)) - is WebSocketMessage.Binary -> session.send(Frame.Binary(true, msg.bytes)) - is WebSocketMessage.Ping -> session.send(Frame.Ping(msg.bytes)) - is WebSocketMessage.Pong -> session.send(Frame.Pong(msg.bytes)) - else -> {} - } - } catch (e: Exception) { - println("Attempted to send on a closed WebSocket: ${e.message}") - throw e - } - } - - override suspend fun recv(): WebSocketMessage? { - return try { - when (val frame = session.incoming.receive()) { - is Frame.Text -> WebSocketMessage.Text(frame.readText()) - is Frame.Binary -> WebSocketMessage.Binary(frame.readBytes()) - is Frame.Ping -> WebSocketMessage.Ping(frame.readBytes()) - is Frame.Pong -> WebSocketMessage.Pong(frame.readBytes()) - else -> null - } - } catch (e: ClosedReceiveChannelException) { - null - } catch (e: Exception) { - throw e - } - } - - override suspend fun closeConnection() { - session.cancel() - session.close() - } -} - -class CoopWebSocketClient(private val httpClient: HttpClient) : CustomWebSocketTransport { - override fun supportPing(): Boolean = false - - override suspend fun connect(url: String, mode: ConnectionMode): WebSocketAdapterWrapper { - try { - val session = httpClient.webSocketSession { - url(url) - } - val adapter = KtorWebSocketAdapter(httpClient, session) - return WebSocketAdapterWrapper(adapter) - } catch (e: Exception) { - throw e - } - } -} diff --git a/shared/src/commonMain/kotlin/su/reya/coop/Nostr.kt b/shared/src/commonMain/kotlin/su/reya/coop/Nostr.kt index e098895..8bccc85 100644 --- a/shared/src/commonMain/kotlin/su/reya/coop/Nostr.kt +++ b/shared/src/commonMain/kotlin/su/reya/coop/Nostr.kt @@ -2,7 +2,6 @@ package su.reya.coop import io.ktor.client.HttpClient import io.ktor.client.call.body -import io.ktor.client.plugins.websocket.WebSockets import io.ktor.client.request.get import io.ktor.client.statement.HttpResponse import kotlinx.coroutines.Job @@ -106,17 +105,16 @@ class Nostr { // Initialize the logger for nostr client initLogger(LogLevel.DEBUG) + // Initialize the database and gossip instance val lmdb = NostrDatabase.lmdb(dbPath) val gossip = NostrGossip.inMemory() + + // Set the idle timeout for relays val idleTimeout = Duration.parse("5m") - val httpClient = HttpClient { - install(WebSockets) - } client = ClientBuilder() .signer(signer) - .websocketTransport(CoopWebSocketClient(httpClient)) .database(lmdb) .gossip(gossip) .gossipConfig(