diff --git a/app/build.gradle.kts b/app/build.gradle.kts
index 71f222e..dcbe9d0 100644
--- a/app/build.gradle.kts
+++ b/app/build.gradle.kts
@@ -97,6 +97,9 @@ dependencies {
implementation(libs.androidx.lifecycle.viewmodel.ktx)
implementation(libs.androidx.lifecycle.viewmodel.compose)
+ // WorkManger
+ implementation(libs.androidx.work.runtime.ktx)
+
// DataStore + Proto + Tink
implementation(libs.datastore.proto)
implementation(libs.protobuf.kotlin.lite)
diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml
index 54e7572..27857b6 100644
--- a/app/src/main/AndroidManifest.xml
+++ b/app/src/main/AndroidManifest.xml
@@ -16,6 +16,7 @@
+
+
+
+
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 }
- }
}
diff --git a/app/src/main/java/com/bitcointxoko/gudariwallet/api/Adapters.kt b/app/src/main/java/com/bitcointxoko/gudariwallet/api/Adapters.kt
new file mode 100644
index 0000000..7f3cc26
--- /dev/null
+++ b/app/src/main/java/com/bitcointxoko/gudariwallet/api/Adapters.kt
@@ -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() {
+
+ 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()
+ 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()
+ }
+ }
+}
diff --git a/app/src/main/java/com/bitcointxoko/gudariwallet/api/LNbitsApi.kt b/app/src/main/java/com/bitcointxoko/gudariwallet/api/LNbitsApi.kt
index 3f69621..9f6764d 100644
--- a/app/src/main/java/com/bitcointxoko/gudariwallet/api/LNbitsApi.kt
+++ b/app/src/main/java/com/bitcointxoko/gudariwallet/api/LNbitsApi.kt
@@ -47,6 +47,16 @@ interface LNbitsApi {
@Header("X-Api-Key") apiKey: String
): List
+ /** 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")
suspend fun decodeInvoice(
diff --git a/app/src/main/java/com/bitcointxoko/gudariwallet/api/LNbitsClient.kt b/app/src/main/java/com/bitcointxoko/gudariwallet/api/LNbitsClient.kt
index 0aa62e0..83529a4 100644
--- a/app/src/main/java/com/bitcointxoko/gudariwallet/api/LNbitsClient.kt
+++ b/app/src/main/java/com/bitcointxoko/gudariwallet/api/LNbitsClient.kt
@@ -3,9 +3,10 @@ package com.bitcointxoko.gudariwallet.api
import com.bitcointxoko.gudariwallet.BuildConfig
import com.bitcointxoko.gudariwallet.security.SecretStore
import com.bitcointxoko.gudariwallet.util.WalletConstants
-import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.runBlocking
+import okhttp3.HttpUrl.Companion.toHttpUrl
+import okhttp3.Interceptor
import okhttp3.OkHttpClient
import okhttp3.logging.HttpLoggingInterceptor
import retrofit2.Retrofit
@@ -16,11 +17,10 @@ object LNbitsClient {
// Single shared client — used by both Retrofit and WebSocket
val httpClient: OkHttpClient = OkHttpClient.Builder()
- .connectTimeout(WalletConstants.CONNECT_TIMEOUT_S, TimeUnit.SECONDS) // time to establish TCP connection
- .readTimeout(WalletConstants.READ_TIMEOUT_S, TimeUnit.SECONDS) // time to wait for data (longer for WebSocket pings)
- .writeTimeout(WalletConstants.WRITE_TIMEOUT_S, TimeUnit.SECONDS) // time to send a request body
+ .connectTimeout(WalletConstants.CONNECT_TIMEOUT_S, TimeUnit.SECONDS)
+ .readTimeout(WalletConstants.READ_TIMEOUT_S, TimeUnit.SECONDS)
+ .writeTimeout(WalletConstants.WRITE_TIMEOUT_S, TimeUnit.SECONDS)
.apply {
- // Only log in debug builds — prevents API keys leaking in production logs
if (BuildConfig.DEBUG) {
addInterceptor(HttpLoggingInterceptor().apply {
level = HttpLoggingInterceptor.Level.BODY
@@ -30,14 +30,57 @@ object LNbitsClient {
.build()
fun create(secrets: SecretStore): LNbitsApi {
- val baseUrl = runBlocking(Dispatchers.IO) {
- secrets.credentials.first().baseUrl
- }.ifBlank { "https://placeholder.invalid" }
- val normalizedUrl = if (baseUrl.endsWith("/")) baseUrl else "$baseUrl/"
+ // Auth header interceptor — reads invoice key per-request (unchanged)
+ val authInterceptor = Interceptor { chain ->
+ val key = runBlocking { secrets.invoiceKey() }
+ 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()
- .baseUrl(normalizedUrl)
- .client(httpClient)
+ .baseUrl("https://placeholder.invalid/") // structurally required by Retrofit;
+ // always overwritten by the interceptor
+ .client(client)
.addConverterFactory(GsonConverterFactory.create(GsonProvider.gson))
.build()
.create(LNbitsApi::class.java)
diff --git a/app/src/main/java/com/bitcointxoko/gudariwallet/api/Models.kt b/app/src/main/java/com/bitcointxoko/gudariwallet/api/Models.kt
index 58d3d90..7395a95 100644
--- a/app/src/main/java/com/bitcointxoko/gudariwallet/api/Models.kt
+++ b/app/src/main/java/com/bitcointxoko/gudariwallet/api/Models.kt
@@ -1,6 +1,7 @@
package com.bitcointxoko.gudariwallet.api
import com.google.gson.TypeAdapter
+import com.google.gson.annotations.JsonAdapter
import com.google.gson.annotations.SerializedName
import com.google.gson.stream.JsonReader
import com.google.gson.stream.JsonToken
@@ -205,7 +206,8 @@ data class PaymentRecord(
data class PaymentExtra(
@SerializedName("tag") val tag: String?,
- @SerializedName("comment") val comment: String?,
+ @JsonAdapter(FlexibleStringAdapter::class)
+ val comment: String?,
@SerializedName("success_action") val successAction: SuccessAction?,
@SerializedName("wallet_id") val walletId: String?,
@SerializedName("destination") val destination: String?,
@@ -308,4 +310,10 @@ data class WsPayment(
data class FiatRateResponse(
val rate: Double, // sat to fiat rate
val price : Double // BTC price in the requested fiat currency
-)
\ No newline at end of file
+)
+
+// ── History sync ─────────────────────────────────────────────────────────────
+data class PaginatedPaymentsResponse(
+ @SerializedName("data") val data: List,
+ @SerializedName("total") val total: Int
+)
diff --git a/app/src/main/java/com/bitcointxoko/gudariwallet/data/HistoricalSyncStore.kt b/app/src/main/java/com/bitcointxoko/gudariwallet/data/HistoricalSyncStore.kt
new file mode 100644
index 0000000..897eabe
--- /dev/null
+++ b/app/src/main/java/com/bitcointxoko/gudariwallet/data/HistoricalSyncStore.kt
@@ -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 =
+ context.historicalSyncDataStore.data.map { it[KEY_COMPLETE] == true }
+}
diff --git a/app/src/main/java/com/bitcointxoko/gudariwallet/data/LNbitsWalletRepository.kt b/app/src/main/java/com/bitcointxoko/gudariwallet/data/LNbitsWalletRepository.kt
index 7a89aaa..c18f11c 100644
--- a/app/src/main/java/com/bitcointxoko/gudariwallet/data/LNbitsWalletRepository.kt
+++ b/app/src/main/java/com/bitcointxoko/gudariwallet/data/LNbitsWalletRepository.kt
@@ -280,6 +280,9 @@ class LNbitsWalletRepository(
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 {
return api.getPaymentDetail(secrets.invoiceKey(), checkingId)
}
diff --git a/app/src/main/java/com/bitcointxoko/gudariwallet/data/WalletRepository.kt b/app/src/main/java/com/bitcointxoko/gudariwallet/data/WalletRepository.kt
index fe761e6..05d7a3d 100644
--- a/app/src/main/java/com/bitcointxoko/gudariwallet/data/WalletRepository.kt
+++ b/app/src/main/java/com/bitcointxoko/gudariwallet/data/WalletRepository.kt
@@ -1,6 +1,7 @@
package com.bitcointxoko.gudariwallet.data
import com.bitcointxoko.gudariwallet.api.LnurlScanResponse
+import com.bitcointxoko.gudariwallet.api.PaginatedPaymentsResponse
import com.bitcointxoko.gudariwallet.api.PaymentDetailResponse
import com.bitcointxoko.gudariwallet.api.PaymentRecord
import com.bitcointxoko.gudariwallet.api.WithdrawCallbackResponse
@@ -36,6 +37,7 @@ interface WalletRepository {
): WithdrawCallbackResponse
suspend fun getLightningAddress(): String?
suspend fun getPayments(offset: Int, limit: Int): List
+ suspend fun getPaymentsPaginated(offset: Int, limit: Int): PaginatedPaymentsResponse
suspend fun getPaymentDetail(checkingId: String): PaymentDetailResponse
suspend fun getFiatRate(currency: String): Double
suspend fun getSupportedCurrencies(): List
diff --git a/app/src/main/java/com/bitcointxoko/gudariwallet/security/SecretStore.kt b/app/src/main/java/com/bitcointxoko/gudariwallet/security/SecretStore.kt
index 346cf8d..01e9d19 100644
--- a/app/src/main/java/com/bitcointxoko/gudariwallet/security/SecretStore.kt
+++ b/app/src/main/java/com/bitcointxoko/gudariwallet/security/SecretStore.kt
@@ -18,6 +18,9 @@ interface SecretStore {
suspend fun isOnboarded(): Boolean
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()
}
@@ -58,6 +61,33 @@ class EncryptedSecretStore(context: Context) : SecretStore {
}
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() {
dataStore.updateData { current ->
current.toBuilder()
diff --git a/app/src/main/java/com/bitcointxoko/gudariwallet/sync/HistoricalSyncWorker.kt b/app/src/main/java/com/bitcointxoko/gudariwallet/sync/HistoricalSyncWorker.kt
new file mode 100644
index 0000000..834fb1a
--- /dev/null
+++ b/app/src/main/java/com/bitcointxoko/gudariwallet/sync/HistoricalSyncWorker.kt
@@ -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()
+ }
+}
diff --git a/app/src/main/java/com/bitcointxoko/gudariwallet/sync/HistoricalSyncWorkerFactory.kt b/app/src/main/java/com/bitcointxoko/gudariwallet/sync/HistoricalSyncWorkerFactory.kt
new file mode 100644
index 0000000..6d425a7
--- /dev/null
+++ b/app/src/main/java/com/bitcointxoko/gudariwallet/sync/HistoricalSyncWorkerFactory.kt
@@ -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
+}
diff --git a/app/src/main/java/com/bitcointxoko/gudariwallet/ui/onboarding/OnboardingPages.kt b/app/src/main/java/com/bitcointxoko/gudariwallet/ui/onboarding/OnboardingPages.kt
index 5cc2d33..c35e80e 100644
--- a/app/src/main/java/com/bitcointxoko/gudariwallet/ui/onboarding/OnboardingPages.kt
+++ b/app/src/main/java/com/bitcointxoko/gudariwallet/ui/onboarding/OnboardingPages.kt
@@ -17,16 +17,29 @@ import androidx.compose.foundation.layout.size
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.material.icons.Icons
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.Visibility
import androidx.compose.material.icons.filled.VisibilityOff
+import androidx.compose.material3.AlertDialog
import androidx.compose.material3.Button
+import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
+import androidx.compose.material3.LinearProgressIndicator
import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.OutlinedButton
import androidx.compose.material3.OutlinedTextField
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.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.Modifier
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.unit.dp
import androidx.core.net.toUri
+import androidx.work.WorkInfo
import com.bitcointxoko.gudariwallet.i18n.AppStrings
+import com.bitcointxoko.gudariwallet.sync.HistoricalSyncWorker
// ── Page 0: Welcome ────────────────────────────────────────────────────────────
@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) ─────────────────────────
private fun openBatterySettings(context: Context) {
val appDetailsIntent = Intent(Settings.ACTION_APPLICATION_DETAILS_SETTINGS).apply {
diff --git a/app/src/main/java/com/bitcointxoko/gudariwallet/ui/onboarding/OnboardingScreen.kt b/app/src/main/java/com/bitcointxoko/gudariwallet/ui/onboarding/OnboardingScreen.kt
index e8294f9..04ffc38 100644
--- a/app/src/main/java/com/bitcointxoko/gudariwallet/ui/onboarding/OnboardingScreen.kt
+++ b/app/src/main/java/com/bitcointxoko/gudariwallet/ui/onboarding/OnboardingScreen.kt
@@ -11,8 +11,13 @@ import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
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.data.HistoricalSyncStore
import com.bitcointxoko.gudariwallet.security.SecretStore
+import com.bitcointxoko.gudariwallet.sync.HistoricalSyncWorker
import kotlinx.coroutines.launch
private const val PAGE_WELCOME = 0
@@ -21,37 +26,50 @@ private const val PAGE_INVOICE = 2
private const val PAGE_ADMIN = 3
private const val PAGE_NOTIFICATIONS = 4
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
fun OnboardingScreen(
- secretStore: SecretStore,
- onRequestNotificationPermission: () -> Unit, // ← new parameter
- onComplete: () -> Unit
+ secretStore : SecretStore,
+ syncStore : HistoricalSyncStore, // ← new
+ onRequestNotificationPermission: () -> Unit,
+ onComplete : () -> Unit
) {
val strings = LocalAppStrings.current
val scope = rememberCoroutineScope()
+ // ── Credential state (unchanged) ──────────────────────────────────────────
var baseUrl by remember { mutableStateOf("https://bitcointxoko.org") }
var invoiceKey by remember { mutableStateOf("") }
var adminKey by remember { mutableStateOf("") }
var adminVisible by remember { mutableStateOf(false) }
var error by remember { mutableStateOf(null) }
-
- // Track whether credentials have been saved (so we don't save twice on back/forward)
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 })
LaunchedEffect(pagerState.currentPage) { error = null }
fun validateCurrentPage(): Boolean {
error = when (pagerState.currentPage) {
- 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_ADMIN -> if (adminKey.isBlank()) strings.onboarding.fieldsRequired else null
- else -> null // PAGE_NOTIFICATIONS and PAGE_BATTERY always pass
+ PAGE_ADMIN -> if (adminKey.isBlank()) strings.onboarding.fieldsRequired else null
+ else -> null
}
return error == null
}
@@ -65,8 +83,6 @@ fun OnboardingScreen(
HorizontalPager(
state = pagerState,
modifier = Modifier.weight(1f),
- // Prevent swiping back past the admin page once credentials are saved,
- // and prevent skipping the permission pages entirely
userScrollEnabled = true
) { page ->
when (page) {
@@ -82,10 +98,23 @@ fun OnboardingScreen(
error = error
)
PAGE_NOTIFICATIONS -> NotificationsPage(
- strings = strings,
- onRequestNotificationPermission = onRequestNotificationPermission
+ strings = strings,
+ onRequestNotificationPermission = onRequestNotificationPermission
)
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,56 +125,45 @@ fun OnboardingScreen(
.padding(vertical = 12.dp)
)
- Row(
- modifier = Modifier
- .fillMaxWidth()
- .padding(horizontal = 24.dp, vertical = 16.dp),
- horizontalArrangement = Arrangement.SpaceBetween,
- verticalAlignment = Alignment.CenterVertically
- ) {
- if (pagerState.currentPage > PAGE_WELCOME) {
- OutlinedButton(onClick = {
- scope.launch {
- pagerState.animateScrollToPage(pagerState.currentPage - 1)
- }
- }) {
- Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = null)
- Spacer(Modifier.width(4.dp))
- Text(strings.onboarding.back)
- }
- } else {
- Spacer(Modifier.width(1.dp))
- }
-
- val isLastPage = pagerState.currentPage == PAGE_BATTERY
-
- Button(onClick = {
- if (!validateCurrentPage()) return@Button
-
- when (pagerState.currentPage) {
- PAGE_ADMIN -> {
- if (!credentialsSaved) {
- scope.launch {
- secretStore.saveCredentials(baseUrl, invoiceKey, adminKey)
- credentialsSaved = true
- pagerState.animateScrollToPage(PAGE_NOTIFICATIONS)
- }
- } else {
- scope.launch { pagerState.animateScrollToPage(PAGE_NOTIFICATIONS) }
- }
- }
- PAGE_BATTERY -> {
+ // ── Bottom nav row ────────────────────────────────────────────────
+ // Hidden on PAGE_SYNC — that page manages its own Continue button
+ if (pagerState.currentPage != PAGE_SYNC) {
+ Row(
+ modifier = Modifier
+ .fillMaxWidth()
+ .padding(horizontal = 24.dp, vertical = 16.dp),
+ horizontalArrangement = Arrangement.SpaceBetween,
+ verticalAlignment = Alignment.CenterVertically
+ ) {
+ if (pagerState.currentPage > PAGE_WELCOME) {
+ OutlinedButton(onClick = {
scope.launch {
- secretStore.setOnboarded()
+ pagerState.animateScrollToPage(pagerState.currentPage - 1)
}
+ }) {
+ Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = null)
+ Spacer(Modifier.width(4.dp))
+ Text(strings.onboarding.back)
}
- else -> scope.launch {
+ } else {
+ Spacer(Modifier.width(1.dp))
+ }
+
+ val isLastPage = pagerState.currentPage == PAGE_BATTERY
+
+ Button(onClick = {
+ scope.launch {
+ // Save each field as soon as the user advances past it
+ when (pagerState.currentPage) {
+ PAGE_SERVER_URL -> secretStore.saveBaseUrl(baseUrl)
+ PAGE_INVOICE -> secretStore.saveInvoiceKey(invoiceKey)
+ PAGE_ADMIN -> secretStore.saveAdminKey(adminKey)
+ else -> Unit
+ }
pagerState.animateScrollToPage(pagerState.currentPage + 1)
}
- }
- }) {
- Text(if (isLastPage) strings.onboarding.done else strings.onboarding.next)
- if (!isLastPage) {
+ }) {
+ Text(if (isLastPage) strings.onboarding.next else strings.onboarding.next)
Spacer(Modifier.width(4.dp))
Icon(Icons.AutoMirrored.Filled.ArrowForward, contentDescription = null)
}
@@ -154,4 +172,3 @@ fun OnboardingScreen(
}
}
}
-
diff --git a/app/src/main/java/com/bitcointxoko/gudariwallet/ui/onboarding/OnboardingViewModel.kt b/app/src/main/java/com/bitcointxoko/gudariwallet/ui/onboarding/OnboardingViewModel.kt
index af90156..754f9c8 100644
--- a/app/src/main/java/com/bitcointxoko/gudariwallet/ui/onboarding/OnboardingViewModel.kt
+++ b/app/src/main/java/com/bitcointxoko/gudariwallet/ui/onboarding/OnboardingViewModel.kt
@@ -1,19 +1,41 @@
package com.bitcointxoko.gudariwallet.ui.onboarding
+import android.app.Application
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.setValue
-import androidx.lifecycle.ViewModel
+import androidx.lifecycle.AndroidViewModel
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.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 java.util.concurrent.TimeUnit
-class OnboardingViewModel(private val secretStore: SecretStore) : ViewModel() {
- var baseUrl by mutableStateOf("https://bitcointxoko.org")
- var invoiceKey by mutableStateOf("")
- var adminKey by mutableStateOf("")
+class OnboardingViewModel(
+ app: Application,
+ private val secretStore: SecretStore,
+ val syncStore: HistoricalSyncStore // exposed so the screen can observe isCompleteFlow
+) : AndroidViewModel(app) {
+
+ var baseUrl by mutableStateOf("https://bitcointxoko.org")
+ var invoiceKey by mutableStateOf("")
+ var adminKey by mutableStateOf("")
var adminKeyVisible by mutableStateOf(false)
- var error by mutableStateOf(null)
+ var error by mutableStateOf(null)
+
+ // ── Credentials ───────────────────────────────────────────────────────────
fun saveAndComplete(onComplete: () -> Unit) {
viewModelScope.launch {
@@ -21,4 +43,46 @@ class OnboardingViewModel(private val secretStore: SecretStore) : ViewModel() {
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()
+ .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 =
+ WorkManager.getInstance(getApplication())
+ .getWorkInfosForUniqueWorkFlow(WalletConstants.HISTORICAL_SYNC_WORK_NAME)
+ .map { it.firstOrNull() }
+ .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), null)
}
diff --git a/app/src/main/java/com/bitcointxoko/gudariwallet/ui/onboarding/OnboardingViewModelFactory.kt b/app/src/main/java/com/bitcointxoko/gudariwallet/ui/onboarding/OnboardingViewModelFactory.kt
new file mode 100644
index 0000000..ebcd85b
--- /dev/null
+++ b/app/src/main/java/com/bitcointxoko/gudariwallet/ui/onboarding/OnboardingViewModelFactory.kt
@@ -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 create(modelClass: Class): T {
+ @Suppress("UNCHECKED_CAST")
+ return OnboardingViewModel(app, secretStore, syncStore) as T
+ }
+}
diff --git a/app/src/main/java/com/bitcointxoko/gudariwallet/util/NotificationConstants.kt b/app/src/main/java/com/bitcointxoko/gudariwallet/util/NotificationConstants.kt
index 7e0c67e..b32e410 100644
--- a/app/src/main/java/com/bitcointxoko/gudariwallet/util/NotificationConstants.kt
+++ b/app/src/main/java/com/bitcointxoko/gudariwallet/util/NotificationConstants.kt
@@ -5,6 +5,7 @@ object NotificationConstants {
// ── Channel IDs ───────────────────────────────────────────────────────────
const val CHANNEL_PAYMENTS = "gudari_payments"
const val CHANNEL_SERVICE_STATUS = "gudari_service"
+ const val CHANNEL_SYNC = "gudari_sync"
// ── Notification IDs ──────────────────────────────────────────────────────
// 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_PAYMENT_RECEIVED = 1001
const val NOTIF_ID_PAYMENT_SENT = 1002
+ const val NOTIF_ID_SYNC_COMPLETE = 1003
}
\ No newline at end of file
diff --git a/app/src/main/java/com/bitcointxoko/gudariwallet/util/NotificationHelper.kt b/app/src/main/java/com/bitcointxoko/gudariwallet/util/NotificationHelper.kt
index 1cd3d91..5d88f01 100644
--- a/app/src/main/java/com/bitcointxoko/gudariwallet/util/NotificationHelper.kt
+++ b/app/src/main/java/com/bitcointxoko/gudariwallet/util/NotificationHelper.kt
@@ -45,6 +45,18 @@ object NotificationHelper {
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 ─────────────────────────────────────────────
@@ -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 ───────────────────────────────────────
fun buildServiceNotification(context: Context): Notification {
diff --git a/app/src/main/java/com/bitcointxoko/gudariwallet/util/WalletConstants.kt b/app/src/main/java/com/bitcointxoko/gudariwallet/util/WalletConstants.kt
index ecf42de..918cc9d 100644
--- a/app/src/main/java/com/bitcointxoko/gudariwallet/util/WalletConstants.kt
+++ b/app/src/main/java/com/bitcointxoko/gudariwallet/util/WalletConstants.kt
@@ -29,6 +29,10 @@ object WalletConstants {
// ── History pagination ────────────────────────────────────────────────────
const val PAYMENTS_PAGE_SIZE = 20
+
+ // ── History sync ──────────────────────────────────────────────────────────
+ const val HISTORICAL_SYNC_WORK_NAME = "historical_payment_sync"
+
// ── Alias cache ───────────────────────────────────────────────────────────
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
diff --git a/app/src/main/res/values-es/strings.xml b/app/src/main/res/values-es/strings.xml
index 6bee854..15e07e7 100644
--- a/app/src/main/res/values-es/strings.xml
+++ b/app/src/main/res/values-es/strings.xml
@@ -1,3 +1,4 @@
+
@@ -16,4 +17,13 @@
Gudari Wallet
Monitorizando pagos entrantes
+
+ Payment history sync
+ Notifies when your historical payment sync completes
+
+
+ Payment history synced
+ %1$d payments downloaded successfully
+
+
\ No newline at end of file
diff --git a/app/src/main/res/values-eu/strings.xml b/app/src/main/res/values-eu/strings.xml
index 14599e5..a377524 100644
--- a/app/src/main/res/values-eu/strings.xml
+++ b/app/src/main/res/values-eu/strings.xml
@@ -17,4 +17,13 @@
Gudari Wallet
Sarrerako ordainketak kontrolatzen
+
+ Payment history sync
+ Notifies when your historical payment sync completes
+
+
+ Payment history synced
+ %1$d payments downloaded successfully
+
+
diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml
index 8c12949..6acda8b 100644
--- a/app/src/main/res/values/strings.xml
+++ b/app/src/main/res/values/strings.xml
@@ -17,4 +17,13 @@
Gudari Wallet
Monitoring for incoming payments
+
+ Payment history sync
+ Notifies when your historical payment sync completes
+
+
+ Payment history synced
+ %1$d payments downloaded successfully
+
+
\ No newline at end of file
diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml
index c1ae03b..3670f91 100644
--- a/gradle/libs.versions.toml
+++ b/gradle/libs.versions.toml
@@ -29,6 +29,7 @@ ui = "1.11.2"
secp256k1-kmp = "0.23.0"
lyricist = "1.8.0"
material3 = "1.4.0"
+work = "2.9.1"
[libraries]
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-processor = { module = "cafe.adriel.lyricist:lyricist-processor", version.ref = "lyricist" }
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]
android-application = { id = "com.android.application", version.ref = "agp" }