feat: add history sync worker

This commit is contained in:
2026-06-07 17:16:47 +02:00
parent 3f25ee4c41
commit 3b14f2fba9
25 changed files with 807 additions and 120 deletions
+3
View File
@@ -97,6 +97,9 @@ dependencies {
implementation(libs.androidx.lifecycle.viewmodel.ktx) implementation(libs.androidx.lifecycle.viewmodel.ktx)
implementation(libs.androidx.lifecycle.viewmodel.compose) implementation(libs.androidx.lifecycle.viewmodel.compose)
// WorkManger
implementation(libs.androidx.work.runtime.ktx)
// DataStore + Proto + Tink // DataStore + Proto + Tink
implementation(libs.datastore.proto) implementation(libs.datastore.proto)
implementation(libs.protobuf.kotlin.lite) implementation(libs.protobuf.kotlin.lite)
+12
View File
@@ -16,6 +16,7 @@
<uses-feature android:name="android.hardware.nfc.hce" android:required="false" /> <uses-feature android:name="android.hardware.nfc.hce" android:required="false" />
<application <application
android:name=".GudariWalletApplication"
android:allowBackup="true" android:allowBackup="true"
android:icon="@mipmap/ic_launcher" android:icon="@mipmap/ic_launcher"
android:label="@string/app_name" android:label="@string/app_name"
@@ -24,6 +25,17 @@
android:networkSecurityConfig="@xml/network_security_config" android:networkSecurityConfig="@xml/network_security_config"
android:theme="@style/Theme.AppCompat.DayNight.NoActionBar"> android:theme="@style/Theme.AppCompat.DayNight.NoActionBar">
<provider
android:name="androidx.startup.InitializationProvider"
android:authorities="${applicationId}.androidx-startup"
android:exported="false"
tools:node="merge">
<meta-data
android:name="androidx.work.WorkManagerInitializer"
android:value="androidx.startup"
tools:node="remove" />
</provider>
<activity <activity
android:name=".MainActivity" android:name=".MainActivity"
android:exported="true" android:exported="true"
@@ -0,0 +1,33 @@
package com.bitcointxoko.gudariwallet
import android.app.Application
import androidx.work.Configuration
import com.bitcointxoko.gudariwallet.api.LNbitsClient
import com.bitcointxoko.gudariwallet.data.HistoricalSyncStore
import com.bitcointxoko.gudariwallet.data.LNbitsWalletRepository
import com.bitcointxoko.gudariwallet.data.PaymentCacheRepository
import com.bitcointxoko.gudariwallet.security.EncryptedSecretStore
import com.bitcointxoko.gudariwallet.sync.HistoricalSyncWorkerFactory
import com.bitcointxoko.gudariwallet.util.NotificationHelper
class GudariWalletApplication : Application(), Configuration.Provider {
// Evaluated lazily on first WorkManager access — EncryptedSecretStore is
// safe to construct here since the Application context is fully ready.
override val workManagerConfiguration: Configuration
get() {
val secrets = EncryptedSecretStore(this)
val api = LNbitsClient.create(secrets)
val repo = LNbitsWalletRepository(api = api, secrets = secrets)
val paymentCache = PaymentCacheRepository(this)
val syncStore = HistoricalSyncStore(this)
return Configuration.Builder()
.setWorkerFactory(HistoricalSyncWorkerFactory(repo, paymentCache, syncStore))
.build()
}
override fun onCreate() {
super.onCreate()
NotificationHelper.createChannels(this) // moved from MainActivity
}
}
@@ -37,6 +37,7 @@ import kotlinx.coroutines.flow.MutableSharedFlow
import androidx.compose.runtime.collectAsState import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import cafe.adriel.lyricist.ProvideStrings import cafe.adriel.lyricist.ProvideStrings
import com.bitcointxoko.gudariwallet.data.HistoricalSyncStore
class MainActivity : AppCompatActivity() { class MainActivity : AppCompatActivity() {
@@ -58,8 +59,6 @@ class MainActivity : AppCompatActivity() {
this, 0, this, 0,
Intent(this, javaClass).apply { Intent(this, javaClass).apply {
addFlags(Intent.FLAG_ACTIVITY_SINGLE_TOP) addFlags(Intent.FLAG_ACTIVITY_SINGLE_TOP)
// Do NOT add FLAG_ACTIVITY_NEW_TASK — this causes
// balDontBringExistingBackgroundTaskStackToFg = true
}, },
PendingIntent.FLAG_MUTABLE or PendingIntent.FLAG_UPDATE_CURRENT PendingIntent.FLAG_MUTABLE or PendingIntent.FLAG_UPDATE_CURRENT
) )
@@ -89,6 +88,7 @@ class MainActivity : AppCompatActivity() {
override fun onCreate(savedInstanceState: Bundle?) { override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState) super.onCreate(savedInstanceState)
secretStore = EncryptedSecretStore(applicationContext) secretStore = EncryptedSecretStore(applicationContext)
val syncStore = HistoricalSyncStore(applicationContext)
val credentialsState = secretStore.credentials val credentialsState = secretStore.credentials
.stateIn( .stateIn(
scope = lifecycleScope, scope = lifecycleScope,
@@ -97,7 +97,7 @@ class MainActivity : AppCompatActivity() {
) )
// Must be first — before any notification is posted // Must be first — before any notification is posted
NotificationHelper.createChannels(applicationContext) // NotificationHelper.createChannels(applicationContext)
// Read hash from the intent that launched the activity (cold start from notification) // Read hash from the intent that launched the activity (cold start from notification)
pendingPaymentHash.value = pendingPaymentHash.value =
@@ -127,7 +127,6 @@ class MainActivity : AppCompatActivity() {
LaunchedEffect(Unit) { LaunchedEffect(Unit) {
WalletNotificationService.start(this@MainActivity) WalletNotificationService.start(this@MainActivity)
requestNotificationPermissionIfNeeded() requestNotificationPermissionIfNeeded()
// promptBatteryOptimizationIfNeeded()
} }
WalletScreen( WalletScreen(
vm = vm, vm = vm,
@@ -139,11 +138,9 @@ class MainActivity : AppCompatActivity() {
} else { } else {
OnboardingScreen( OnboardingScreen(
secretStore = secretStore, secretStore = secretStore,
syncStore = syncStore,
onRequestNotificationPermission = { requestNotificationPermissionIfNeeded() }, // ← new onRequestNotificationPermission = { requestNotificationPermissionIfNeeded() }, // ← new
onComplete = { onComplete = {}
// WalletNotificationService.start(this@MainActivity)
// No permission/battery calls here — handled in-flow
}
) )
} }
} }
@@ -218,37 +215,4 @@ class MainActivity : AppCompatActivity() {
requestNotificationPermission.launch(Manifest.permission.POST_NOTIFICATIONS) requestNotificationPermission.launch(Manifest.permission.POST_NOTIFICATIONS)
} }
} }
private fun promptBatteryOptimizationIfNeeded() {
val pm = getSystemService(POWER_SERVICE) as PowerManager
if (pm.isIgnoringBatteryOptimizations(packageName)) return
val prefs = getSharedPreferences("gudari_ui_prefs", MODE_PRIVATE)
if (prefs.getBoolean("battery_opt_prompted", false)) return
prefs.edit { putBoolean("battery_opt_prompted", true) }
AlertDialog.Builder(this)
.setTitle("Enable background notifications")
.setMessage(
"To receive payment alerts when the app is closed, allow Gudari Wallet " +
"to run without battery restrictions.\n\n" +
"Tap 'Open Settings', then select Battery → Unrestricted."
)
.setPositiveButton("Open Settings") { _, _ -> openBatterySettings() }
.setNegativeButton("Not now", null)
.show()
}
private fun openBatterySettings() {
val appDetailsIntent =
Intent(Settings.ACTION_APPLICATION_DETAILS_SETTINGS).apply {
data = "package:$packageName".toUri()
}
if (tryStartActivity(appDetailsIntent)) return
tryStartActivity(Intent(Settings.ACTION_IGNORE_BATTERY_OPTIMIZATION_SETTINGS))
}
private fun tryStartActivity(intent: Intent): Boolean {
return try { startActivity(intent); true } catch (e: ActivityNotFoundException) { false }
}
} }
@@ -0,0 +1,41 @@
package com.bitcointxoko.gudariwallet.api
import com.google.gson.TypeAdapter
import com.google.gson.stream.JsonReader
import com.google.gson.stream.JsonToken
import com.google.gson.stream.JsonWriter
/**
* LNbits returns `comment` in PaymentExtra as either:
* - a plain string "Thanks!"
* - a JSON array ["Thanks!"]
* - null
*
* Registered centrally in [GsonProvider] — but also applied via @JsonAdapter
* on the field for extra safety.
*/
class FlexibleStringAdapter : TypeAdapter<String?>() {
override fun write(out: JsonWriter, value: String?) {
if (value == null) out.nullValue() else out.value(value)
}
override fun read(reader: JsonReader): String? {
return when (reader.peek()) {
JsonToken.NULL -> { reader.nextNull(); null }
JsonToken.BEGIN_ARRAY -> {
val parts = mutableListOf<String>()
reader.beginArray()
while (reader.hasNext()) {
when (reader.peek()) {
JsonToken.NULL -> reader.nextNull()
else -> parts.add(reader.nextString())
}
}
reader.endArray()
parts.joinToString(", ").ifEmpty { null }
}
else -> reader.nextString()
}
}
}
@@ -47,6 +47,16 @@ interface LNbitsApi {
@Header("X-Api-Key") apiKey: String @Header("X-Api-Key") apiKey: String
): List<WsPayment> ): List<WsPayment>
/** GET /api/v1/payments/paginated — returns bulk payment history */
@GET("api/v1/payments/paginated")
suspend fun getPaymentsPaginated(
@Header("X-Api-Key") apiKey : String,
@Query("offset") offset : Int,
@Query("limit") limit : Int
): PaginatedPaymentsResponse
/** POST /api/v1/payments/decode — returns decoded payment request */ /** POST /api/v1/payments/decode — returns decoded payment request */
@POST("api/v1/payments/decode") @POST("api/v1/payments/decode")
suspend fun decodeInvoice( suspend fun decodeInvoice(
@@ -3,9 +3,10 @@ package com.bitcointxoko.gudariwallet.api
import com.bitcointxoko.gudariwallet.BuildConfig import com.bitcointxoko.gudariwallet.BuildConfig
import com.bitcointxoko.gudariwallet.security.SecretStore import com.bitcointxoko.gudariwallet.security.SecretStore
import com.bitcointxoko.gudariwallet.util.WalletConstants import com.bitcointxoko.gudariwallet.util.WalletConstants
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.first
import kotlinx.coroutines.runBlocking import kotlinx.coroutines.runBlocking
import okhttp3.HttpUrl.Companion.toHttpUrl
import okhttp3.Interceptor
import okhttp3.OkHttpClient import okhttp3.OkHttpClient
import okhttp3.logging.HttpLoggingInterceptor import okhttp3.logging.HttpLoggingInterceptor
import retrofit2.Retrofit import retrofit2.Retrofit
@@ -16,11 +17,10 @@ object LNbitsClient {
// Single shared client — used by both Retrofit and WebSocket // Single shared client — used by both Retrofit and WebSocket
val httpClient: OkHttpClient = OkHttpClient.Builder() val httpClient: OkHttpClient = OkHttpClient.Builder()
.connectTimeout(WalletConstants.CONNECT_TIMEOUT_S, TimeUnit.SECONDS) // time to establish TCP connection .connectTimeout(WalletConstants.CONNECT_TIMEOUT_S, TimeUnit.SECONDS)
.readTimeout(WalletConstants.READ_TIMEOUT_S, TimeUnit.SECONDS) // time to wait for data (longer for WebSocket pings) .readTimeout(WalletConstants.READ_TIMEOUT_S, TimeUnit.SECONDS)
.writeTimeout(WalletConstants.WRITE_TIMEOUT_S, TimeUnit.SECONDS) // time to send a request body .writeTimeout(WalletConstants.WRITE_TIMEOUT_S, TimeUnit.SECONDS)
.apply { .apply {
// Only log in debug builds — prevents API keys leaking in production logs
if (BuildConfig.DEBUG) { if (BuildConfig.DEBUG) {
addInterceptor(HttpLoggingInterceptor().apply { addInterceptor(HttpLoggingInterceptor().apply {
level = HttpLoggingInterceptor.Level.BODY level = HttpLoggingInterceptor.Level.BODY
@@ -30,14 +30,57 @@ object LNbitsClient {
.build() .build()
fun create(secrets: SecretStore): LNbitsApi { fun create(secrets: SecretStore): LNbitsApi {
val baseUrl = runBlocking(Dispatchers.IO) { // Auth header interceptor — reads invoice key per-request (unchanged)
secrets.credentials.first().baseUrl val authInterceptor = Interceptor { chain ->
}.ifBlank { "https://placeholder.invalid" } val key = runBlocking { secrets.invoiceKey() }
val normalizedUrl = if (baseUrl.endsWith("/")) baseUrl else "$baseUrl/" chain.proceed(
chain.request().newBuilder()
.header("X-Api-Key", key)
.build()
)
}
// Dynamic base URL interceptor — rewrites the placeholder host to the
// real base URL at request time, not at Retrofit construction time.
// This means credentials only need to be in DataStore by the time the
// first HTTP request fires, not at app startup.
val dynamicBaseUrlInterceptor = Interceptor { chain ->
val realBaseUrl = runBlocking { secrets.baseUrl() }
.ifBlank { "https://placeholder.invalid" }
.trimEnd('/')
val realHttpUrl = realBaseUrl.toHttpUrl()
val originalUrl = chain.request().url
val newUrl = originalUrl.newBuilder()
.scheme(realHttpUrl.scheme)
.host(realHttpUrl.host)
.port(realHttpUrl.port)
.build()
chain.proceed(
chain.request().newBuilder()
.url(newUrl)
.build()
)
}
val client = httpClient.newBuilder()
.addInterceptor(authInterceptor)
.addInterceptor(dynamicBaseUrlInterceptor)
.apply {
// Logging goes LAST — after URL rewrite — so logs show the real URL
if (BuildConfig.DEBUG) {
addInterceptor(HttpLoggingInterceptor().apply {
level = HttpLoggingInterceptor.Level.BODY
})
}
}
.build()
return Retrofit.Builder() return Retrofit.Builder()
.baseUrl(normalizedUrl) .baseUrl("https://placeholder.invalid/") // structurally required by Retrofit;
.client(httpClient) // always overwritten by the interceptor
.client(client)
.addConverterFactory(GsonConverterFactory.create(GsonProvider.gson)) .addConverterFactory(GsonConverterFactory.create(GsonProvider.gson))
.build() .build()
.create(LNbitsApi::class.java) .create(LNbitsApi::class.java)
@@ -1,6 +1,7 @@
package com.bitcointxoko.gudariwallet.api package com.bitcointxoko.gudariwallet.api
import com.google.gson.TypeAdapter import com.google.gson.TypeAdapter
import com.google.gson.annotations.JsonAdapter
import com.google.gson.annotations.SerializedName import com.google.gson.annotations.SerializedName
import com.google.gson.stream.JsonReader import com.google.gson.stream.JsonReader
import com.google.gson.stream.JsonToken import com.google.gson.stream.JsonToken
@@ -205,7 +206,8 @@ data class PaymentRecord(
data class PaymentExtra( data class PaymentExtra(
@SerializedName("tag") val tag: String?, @SerializedName("tag") val tag: String?,
@SerializedName("comment") val comment: String?, @JsonAdapter(FlexibleStringAdapter::class)
val comment: String?,
@SerializedName("success_action") val successAction: SuccessAction?, @SerializedName("success_action") val successAction: SuccessAction?,
@SerializedName("wallet_id") val walletId: String?, @SerializedName("wallet_id") val walletId: String?,
@SerializedName("destination") val destination: String?, @SerializedName("destination") val destination: String?,
@@ -309,3 +311,9 @@ data class FiatRateResponse(
val rate: Double, // sat to fiat rate val rate: Double, // sat to fiat rate
val price : Double // BTC price in the requested fiat currency val price : Double // BTC price in the requested fiat currency
) )
// ── History sync ─────────────────────────────────────────────────────────────
data class PaginatedPaymentsResponse(
@SerializedName("data") val data: List<PaymentRecord>,
@SerializedName("total") val total: Int
)
@@ -0,0 +1,28 @@
package com.bitcointxoko.gudariwallet.data
import android.content.Context
import androidx.datastore.preferences.core.booleanPreferencesKey
import androidx.datastore.preferences.core.edit
import androidx.datastore.preferences.preferencesDataStore
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.map
private val Context.historicalSyncDataStore by preferencesDataStore("historical_sync_prefs")
class HistoricalSyncStore(private val context: Context) {
private val KEY_COMPLETE = booleanPreferencesKey("historical_sync_complete")
/** One-shot suspend check — used by the worker guard at startup. */
suspend fun isComplete(): Boolean =
context.historicalSyncDataStore.data.first()[KEY_COMPLETE] == true
/** Persist the completion flag. Called once by the worker on success. */
suspend fun markComplete() =
context.historicalSyncDataStore.edit { it[KEY_COMPLETE] = true }
/** Observable — used by the onboarding UI to react to completion. */
val isCompleteFlow: Flow<Boolean> =
context.historicalSyncDataStore.data.map { it[KEY_COMPLETE] == true }
}
@@ -280,6 +280,9 @@ class LNbitsWalletRepository(
return api.getPayments(secrets.invoiceKey(), limit, offset) return api.getPayments(secrets.invoiceKey(), limit, offset)
} }
override suspend fun getPaymentsPaginated(offset: Int, limit: Int): PaginatedPaymentsResponse =
api.getPaymentsPaginated(secrets.invoiceKey(), offset, limit)
override suspend fun getPaymentDetail(checkingId: String): PaymentDetailResponse { override suspend fun getPaymentDetail(checkingId: String): PaymentDetailResponse {
return api.getPaymentDetail(secrets.invoiceKey(), checkingId) return api.getPaymentDetail(secrets.invoiceKey(), checkingId)
} }
@@ -1,6 +1,7 @@
package com.bitcointxoko.gudariwallet.data package com.bitcointxoko.gudariwallet.data
import com.bitcointxoko.gudariwallet.api.LnurlScanResponse import com.bitcointxoko.gudariwallet.api.LnurlScanResponse
import com.bitcointxoko.gudariwallet.api.PaginatedPaymentsResponse
import com.bitcointxoko.gudariwallet.api.PaymentDetailResponse import com.bitcointxoko.gudariwallet.api.PaymentDetailResponse
import com.bitcointxoko.gudariwallet.api.PaymentRecord import com.bitcointxoko.gudariwallet.api.PaymentRecord
import com.bitcointxoko.gudariwallet.api.WithdrawCallbackResponse import com.bitcointxoko.gudariwallet.api.WithdrawCallbackResponse
@@ -36,6 +37,7 @@ interface WalletRepository {
): WithdrawCallbackResponse ): WithdrawCallbackResponse
suspend fun getLightningAddress(): String? suspend fun getLightningAddress(): String?
suspend fun getPayments(offset: Int, limit: Int): List<PaymentRecord> suspend fun getPayments(offset: Int, limit: Int): List<PaymentRecord>
suspend fun getPaymentsPaginated(offset: Int, limit: Int): PaginatedPaymentsResponse
suspend fun getPaymentDetail(checkingId: String): PaymentDetailResponse suspend fun getPaymentDetail(checkingId: String): PaymentDetailResponse
suspend fun getFiatRate(currency: String): Double suspend fun getFiatRate(currency: String): Double
suspend fun getSupportedCurrencies(): List<String> suspend fun getSupportedCurrencies(): List<String>
@@ -18,6 +18,9 @@ interface SecretStore {
suspend fun isOnboarded(): Boolean suspend fun isOnboarded(): Boolean
suspend fun saveCredentials(baseUrl: String, invoiceKey: String, adminKey: String) suspend fun saveCredentials(baseUrl: String, invoiceKey: String, adminKey: String)
suspend fun saveBaseUrl(baseUrl: String)
suspend fun saveInvoiceKey(invoiceKey: String)
suspend fun saveAdminKey(adminKey: String)
suspend fun setOnboarded() suspend fun setOnboarded()
} }
@@ -58,6 +61,33 @@ class EncryptedSecretStore(context: Context) : SecretStore {
} }
Log.i(TAG, "Credentials saved and DataStore updated") Log.i(TAG, "Credentials saved and DataStore updated")
} }
override suspend fun saveBaseUrl(baseUrl: String) {
dataStore.updateData { current ->
current.toBuilder()
.setBaseUrl(baseUrl)
.build()
}
Log.i(TAG, "BaseUrl saved and DataStore updated")
}
override suspend fun saveInvoiceKey(invoiceKey: String) {
dataStore.updateData { current ->
current.toBuilder()
.setInvoiceKey(invoiceKey)
.build()
}
Log.i(TAG, "InvoiceKey saved and DataStore updated")
}
override suspend fun saveAdminKey(adminKey: String) {
dataStore.updateData { current ->
current.toBuilder()
.setAdminKey(adminKey)
.build()
}
Log.i(TAG, "AdminKey saved and DataStore updated")
}
override suspend fun setOnboarded() { override suspend fun setOnboarded() {
dataStore.updateData { current -> dataStore.updateData { current ->
current.toBuilder() current.toBuilder()
@@ -0,0 +1,77 @@
package com.bitcointxoko.gudariwallet.sync
import android.content.Context
import android.util.Log
import androidx.work.CoroutineWorker
import androidx.work.WorkerParameters
import androidx.work.workDataOf
import com.bitcointxoko.gudariwallet.data.HistoricalSyncStore
import com.bitcointxoko.gudariwallet.data.PaymentCacheRepository
import com.bitcointxoko.gudariwallet.data.WalletRepository
import com.bitcointxoko.gudariwallet.util.NotificationHelper
private const val TAG = "HistoricalSyncWorker"
private const val PAGE_SIZE = 100
class HistoricalSyncWorker(
appContext : Context,
params : WorkerParameters,
private val repo : WalletRepository,
private val paymentCache : PaymentCacheRepository,
private val syncStore : HistoricalSyncStore
) : CoroutineWorker(appContext, params) {
companion object {
const val KEY_FETCHED = "fetched"
const val KEY_TOTAL = "total"
}
override suspend fun doWork(): Result {
// Guard: already completed — safe if WorkManager ever retries a success
if (syncStore.isComplete()) {
Log.d(TAG, "Already complete — skipping")
return Result.success()
}
Log.d(TAG, "Starting historical payment sync")
var offset = 0
var total = -1
while (true) {
val page = runCatching {
repo.getPaymentsPaginated(offset = offset, limit = PAGE_SIZE)
}.getOrElse { e ->
Log.w(TAG, "Network error on offset=$offset: ${e.message} — scheduling retry")
return Result.retry() // WorkManager applies exponential backoff automatically
}
// First page: learn the real total from the server
if (total == -1) {
total = page.total
Log.d(TAG, "Server reports $total total payments")
if (total == 0) {
syncStore.markComplete()
return Result.success()
}
}
// Full overwrite — mergePayments uses INSERT OR REPLACE, no skip logic
paymentCache.mergePayments(page.data)
offset += page.data.size
Log.d(TAG, "Progress: $offset / $total")
setProgress(workDataOf(
KEY_FETCHED to offset,
KEY_TOTAL to total
))
// Stop if server returned a short page (end of data) or we've reached total
if (page.data.size < PAGE_SIZE || offset >= total) break
}
syncStore.markComplete()
Log.d(TAG, "Historical sync complete — $offset payments stored")
NotificationHelper.notifySyncComplete(applicationContext, offset)
return Result.success()
}
}
@@ -0,0 +1,26 @@
package com.bitcointxoko.gudariwallet.sync
import android.content.Context
import androidx.work.ListenableWorker
import androidx.work.WorkerFactory
import androidx.work.WorkerParameters
import com.bitcointxoko.gudariwallet.data.HistoricalSyncStore
import com.bitcointxoko.gudariwallet.data.PaymentCacheRepository
import com.bitcointxoko.gudariwallet.data.WalletRepository
class HistoricalSyncWorkerFactory(
private val repo : WalletRepository,
private val paymentCache : PaymentCacheRepository,
private val syncStore : HistoricalSyncStore
) : WorkerFactory() {
override fun createWorker(
appContext : Context,
workerClassName : String,
workerParameters : WorkerParameters
): ListenableWorker? =
if (workerClassName == HistoricalSyncWorker::class.java.name)
HistoricalSyncWorker(appContext, workerParameters, repo, paymentCache, syncStore)
else
null // return null to delegate to the next factory in the chain
}
@@ -17,16 +17,29 @@ import androidx.compose.foundation.layout.size
import androidx.compose.foundation.text.KeyboardOptions import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.material.icons.Icons import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.BatteryFull import androidx.compose.material.icons.filled.BatteryFull
import androidx.compose.material.icons.filled.History
import androidx.compose.material.icons.filled.Notifications import androidx.compose.material.icons.filled.Notifications
import androidx.compose.material.icons.filled.Visibility import androidx.compose.material.icons.filled.Visibility
import androidx.compose.material.icons.filled.VisibilityOff import androidx.compose.material.icons.filled.VisibilityOff
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.Button import androidx.compose.material3.Button
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton import androidx.compose.material3.IconButton
import androidx.compose.material3.LinearProgressIndicator
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedButton
import androidx.compose.material3.OutlinedTextField import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.material3.TimePicker
import androidx.compose.material3.TimePickerState
import androidx.compose.material3.rememberTimePickerState
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalContext
@@ -36,7 +49,9 @@ import androidx.compose.ui.text.input.VisualTransformation
import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.core.net.toUri import androidx.core.net.toUri
import androidx.work.WorkInfo
import com.bitcointxoko.gudariwallet.i18n.AppStrings import com.bitcointxoko.gudariwallet.i18n.AppStrings
import com.bitcointxoko.gudariwallet.sync.HistoricalSyncWorker
// ── Page 0: Welcome ──────────────────────────────────────────────────────────── // ── Page 0: Welcome ────────────────────────────────────────────────────────────
@Composable @Composable
@@ -255,6 +270,231 @@ fun BatteryPage(strings: AppStrings) {
} }
} }
// ── Page 6: Sync payment history ────────────────────────────────────────────
@Composable
fun SyncHistoryPage(
workInfo : WorkInfo?,
isAlreadyDone : Boolean,
syncIsDeferred: Boolean,
onSyncNow : () -> Unit,
onSchedule : (delayMillis: Long) -> Unit,
onContinue : () -> Unit
) {
// Derive UI state from WorkInfo
val state = when {
isAlreadyDone -> SyncUiState.Done(total = 0)
workInfo == null -> SyncUiState.Idle
workInfo.state == WorkInfo.State.SUCCEEDED ->
SyncUiState.Done(
total = workInfo.progress.getInt(HistoricalSyncWorker.KEY_TOTAL, 0)
)
workInfo.state == WorkInfo.State.FAILED ||
workInfo.state == WorkInfo.State.CANCELLED ->
SyncUiState.Failed
// ENQUEUED + user chose a delay → genuinely deferred
workInfo.state == WorkInfo.State.ENQUEUED && syncIsDeferred ->
SyncUiState.Scheduled
// ENQUEUED + no delay, or RUNNING → show progress
// (ENQUEUED briefly precedes RUNNING even for immediate work)
else -> {
val fetched = workInfo.progress.getInt(HistoricalSyncWorker.KEY_FETCHED, 0)
val total = workInfo.progress.getInt(HistoricalSyncWorker.KEY_TOTAL, 0)
SyncUiState.Running(fetched, total)
}
}
OnboardingPageScaffold(
title = "Payment history",
subtitle = "Download your past payments so they're available offline."
) {
Icon(
imageVector = Icons.Default.History,
contentDescription = null,
modifier = Modifier.size(72.dp),
tint = when (state) {
is SyncUiState.Done -> MaterialTheme.colorScheme.primary
is SyncUiState.Failed -> MaterialTheme.colorScheme.error
else -> MaterialTheme.colorScheme.onSurfaceVariant
}
)
Spacer(Modifier.height(24.dp))
when (state) {
// ── Not yet started ───────────────────────────────────────────────
SyncUiState.Idle -> {
Button(
onClick = onSyncNow,
modifier = Modifier.fillMaxWidth()
) {
Text("Sync now")
}
Spacer(Modifier.height(8.dp))
ScheduleLaterPicker(onSchedule = onSchedule)
Spacer(Modifier.height(8.dp))
TextButton(
onClick = onContinue,
modifier = Modifier.fillMaxWidth()
) {
Text("Skip for now")
}
}
// ── Scheduled (deferred, waiting for delay + network) ─────────────
SyncUiState.Scheduled -> {
Text(
text = "Sync scheduled — will run when your device is online.",
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
textAlign = TextAlign.Center
)
Spacer(Modifier.height(24.dp))
Button(
onClick = onContinue,
modifier = Modifier.fillMaxWidth()
) {
Text("Continue to app →")
}
}
// ── Running ───────────────────────────────────────────────────────
is SyncUiState.Running -> {
if (state.total > 0) {
val progress = state.fetched.toFloat() / state.total
LinearProgressIndicator(
progress = { progress },
modifier = Modifier.fillMaxWidth()
)
Spacer(Modifier.height(8.dp))
Text(
text = "${state.fetched} / ${state.total} payments",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
} else {
// Worker enqueued but hasn't reported first page yet
LinearProgressIndicator(modifier = Modifier.fillMaxWidth()) // indeterminate
Spacer(Modifier.height(8.dp))
Text(
text = "Connecting…",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
Spacer(Modifier.height(24.dp))
// Non-blocking — user can proceed while sync runs in background
Button(
onClick = onContinue,
modifier = Modifier.fillMaxWidth()
) {
Text("Continue to app →")
}
}
// ── Complete ──────────────────────────────────────────────────────
is SyncUiState.Done -> {
Text(
text = if (state.total > 0)
"${state.total} payments synced"
else
"✓ Sync complete",
style = MaterialTheme.typography.bodyLarge,
color = MaterialTheme.colorScheme.primary,
textAlign = TextAlign.Center
)
Spacer(Modifier.height(24.dp))
Button(
onClick = onContinue,
modifier = Modifier.fillMaxWidth()
) {
Text("Continue to app →")
}
}
// ── Failed ────────────────────────────────────────────────────────
SyncUiState.Failed -> {
Text(
text = "Sync failed. You can retry from Settings.",
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.error,
textAlign = TextAlign.Center
)
Spacer(Modifier.height(24.dp))
Button(
onClick = onContinue,
modifier = Modifier.fillMaxWidth()
) {
Text("Continue to app →")
}
}
}
}
}
// ── Sealed state for SyncHistoryPage ─────────────────────────────────────────
private sealed interface SyncUiState {
data object Idle : SyncUiState
data object Scheduled : SyncUiState
data object Failed : SyncUiState
data class Running(val fetched: Int, val total: Int) : SyncUiState
data class Done(val total: Int) : SyncUiState
}
// ── "Schedule for later" time picker ─────────────────────────────────────────
@OptIn(ExperimentalMaterial3Api::class)
@Composable
private fun ScheduleLaterPicker(onSchedule: (delayMillis: Long) -> Unit) {
var showPicker by remember { mutableStateOf(false) }
TextButton(onClick = { showPicker = true }) {
Text("Schedule for later")
}
if (showPicker) {
val timePickerState = rememberTimePickerState(is24Hour = true)
AlertDialog(
onDismissRequest = { showPicker = false },
title = { Text("Schedule sync") },
text = {
Column(horizontalAlignment = Alignment.CenterHorizontally) {
TimePicker(state = timePickerState)
}
},
confirmButton = {
TextButton(onClick = {
showPicker = false
val delayMs = timePickerState.toDelayMillis()
onSchedule(delayMs)
}) { Text("Schedule") }
},
dismissButton = {
TextButton(onClick = { showPicker = false }) { Text("Cancel") }
}
)
}
}
/** Converts a TimePickerState (hour + minute) to a delay in milliseconds from now. */
@OptIn(ExperimentalMaterial3Api::class)
private fun TimePickerState.toDelayMillis(): Long {
val now = java.util.Calendar.getInstance()
val target = java.util.Calendar.getInstance().apply {
set(java.util.Calendar.HOUR_OF_DAY, hour)
set(java.util.Calendar.MINUTE, minute)
set(java.util.Calendar.SECOND, 0)
set(java.util.Calendar.MILLISECOND, 0)
}
// If the chosen time is already past today, schedule for tomorrow
if (target.before(now)) target.add(java.util.Calendar.DAY_OF_YEAR, 1)
return target.timeInMillis - now.timeInMillis
}
// ── Battery settings helper (moved from MainActivity) ───────────────────────── // ── Battery settings helper (moved from MainActivity) ─────────────────────────
private fun openBatterySettings(context: Context) { private fun openBatterySettings(context: Context) {
val appDetailsIntent = Intent(Settings.ACTION_APPLICATION_DETAILS_SETTINGS).apply { val appDetailsIntent = Intent(Settings.ACTION_APPLICATION_DETAILS_SETTINGS).apply {
@@ -11,8 +11,13 @@ import androidx.compose.runtime.*
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.lifecycle.viewmodel.compose.viewModel
import androidx.work.WorkInfo
import com.bitcointxoko.gudariwallet.LocalAppStrings import com.bitcointxoko.gudariwallet.LocalAppStrings
import com.bitcointxoko.gudariwallet.data.HistoricalSyncStore
import com.bitcointxoko.gudariwallet.security.SecretStore import com.bitcointxoko.gudariwallet.security.SecretStore
import com.bitcointxoko.gudariwallet.sync.HistoricalSyncWorker
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
private const val PAGE_WELCOME = 0 private const val PAGE_WELCOME = 0
@@ -21,27 +26,40 @@ private const val PAGE_INVOICE = 2
private const val PAGE_ADMIN = 3 private const val PAGE_ADMIN = 3
private const val PAGE_NOTIFICATIONS = 4 private const val PAGE_NOTIFICATIONS = 4
private const val PAGE_BATTERY = 5 private const val PAGE_BATTERY = 5
private const val PAGE_COUNT = 6 private const val PAGE_SYNC = 6 // ← new
private const val PAGE_COUNT = 7 // ← was 6
@Composable @Composable
fun OnboardingScreen( fun OnboardingScreen(
secretStore : SecretStore, secretStore : SecretStore,
onRequestNotificationPermission: () -> Unit, // ← new parameter syncStore : HistoricalSyncStore, // ← new
onRequestNotificationPermission: () -> Unit,
onComplete : () -> Unit onComplete : () -> Unit
) { ) {
val strings = LocalAppStrings.current val strings = LocalAppStrings.current
val scope = rememberCoroutineScope() val scope = rememberCoroutineScope()
// ── Credential state (unchanged) ──────────────────────────────────────────
var baseUrl by remember { mutableStateOf("https://bitcointxoko.org") } var baseUrl by remember { mutableStateOf("https://bitcointxoko.org") }
var invoiceKey by remember { mutableStateOf("") } var invoiceKey by remember { mutableStateOf("") }
var adminKey by remember { mutableStateOf("") } var adminKey by remember { mutableStateOf("") }
var adminVisible by remember { mutableStateOf(false) } var adminVisible by remember { mutableStateOf(false) }
var error by remember { mutableStateOf<String?>(null) } var error by remember { mutableStateOf<String?>(null) }
// Track whether credentials have been saved (so we don't save twice on back/forward)
var credentialsSaved by remember { mutableStateOf(false) } var credentialsSaved by remember { mutableStateOf(false) }
// ── Sync state ────────────────────────────────────────────────────────────
val vm: OnboardingViewModel = viewModel(
factory = OnboardingViewModelFactory(
// LocalContext.current.applicationContext cast to Application
app = androidx.compose.ui.platform.LocalContext.current
.applicationContext as android.app.Application,
secretStore = secretStore,
syncStore = syncStore
)
)
val syncWorkInfo by vm.syncWorkInfo.collectAsStateWithLifecycle()
val syncAlreadyDone by syncStore.isCompleteFlow.collectAsStateWithLifecycle(false)
val pagerState = rememberPagerState(pageCount = { PAGE_COUNT }) val pagerState = rememberPagerState(pageCount = { PAGE_COUNT })
LaunchedEffect(pagerState.currentPage) { error = null } LaunchedEffect(pagerState.currentPage) { error = null }
@@ -51,7 +69,7 @@ fun OnboardingScreen(
PAGE_SERVER_URL -> if (baseUrl.isBlank()) strings.onboarding.fieldsRequired else null PAGE_SERVER_URL -> if (baseUrl.isBlank()) strings.onboarding.fieldsRequired else null
PAGE_INVOICE -> if (invoiceKey.isBlank()) strings.onboarding.fieldsRequired else null PAGE_INVOICE -> if (invoiceKey.isBlank()) strings.onboarding.fieldsRequired else null
PAGE_ADMIN -> if (adminKey.isBlank()) strings.onboarding.fieldsRequired else null PAGE_ADMIN -> if (adminKey.isBlank()) strings.onboarding.fieldsRequired else null
else -> null // PAGE_NOTIFICATIONS and PAGE_BATTERY always pass else -> null
} }
return error == null return error == null
} }
@@ -65,8 +83,6 @@ fun OnboardingScreen(
HorizontalPager( HorizontalPager(
state = pagerState, state = pagerState,
modifier = Modifier.weight(1f), modifier = Modifier.weight(1f),
// Prevent swiping back past the admin page once credentials are saved,
// and prevent skipping the permission pages entirely
userScrollEnabled = true userScrollEnabled = true
) { page -> ) { page ->
when (page) { when (page) {
@@ -86,6 +102,19 @@ fun OnboardingScreen(
onRequestNotificationPermission = onRequestNotificationPermission onRequestNotificationPermission = onRequestNotificationPermission
) )
PAGE_BATTERY -> BatteryPage(strings) PAGE_BATTERY -> BatteryPage(strings)
PAGE_SYNC -> SyncHistoryPage(
workInfo = syncWorkInfo,
isAlreadyDone = syncAlreadyDone,
syncIsDeferred = vm.syncIsDeferred,
onSyncNow = { vm.enqueueHistoricalSync(delayMillis = 0L) },
onSchedule = { delayMs -> vm.enqueueHistoricalSync(delayMillis = delayMs) },
onContinue = {
scope.launch {
secretStore.setOnboarded() // moved here from PAGE_BATTERY
onComplete()
}
}
)
} }
} }
@@ -96,6 +125,9 @@ fun OnboardingScreen(
.padding(vertical = 12.dp) .padding(vertical = 12.dp)
) )
// ── Bottom nav row ────────────────────────────────────────────────
// Hidden on PAGE_SYNC — that page manages its own Continue button
if (pagerState.currentPage != PAGE_SYNC) {
Row( Row(
modifier = Modifier modifier = Modifier
.fillMaxWidth() .fillMaxWidth()
@@ -120,32 +152,18 @@ fun OnboardingScreen(
val isLastPage = pagerState.currentPage == PAGE_BATTERY val isLastPage = pagerState.currentPage == PAGE_BATTERY
Button(onClick = { Button(onClick = {
if (!validateCurrentPage()) return@Button scope.launch {
// Save each field as soon as the user advances past it
when (pagerState.currentPage) { when (pagerState.currentPage) {
PAGE_ADMIN -> { PAGE_SERVER_URL -> secretStore.saveBaseUrl(baseUrl)
if (!credentialsSaved) { PAGE_INVOICE -> secretStore.saveInvoiceKey(invoiceKey)
scope.launch { PAGE_ADMIN -> secretStore.saveAdminKey(adminKey)
secretStore.saveCredentials(baseUrl, invoiceKey, adminKey) else -> Unit
credentialsSaved = true
pagerState.animateScrollToPage(PAGE_NOTIFICATIONS)
} }
} else {
scope.launch { pagerState.animateScrollToPage(PAGE_NOTIFICATIONS) }
}
}
PAGE_BATTERY -> {
scope.launch {
secretStore.setOnboarded()
}
}
else -> scope.launch {
pagerState.animateScrollToPage(pagerState.currentPage + 1) pagerState.animateScrollToPage(pagerState.currentPage + 1)
} }
}
}) { }) {
Text(if (isLastPage) strings.onboarding.done else strings.onboarding.next) Text(if (isLastPage) strings.onboarding.next else strings.onboarding.next)
if (!isLastPage) {
Spacer(Modifier.width(4.dp)) Spacer(Modifier.width(4.dp))
Icon(Icons.AutoMirrored.Filled.ArrowForward, contentDescription = null) Icon(Icons.AutoMirrored.Filled.ArrowForward, contentDescription = null)
} }
@@ -154,4 +172,3 @@ fun OnboardingScreen(
} }
} }
} }
@@ -1,24 +1,88 @@
package com.bitcointxoko.gudariwallet.ui.onboarding package com.bitcointxoko.gudariwallet.ui.onboarding
import android.app.Application
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.setValue import androidx.compose.runtime.setValue
import androidx.lifecycle.ViewModel import androidx.lifecycle.AndroidViewModel
import androidx.lifecycle.viewModelScope import androidx.lifecycle.viewModelScope
import androidx.work.Constraints
import androidx.work.ExistingWorkPolicy
import androidx.work.NetworkType
import androidx.work.OneTimeWorkRequestBuilder
import androidx.work.WorkInfo
import androidx.work.WorkManager
import com.bitcointxoko.gudariwallet.data.HistoricalSyncStore
import com.bitcointxoko.gudariwallet.security.SecretStore import com.bitcointxoko.gudariwallet.security.SecretStore
import com.bitcointxoko.gudariwallet.sync.HistoricalSyncWorker
import com.bitcointxoko.gudariwallet.util.WalletConstants
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import java.util.concurrent.TimeUnit
class OnboardingViewModel(
app: Application,
private val secretStore: SecretStore,
val syncStore: HistoricalSyncStore // exposed so the screen can observe isCompleteFlow
) : AndroidViewModel(app) {
class OnboardingViewModel(private val secretStore: SecretStore) : ViewModel() {
var baseUrl by mutableStateOf("https://bitcointxoko.org") var baseUrl by mutableStateOf("https://bitcointxoko.org")
var invoiceKey by mutableStateOf("") var invoiceKey by mutableStateOf("")
var adminKey by mutableStateOf("") var adminKey by mutableStateOf("")
var adminKeyVisible by mutableStateOf(false) var adminKeyVisible by mutableStateOf(false)
var error by mutableStateOf<String?>(null) var error by mutableStateOf<String?>(null)
// ── Credentials ───────────────────────────────────────────────────────────
fun saveAndComplete(onComplete: () -> Unit) { fun saveAndComplete(onComplete: () -> Unit) {
viewModelScope.launch { viewModelScope.launch {
secretStore.saveCredentials(baseUrl, invoiceKey, adminKey) secretStore.saveCredentials(baseUrl, invoiceKey, adminKey)
onComplete() onComplete()
} }
} }
// ── Historical sync ───────────────────────────────────────────────────────
var syncIsDeferred by mutableStateOf(false)
private set
/**
* Enqueues the one-time historical sync worker.
*
* @param delayMillis 0 = start immediately; >0 = defer by this many ms.
* Use this to let the user schedule an overnight sync.
*/
fun enqueueHistoricalSync(delayMillis: Long = 0L) {
syncIsDeferred = delayMillis > 0L // ← track whether user chose a delay
val request = OneTimeWorkRequestBuilder<HistoricalSyncWorker>()
.setConstraints(
Constraints.Builder()
.setRequiredNetworkType(NetworkType.CONNECTED)
.build()
)
.apply {
if (delayMillis > 0L) setInitialDelay(delayMillis, TimeUnit.MILLISECONDS)
}
.build()
WorkManager.getInstance(getApplication())
.enqueueUniqueWork(
WalletConstants.HISTORICAL_SYNC_WORK_NAME,
ExistingWorkPolicy.KEEP,
request
)
}
/**
* Live WorkInfo for the historical sync job.
* Emits null until the work is first enqueued.
* The UI reads KEY_FETCHED / KEY_TOTAL from workInfo.progress for the progress bar.
*/
val syncWorkInfo: StateFlow<WorkInfo?> =
WorkManager.getInstance(getApplication())
.getWorkInfosForUniqueWorkFlow(WalletConstants.HISTORICAL_SYNC_WORK_NAME)
.map { it.firstOrNull() }
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), null)
} }
@@ -0,0 +1,18 @@
package com.bitcointxoko.gudariwallet.ui.onboarding
import android.app.Application
import androidx.lifecycle.ViewModel
import androidx.lifecycle.ViewModelProvider
import com.bitcointxoko.gudariwallet.data.HistoricalSyncStore
import com.bitcointxoko.gudariwallet.security.SecretStore
class OnboardingViewModelFactory(
private val app : Application,
private val secretStore: SecretStore,
private val syncStore : HistoricalSyncStore
) : ViewModelProvider.Factory {
override fun <T : ViewModel> create(modelClass: Class<T>): T {
@Suppress("UNCHECKED_CAST")
return OnboardingViewModel(app, secretStore, syncStore) as T
}
}
@@ -5,6 +5,7 @@ object NotificationConstants {
// ── Channel IDs ─────────────────────────────────────────────────────────── // ── Channel IDs ───────────────────────────────────────────────────────────
const val CHANNEL_PAYMENTS = "gudari_payments" const val CHANNEL_PAYMENTS = "gudari_payments"
const val CHANNEL_SERVICE_STATUS = "gudari_service" const val CHANNEL_SERVICE_STATUS = "gudari_service"
const val CHANNEL_SYNC = "gudari_sync"
// ── Notification IDs ────────────────────────────────────────────────────── // ── Notification IDs ──────────────────────────────────────────────────────
// ID 1 is reserved for the foreground service — Android requires a stable, // ID 1 is reserved for the foreground service — Android requires a stable,
@@ -12,4 +13,5 @@ object NotificationConstants {
const val NOTIF_ID_SERVICE = 1 const val NOTIF_ID_SERVICE = 1
const val NOTIF_ID_PAYMENT_RECEIVED = 1001 const val NOTIF_ID_PAYMENT_RECEIVED = 1001
const val NOTIF_ID_PAYMENT_SENT = 1002 const val NOTIF_ID_PAYMENT_SENT = 1002
const val NOTIF_ID_SYNC_COMPLETE = 1003
} }
@@ -45,6 +45,18 @@ object NotificationHelper {
setSound(null, null) setSound(null, null)
} }
) )
nm.createNotificationChannel(
NotificationChannel(
NotificationConstants.CHANNEL_SYNC,
context.getString(R.string.notif_channel_sync_name),
NotificationManager.IMPORTANCE_DEFAULT
).apply {
description = context.getString(R.string.notif_channel_sync_desc)
enableVibration(false)
setSound(null, null)
}
)
} }
// ── User-facing notifications ───────────────────────────────────────────── // ── User-facing notifications ─────────────────────────────────────────────
@@ -92,6 +104,26 @@ object NotificationHelper {
) )
} }
/**
* Posts a notification when the one-time historical payment sync completes.
* No tap destination needed the user is already in the app or will land
* on the main screen via the default MainActivity launch.
*/
fun notifySyncComplete(context: Context, paymentCount: Int) {
val title = context.getString(R.string.notif_sync_complete_title)
val text = context.getString(R.string.notif_sync_complete_text, paymentCount)
notify(
context,
NotificationConstants.NOTIF_ID_SYNC_COMPLETE,
NotificationCompat.Builder(context, NotificationConstants.CHANNEL_SYNC)
.setSmallIcon(R.drawable.ic_notification)
.setContentTitle(title)
.setContentText(text)
.setAutoCancel(true)
.build()
)
}
// ── Foreground service notification ─────────────────────────────────────── // ── Foreground service notification ───────────────────────────────────────
fun buildServiceNotification(context: Context): Notification { fun buildServiceNotification(context: Context): Notification {
@@ -29,6 +29,10 @@ object WalletConstants {
// ── History pagination ──────────────────────────────────────────────────── // ── History pagination ────────────────────────────────────────────────────
const val PAYMENTS_PAGE_SIZE = 20 const val PAYMENTS_PAGE_SIZE = 20
// ── History sync ──────────────────────────────────────────────────────────
const val HISTORICAL_SYNC_WORK_NAME = "historical_payment_sync"
// ── Alias cache ─────────────────────────────────────────────────────────── // ── Alias cache ───────────────────────────────────────────────────────────
const val ALIAS_CACHE_TTL_MS = 3_600_000L // 1 hour — in-memory stale threshold const val ALIAS_CACHE_TTL_MS = 3_600_000L // 1 hour — in-memory stale threshold
const val ALIAS_PERSIST_TTL_MS = 21 * 24 * 60 * 60 * 1000L // 21 days — drop on load if older than this const val ALIAS_PERSIST_TTL_MS = 21 * 24 * 60 * 60 * 1000L // 21 days — drop on load if older than this
+10
View File
@@ -1,3 +1,4 @@
<?xml version="1.0" encoding="utf-8"?>
<resources> <resources>
<!-- NotificationHelper --> <!-- NotificationHelper -->
@@ -16,4 +17,13 @@
<string name="notif_service_title">Gudari Wallet</string> <string name="notif_service_title">Gudari Wallet</string>
<string name="notif_service_text">Monitorizando pagos entrantes</string> <string name="notif_service_text">Monitorizando pagos entrantes</string>
<!-- Sync notification channel -->
<string name="notif_channel_sync_name">Payment history sync</string>
<string name="notif_channel_sync_desc">Notifies when your historical payment sync completes</string>
<!-- Sync complete notification -->
<string name="notif_sync_complete_title">Payment history synced</string>
<string name="notif_sync_complete_text">%1$d payments downloaded successfully</string>
</resources> </resources>
+9
View File
@@ -17,4 +17,13 @@
<string name="notif_service_title">Gudari Wallet</string> <string name="notif_service_title">Gudari Wallet</string>
<string name="notif_service_text">Sarrerako ordainketak kontrolatzen</string> <string name="notif_service_text">Sarrerako ordainketak kontrolatzen</string>
<!-- Sync notification channel -->
<string name="notif_channel_sync_name">Payment history sync</string>
<string name="notif_channel_sync_desc">Notifies when your historical payment sync completes</string>
<!-- Sync complete notification -->
<string name="notif_sync_complete_title">Payment history synced</string>
<string name="notif_sync_complete_text">%1$d payments downloaded successfully</string>
</resources> </resources>
+9
View File
@@ -17,4 +17,13 @@
<string name="notif_service_title">Gudari Wallet</string> <string name="notif_service_title">Gudari Wallet</string>
<string name="notif_service_text">Monitoring for incoming payments</string> <string name="notif_service_text">Monitoring for incoming payments</string>
<!-- Sync notification channel -->
<string name="notif_channel_sync_name">Payment history sync</string>
<string name="notif_channel_sync_desc">Notifies when your historical payment sync completes</string>
<!-- Sync complete notification -->
<string name="notif_sync_complete_title">Payment history synced</string>
<string name="notif_sync_complete_text">%1$d payments downloaded successfully</string>
</resources> </resources>
+2
View File
@@ -29,6 +29,7 @@ ui = "1.11.2"
secp256k1-kmp = "0.23.0" secp256k1-kmp = "0.23.0"
lyricist = "1.8.0" lyricist = "1.8.0"
material3 = "1.4.0" material3 = "1.4.0"
work = "2.9.1"
[libraries] [libraries]
androidx-biometric = { module = "androidx.biometric:biometric", version.ref = "biometric" } androidx-biometric = { module = "androidx.biometric:biometric", version.ref = "biometric" }
@@ -73,6 +74,7 @@ secp256k1-kmp-android = { group = "fr.acinq.secp256k1", name = "secp256k1-kmp-jn
lyricist = { module = "cafe.adriel.lyricist:lyricist", version.ref = "lyricist" } lyricist = { module = "cafe.adriel.lyricist:lyricist", version.ref = "lyricist" }
lyricist-processor = { module = "cafe.adriel.lyricist:lyricist-processor", version.ref = "lyricist" } lyricist-processor = { module = "cafe.adriel.lyricist:lyricist-processor", version.ref = "lyricist" }
androidx-material3 = { group = "androidx.compose.material3", name = "material3", version.ref = "material3" } androidx-material3 = { group = "androidx.compose.material3", name = "material3", version.ref = "material3" }
androidx-work-runtime-ktx = { group = "androidx.work", name = "work-runtime-ktx", version.ref = "work" }
[plugins] [plugins]
android-application = { id = "com.android.application", version.ref = "agp" } android-application = { id = "com.android.application", version.ref = "agp" }