diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml
index 27dd53f..ef655b7 100644
--- a/android/app/src/main/AndroidManifest.xml
+++ b/android/app/src/main/AndroidManifest.xml
@@ -4,9 +4,6 @@
-
-
-
MessageStreamService.start(this@MainActivity)
+ AuthState.Authenticated -> {
+ MessageStreamService.start(this@MainActivity)
+ chatViewModel.loadAccessibleChannels()
+ }
AuthState.Unauthenticated -> MessageStreamService.stop(this@MainActivity)
AuthState.AwaitingTotp -> {}
}
}
+ LaunchedEffect(Unit) {
+ chatViewModel.onUnauthorized = {
+ authViewModel.logout()
+ chatViewModel.clearChat()
+ }
+ }
+
LaunchedEffect(Unit) {
intent.getIntExtra("channel_id", -1).takeIf { it != -1 }?.let {
chatViewModel.switchChannel(it.toLong())
diff --git a/android/app/src/main/java/dev/zxq5/chatapp/android/api/SettingsClient.kt b/android/app/src/main/java/dev/zxq5/chatapp/android/api/SettingsClient.kt
index 2265ca2..03d7eb7 100644
--- a/android/app/src/main/java/dev/zxq5/chatapp/android/api/SettingsClient.kt
+++ b/android/app/src/main/java/dev/zxq5/chatapp/android/api/SettingsClient.kt
@@ -4,6 +4,7 @@ import android.util.Log
import dev.zxq5.chatapp.android.BuildConfig.BASE_URL
import dev.zxq5.chatapp.android.api.model.AccountDeleteRequest
import dev.zxq5.chatapp.android.api.model.DisplayNameRequest
+import dev.zxq5.chatapp.android.api.model.InviteRequest
import dev.zxq5.chatapp.android.api.model.PasswordChangeRequest
import dev.zxq5.chatapp.android.api.model.QrResponse
import dev.zxq5.chatapp.android.api.model.TOTPSixDigitCode
@@ -45,6 +46,22 @@ class SettingsClient(private val token: String) {
}
}
+ suspend fun createInvite(request: InviteRequest): ApiResult {
+ return try {
+ val response = http.post("${BASE_URL}/api/invite") {
+ contentType(ContentType.Application.Json)
+ setBody(request)
+ }
+ if (response.status.isSuccess()) {
+ ApiResult.Success(response.body())
+ } else {
+ ApiResult.HttpError(response.status.value, "Failed to create invite")
+ }
+ } catch (e: Exception) {
+ ApiResult.NetworkError(e.localizedMessage ?: "Network error")
+ }
+ }
+
suspend fun getTotpQr(password: String): ApiResult {
return try {
val response = http.post("${BASE_URL}/api/totp.jpg") {
diff --git a/android/app/src/main/java/dev/zxq5/chatapp/android/api/model/InviteRequest.kt b/android/app/src/main/java/dev/zxq5/chatapp/android/api/model/InviteRequest.kt
new file mode 100644
index 0000000..0fbc66e
--- /dev/null
+++ b/android/app/src/main/java/dev/zxq5/chatapp/android/api/model/InviteRequest.kt
@@ -0,0 +1,13 @@
+package dev.zxq5.chatapp.android.api.model
+
+import kotlinx.serialization.Serializable
+import kotlin.time.ExperimentalTime
+import kotlin.time.Instant
+
+@Serializable
+data class InviteRequest @OptIn(ExperimentalTime::class) constructor(
+ val name: String,
+ val max_uses: Int,
+ val expiry_date: Instant,
+ val start_date: Instant
+)
diff --git a/android/app/src/main/java/dev/zxq5/chatapp/android/model/LoginState.kt b/android/app/src/main/java/dev/zxq5/chatapp/android/api/model/LoginState.kt
similarity index 84%
rename from android/app/src/main/java/dev/zxq5/chatapp/android/model/LoginState.kt
rename to android/app/src/main/java/dev/zxq5/chatapp/android/api/model/LoginState.kt
index 32f53d6..87ff30a 100644
--- a/android/app/src/main/java/dev/zxq5/chatapp/android/model/LoginState.kt
+++ b/android/app/src/main/java/dev/zxq5/chatapp/android/api/model/LoginState.kt
@@ -1,4 +1,4 @@
-package dev.zxq5.chatapp.android.model
+package dev.zxq5.chatapp.android.api.model
sealed class LoginState {
object Idle : LoginState()
diff --git a/android/app/src/main/java/dev/zxq5/chatapp/android/core/data/TokenStore.kt b/android/app/src/main/java/dev/zxq5/chatapp/android/core/data/TokenStore.kt
index a7b5a88..78dab1c 100644
--- a/android/app/src/main/java/dev/zxq5/chatapp/android/core/data/TokenStore.kt
+++ b/android/app/src/main/java/dev/zxq5/chatapp/android/core/data/TokenStore.kt
@@ -3,10 +3,12 @@ package dev.zxq5.chatapp.android.core.data
import android.content.Context
import android.content.SharedPreferences
import android.util.Base64
+import android.util.Log
import androidx.core.content.edit
import androidx.security.crypto.EncryptedSharedPreferences
import androidx.security.crypto.MasterKey
import org.json.JSONObject
+import java.time.Instant
private const val KEY = "auth_token"
private const val TWOFA_KEY = "twofa_enabled"
@@ -27,11 +29,37 @@ class TokenStore(appContext: Context) {
)
}
- fun save(token: String) =
- prefs().edit { putString(KEY, token) }
+ fun save(token: String) {
+ Log.d("TokenStore", "Saving token: $token")
+
+ prefs().edit { putString(KEY, token) }
+ }
+
+ fun get(): String? {
+ val ret = prefs().getString(KEY, null)
+ Log.d("TokenStore", "Retrieved token: $ret")
+ return ret
+ }
+
+ fun isExpired(): Boolean {
+ val token = get() ?: return true
+ return try {
+ val payload = token.split(".")[1]
+ val padded = payload + "==".take((4 - payload.length % 4) % 4)
+ val jsonString = String(Base64.decode(padded, Base64.URL_SAFE))
+ val json = JSONObject(jsonString)
+ if (json.has("exp")) {
+ val exp = json.getLong("exp")
+ val now = Instant.now().epochSecond
+ now >= exp
+ } else {
+ false // If no exp claim, assume not expired or handle differently
+ }
+ } catch (e: Exception) {
+ true // If we can't parse it, treat it as expired
+ }
+ }
- fun get(): String? =
- prefs().getString(KEY, null)
fun save2faEnabled( enabled: Boolean) =
prefs().edit { putBoolean(TWOFA_KEY, enabled) }
diff --git a/android/app/src/main/java/dev/zxq5/chatapp/android/core/service/MessageStreamService.kt b/android/app/src/main/java/dev/zxq5/chatapp/android/core/service/MessageStreamService.kt
index 49b41e8..829057e 100644
--- a/android/app/src/main/java/dev/zxq5/chatapp/android/core/service/MessageStreamService.kt
+++ b/android/app/src/main/java/dev/zxq5/chatapp/android/core/service/MessageStreamService.kt
@@ -3,7 +3,6 @@ package dev.zxq5.chatapp.android.core.service
import android.app.Service
import android.content.Context
import android.content.Intent
-import android.os.Build
import android.os.IBinder
import android.util.Log
import dev.zxq5.chatapp.android.ChatApplication
@@ -15,21 +14,17 @@ import kotlinx.coroutines.cancel
import kotlinx.coroutines.flow.catch
import kotlinx.coroutines.launch
-// core/service/MessageStreamService.kt
class MessageStreamService : Service() {
private val serviceScope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
private lateinit var notificationService: NotificationService
private lateinit var chatRepository: ChatRepository
- // which channel the user is currently looking at
- // set by the ViewModel when the user opens/closes a channel
var activeChannelId: Long? = null
set(value) {
field = value
Log.d("Service", "activeChannelId set to $value")
if (value != null) {
- // restart stream with new channel
currentStreamJob?.cancel()
observeMessages()
}
@@ -42,11 +37,9 @@ class MessageStreamService : Service() {
fun start(context: Context) {
val intent = Intent(context, MessageStreamService::class.java)
- if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
- context.startForegroundService(intent)
- } else {
- context.startService(intent)
- }
+ // Use startService to avoid the requirement for a persistent notification.
+ // This also prevents ForegroundServiceDidNotStartInTimeException.
+ context.startService(intent)
}
fun stop(context: Context) {
@@ -62,32 +55,25 @@ class MessageStreamService : Service() {
}
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
- startForeground(
- NotificationService.FOREGROUND_NOTIFICATION_ID,
- notificationService.buildForegroundNotification()
- )
observeMessages()
- return START_STICKY // restart if killed
+ return START_STICKY
}
private fun observeMessages() {
val channelId = activeChannelId ?: chatRepository.getLastActiveChannel()
- Log.d("Service", "observeMessages called, channelId=$channelId")
- if (channelId == null) {
- Log.d("Service", "No channel to observe, waiting for switchChannel")
- return
- }
+ if (channelId == null) return
- Log.d("Service", "Starting stream for channel $channelId")
currentStreamJob = serviceScope.launch {
chatRepository.messageStream(channelId)
.catch { e -> Log.e("Service", "Stream error", e) }
.collect { message ->
- if (!ChatApplication.AppState.isInForeground) { // no channel focused, always notify
+ // Only show notification when an event (new message) is received
+ // and the app is not in the foreground on this channel.
+ if (!ChatApplication.AppState.isInForeground || activeChannelId != channelId) {
notificationService.showMessageNotification(
- conversationId = activeChannelId.toString(),
+ conversationId = channelId.toString(),
senderName = message.display_name,
- messagePreview = message.text.take(80)
+ messagePreview = message.text
)
}
}
@@ -101,4 +87,4 @@ class MessageStreamService : Service() {
instance = null
serviceScope.cancel()
}
-}
\ No newline at end of file
+}
diff --git a/android/app/src/main/java/dev/zxq5/chatapp/android/core/service/NotificationService.kt b/android/app/src/main/java/dev/zxq5/chatapp/android/core/service/NotificationService.kt
index db0ac8b..6c83a46 100644
--- a/android/app/src/main/java/dev/zxq5/chatapp/android/core/service/NotificationService.kt
+++ b/android/app/src/main/java/dev/zxq5/chatapp/android/core/service/NotificationService.kt
@@ -15,51 +15,41 @@ class NotificationService(private val context: Context) {
companion object {
const val CHANNEL_ID = "messages"
- const val FOREGROUND_NOTIFICATION_ID = 1 // ← this needs to exist
+ const val SERVICE_CHANNEL_ID = "service"
+ const val FOREGROUND_NOTIFICATION_ID = 1
}
private val manager = context.getSystemService(NotificationManager::class.java)
- fun createChannels() {
- // channel for new message notifications
- val messageChannel = NotificationChannel(
- CHANNEL_ID,
- "Messages",
- NotificationManager.IMPORTANCE_HIGH
- ).apply {
- enableVibration(true)
+ fun createForegroundNotification(): Notification {
+ val intent = Intent(context, MainActivity::class.java).apply {
+ flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TASK
}
-
- // channel for the persistent foreground service notification
- // low importance so it doesn't make noise
- val serviceChannel = NotificationChannel(
- "service",
- "Background connection",
- NotificationManager.IMPORTANCE_LOW
+
+ val pendingIntent = PendingIntent.getActivity(
+ context,
+ 0,
+ intent,
+ PendingIntent.FLAG_IMMUTABLE
)
- val mgr = context.getSystemService(NotificationManager::class.java)
- mgr.createNotificationChannel(messageChannel)
- mgr.createNotificationChannel(serviceChannel)
- }
-
- fun buildForegroundNotification(): Notification {
- return NotificationCompat.Builder(context, "service")
+ return NotificationCompat.Builder(context, SERVICE_CHANNEL_ID)
.setSmallIcon(R.drawable.ic_notification)
- .setContentTitle("chatapp")
- .setContentText("Connected")
+ .setContentTitle("Chat App")
+ .setContentText("Connecting to message stream...")
+ .setPriority(NotificationCompat.PRIORITY_LOW)
+ .setCategory(NotificationCompat.CATEGORY_SERVICE)
+ .setContentIntent(pendingIntent)
.setOngoing(true)
- .setSilent(true)
.build()
}
fun showMessageNotification(
conversationId: String,
senderName: String,
- messagePreview: String, // for E2E this would be "New message" — no plaintext
+ messagePreview: String,
notificationId: Int = conversationId.hashCode()
) {
- // intent that opens the app to the right conversation when tapped
val intent = Intent(context, MainActivity::class.java).apply {
flags = Intent.FLAG_ACTIVITY_SINGLE_TOP
putExtra("conversation_id", conversationId)
@@ -72,13 +62,13 @@ class NotificationService(private val context: Context) {
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE
)
- val notification = NotificationCompat.Builder(context, "messages")
+ val notification = NotificationCompat.Builder(context, CHANNEL_ID)
.setSmallIcon(R.drawable.ic_notification)
.setContentTitle(senderName)
.setContentText(messagePreview)
.setPriority(NotificationCompat.PRIORITY_HIGH)
.setContentIntent(pendingIntent)
- .setAutoCancel(true) // dismiss on tap
+ .setAutoCancel(true)
.build()
manager.notify(notificationId, notification)
@@ -91,4 +81,4 @@ class NotificationService(private val context: Context) {
fun dismissAll() {
manager.cancelAll()
}
-}
\ No newline at end of file
+}
diff --git a/android/app/src/main/java/dev/zxq5/chatapp/android/data/repository/AuthRepository.kt b/android/app/src/main/java/dev/zxq5/chatapp/android/data/repository/AuthRepository.kt
index 7bb6e25..9bd209b 100644
--- a/android/app/src/main/java/dev/zxq5/chatapp/android/data/repository/AuthRepository.kt
+++ b/android/app/src/main/java/dev/zxq5/chatapp/android/data/repository/AuthRepository.kt
@@ -56,6 +56,10 @@ class AuthRepository(
fun getAuthState(): AuthState {
val token = tokenStore.get() ?: return AuthState.Unauthenticated
+ if (tokenStore.isExpired()) {
+ tokenStore.clear()
+ return AuthState.Unauthenticated
+ }
return when (getScopeFromToken(token)) {
TokenScope.FULL -> AuthState.Authenticated
TokenScope.TOTP_PENDING -> AuthState.AwaitingTotp
diff --git a/android/app/src/main/java/dev/zxq5/chatapp/android/data/repository/SettingsRepository.kt b/android/app/src/main/java/dev/zxq5/chatapp/android/data/repository/SettingsRepository.kt
index 78d0ac5..725f1e9 100644
--- a/android/app/src/main/java/dev/zxq5/chatapp/android/data/repository/SettingsRepository.kt
+++ b/android/app/src/main/java/dev/zxq5/chatapp/android/data/repository/SettingsRepository.kt
@@ -2,6 +2,7 @@ package dev.zxq5.chatapp.android.data.repository
import dev.zxq5.chatapp.android.api.model.QrResponse
import dev.zxq5.chatapp.android.api.SettingsClient
+import dev.zxq5.chatapp.android.api.model.InviteRequest
import dev.zxq5.chatapp.android.api.model.TotpStatus
import dev.zxq5.chatapp.android.core.data.TokenStore
import dev.zxq5.chatapp.android.core.error.ApiResult
@@ -25,6 +26,10 @@ class SettingsRepository(private val tokenStore: TokenStore) {
_lastToken = null
}
+ suspend fun createInvite(request: InviteRequest): ApiResult {
+ return getSettingsClient()?.createInvite(request) ?: ApiResult.NetworkError("Not authenticated")
+ }
+
suspend fun getTotpQr(password: String): ApiResult {
val settingsClient = getSettingsClient() ?: return ApiResult.NetworkError("Not authenticated")
return settingsClient.getTotpQr(password)
diff --git a/android/app/src/main/java/dev/zxq5/chatapp/android/feature/auth/AuthScreen.kt b/android/app/src/main/java/dev/zxq5/chatapp/android/feature/auth/AuthScreen.kt
index dd3400b..91c905a 100644
--- a/android/app/src/main/java/dev/zxq5/chatapp/android/feature/auth/AuthScreen.kt
+++ b/android/app/src/main/java/dev/zxq5/chatapp/android/feature/auth/AuthScreen.kt
@@ -3,7 +3,7 @@ package dev.zxq5.chatapp.android.feature.auth
import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
-import dev.zxq5.chatapp.android.model.LoginState
+import dev.zxq5.chatapp.android.api.model.LoginState
@Composable
fun AuthScreen(viewModel: AuthViewModel) {
diff --git a/android/app/src/main/java/dev/zxq5/chatapp/android/feature/auth/AuthViewModel.kt b/android/app/src/main/java/dev/zxq5/chatapp/android/feature/auth/AuthViewModel.kt
index a3cd4d0..7989331 100644
--- a/android/app/src/main/java/dev/zxq5/chatapp/android/feature/auth/AuthViewModel.kt
+++ b/android/app/src/main/java/dev/zxq5/chatapp/android/feature/auth/AuthViewModel.kt
@@ -7,7 +7,7 @@ import dev.zxq5.chatapp.android.data.repository.AuthRepository
import dev.zxq5.chatapp.android.data.repository.LoginResult
import dev.zxq5.chatapp.android.data.repository.SignupResult
import dev.zxq5.chatapp.android.data.repository.AuthState
-import dev.zxq5.chatapp.android.model.LoginState
+import dev.zxq5.chatapp.android.api.model.LoginState
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.launch
diff --git a/android/app/src/main/java/dev/zxq5/chatapp/android/feature/auth/LoginScreen.kt b/android/app/src/main/java/dev/zxq5/chatapp/android/feature/auth/LoginScreen.kt
index 7a113f5..ffe2d4d 100644
--- a/android/app/src/main/java/dev/zxq5/chatapp/android/feature/auth/LoginScreen.kt
+++ b/android/app/src/main/java/dev/zxq5/chatapp/android/feature/auth/LoginScreen.kt
@@ -28,7 +28,7 @@ import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.unit.dp
-import dev.zxq5.chatapp.android.model.LoginState
+import dev.zxq5.chatapp.android.api.model.LoginState
import dev.zxq5.chatapp.android.ui.components.TextField
@Composable
diff --git a/android/app/src/main/java/dev/zxq5/chatapp/android/feature/auth/SignupScreen.kt b/android/app/src/main/java/dev/zxq5/chatapp/android/feature/auth/SignupScreen.kt
index a35efb4..da780a9 100644
--- a/android/app/src/main/java/dev/zxq5/chatapp/android/feature/auth/SignupScreen.kt
+++ b/android/app/src/main/java/dev/zxq5/chatapp/android/feature/auth/SignupScreen.kt
@@ -28,7 +28,7 @@ import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.unit.dp
-import dev.zxq5.chatapp.android.model.LoginState
+import dev.zxq5.chatapp.android.api.model.LoginState
import dev.zxq5.chatapp.android.ui.components.TextField
@Composable
diff --git a/android/app/src/main/java/dev/zxq5/chatapp/android/feature/chat/ChatViewModel.kt b/android/app/src/main/java/dev/zxq5/chatapp/android/feature/chat/ChatViewModel.kt
index 9c20c01..779f94a 100644
--- a/android/app/src/main/java/dev/zxq5/chatapp/android/feature/chat/ChatViewModel.kt
+++ b/android/app/src/main/java/dev/zxq5/chatapp/android/feature/chat/ChatViewModel.kt
@@ -9,6 +9,8 @@ import dev.zxq5.chatapp.android.api.model.Message
import dev.zxq5.chatapp.android.api.model.Space
import dev.zxq5.chatapp.android.api.model.SpaceDto
import dev.zxq5.chatapp.android.core.service.MessageStreamService
+import io.ktor.client.plugins.ResponseException
+import io.ktor.http.HttpStatusCode
import kotlinx.coroutines.Job
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
@@ -40,6 +42,8 @@ class ChatViewModel(private val chatRepository: ChatRepository) : ViewModel() {
val channelError: StateFlow = _channelError
private var streamJob: Job? = null
+
+ var onUnauthorized: (() -> Unit)? = null
init {
_currentUserId.value = chatRepository.getUserId()
@@ -49,6 +53,7 @@ class ChatViewModel(private val chatRepository: ChatRepository) : ViewModel() {
fun loadAccessibleChannels() {
_error.value = null
+ _currentUserId.value = chatRepository.getUserId()
viewModelScope.launch {
runCatching {
chatRepository.getAccessibleChannels()
@@ -56,7 +61,11 @@ class ChatViewModel(private val chatRepository: ChatRepository) : ViewModel() {
_spaces.value = data
}.onFailure { e ->
Log.e("Chat", "Failed to load spaces", e)
- _error.value = "Failed to load channels: ${e.message}"
+ if (e is ResponseException && e.response.status == HttpStatusCode.Unauthorized) {
+ onUnauthorized?.invoke()
+ } else {
+ _error.value = "Failed to load channels: ${e.message}"
+ }
}
}
}
@@ -72,7 +81,11 @@ class ChatViewModel(private val chatRepository: ChatRepository) : ViewModel() {
chatRepository.messageStream(id)
.catch { e ->
Log.e("Chat", "Stream error", e)
- _channelError.value = "Connection lost: ${e.message}"
+ if (e is ResponseException && e.response.status == HttpStatusCode.Unauthorized) {
+ onUnauthorized?.invoke()
+ } else {
+ _channelError.value = "Connection lost: ${e.message}"
+ }
}
.collect { message ->
_messages.update { it + message }
@@ -108,7 +121,11 @@ class ChatViewModel(private val chatRepository: ChatRepository) : ViewModel() {
)
}.onFailure { e ->
Log.e("Chat", "Send message error", e)
- _channelError.value = "Failed to send message"
+ if (e is ResponseException && e.response.status == HttpStatusCode.Unauthorized) {
+ onUnauthorized?.invoke()
+ } else {
+ _channelError.value = "Failed to send message"
+ }
}
}
}
@@ -117,6 +134,7 @@ class ChatViewModel(private val chatRepository: ChatRepository) : ViewModel() {
_messages.value = emptyList()
_channelId.value = null
_currentUserId.value = null
+ _spaces.value = emptyList()
_error.value = null
_channelError.value = null
streamJob?.cancel()
diff --git a/android/app/src/main/java/dev/zxq5/chatapp/android/feature/settings/SettingsViewModel.kt b/android/app/src/main/java/dev/zxq5/chatapp/android/feature/settings/SettingsViewModel.kt
index 9549ea8..29a465d 100644
--- a/android/app/src/main/java/dev/zxq5/chatapp/android/feature/settings/SettingsViewModel.kt
+++ b/android/app/src/main/java/dev/zxq5/chatapp/android/feature/settings/SettingsViewModel.kt
@@ -2,6 +2,7 @@ package dev.zxq5.chatapp.android.feature.settings
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
+import dev.zxq5.chatapp.android.api.model.InviteRequest
import dev.zxq5.chatapp.android.api.model.QrResponse
import dev.zxq5.chatapp.android.core.error.ApiResult
import dev.zxq5.chatapp.android.data.repository.SettingsRepository
@@ -27,6 +28,9 @@ class SettingsViewModel(private val settingsRepository: SettingsRepository) : Vi
private val _isSuccessState = MutableStateFlow