244 lines
9.9 KiB
Kotlin
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 }
|
|
}
|
|
}
|