From 3b14f2fba9b0e0f777a07f0ec952cccfb89d8b84 Mon Sep 17 00:00:00 2001 From: rasputin Date: Sun, 7 Jun 2026 17:16:47 +0200 Subject: [PATCH] feat: add history sync worker --- app/build.gradle.kts | 3 + app/src/main/AndroidManifest.xml | 12 + .../gudariwallet/GudariWalletApplication.kt | 33 +++ .../bitcointxoko/gudariwallet/MainActivity.kt | 46 +--- .../bitcointxoko/gudariwallet/api/Adapters.kt | 41 +++ .../gudariwallet/api/LNbitsApi.kt | 10 + .../gudariwallet/api/LNbitsClient.kt | 65 ++++- .../bitcointxoko/gudariwallet/api/Models.kt | 12 +- .../gudariwallet/data/HistoricalSyncStore.kt | 28 ++ .../data/LNbitsWalletRepository.kt | 3 + .../gudariwallet/data/WalletRepository.kt | 2 + .../gudariwallet/security/SecretStore.kt | 30 +++ .../gudariwallet/sync/HistoricalSyncWorker.kt | 77 ++++++ .../sync/HistoricalSyncWorkerFactory.kt | 26 ++ .../ui/onboarding/OnboardingPages.kt | 240 ++++++++++++++++++ .../ui/onboarding/OnboardingScreen.kt | 137 +++++----- .../ui/onboarding/OnboardingViewModel.kt | 76 +++++- .../onboarding/OnboardingViewModelFactory.kt | 18 ++ .../util/NotificationConstants.kt | 2 + .../gudariwallet/util/NotificationHelper.kt | 32 +++ .../gudariwallet/util/WalletConstants.kt | 4 + app/src/main/res/values-es/strings.xml | 10 + app/src/main/res/values-eu/strings.xml | 9 + app/src/main/res/values/strings.xml | 9 + gradle/libs.versions.toml | 2 + 25 files changed, 807 insertions(+), 120 deletions(-) create mode 100644 app/src/main/java/com/bitcointxoko/gudariwallet/GudariWalletApplication.kt create mode 100644 app/src/main/java/com/bitcointxoko/gudariwallet/api/Adapters.kt create mode 100644 app/src/main/java/com/bitcointxoko/gudariwallet/data/HistoricalSyncStore.kt create mode 100644 app/src/main/java/com/bitcointxoko/gudariwallet/sync/HistoricalSyncWorker.kt create mode 100644 app/src/main/java/com/bitcointxoko/gudariwallet/sync/HistoricalSyncWorkerFactory.kt create mode 100644 app/src/main/java/com/bitcointxoko/gudariwallet/ui/onboarding/OnboardingViewModelFactory.kt 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" }