MainViewModel.kt

/*
 * This file is part of GNU Taler
 * (C) 2026 Taler Systems S.A.
 *
 * GNU Taler is free software; you can redistribute it and/or modify it under the
 * terms of the GNU General Public License as published by the Free Software
 * Foundation; either version 3, or (at your option) any later version.
 *
 * GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY
 * WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
 * A PARTICULAR PURPOSE.  See the GNU General Public License for more details.
 *
 * You should have received a copy of the GNU General Public License along with
 * GNU Taler; see the file COPYING.  If not, see <http://www.gnu.org/licenses/>
 */

package net.taler.wallet.main

import android.app.Application
import android.util.Log
import androidx.annotation.UiThread
import androidx.lifecycle.AndroidViewModel
import androidx.lifecycle.LiveData
import androidx.lifecycle.MutableLiveData
import androidx.lifecycle.viewModelScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.getAndUpdate
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
import net.taler.common.Amount
import net.taler.common.AmountParserException
import net.taler.common.Event
import net.taler.wallet.accounts.AccountManager
import net.taler.wallet.backend.BackendManager
import net.taler.wallet.backend.NotificationPayload
import net.taler.wallet.backend.NotificationReceiver
import net.taler.wallet.backend.TalerErrorCode
import net.taler.wallet.backend.TalerErrorInfo
import net.taler.wallet.backend.InitReceiver
import net.taler.wallet.backend.WalletBackendApi
import net.taler.wallet.backend.WalletDatabaseBackend
import net.taler.wallet.backend.WalletRunConfig
import net.taler.wallet.backend.WalletRunConfig.Features
import net.taler.wallet.backend.WalletRunConfig.Testing
import net.taler.wallet.balances.BalanceManager
import net.taler.wallet.balances.ScopeInfo
import net.taler.wallet.deposit.DepositManager
import net.taler.wallet.events.ObservabilityEvent
import net.taler.wallet.exchanges.ExchangeManager
import net.taler.wallet.payment.PaymentManager
import net.taler.wallet.peer.PeerManager
import net.taler.wallet.refund.RefundManager
import net.taler.wallet.settings.SettingsManager
import net.taler.wallet.transactions.TransactionManager
import net.taler.wallet.withdraw.WithdrawManager
import net.taler.wallet.BuildConfig
import net.taler.wallet.NetworkManager
import net.taler.wallet.backend.InitResponse
import net.taler.wallet.backend.MigrateDatabaseResponse
import net.taler.wallet.contacts.ContactManager
import net.taler.wallet.donau.DonauManager
import net.taler.wallet.mailbox.MailboxManager
import net.taler.wallet.tokens.TokenManager
import java.util.UUID

const val TAG = "taler-wallet"
const val OBSERVABILITY_LIMIT = 100

private val transactionNotifications = listOf(
    "transaction-state-transition",
)

private val observabilityNotifications = listOf(
    "task-observability-event",
    "request-observability-event",
)

data class NotifyMessage(
    val content: String,
    val actionLabel: String? = null,
    val onAction: (() -> Unit)? = null,
)
class MainViewModel(
    app: Application,
) : AndroidViewModel(app), InitReceiver, NotificationReceiver {

    private val mDevMode = MutableLiveData(BuildConfig.DEBUG)
    val devMode: LiveData<Boolean> = mDevMode

    val showProgressBar = MutableLiveData<Boolean>()
    var walletVersion: String? = null
        private set
    var walletVersionHash: String? = null
        private set
    var exchangeVersion: String? = null
        private set
    var merchantVersion: String? = null
        private set
    private val mDatabaseBackend = MutableStateFlow<WalletDatabaseBackend?>(null)
    val databaseBackend: StateFlow<WalletDatabaseBackend?> = mDatabaseBackend

    @set:Synchronized
    private var walletConfig = WalletRunConfig(
        testing = Testing(
            emitObservabilityEvents = true,
            devModeActive = devMode.value == true,
        ),
        features = Features(
            enableV1Contracts = true,
            useNativeDb = true,
        ),
        logLevel = if (devMode.value == true) "TRACE" else "INFO",
    )

    private val api = WalletBackendApi(app, walletConfig, this, this)

    val networkManager = NetworkManager(app.applicationContext)
    val exchangeManager: ExchangeManager = ExchangeManager(api, viewModelScope)
    val balanceManager = BalanceManager(api, viewModelScope, exchangeManager)
    val paymentManager = PaymentManager(api, viewModelScope, exchangeManager)
    val transactionManager: TransactionManager = TransactionManager(api, viewModelScope, exchangeManager)
    val refundManager = RefundManager(api, viewModelScope)
    val withdrawManager = WithdrawManager(api, viewModelScope, exchangeManager, transactionManager)
    val peerManager: PeerManager = PeerManager(api, exchangeManager, viewModelScope)
    val settingsManager: SettingsManager = SettingsManager(app.applicationContext, api, viewModelScope, balanceManager)
    val accountManager: AccountManager = AccountManager(api, viewModelScope)
    val depositManager: DepositManager = DepositManager(api, viewModelScope, balanceManager, exchangeManager)
    val tokenManager: TokenManager = TokenManager(api)
    val donauManager: DonauManager = DonauManager(api, viewModelScope, exchangeManager)
    val contactManager: ContactManager = ContactManager(api, viewModelScope)
    val mailboxManager: MailboxManager = MailboxManager(
        api = api,
        scope = viewModelScope,
        isDevMode = { devMode.value == true },
    ) { msg, actionLabel, onAction ->
        showInfoMessage(msg, actionLabel, onAction)
    }

    private val mAuthenticated = MutableStateFlow(false)
    val authenticated: StateFlow<Boolean> = mAuthenticated

    private val mTransactionsEvent = MutableLiveData<Event<ScopeInfo>>()
    val transactionsEvent: LiveData<Event<ScopeInfo>> = mTransactionsEvent

    private val mObservabilityLog = MutableStateFlow<List<ObservabilityEvent>>(emptyList())
    val observabilityLog: StateFlow<List<ObservabilityEvent>> = mObservabilityLog

    private val mShowObservabilityLog = MutableStateFlow(false)
    val showObservabilityLog: StateFlow<Boolean> = mShowObservabilityLog

    private val mScanCodeEvent = MutableLiveData<Event<Boolean>>()
    val scanCodeEvent: LiveData<Event<Boolean>> = mScanCodeEvent

    private val mShowMessageEvent = MutableLiveData<Event<NotifyMessage>>()
    val showMessageEvent: LiveData<Event<NotifyMessage>> = mShowMessageEvent
    private val mViewMode = MutableStateFlow(ViewMode())
    val viewMode: StateFlow<ViewMode> = mViewMode

    private val mRequestProgress = MutableStateFlow<RequestProgress?>(null)
    val requestProgress: StateFlow<RequestProgress?> = mRequestProgress

    private val mInitError = MutableStateFlow<TalerErrorInfo?>(null)
    val initError: StateFlow<TalerErrorInfo?> = mInitError

    private val mDatabaseMigrationState =
        MutableStateFlow<DatabaseMigrationState>(DatabaseMigrationState.None)
    val databaseMigrationState: StateFlow<DatabaseMigrationState> = mDatabaseMigrationState

    fun startWallet() {
        api.startWallet()
    }

    fun stopWallet() {
        api.stopWallet()
    }

    override fun onInitErrorReceived(error: TalerErrorInfo) {
        mInitError.value = error
    }

    override fun onInitReceived(init: InitResponse) {
        walletVersion = init.versionInfo.implementationSemver
        walletVersionHash = init.versionInfo.implementationGitHash
        exchangeVersion = init.versionInfo.exchange
        merchantVersion = init.versionInfo.merchant
        mDatabaseBackend.value = init.databaseBackend
        if (
            init.databaseBackend == WalletDatabaseBackend.IndexedDB &&
            mDatabaseMigrationState.value == DatabaseMigrationState.None
        ) {
            mDatabaseMigrationState.value = DatabaseMigrationState.Prompt
        }
        mInitError.value = null
        mailboxManager.start()
    }

    override fun onNotificationReceived(payload: NotificationPayload) {
        val str = BackendManager.json.encodeToString(payload)
        Log.i(TAG, "Received notification from wallet-core: $str")

        when (payload) {
            // Only update balances when we're told they changed
            is NotificationPayload.BalanceChange -> {
                viewModelScope.launch(Dispatchers.Main) {
                    balanceManager.loadAssets()
                }
            }

            is NotificationPayload.TransactionStateTransition -> {
                paymentManager.onTransactionStateTransition(payload)
                payload.transactionId?.let { id ->
                    transactionManager.onTransactionStateTransition(id)
                }
            }

            is NotificationPayload.TaskObservabilityEvent,
            is NotificationPayload.RequestObservabilityEvent -> {
                val event = when(payload) {
                    is NotificationPayload.TaskObservabilityEvent -> payload.event
                    is NotificationPayload.RequestObservabilityEvent -> payload.event
                }
                if (event != null) {
                    mObservabilityLog.getAndUpdate { logs ->
                        logs.takeLast(OBSERVABILITY_LIMIT)
                            .toMutableList().apply {
                                add(event)
                            }
                    }
                }
            }

            is NotificationPayload.DatabaseMaintenanceProgress -> {
                updateDatabaseMigrationProgress(payload)
            }

            is NotificationPayload.RequestProgressPhase -> {
                mRequestProgress.update { it.withPhase(payload) }
            }

            is NotificationPayload.RequestProgressError -> {
                mRequestProgress.update { it.withError(payload) }
            }

            else -> {}
        }
    }

    @UiThread
    fun lockWallet() {
        mAuthenticated.value = false
    }

    @UiThread
    fun unlockWallet() {
        mAuthenticated.value = true
    }

    fun setViewMode(v: ViewMode?) {
        val newMode = v ?: ViewMode()
        val current = mViewMode.value
        // Guard against redundant updates: recompositions from the pager, the
        // balances list and other screens all funnel through here, and each
        // change is persisted immediately.
        if (current.selectedScope == newMode.selectedScope) return
        mViewMode.value = newMode
        settingsManager.saveViewMode(newMode)
    }

    fun displayScope(scopeInfo: ScopeInfo?) {
        if (scopeInfo != null) {
            setViewMode(ViewMode(scopeInfo))
        } else {
            setViewMode(ViewMode())
        }
    }

    fun showAssets() {
        displayScope(null)
    }

    // FIXME: get rid of this ugliness! use wallet-core!
    @UiThread
    fun createAmount(amountText: String, currency: String, incoming: Boolean = false): AmountResult {
        val amount = try {
            Amount.fromString(currency, amountText)
        } catch (e: AmountParserException) {
            return AmountResult.InvalidAmount
        }
        if (incoming || balanceManager.hasSufficientBalance(amount)) return AmountResult.Success(amount)
        return AmountResult.InsufficientBalance(amount)
    }

    @UiThread
    fun dangerouslyReset() {
        withdrawManager.resetTestWithdrawal()
        balanceManager.resetBalances()
    }

    fun setDevMode(enabled: Boolean, onError: (error: TalerErrorInfo) -> Unit) {
        mDevMode.postValue(enabled)
        viewModelScope.launch {
            val config = walletConfig.copy(
                testing = walletConfig.testing?.copy(
                    devModeActive = enabled,
                ) ?: Testing(
                    devModeActive = enabled,
                ),
                logLevel = if (enabled) "TRACE" else "INFO",
            )

            api.setWalletConfig(config)
                .onSuccess {
                    walletConfig = config
                }.onError(onError)
        }
    }

    fun offerDatabaseMigration() {
        if (
            mDatabaseBackend.value == WalletDatabaseBackend.IndexedDB &&
            mDatabaseMigrationState.value !is DatabaseMigrationState.Migrating &&
            mDatabaseMigrationState.value !is DatabaseMigrationState.Cancelling
        ) {
            mDatabaseMigrationState.value = DatabaseMigrationState.Prompt
        }
    }

    fun deferDatabaseMigration() {
        if (
            mDatabaseMigrationState.value == DatabaseMigrationState.Prompt ||
            mDatabaseMigrationState.value is DatabaseMigrationState.Failed
        ) {
            mDatabaseMigrationState.value = DatabaseMigrationState.Deferred
        }
    }

    fun migrateDatabase() {
        if (mDatabaseBackend.value != WalletDatabaseBackend.IndexedDB) return
        if (
            mDatabaseMigrationState.value is DatabaseMigrationState.Migrating ||
            mDatabaseMigrationState.value is DatabaseMigrationState.Cancelling
        ) return

        val progressToken = UUID.randomUUID().toString()
        mDatabaseMigrationState.value = DatabaseMigrationState.Migrating(progressToken, 0)

        viewModelScope.launch {
            api.migrateDatabase(progressToken)
                .onSuccess { response ->
                    mDatabaseBackend.value = response.databaseBackend
                    mDatabaseMigrationState.value = response.toDatabaseMigrationState()
                }
                .onError { error ->
                    mDatabaseMigrationState.value =
                        mDatabaseMigrationState.value.withMigrationError(error)
                }
        }
    }

    fun cancelDatabaseMigration(onError: (error: TalerErrorInfo) -> Unit) {
        val state = mDatabaseMigrationState.value as? DatabaseMigrationState.Migrating ?: return
        mDatabaseMigrationState.value = DatabaseMigrationState.Cancelling(
            progressToken = state.progressToken,
            completionPercent = state.completionPercent,
        )
        viewModelScope.launch {
            api.cancelDatabaseMigration(state.progressToken).onError { error ->
                val current = mDatabaseMigrationState.value
                if (
                    current is DatabaseMigrationState.Cancelling &&
                    current.progressToken == state.progressToken
                ) {
                    mDatabaseMigrationState.value = DatabaseMigrationState.Migrating(
                        progressToken = state.progressToken,
                        completionPercent = current.completionPercent,
                    )
                    onError(error)
                }
            }
        }
    }

    fun acknowledgeDatabaseMigrationComplete() {
        if (mDatabaseMigrationState.value == DatabaseMigrationState.Complete) {
            mDatabaseMigrationState.value = DatabaseMigrationState.None
        }
    }

    fun consumeDatabaseMigrationFailure(): TalerErrorInfo? {
        val state = mDatabaseMigrationState.value as? DatabaseMigrationState.Failed ?: return null
        mDatabaseMigrationState.value = DatabaseMigrationState.Deferred
        return state.error
    }

    private fun updateDatabaseMigrationProgress(
        payload: NotificationPayload.DatabaseMaintenanceProgress,
    ) {
        mDatabaseMigrationState.value = mDatabaseMigrationState.value.withProgress(payload)
    }

    fun showObservabilityLog() {
        mShowObservabilityLog.value = true
    }

    fun hideObservabilityLog() {
        mShowObservabilityLog.value = false
    }

    fun hintNetworkAvailability(isAvailable: Boolean) {
        viewModelScope.launch {
            api.request<Unit>("hintNetworkAvailability") {
                put("isNetworkAvailable", isAvailable)
            }
        }
    }

    fun applyDevExperiment(
        uri: String,
        onSuccess: () -> Unit,
        onError: (error: TalerErrorInfo) -> Unit,
    ) {
        viewModelScope.launch {
            api.request<Unit>("applyDevExperiment") {
                put("devExperimentUri", uri)
            }
                .onSuccess { onSuccess() }
                .onError(onError)
        }
    }

    fun showInfoMessage(content: String, actionLabel: String? = null, onAction: (() -> Unit)? = null) {
        mShowMessageEvent.value = Event(NotifyMessage(content, actionLabel, onAction))
    }

    init {
        viewModelScope.launch {
            val viewMode = settingsManager.getViewMode().first()
            if (mViewMode.value.selectedScope == null) setViewMode(viewMode)
        }
    }
}

sealed class AmountResult {
    data class Success(val amount: Amount) : AmountResult()
    data class InsufficientBalance(val amount: Amount) : AmountResult()
    data object InvalidAmount : AmountResult()
}

sealed interface DatabaseMigrationState {
    data object None : DatabaseMigrationState
    data object Prompt : DatabaseMigrationState
    data object Deferred : DatabaseMigrationState
    data class Migrating(
        val progressToken: String,
        val completionPercent: Int,
    ) : DatabaseMigrationState

    data class Cancelling(
        val progressToken: String,
        val completionPercent: Int,
    ) : DatabaseMigrationState

    data object Complete : DatabaseMigrationState
    data class Failed(val error: TalerErrorInfo) : DatabaseMigrationState
}

internal fun DatabaseMigrationState.withProgress(
    payload: NotificationPayload.DatabaseMaintenanceProgress,
): DatabaseMigrationState {
    if (payload.operation != "indexeddb-to-native-migration") return this
    val percent = payload.completionPercent?.coerceIn(0, 100)

    return when (this) {
        is DatabaseMigrationState.Migrating -> {
            if (payload.progressToken != progressToken) this
            else copy(completionPercent = percent ?: completionPercent)
        }

        is DatabaseMigrationState.Cancelling -> {
            if (payload.progressToken != progressToken) this
            else copy(completionPercent = percent ?: completionPercent)
        }

        else -> this
    }
}

internal fun DatabaseMigrationState.withMigrationError(
    error: TalerErrorInfo,
): DatabaseMigrationState =
    if (
        this is DatabaseMigrationState.Cancelling &&
        error.code == TalerErrorCode.WALLET_CORE_REQUEST_CANCELLED
    ) {
        DatabaseMigrationState.Deferred
    } else {
        DatabaseMigrationState.Failed(error)
    }

internal fun MigrateDatabaseResponse.toDatabaseMigrationState(): DatabaseMigrationState =
    if (databaseBackend == WalletDatabaseBackend.Sqlite) {
        DatabaseMigrationState.Complete
    } else {
        DatabaseMigrationState.Failed(
            TalerErrorInfo.makeCustomError(
                message = "Database migration completed without switching to SQLite",
            ),
        )
    }