Files
gudari/app/src/main/java/com/bitcointxoko/gudariwallet/MainActivity.kt
T

244 lines
9.9 KiB
Kotlin

package com.bitcointxoko.gudariwallet
import android.Manifest
import android.app.PendingIntent
import android.content.ActivityNotFoundException
import android.content.Intent
import android.content.IntentFilter
import android.nfc.NfcAdapter
import android.nfc.Tag
import android.os.Build
import android.os.Bundle
import android.os.PowerManager
import android.provider.Settings
import androidx.activity.compose.setContent
import androidx.activity.enableEdgeToEdge
import androidx.activity.result.contract.ActivityResultContracts
import androidx.activity.viewModels
import androidx.appcompat.app.AlertDialog
import androidx.appcompat.app.AppCompatActivity
import androidx.compose.runtime.*
import androidx.core.content.edit
import androidx.core.net.toUri
import androidx.lifecycle.lifecycleScope
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.stateIn
import com.bitcointxoko.gudariwallet.security.EncryptedSecretStore
import com.bitcointxoko.gudariwallet.service.WalletNotificationService
import com.bitcointxoko.gudariwallet.ui.OnboardingScreen
import com.bitcointxoko.gudariwallet.ui.WalletScreen
import com.bitcointxoko.gudariwallet.ui.WalletViewModel
import com.bitcointxoko.gudariwallet.ui.WalletViewModelFactory
import com.bitcointxoko.gudariwallet.ui.nfc.NfcViewModel
import com.bitcointxoko.gudariwallet.ui.theme.GudariWalletTheme
import com.bitcointxoko.gudariwallet.util.NotificationHelper
import com.bitcointxoko.gudariwallet.util.IntentUtils
import kotlinx.coroutines.flow.MutableSharedFlow
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
class MainActivity : AppCompatActivity() {
private lateinit var secretStore: EncryptedSecretStore
private val vm: WalletViewModel by viewModels {
WalletViewModelFactory(application)
}
// ── NFC ──────────────────────────────────────────────────────────────────
val nfcVm: NfcViewModel by viewModels()
private var nfcAdapter: NfcAdapter? = null
/** Pending intent used by foreground dispatch — routes tag intents back here. */
private val nfcPendingIntent: PendingIntent by lazy {
PendingIntent.getActivity(
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
)
}
// ── Existing state ───────────────────────────────────────────────────────
// Holds a paymentHash that arrived via notification tap.
// WalletScreen observes this and navigates to the detail screen.
// MutableState so Compose recomposes when it changes.
private val pendingPaymentHash = mutableStateOf<String?>(null)
// Holds a payment URI that arrived via deep link / intent (BTCPay, external apps).
private val pendingPaymentUri = mutableStateOf<String?>(null)
private val requestNotificationPermission =
registerForActivityResult(ActivityResultContracts.RequestPermission()) { /* informational */ }
// Broadcasts window focus gains to any observer in the composition
val windowFocusEvents = MutableSharedFlow<Unit>(extraBufferCapacity = 1)
override fun onWindowFocusChanged(hasFocus: Boolean) {
super.onWindowFocusChanged(hasFocus)
if (hasFocus) windowFocusEvents.tryEmit(Unit)
}
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
secretStore = EncryptedSecretStore(applicationContext)
val credentialsState = secretStore.credentials
.stateIn(
scope = lifecycleScope,
started = SharingStarted.WhileSubscribed(5_000),
initialValue = null
)
// Must be first — before any notification is posted
NotificationHelper.createChannels(applicationContext)
// Read hash from the intent that launched the activity (cold start from notification)
pendingPaymentHash.value =
intent.getStringExtra(NotificationHelper.EXTRA_PAYMENT_HASH)
// Read payment URI from the intent that launched the activity (cold start from deep link)
pendingPaymentUri.value = IntentUtils.extractPaymentUri(intent)
// Initialise NFC adapter (null on devices without NFC — handled gracefully)
nfcAdapter = NfcAdapter.getDefaultAdapter(this)
nfcVm.onNfcIntent(intent)
enableEdgeToEdge()
setContent {
GudariWalletTheme {
val credentials by credentialsState.collectAsState()
// Treat null (loading) the same as not-onboarded to avoid a flash
val isOnboarded = credentials?.isOnboarded == true
if (isOnboarded) {
LaunchedEffect(Unit) {
WalletNotificationService.start(this@MainActivity)
requestNotificationPermissionIfNeeded()
promptBatteryOptimizationIfNeeded()
}
WalletScreen(
vm = vm,
nfcVm = nfcVm,
pendingPaymentHash = pendingPaymentHash,
pendingPaymentUri = pendingPaymentUri
)
} else {
OnboardingScreen(
secretStore = secretStore,
onComplete = { /* no-op: Flow update triggers recomposition automatically */ }
)
}
}
}
}
// ── NFC foreground dispatch ───────────────────────────────────────────────
override fun onResume() {
super.onResume()
// Restrict to NDEF only — avoids TAG_DISCOVERED BAL blocks for non-NDEF tags
val intentFilters = arrayOf(
IntentFilter(NfcAdapter.ACTION_NDEF_DISCOVERED).also { f ->
try { f.addDataType("*/*") } catch (_: IntentFilter.MalformedMimeTypeException) {}
}
)
val techLists = arrayOf(arrayOf(android.nfc.tech.Ndef::class.java.name))
nfcAdapter?.enableForegroundDispatch(
this,
nfcPendingIntent,
intentFilters,
techLists
)
}
override fun onPause() {
super.onPause()
nfcAdapter?.disableForegroundDispatch(this)
}
// ── Intent routing ────────────────────────────────────────────────────────
// Called when the activity is already running and a notification is tapped,
// a deep link arrives, or an NFC tag is discovered while foregrounded.
// (FLAG_ACTIVITY_SINGLE_TOP prevents a new instance being created)
override fun onNewIntent(intent: Intent) {
super.onNewIntent(intent)
setIntent(intent)
// Notification tap → navigate to payment detail
val hash = intent.getStringExtra(NotificationHelper.EXTRA_PAYMENT_HASH)
if (hash != null) {
pendingPaymentHash.value = hash
}
// Deep link / external app URI
val uri = IntentUtils.extractPaymentUri(intent)
if (uri != null) {
pendingPaymentUri.value = uri
}
// NFC tag discovered → hand off to NfcViewModel for reading
val tag = intent.getNfcTag()
if (tag != null) {
nfcVm.onTagDiscovered(tag)
}
}
private fun Intent.getNfcTag(): Tag? =
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
getParcelableExtra(NfcAdapter.EXTRA_TAG, Tag::class.java)
} else {
@Suppress("DEPRECATION")
getParcelableExtra(NfcAdapter.EXTRA_TAG)
}
// ── Permission / battery helpers (unchanged) ──────────────────────────────
private fun requestNotificationPermissionIfNeeded() {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
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 }
}
}