feat: add crash screen #10
@@ -19,6 +19,12 @@
|
|||||||
android:supportsRtl="true"
|
android:supportsRtl="true"
|
||||||
android:theme="@android:style/Theme.Material.Light.NoActionBar">
|
android:theme="@android:style/Theme.Material.Light.NoActionBar">
|
||||||
|
|
||||||
|
<activity
|
||||||
|
android:name=".CrashActivity"
|
||||||
|
android:exported="false"
|
||||||
|
android:process=":crash_handler"
|
||||||
|
android:theme="@android:style/Theme.Material.Light.NoActionBar" />
|
||||||
|
|
||||||
<activity
|
<activity
|
||||||
android:name=".MainActivity"
|
android:name=".MainActivity"
|
||||||
android:exported="true"
|
android:exported="true"
|
||||||
|
|||||||
108
composeApp/src/androidMain/kotlin/su/reya/coop/CrashActivity.kt
Normal file
108
composeApp/src/androidMain/kotlin/su/reya/coop/CrashActivity.kt
Normal file
@@ -0,0 +1,108 @@
|
|||||||
|
package su.reya.coop
|
||||||
|
|
||||||
|
import android.content.ClipData
|
||||||
|
import android.content.ClipboardManager
|
||||||
|
import android.os.Bundle
|
||||||
|
import androidx.activity.ComponentActivity
|
||||||
|
import androidx.activity.compose.setContent
|
||||||
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
|
import androidx.compose.foundation.layout.Box
|
||||||
|
import androidx.compose.foundation.layout.Column
|
||||||
|
import androidx.compose.foundation.layout.Row
|
||||||
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.foundation.rememberScrollState
|
||||||
|
import androidx.compose.foundation.verticalScroll
|
||||||
|
import androidx.compose.material3.Button
|
||||||
|
import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi
|
||||||
|
import androidx.compose.material3.FilledTonalButton
|
||||||
|
import androidx.compose.material3.MaterialExpressiveTheme
|
||||||
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.material3.Scaffold
|
||||||
|
import androidx.compose.material3.Surface
|
||||||
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.text.font.FontFamily
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import androidx.compose.ui.unit.sp
|
||||||
|
import kotlin.system.exitProcess
|
||||||
|
|
||||||
|
class CrashActivity : ComponentActivity() {
|
||||||
|
@OptIn(ExperimentalMaterial3ExpressiveApi::class)
|
||||||
|
override fun onCreate(savedInstanceState: Bundle?) {
|
||||||
|
super.onCreate(savedInstanceState)
|
||||||
|
val errorText = intent.getStringExtra("error") ?: "Unknown error"
|
||||||
|
|
||||||
|
setContent {
|
||||||
|
MaterialExpressiveTheme {
|
||||||
|
Scaffold(
|
||||||
|
content = { innerPadding ->
|
||||||
|
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")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -11,6 +11,7 @@ import androidx.core.splashscreen.SplashScreen.Companion.installSplashScreen
|
|||||||
import androidx.lifecycle.ViewModel
|
import androidx.lifecycle.ViewModel
|
||||||
import androidx.lifecycle.ViewModelProvider
|
import androidx.lifecycle.ViewModelProvider
|
||||||
import su.reya.coop.coop.storage.SecretStore
|
import su.reya.coop.coop.storage.SecretStore
|
||||||
|
import kotlin.system.exitProcess
|
||||||
|
|
||||||
class MainActivity : ComponentActivity() {
|
class MainActivity : ComponentActivity() {
|
||||||
private val viewModel: NostrViewModel by viewModels {
|
private val viewModel: NostrViewModel by viewModels {
|
||||||
@@ -23,6 +24,26 @@ class MainActivity : ComponentActivity() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
override fun onCreate(savedInstanceState: Bundle?) {
|
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()
|
val splashScreen = installSplashScreen()
|
||||||
enableEdgeToEdge()
|
enableEdgeToEdge()
|
||||||
|
|
||||||
|
|||||||
@@ -6,8 +6,10 @@ import android.app.NotificationManager
|
|||||||
import android.app.PendingIntent
|
import android.app.PendingIntent
|
||||||
import android.app.Service
|
import android.app.Service
|
||||||
import android.content.Intent
|
import android.content.Intent
|
||||||
|
import android.content.pm.ServiceInfo
|
||||||
import android.os.Build
|
import android.os.Build
|
||||||
import android.os.IBinder
|
import android.os.IBinder
|
||||||
|
import android.util.Log
|
||||||
import androidx.annotation.RequiresApi
|
import androidx.annotation.RequiresApi
|
||||||
import androidx.core.app.NotificationCompat
|
import androidx.core.app.NotificationCompat
|
||||||
import androidx.core.net.toUri
|
import androidx.core.net.toUri
|
||||||
@@ -22,7 +24,7 @@ import java.io.File
|
|||||||
|
|
||||||
class NostrForegroundService : Service() {
|
class NostrForegroundService : Service() {
|
||||||
private val serviceScope = CoroutineScope(Dispatchers.IO + SupervisorJob())
|
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
|
override fun onBind(intent: Intent?): IBinder? = null
|
||||||
|
|
||||||
@@ -30,18 +32,30 @@ class NostrForegroundService : Service() {
|
|||||||
return ProcessLifecycleOwner.get().lifecycle.currentState.isAtLeast(Lifecycle.State.STARTED)
|
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) {
|
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
|
||||||
createNotificationChannel()
|
createNotificationChannel()
|
||||||
}
|
}
|
||||||
|
|
||||||
val notification = createNotification()
|
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 {
|
serviceScope.launch {
|
||||||
try {
|
try {
|
||||||
|
Log.d("Coop", "Starting Nostr in background")
|
||||||
|
|
||||||
|
// Create a database directory
|
||||||
val dbDir = File(filesDir, "nostr")
|
val dbDir = File(filesDir, "nostr")
|
||||||
dbDir.mkdirs()
|
dbDir.mkdirs()
|
||||||
|
|
||||||
// Initialize Nostr client
|
// Initialize Nostr client
|
||||||
nostr.init(dbDir.absolutePath)
|
nostr.init(dbDir.absolutePath)
|
||||||
// Connect to bootstrap relays
|
// Connect to bootstrap relays
|
||||||
@@ -67,10 +81,9 @@ class NostrForegroundService : Service() {
|
|||||||
}
|
}
|
||||||
)
|
)
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
println("Failed to start Nostr in background: ${e.message}")
|
Log.e("Coop", "Failed to start Nostr", e)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return START_STICKY
|
return START_STICKY
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
[versions]
|
[versions]
|
||||||
agp = "9.2.1"
|
agp = "9.2.1"
|
||||||
android-compileSdk = "36"
|
android-compileSdk = "37"
|
||||||
android-minSdk = "24"
|
android-minSdk = "24"
|
||||||
android-targetSdk = "36"
|
android-targetSdk = "37"
|
||||||
androidx-activity = "1.13.0"
|
androidx-activity = "1.13.0"
|
||||||
androidx-appcompat = "1.7.1"
|
androidx-appcompat = "1.7.1"
|
||||||
androidx-core = "1.18.0"
|
androidx-core = "1.18.0"
|
||||||
|
|||||||
@@ -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
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -2,7 +2,6 @@ package su.reya.coop
|
|||||||
|
|
||||||
import io.ktor.client.HttpClient
|
import io.ktor.client.HttpClient
|
||||||
import io.ktor.client.call.body
|
import io.ktor.client.call.body
|
||||||
import io.ktor.client.plugins.websocket.WebSockets
|
|
||||||
import io.ktor.client.request.get
|
import io.ktor.client.request.get
|
||||||
import io.ktor.client.statement.HttpResponse
|
import io.ktor.client.statement.HttpResponse
|
||||||
import kotlinx.coroutines.Job
|
import kotlinx.coroutines.Job
|
||||||
@@ -106,17 +105,16 @@ class Nostr {
|
|||||||
// Initialize the logger for nostr client
|
// Initialize the logger for nostr client
|
||||||
initLogger(LogLevel.DEBUG)
|
initLogger(LogLevel.DEBUG)
|
||||||
|
|
||||||
|
// Initialize the database and gossip instance
|
||||||
val lmdb = NostrDatabase.lmdb(dbPath)
|
val lmdb = NostrDatabase.lmdb(dbPath)
|
||||||
val gossip = NostrGossip.inMemory()
|
val gossip = NostrGossip.inMemory()
|
||||||
|
|
||||||
|
// Set the idle timeout for relays
|
||||||
val idleTimeout = Duration.parse("5m")
|
val idleTimeout = Duration.parse("5m")
|
||||||
val httpClient = HttpClient {
|
|
||||||
install(WebSockets)
|
|
||||||
}
|
|
||||||
|
|
||||||
client =
|
client =
|
||||||
ClientBuilder()
|
ClientBuilder()
|
||||||
.signer(signer)
|
.signer(signer)
|
||||||
.websocketTransport(CoopWebSocketClient(httpClient))
|
|
||||||
.database(lmdb)
|
.database(lmdb)
|
||||||
.gossip(gossip)
|
.gossip(gossip)
|
||||||
.gossipConfig(
|
.gossipConfig(
|
||||||
|
|||||||
Reference in New Issue
Block a user