feat: add history sync worker
This commit is contained in:
@@ -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)
|
||||
|
||||
@@ -16,6 +16,7 @@
|
||||
<uses-feature android:name="android.hardware.nfc.hce" android:required="false" />
|
||||
|
||||
<application
|
||||
android:name=".GudariWalletApplication"
|
||||
android:allowBackup="true"
|
||||
android:icon="@mipmap/ic_launcher"
|
||||
android:label="@string/app_name"
|
||||
@@ -24,6 +25,17 @@
|
||||
android:networkSecurityConfig="@xml/network_security_config"
|
||||
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
|
||||
android:name=".MainActivity"
|
||||
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.getValue
|
||||
import cafe.adriel.lyricist.ProvideStrings
|
||||
import com.bitcointxoko.gudariwallet.data.HistoricalSyncStore
|
||||
|
||||
class MainActivity : AppCompatActivity() {
|
||||
|
||||
@@ -58,8 +59,6 @@ class MainActivity : AppCompatActivity() {
|
||||
this, 0,
|
||||
Intent(this, javaClass).apply {
|
||||
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
|
||||
)
|
||||
@@ -89,6 +88,7 @@ class MainActivity : AppCompatActivity() {
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
secretStore = EncryptedSecretStore(applicationContext)
|
||||
val syncStore = HistoricalSyncStore(applicationContext)
|
||||
val credentialsState = secretStore.credentials
|
||||
.stateIn(
|
||||
scope = lifecycleScope,
|
||||
@@ -97,7 +97,7 @@ class MainActivity : AppCompatActivity() {
|
||||
)
|
||||
|
||||
// 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)
|
||||
pendingPaymentHash.value =
|
||||
@@ -127,7 +127,6 @@ class MainActivity : AppCompatActivity() {
|
||||
LaunchedEffect(Unit) {
|
||||
WalletNotificationService.start(this@MainActivity)
|
||||
requestNotificationPermissionIfNeeded()
|
||||
// promptBatteryOptimizationIfNeeded()
|
||||
}
|
||||
WalletScreen(
|
||||
vm = vm,
|
||||
@@ -139,11 +138,9 @@ class MainActivity : AppCompatActivity() {
|
||||
} else {
|
||||
OnboardingScreen(
|
||||
secretStore = secretStore,
|
||||
syncStore = syncStore,
|
||||
onRequestNotificationPermission = { requestNotificationPermissionIfNeeded() }, // ← new
|
||||
onComplete = {
|
||||
// WalletNotificationService.start(this@MainActivity)
|
||||
// No permission/battery calls here — handled in-flow
|
||||
}
|
||||
onComplete = {}
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -218,37 +215,4 @@ class MainActivity : AppCompatActivity() {
|
||||
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
|
||||
): 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")
|
||||
suspend fun decodeInvoice(
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
)
|
||||
)
|
||||
|
||||
// ── 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)
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
|
||||
@@ -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<PaymentRecord>
|
||||
suspend fun getPaymentsPaginated(offset: Int, limit: Int): PaginatedPaymentsResponse
|
||||
suspend fun getPaymentDetail(checkingId: String): PaymentDetailResponse
|
||||
suspend fun getFiatRate(currency: String): Double
|
||||
suspend fun getSupportedCurrencies(): List<String>
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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.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 {
|
||||
|
||||
@@ -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<String?>(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(
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+70
-6
@@ -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<String?>(null)
|
||||
var error by mutableStateOf<String?>(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<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)
|
||||
}
|
||||
|
||||
+18
@@ -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 ───────────────────────────────────────────────────────────
|
||||
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
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources>
|
||||
|
||||
<!-- NotificationHelper -->
|
||||
@@ -16,4 +17,13 @@
|
||||
<string name="notif_service_title">Gudari Wallet</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>
|
||||
@@ -17,4 +17,13 @@
|
||||
<string name="notif_service_title">Gudari Wallet</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>
|
||||
|
||||
@@ -17,4 +17,13 @@
|
||||
<string name="notif_service_title">Gudari Wallet</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>
|
||||
@@ -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" }
|
||||
|
||||
Reference in New Issue
Block a user