Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions app/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -133,6 +133,7 @@ dependencies {
implementation(libs.markwon.core)
implementation(libs.markwon.ext.strikethrough)
implementation(libs.markwon.linkify)
implementation(libs.markwon.html)

implementation(libs.paging.runtime)
implementation(libs.paging.compose)
Expand Down
23 changes: 23 additions & 0 deletions app/src/main/java/ch/rhosys/email/MainActivity.kt
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ package ch.rhosys.email
import android.content.Intent
import android.os.Bundle
import androidx.activity.compose.setContent
import androidx.activity.enableEdgeToEdge
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
Expand All @@ -17,12 +18,22 @@ import ch.rhosys.email.presentation.auth.BiometricLockScreen
import ch.rhosys.email.presentation.navigation.RootNavGraph
import ch.rhosys.email.sync.SyncForegroundService
import ch.rhosys.email.ui.theme.EmailTheme
import kotlinx.coroutines.Job
import kotlinx.coroutines.flow.filterNotNull
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.launch

class MainActivity : FragmentActivity() {
private var realtimeJob: Job? = null
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
// Forced automatically on Android 15+ (targetSdk 35) but not on older
// OSes, which left decorFitsSystemWindows=true there while TopAppBar's
// own statusBars inset padding assumed edge-to-edge — the two together
// reserved the status bar's height twice, showing as a blank strip
// above the header on API < 35 devices. Calling this explicitly makes
// the behavior consistent everywhere the app's minSdk supports.
enableEdgeToEdge()
val appContainer = (application as EmailApp).appContainer

// The Authress redirect can arrive either as the intent that started the
Expand Down Expand Up @@ -78,9 +89,21 @@ class MainActivity : FragmentActivity() {
override fun onStart() {
super.onStart()
SyncForegroundService.start(this)
val appContainer = (application as EmailApp).appContainer
// Live updates only while foregrounded — no FCM/push service needed to
// get them; decision #29's fetch-on-open + pull-to-refresh still covers
// the backgrounded case.
realtimeJob = lifecycleScope.launch {
appContainer.accountRepository.activeAccountId().filterNotNull().collect { accountId ->
appContainer.realtimeClient.start(accountId)
}
}
}

override fun onStop() {
realtimeJob?.cancel()
realtimeJob = null
(application as EmailApp).appContainer.realtimeClient.stop()
SyncForegroundService.stop(this)
super.onStop()
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,7 @@ import ch.rhosys.email.data.local.entity.ViewEntity
LabelEntity::class, RuleEntity::class, TemplateEntity::class, ViewEntity::class,
LogEntryEntity::class,
],
version = 3,
version = 4,
exportSchema = true,
)
@TypeConverters(Converters::class)
Expand Down
15 changes: 15 additions & 0 deletions app/src/main/java/ch/rhosys/email/data/local/dao/ThreadDao.kt
Original file line number Diff line number Diff line change
Expand Up @@ -18,9 +18,17 @@ interface ThreadDao {
@Query("SELECT * FROM threads WHERE accountId = :accountId AND status = :status ORDER BY lastSignalAt DESC")
fun pagingSource(accountId: String, status: String): PagingSource<Int, ThreadEntity>

/** Backs the "All" inbox tab — every thread for the account, regardless of status. */
@Query("SELECT * FROM threads WHERE accountId = :accountId ORDER BY lastSignalAt DESC")
fun pagingSourceAll(accountId: String): PagingSource<Int, ThreadEntity>

@Query("SELECT * FROM threads WHERE accountId = :accountId AND status = :status ORDER BY lastSignalAt DESC")
fun observeByStatus(accountId: String, status: String): Flow<List<ThreadEntity>>

/** Backs the Inbox badge in the nav drawer. */
@Query("SELECT COUNT(*) FROM threads WHERE accountId = :accountId AND status = :status")
fun observeCountByStatus(accountId: String, status: String): Flow<Int>

@Query(
"SELECT * FROM threads WHERE accountId = :accountId AND status = :status " +
"AND labels LIKE '%' || :label || '%' ORDER BY lastSignalAt DESC",
Expand Down Expand Up @@ -66,4 +74,11 @@ interface ThreadDao {

@Query("DELETE FROM threads WHERE accountId = :accountId")
suspend fun clearAccount(accountId: String)

/**
* Scoped clear used ahead of a status-filtered refresh, so refreshing one
* tab (e.g. Archived) doesn't wipe another tab's (e.g. Active) cached rows.
*/
@Query("DELETE FROM threads WHERE accountId = :accountId AND status = :status")
suspend fun clearAccountStatus(accountId: String, status: String)
}
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ data class LabelEntity(
val name: String,
val color: String?,
val icon: String?,
val applyInstruction: String,
val createdAt: Long?,
)

Expand Down Expand Up @@ -71,6 +72,7 @@ fun LabelEntity.toDomain() = Label(
name = name,
color = color,
icon = icon,
applyInstruction = applyInstruction,
createdAt = createdAt?.let(Instant::ofEpochMilli),
)

Expand All @@ -80,6 +82,7 @@ fun Label.toEntity() = LabelEntity(
name = name,
color = color,
icon = icon,
applyInstruction = applyInstruction,
createdAt = createdAt?.toEpochMilli(),
)

Expand Down
135 changes: 135 additions & 0 deletions app/src/main/java/ch/rhosys/email/data/realtime/RealtimeClient.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,135 @@
package ch.rhosys.email.data.realtime

import ch.rhosys.email.data.auth.AuthressLoginClient
import ch.rhosys.email.data.log.AppLogger
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.delay
import kotlinx.coroutines.isActive
import kotlinx.coroutines.launch
import okhttp3.OkHttpClient
import okhttp3.Request
import okhttp3.WebSocket
import okhttp3.WebSocketListener
import org.json.JSONObject
import java.net.URLEncoder

/**
* Live thread updates over a WebSocket, mirroring the web app's SharedWorker
* (`workers/realtime.shared.ts`): connect to `<apiBase>?token=&accountId=`,
* ping every 25s to keep the connection alive, reconnect with exponential
* backoff on drop. This works entirely while the app is foregrounded — no
* push notification service (FCM) is required to get live updates.
*
* The only event the server emits is `thread:updated`; everything else
* (rules, labels, archived status) stays fetch-on-navigation, same as web.
*/
class RealtimeClient(
private val wsBaseUrl: String,
private val httpClient: OkHttpClient,
private val authManager: AuthressLoginClient,
private val logger: AppLogger,
private val onThreadUpdated: suspend (accountId: String, threadId: String) -> Unit,
) {
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
private var connectJob: Job? = null
private var pingJob: Job? = null
private var webSocket: WebSocket? = null
private var reconnectDelayMs = INITIAL_RECONNECT_DELAY_MS
private var currentAccountId: String? = null
private var stopped = true

/** Idempotent: switching accounts closes the old socket and reconnects under the new one. */
fun start(accountId: String) {
if (!stopped && currentAccountId == accountId && webSocket != null) return
stopped = false
val accountChanged = currentAccountId != accountId
currentAccountId = accountId
if (accountChanged) {
webSocket?.close(NORMAL_CLOSURE, "switching account")
webSocket = null
}
reconnectDelayMs = INITIAL_RECONNECT_DELAY_MS
connect()
}

fun stop() {
stopped = true
connectJob?.cancel()
pingJob?.cancel()
webSocket?.close(NORMAL_CLOSURE, "app backgrounded")
webSocket = null
}

private fun connect() {
val accountId = currentAccountId ?: return
connectJob?.cancel()
connectJob = scope.launch {
val token = runCatching { authManager.waitForToken() }.getOrNull().orEmpty()
if (stopped) return@launch
val url = "$wsBaseUrl?token=${URLEncoder.encode(token, "UTF-8")}&accountId=$accountId"
val request = Request.Builder().url(url).build()
webSocket = httpClient.newWebSocket(request, listener)
}
}

private fun schedulePing() {
pingJob?.cancel()
pingJob = scope.launch {
while (isActive) {
delay(PING_INTERVAL_MS)
webSocket?.send("""{"type":"ping"}""")
}
}
}

private fun scheduleReconnect() {
if (stopped) return
scope.launch {
delay(reconnectDelayMs)
reconnectDelayMs = (reconnectDelayMs * 2).coerceAtMost(MAX_RECONNECT_DELAY_MS)
if (!stopped) connect()
}
}

private val listener = object : WebSocketListener() {
override fun onOpen(webSocket: WebSocket, response: okhttp3.Response) {
reconnectDelayMs = INITIAL_RECONNECT_DELAY_MS
logger.info("Realtime", "connected")
schedulePing()
}

override fun onMessage(webSocket: WebSocket, text: String) {
val accountId = currentAccountId ?: return
val json = runCatching { JSONObject(text) }.getOrNull() ?: return
when (json.optString("type")) {
"thread:updated" -> {
val threadId = json.optString("threadId").takeIf { it.isNotBlank() } ?: return
scope.launch { runCatching { onThreadUpdated(accountId, threadId) } }
}
// "connected" (handshake ack) and "pong" need no action.
}
}

override fun onClosed(webSocket: WebSocket, code: Int, reason: String) {
pingJob?.cancel()
logger.info("Realtime", "closed: code=$code reason=$reason")
scheduleReconnect()
}

override fun onFailure(webSocket: WebSocket, t: Throwable, response: okhttp3.Response?) {
pingJob?.cancel()
logger.warn("Realtime", "connection failed", t)
scheduleReconnect()
}
}

private companion object {
const val PING_INTERVAL_MS = 25_000L
const val INITIAL_RECONNECT_DELAY_MS = 1_000L
const val MAX_RECONNECT_DELAY_MS = 30_000L
const val NORMAL_CLOSURE = 1000
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,10 @@ import ch.rhosys.email.data.remote.dto.PatchLabelRequest
import ch.rhosys.email.data.remote.dto.PatchRuleRequest
import ch.rhosys.email.data.remote.dto.PatchSignalRequest
import ch.rhosys.email.data.remote.dto.PatchThreadRequest
import ch.rhosys.email.data.remote.dto.PatchResourceRequest
import ch.rhosys.email.data.remote.dto.QuarantineResponseRequest
import ch.rhosys.email.data.remote.dto.ResourceDto
import ch.rhosys.email.data.remote.dto.ResourceListResponse
import ch.rhosys.email.data.remote.dto.RuleDto
import ch.rhosys.email.data.remote.dto.RuleListResponse
import ch.rhosys.email.data.remote.dto.SetAliasSenderRequest
Expand Down Expand Up @@ -154,6 +157,23 @@ interface EmailApiService {
@Path("threadId") threadId: String,
): UnsubscribeResultDto

// ── Resources ───────────────────────────────────────────────────────────

@GET("accounts/{accountId}/resources")
suspend fun getResources(
@Path("accountId") accountId: String,
@Query("status") status: String? = null,
@Query("cursor") cursor: String? = null,
@Query("limit") limit: Int? = null,
): ResourceListResponse

@PATCH("accounts/{accountId}/resources/{resourceId}")
suspend fun patchResource(
@Path("accountId") accountId: String,
@Path("resourceId") resourceId: String,
@Body body: PatchResourceRequest,
): ResourceDto

// ── Signals ─────────────────────────────────────────────────────────────

@GET("accounts/{accountId}/threads/{threadId}/signals")
Expand Down
26 changes: 26 additions & 0 deletions app/src/main/java/ch/rhosys/email/data/remote/dto/Mappers.kt
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,9 @@ import ch.rhosys.email.domain.model.Attachment
import ch.rhosys.email.domain.model.EmailAddress
import ch.rhosys.email.domain.model.Label
import ch.rhosys.email.domain.model.MailThread
import ch.rhosys.email.domain.model.Resource
import ch.rhosys.email.domain.model.ResourceAsset
import ch.rhosys.email.domain.model.ResourceStatus
import ch.rhosys.email.domain.model.Rule
import ch.rhosys.email.domain.model.RuleAction
import ch.rhosys.email.domain.model.RuleActionType
Expand Down Expand Up @@ -140,6 +143,7 @@ internal fun LabelDto.toDomain(accountId: String) = Label(
name = name,
color = color,
icon = icon,
applyInstruction = applyInstruction,
createdAt = createdAt.toInstantOrNull(),
)

Expand Down Expand Up @@ -176,3 +180,25 @@ internal fun AliasSenderDto.toDomain() = AliasSender(
sender = sender,
policy = SenderPolicy.fromWire(policy),
)

internal fun ResourceAssetDto.toDomain() = ResourceAsset(
type = type,
label = label,
rawValue = rawValue,
sourceSignalId = sourceSignalId,
url = url,
extractedAt = extractedAt.toInstantOrNull(),
)

internal fun ResourceDto.toDomain() = Resource(
resourceId = resourceId,
threadId = threadId,
workflow = Workflow.fromWire(workflow),
status = ResourceStatus.fromWire(status),
expectedResolutionDate = expectedResolutionDate.toInstantOrNull(),
displayDate = displayDate,
resolvedAt = resolvedAt.toInstantOrNull(),
assets = assets.map { it.toDomain() },
createdAt = createdAt.toInstantOrNull(),
updatedAt = updatedAt.toInstantOrNull(),
)
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ data class LabelDto(
val name: String,
val color: String? = null,
val icon: String? = null,
val applyInstruction: String = "",
val createdAt: String,
)

Expand All @@ -22,13 +23,15 @@ data class LabelListResponse(
@JsonClass(generateAdapter = true)
data class CreateLabelRequest(
val name: String,
val applyInstruction: String,
val color: String? = null,
val icon: String? = null,
)

@JsonClass(generateAdapter = true)
data class PatchLabelRequest(
val name: String? = null,
val applyInstruction: String? = null,
val color: String? = null,
val icon: String? = null,
)
Expand Down
Loading