diff --git a/phoenix-android/build.gradle.kts b/phoenix-android/build.gradle.kts index b3726174c..4f5436433 100644 --- a/phoenix-android/build.gradle.kts +++ b/phoenix-android/build.gradle.kts @@ -55,6 +55,16 @@ android { } } + flavorDimensions += "main" + productFlavors { + create("google") { + versionNameSuffix = "-google" + } + create("foss") { + versionNameSuffix = "-foss" + } + } + compileOptions { sourceCompatibility = JavaVersion.VERSION_1_8 targetCompatibility = JavaVersion.VERSION_1_8 @@ -134,9 +144,9 @@ dependencies { implementation("org.slf4j:slf4j-api:${Versions.slf4j}") implementation("com.github.tony19:logback-android:${Versions.Android.logback}") - // firebase cloud messaging - implementation("com.google.firebase:firebase-messaging:${Versions.Android.fcm}") - implementation("com.google.android.gms:play-services-base:18.5.0") + // firebase cloud messaging -- only for the google flavor + "googleImplementation"("com.google.firebase:firebase-messaging:${Versions.Android.fcm}") + "googleImplementation"("com.google.android.gms:play-services-base:18.5.0") implementation("com.google.guava:listenablefuture:9999.0-empty-to-avoid-conflict-with-guava") diff --git a/phoenix-android/src/foss/AndroidManifest.xml b/phoenix-android/src/foss/AndroidManifest.xml new file mode 100644 index 000000000..f76918f51 --- /dev/null +++ b/phoenix-android/src/foss/AndroidManifest.xml @@ -0,0 +1,73 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/phoenix-android/src/foss/kotlin/fr/acinq/phoenix/android/MainActivityFoss.kt b/phoenix-android/src/foss/kotlin/fr/acinq/phoenix/android/MainActivityFoss.kt new file mode 100644 index 000000000..5f2ed8292 --- /dev/null +++ b/phoenix-android/src/foss/kotlin/fr/acinq/phoenix/android/MainActivityFoss.kt @@ -0,0 +1,40 @@ +/* + * Copyright 2024 ACINQ SAS + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package fr.acinq.phoenix.android + +import android.content.Context +import android.content.Intent +import fr.acinq.phoenix.android.services.HeadlessActions +import fr.acinq.phoenix.android.services.NodeService +import fr.acinq.phoenix.android.services.NodeServiceFoss + +class MainActivityFoss : MainActivity() { + + override fun bindService() { + Intent(this, NodeServiceFoss::class.java).let { intent -> + applicationContext.bindService(intent, appViewModel.serviceConnection, Context.BIND_AUTO_CREATE or Context.BIND_ADJUST_WITH_ACTIVITY) + } + } + + fun headlessServiceAction(action: HeadlessActions) { + val intent = Intent(applicationContext, NodeServiceFoss::class.java).apply { + this.action = action.name + putExtra(NodeService.EXTRA_ORIGIN, NodeService.ORIGIN_HEADLESS) + } + applicationContext.startForegroundService(intent) + } +} \ No newline at end of file diff --git a/phoenix-android/src/foss/kotlin/fr/acinq/phoenix/android/PhoenixApplicationFoss.kt b/phoenix-android/src/foss/kotlin/fr/acinq/phoenix/android/PhoenixApplicationFoss.kt new file mode 100644 index 000000000..e331ad444 --- /dev/null +++ b/phoenix-android/src/foss/kotlin/fr/acinq/phoenix/android/PhoenixApplicationFoss.kt @@ -0,0 +1,29 @@ +/* + * Copyright 2020 ACINQ SAS + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package fr.acinq.phoenix.android + +import android.content.Intent +import fr.acinq.phoenix.android.services.NodeService +import fr.acinq.phoenix.android.services.NodeServiceFoss + + +class PhoenixApplicationFoss : PhoenixApplication() { + + override val mainActivityClass: Class get() = MainActivityFoss::class.java + override val nodeServiceClass: Class get() = NodeServiceFoss::class.java + +} diff --git a/phoenix-android/src/foss/kotlin/fr/acinq/phoenix/android/home/BackgroundRestrictionBadgeFoss.kt b/phoenix-android/src/foss/kotlin/fr/acinq/phoenix/android/home/BackgroundRestrictionBadgeFoss.kt new file mode 100644 index 000000000..9f6f29754 --- /dev/null +++ b/phoenix-android/src/foss/kotlin/fr/acinq/phoenix/android/home/BackgroundRestrictionBadgeFoss.kt @@ -0,0 +1,112 @@ +/* + * Copyright 2024 ACINQ SAS + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package fr.acinq.phoenix.android.home + +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.width +import androidx.compose.material.MaterialTheme +import androidx.compose.material.Text +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.Modifier +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp +import fr.acinq.phoenix.android.AppViewModel +import fr.acinq.phoenix.android.R +import fr.acinq.phoenix.android.Screen +import fr.acinq.phoenix.android.components.Clickable +import fr.acinq.phoenix.android.components.Dialog +import fr.acinq.phoenix.android.components.TextWithIcon +import fr.acinq.phoenix.android.navController +import fr.acinq.phoenix.android.utils.warningColor + +@Composable +fun BackgroundRestrictionBadge( + appViewModel: AppViewModel, + isPowerSaverMode: Boolean, + isTorEnabled: Boolean, +) { + val isHeadlessModeEnabled = appViewModel.service?.isHeadless == true + + if (isTorEnabled || isPowerSaverMode || !isHeadlessModeEnabled) { + var showDialog by remember { mutableStateOf(false) } + + TopBadgeButton( + text = null, + icon = R.drawable.ic_alert_triangle, + iconTint = warningColor, + onClick = { showDialog = true }, + ) + Spacer(modifier = Modifier.width(4.dp)) + + if (showDialog) { + Dialog( + onDismiss = { showDialog = false }, + title = stringResource(id = R.string.home_background_restriction_title) + ) { + Text( + text = stringResource(id = R.string.home_background_restriction_body_1), + modifier = Modifier.padding(horizontal = 24.dp), + ) + Spacer(modifier = Modifier.height(16.dp)) + + if (isTorEnabled) { + TextWithIcon( + text = stringResource(id = R.string.home_background_restriction_tor), + textStyle = MaterialTheme.typography.body2, + icon = R.drawable.ic_tor_shield_ok, + modifier = Modifier.padding(horizontal = 24.dp, vertical = 6.dp), + ) + } + + if (isPowerSaverMode) { + TextWithIcon( + text = stringResource(id = R.string.home_background_restriction_powersaver), + textStyle = MaterialTheme.typography.body2, + icon = R.drawable.ic_battery_charging, + modifier = Modifier.padding(horizontal = 24.dp, vertical = 6.dp) + ) + } + + if (!isHeadlessModeEnabled) { + val nc = navController + Clickable(onClick = { nc.navigate(Screen.Experimental.route) }) { + Column(modifier = Modifier.padding(horizontal = 24.dp, vertical = 6.dp)) { + TextWithIcon( + text = stringResource(id = R.string.home_background_restriction_headless), + textStyle = MaterialTheme.typography.body2, + icon = R.drawable.ic_phone_tech, + ) + Spacer(modifier = Modifier.height(4.dp)) + Text( + text = stringResource(id = R.string.home_background_restriction_headless_desc), + style = MaterialTheme.typography.subtitle2, + modifier = Modifier.padding(start = 24.dp), + ) + } + } + } + } + } + } +} \ No newline at end of file diff --git a/phoenix-android/src/foss/kotlin/fr/acinq/phoenix/android/services/BootReceiverFoss.kt b/phoenix-android/src/foss/kotlin/fr/acinq/phoenix/android/services/BootReceiverFoss.kt new file mode 100644 index 000000000..acf2e4986 --- /dev/null +++ b/phoenix-android/src/foss/kotlin/fr/acinq/phoenix/android/services/BootReceiverFoss.kt @@ -0,0 +1,60 @@ +/* + * Copyright 2019 ACINQ SAS + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package fr.acinq.phoenix.android.services + +import android.content.BroadcastReceiver +import android.content.Context +import android.content.Intent +import fr.acinq.phoenix.android.PhoenixApplication +import fr.acinq.phoenix.legacy.internalData +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.launch +import org.slf4j.LoggerFactory + +/** + * This receiver is started when the device has booted, and schedules background jobs. + */ +class BootReceiverFoss : BroadcastReceiver() { + + private val log = LoggerFactory.getLogger(this::class.java) + + override fun onReceive(context: Context, intent: Intent) { + if (Intent.ACTION_BOOT_COMPLETED == intent.action) { + ChannelsWatcher.schedule(context) + InflightPaymentsWatcher.scheduleOnce(context) + + val internalPrefs = (context as? PhoenixApplication)?.internalDataRepository + val scope = CoroutineScope(Dispatchers.Main.immediate + SupervisorJob()) + scope.launch { + internalPrefs?.getBackgroundServiceMode?.first()?.let { (isEnabled, isWakelock) -> + if (isEnabled) { + val action = if (isWakelock) HeadlessActions.Start.WithWakeLock else HeadlessActions.Start.NoWakeLock + log.info("starting foreground service with action=$action") + Intent(context, NodeServiceFoss::class.java).apply { + this.action = action.name + putExtra(NodeService.EXTRA_ORIGIN, NodeService.ORIGIN_HEADLESS) + } + context.startForegroundService(intent) + } + } + } + } + } +} diff --git a/phoenix-android/src/foss/kotlin/fr/acinq/phoenix/android/services/NodeServiceFoss.kt b/phoenix-android/src/foss/kotlin/fr/acinq/phoenix/android/services/NodeServiceFoss.kt new file mode 100644 index 000000000..635e410be --- /dev/null +++ b/phoenix-android/src/foss/kotlin/fr/acinq/phoenix/android/services/NodeServiceFoss.kt @@ -0,0 +1,124 @@ +/* + * Copyright 2024 ACINQ SAS + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package fr.acinq.phoenix.android.services + +import android.annotation.SuppressLint +import android.content.Context +import android.content.Intent +import android.content.pm.ServiceInfo +import android.os.PowerManager +import fr.acinq.phoenix.PhoenixBusiness +import fr.acinq.phoenix.android.security.SeedManager +import fr.acinq.phoenix.android.utils.SystemNotificationHelper + + +sealed class HeadlessActions { + abstract val name: String + sealed class Start : HeadlessActions() { + data object WithWakeLock : Start() { + override val name: String = "START_WITH_WAKELOCK" + } + data object NoWakeLock : Start() { + override val name: String = "START_NO_WAKELOCK" + } + } + data object Stop : HeadlessActions() { + override val name: String = "STOP" + } + + companion object { + fun read(name: String?) : HeadlessActions? { + return when (name) { + Start.NoWakeLock.name -> Start.NoWakeLock + Start.WithWakeLock.name -> Start.WithWakeLock + Stop.name -> Stop + else -> null + } + } + } +} + +/** + * Implementation of [NodeService] without Google-based FCM support. Instead, to receive payments in the + * background, the user must first foreground this service manually and let the app run headless. + * + * Uses a [ServiceInfo.FOREGROUND_SERVICE_TYPE_REMOTE_MESSAGING] foreground type. + * + * See https://github.com/robertohuertasm/endless-service + */ +class NodeServiceFoss : NodeService() { + + override suspend fun monitorFcmToken(business: PhoenixBusiness) {} // NOOP + override fun refreshFcmToken() {} // NOOP + override fun deleteFcmToken() {} // NOOP + override fun isFcmAvailable(context: Context): Boolean = false + + var wakeLock: PowerManager.WakeLock? = null + private set + + @SuppressLint("InlinedApi") + override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int { + super.onStartCommand(intent, flags, startId) + val origin = intent?.getStringExtra(EXTRA_ORIGIN) + when (val action = HeadlessActions.read(intent?.action)) { + is HeadlessActions.Start -> { + log.info("--- start headless mode ---") + val (notif, serviceType) = when (state.value) { + is NodeServiceState.Off -> { + try { + val encryptedSeed = SeedManager.loadSeedFromDisk(applicationContext) + val seed = encryptedSeed!!.decrypt() + log.debug("successfully decrypted seed in the background, starting wallet...") + startBusiness(seed, requestCheckLegacyChannels = false) + SystemNotificationHelper.getHeadlessNotification(applicationContext) to ServiceInfo.FOREGROUND_SERVICE_TYPE_REMOTE_MESSAGING + } catch (e: Exception) { + log.error("failed to decrypt seed: {}", e.localizedMessage) + SystemNotificationHelper.getHeadlessFailureNotification(applicationContext) to ServiceInfo.FOREGROUND_SERVICE_TYPE_SHORT_SERVICE + } + } + else -> { + SystemNotificationHelper.getHeadlessNotification(applicationContext) to ServiceInfo.FOREGROUND_SERVICE_TYPE_REMOTE_MESSAGING + } + } + isHeadless = true + startForeground(notif, serviceType) + val powerManager = getSystemService(Context.POWER_SERVICE) as PowerManager + if (action is HeadlessActions.Start.WithWakeLock) { + log.info("--- acquire wakelock ---") + wakeLock = powerManager.newWakeLock(PowerManager.PARTIAL_WAKE_LOCK, "PhoenixService::lock") + .apply { acquire() } + } + return START_STICKY + } + is HeadlessActions.Stop -> { + log.info("--- stop headless mode ---") + isHeadless = false + if (wakeLock?.isHeld == true) { + log.info("--- release wakelock ---") + wakeLock?.release() + } + stopForeground(STOP_FOREGROUND_REMOVE) + return START_NOT_STICKY + } + else -> { + log.info("--- unhandled start-command [ intent=${intent?.action} origin=$origin in state=${state.value} ] ---") + return START_NOT_STICKY + } + } + } + +} \ No newline at end of file diff --git a/phoenix-android/src/foss/kotlin/fr/acinq/phoenix/android/settings/ExperimentalViewFoss.kt b/phoenix-android/src/foss/kotlin/fr/acinq/phoenix/android/settings/ExperimentalViewFoss.kt new file mode 100644 index 000000000..ee4554cfd --- /dev/null +++ b/phoenix-android/src/foss/kotlin/fr/acinq/phoenix/android/settings/ExperimentalViewFoss.kt @@ -0,0 +1,178 @@ +/* + * Copyright 2024 ACINQ SAS + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package fr.acinq.phoenix.android.settings + +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.sizeIn +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll +import androidx.compose.material.MaterialTheme +import androidx.compose.material.Text +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.ModalBottomSheet +import androidx.compose.material3.rememberModalBottomSheetState +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp +import fr.acinq.phoenix.android.AppViewModel +import fr.acinq.phoenix.android.MainActivityFoss +import fr.acinq.phoenix.android.R +import fr.acinq.phoenix.android.components.Card +import fr.acinq.phoenix.android.components.CardHeader +import fr.acinq.phoenix.android.components.FilledButton +import fr.acinq.phoenix.android.components.SwitchView +import fr.acinq.phoenix.android.components.settings.SettingSwitch +import fr.acinq.phoenix.android.internalData +import fr.acinq.phoenix.android.services.HeadlessActions +import fr.acinq.phoenix.android.services.NodeServiceFoss +import fr.acinq.phoenix.android.utils.findActivity +import kotlinx.coroutines.launch + +@Composable +fun ManageHeadlessView(appViewModel: AppViewModel) { + CardHeader(text = stringResource(id = R.string.background_header)) + Card(modifier = Modifier.fillMaxWidth()) { + val service = appViewModel.service as NodeServiceFoss? + val activity = LocalContext.current.findActivity() as MainActivityFoss + val scope = rememberCoroutineScope() + val internalPrefs = internalData + + var showConfirmDialog by remember { mutableStateOf(false) } + + SettingSwitch( + title = { + Text( + text = stringResource(id = R.string.background_mode_title), + style = MaterialTheme.typography.body2, + modifier = Modifier.weight(1f) + ) + }, + subtitle = when (service?.isHeadless) { + null -> null + true -> if (service.wakeLock?.isHeld == true) { + { Text(stringResource(id = R.string.background_mode_on_wakelock), style = MaterialTheme.typography.subtitle2) } + } else { + { Text(stringResource(id = R.string.background_mode_on_no_wakelock), style = MaterialTheme.typography.subtitle2) } + } + false -> { + { Text(text = stringResource(id = R.string.background_mode_off), style = MaterialTheme.typography.subtitle2) } + } + }, + icon = R.drawable.ic_phone_tech, + enabled = service?.isHeadless != null, + isChecked = service?.isHeadless ?: false, + onCheckChangeAttempt = { runHeadless -> + scope.launch { + if (runHeadless) { + showConfirmDialog = true + } else { + scope.launch { + activity.headlessServiceAction(HeadlessActions.Stop) + internalPrefs.saveBackgroundServiceModeDisabled() + } + } + } + } + ) + + if (showConfirmDialog) { + ConfirmHeadlessDialog( + onDismiss = { showConfirmDialog = false }, + onConfirm = { withWakeLock -> + scope.launch { + activity.headlessServiceAction(if (withWakeLock) HeadlessActions.Start.WithWakeLock else HeadlessActions.Start.NoWakeLock) + internalPrefs.saveBackgroundServiceModeEnabled(withWakeLock) + showConfirmDialog = false + } + } + ) + } + } +} + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +private fun ConfirmHeadlessDialog( + onDismiss: () -> Unit, + onConfirm: (Boolean) -> Unit, +) { + val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true) + + ModalBottomSheet( + sheetState = sheetState, + onDismissRequest = onDismiss, + containerColor = MaterialTheme.colors.surface, + contentColor = MaterialTheme.colors.onSurface, + scrimColor = MaterialTheme.colors.onBackground.copy(alpha = 0.2f), + ) { + var withWakeLock by remember { mutableStateOf(true) } + Column( + modifier = Modifier + .verticalScroll(rememberScrollState()) + .sizeIn(minHeight = 400.dp, maxHeight = 700.dp) + .padding(horizontal = 24.dp), + ) { + Text(text = stringResource(id = R.string.background_mode_dialog_title), style = MaterialTheme.typography.h4) + Spacer(modifier = Modifier.height(8.dp)) + Text(text = stringResource(id = R.string.background_mode_dialog_desc)) + Spacer(modifier = Modifier.height(8.dp)) + Text(text = stringResource(id = R.string.background_mode_dialog_perfs_desc)) + Spacer(modifier = Modifier.height(12.dp)) + SwitchView( + text = stringResource(id = R.string.background_mode_dialog_wakelock_title), + textStyle = MaterialTheme.typography.body2, + description = stringResource(id = R.string.background_mode_dialog_wakelock_desc), + checked = withWakeLock, + onCheckedChange = { withWakeLock = it } + ) + Spacer(modifier = Modifier.height(16.dp)) + Text(text = stringResource(id = R.string.background_mode_dialog_alternative_title), style = MaterialTheme.typography.h4) + Spacer(modifier = Modifier.height(8.dp)) + Text(text = stringResource(id = R.string.background_mode_dialog_alternative_desc)) + Spacer(modifier = Modifier.height(32.dp)) + FilledButton( + text = stringResource(id = R.string.btn_start), + icon = R.drawable.ic_start, + onClick = { onConfirm(withWakeLock) }, + modifier = Modifier.align(Alignment.End) + ) + Spacer(modifier = Modifier.height(8.dp)) + FilledButton( + text = stringResource(id = R.string.btn_cancel), + icon = R.drawable.ic_check, + onClick = onDismiss, + modifier = Modifier.align(Alignment.End), + backgroundColor = Color.Transparent, + textStyle = MaterialTheme.typography.button + ) + Spacer(modifier = Modifier.height(40.dp)) + } + } +} \ No newline at end of file diff --git a/phoenix-android/src/foss/res/drawable/ic_phone_tech.xml b/phoenix-android/src/foss/res/drawable/ic_phone_tech.xml new file mode 100644 index 000000000..11e6587a4 --- /dev/null +++ b/phoenix-android/src/foss/res/drawable/ic_phone_tech.xml @@ -0,0 +1,99 @@ + + + + + + + + + + + + + + + diff --git a/phoenix-android/src/foss/res/drawable/ic_start.xml b/phoenix-android/src/foss/res/drawable/ic_start.xml new file mode 100644 index 000000000..578b68719 --- /dev/null +++ b/phoenix-android/src/foss/res/drawable/ic_start.xml @@ -0,0 +1,29 @@ + + + + + diff --git a/phoenix-android/src/foss/res/values/strings.xml b/phoenix-android/src/foss/res/values/strings.xml new file mode 100644 index 000000000..929eb1312 --- /dev/null +++ b/phoenix-android/src/foss/res/values/strings.xml @@ -0,0 +1,36 @@ + + + + Background service mode is disabled + Tap for details. + + Background processing + Background service mode + Not running. Enable to improve payments reliability + Service is running (wakelock enabled) + Service is running + + Always-on service + When enabled, Phoenix will keep running as a background service even if the app is closed, allowing you to receive payments more reliably. + Performance cost + Phoenix battery usage will increase significantly. + Keep wake-lock + Makes the service more reliable, but uses much more battery. + Alternatives + If you prefer optimised, just-in-time notifications for background processing, install the Google Play version of Phoenix instead. + + \ No newline at end of file diff --git a/phoenix-android/src/google/AndroidManifest.xml b/phoenix-android/src/google/AndroidManifest.xml new file mode 100644 index 000000000..a19327c42 --- /dev/null +++ b/phoenix-android/src/google/AndroidManifest.xml @@ -0,0 +1,78 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/phoenix-android/src/main/kotlin/fr/acinq/phoenix/android/utils/FCMHelper.kt b/phoenix-android/src/google/kotlin/fr/acinq/phoenix/android/MainActivityGoogle.kt similarity index 59% rename from phoenix-android/src/main/kotlin/fr/acinq/phoenix/android/utils/FCMHelper.kt rename to phoenix-android/src/google/kotlin/fr/acinq/phoenix/android/MainActivityGoogle.kt index 05944a3b0..df6ef9eeb 100644 --- a/phoenix-android/src/main/kotlin/fr/acinq/phoenix/android/utils/FCMHelper.kt +++ b/phoenix-android/src/google/kotlin/fr/acinq/phoenix/android/MainActivityGoogle.kt @@ -14,14 +14,17 @@ * limitations under the License. */ -package fr.acinq.phoenix.android.utils +package fr.acinq.phoenix.android import android.content.Context -import com.google.android.gms.common.ConnectionResult -import com.google.android.gms.common.GoogleApiAvailability +import android.content.Intent +import fr.acinq.phoenix.android.services.NodeServiceGoogle -object FCMHelper { - fun isFCMAvailable(context: Context): Boolean { - return GoogleApiAvailability.getInstance().isGooglePlayServicesAvailable(context) == ConnectionResult.SUCCESS +class MainActivityGoogle : MainActivity() { + + override fun bindService() { + Intent(this, NodeServiceGoogle::class.java).let { intent -> + applicationContext.bindService(intent, appViewModel.serviceConnection, Context.BIND_AUTO_CREATE or Context.BIND_ADJUST_WITH_ACTIVITY) + } } } \ No newline at end of file diff --git a/phoenix-android/src/google/kotlin/fr/acinq/phoenix/android/PhoenixApplicationGoogle.kt b/phoenix-android/src/google/kotlin/fr/acinq/phoenix/android/PhoenixApplicationGoogle.kt new file mode 100644 index 000000000..757b8fc3a --- /dev/null +++ b/phoenix-android/src/google/kotlin/fr/acinq/phoenix/android/PhoenixApplicationGoogle.kt @@ -0,0 +1,27 @@ +/* + * Copyright 2020 ACINQ SAS + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package fr.acinq.phoenix.android + +import fr.acinq.phoenix.android.services.NodeService +import fr.acinq.phoenix.android.services.NodeServiceGoogle + + +class PhoenixApplicationGoogle : PhoenixApplication() { + + override val mainActivityClass: Class get() = MainActivityGoogle::class.java + override val nodeServiceClass: Class get() = NodeServiceGoogle::class.java +} diff --git a/phoenix-android/src/google/kotlin/fr/acinq/phoenix/android/home/BackgroundRestrictionBadgeGoogle.kt b/phoenix-android/src/google/kotlin/fr/acinq/phoenix/android/home/BackgroundRestrictionBadgeGoogle.kt new file mode 100644 index 000000000..038431a5e --- /dev/null +++ b/phoenix-android/src/google/kotlin/fr/acinq/phoenix/android/home/BackgroundRestrictionBadgeGoogle.kt @@ -0,0 +1,108 @@ +/* + * Copyright 2024 ACINQ SAS + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package fr.acinq.phoenix.android.home + +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.width +import androidx.compose.material.MaterialTheme +import androidx.compose.material.Text +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.Modifier +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp +import fr.acinq.phoenix.android.AppViewModel +import fr.acinq.phoenix.android.R +import fr.acinq.phoenix.android.components.Dialog +import fr.acinq.phoenix.android.components.TextWithIcon +import fr.acinq.phoenix.android.utils.warningColor + +@Composable +fun BackgroundRestrictionBadge( + appViewModel: AppViewModel, + isPowerSaverMode: Boolean, + isTorEnabled: Boolean, +) { + val context = LocalContext.current + val isFcmAvailable = appViewModel.service?.isFcmAvailable(context) == true + + if (isTorEnabled || isPowerSaverMode || !isFcmAvailable) { + var showDialog by remember { mutableStateOf(false) } + + TopBadgeButton( + text = null, + icon = R.drawable.ic_alert_triangle, + iconTint = warningColor, + onClick = { showDialog = true }, + ) + Spacer(modifier = Modifier.width(4.dp)) + + if (showDialog) { + Dialog( + onDismiss = { showDialog = false }, + title = stringResource(id = R.string.home_background_restriction_title) + ) { + Text( + text = stringResource(id = R.string.home_background_restriction_body_1), + modifier = Modifier.padding(horizontal = 24.dp), + ) + Spacer(modifier = Modifier.height(16.dp)) + + if (isTorEnabled) { + TextWithIcon( + text = stringResource(id = R.string.home_background_restriction_tor), + textStyle = MaterialTheme.typography.body2, + icon = R.drawable.ic_tor_shield_ok, + modifier = Modifier.padding(horizontal = 24.dp, vertical = 6.dp) + ) + } + + if (isPowerSaverMode) { + TextWithIcon( + text = stringResource(id = R.string.home_background_restriction_powersaver), + textStyle = MaterialTheme.typography.body2, + icon = R.drawable.ic_battery_charging, + modifier = Modifier.padding(horizontal = 24.dp, vertical = 6.dp) + ) + } + + if (!isFcmAvailable) { + Column(modifier = Modifier.padding(horizontal = 24.dp, vertical = 6.dp)) { + TextWithIcon( + text = stringResource(id = R.string.home_background_restriction_fcm), + textStyle = MaterialTheme.typography.body2, + icon = R.drawable.ic_battery_charging, + ) + Spacer(modifier = Modifier.height(4.dp)) + Text( + text = stringResource(id = R.string.home_background_restriction_fcm_details), + style = MaterialTheme.typography.subtitle2, + modifier = Modifier.padding(start = 24.dp), + ) + } + } + } + } + } +} \ No newline at end of file diff --git a/phoenix-android/src/main/kotlin/fr/acinq/phoenix/android/services/FCMService.kt b/phoenix-android/src/google/kotlin/fr/acinq/phoenix/android/services/FCMService.kt similarity index 93% rename from phoenix-android/src/main/kotlin/fr/acinq/phoenix/android/services/FCMService.kt rename to phoenix-android/src/google/kotlin/fr/acinq/phoenix/android/services/FCMService.kt index 98c24c0bc..cd1662a0c 100644 --- a/phoenix-android/src/main/kotlin/fr/acinq/phoenix/android/services/FCMService.kt +++ b/phoenix-android/src/google/kotlin/fr/acinq/phoenix/android/services/FCMService.kt @@ -35,7 +35,7 @@ class FCMService : FirebaseMessagingService() { when { encryptedSeed !is EncryptedSeed.V2.NoAuth -> { - log.warn("ignored fcm message with unhandled seed=${encryptedSeed?.name()}") + log.warn("ignored fcm message with unhandled seed}") } remoteMessage.priority != RemoteMessage.PRIORITY_HIGH -> { // cannot start foreground service from low/normal priority message @@ -54,8 +54,11 @@ class FCMService : FirebaseMessagingService() { } private fun startPhoenixForegroundService(reason: String?) { - ContextCompat.startForegroundService(applicationContext, Intent(applicationContext, NodeService::class.java) - .apply { reason?.let { putExtra(NodeService.EXTRA_REASON, it) } }) + ContextCompat.startForegroundService(applicationContext, Intent(applicationContext, NodeServiceGoogle::class.java) + .apply { + reason?.let { putExtra(NodeService.EXTRA_REASON, it) } + putExtra(NodeService.EXTRA_ORIGIN, NodeService.ORIGIN_FCM) + }) } override fun onNewToken(token: String) { diff --git a/phoenix-android/src/google/kotlin/fr/acinq/phoenix/android/services/NodeServiceGoogle.kt b/phoenix-android/src/google/kotlin/fr/acinq/phoenix/android/services/NodeServiceGoogle.kt new file mode 100644 index 000000000..fbdc714e2 --- /dev/null +++ b/phoenix-android/src/google/kotlin/fr/acinq/phoenix/android/services/NodeServiceGoogle.kt @@ -0,0 +1,141 @@ +/* + * Copyright 2024 ACINQ SAS + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package fr.acinq.phoenix.android.services + +import android.annotation.SuppressLint +import android.content.Context +import android.content.Intent +import android.content.pm.ServiceInfo +import android.os.Handler +import android.os.IBinder +import android.os.Looper +import com.google.android.gms.common.ConnectionResult +import com.google.android.gms.common.GoogleApiAvailability +import com.google.android.gms.tasks.OnCompleteListener +import com.google.firebase.messaging.FirebaseMessaging +import fr.acinq.lightning.utils.Connection +import fr.acinq.phoenix.PhoenixBusiness +import fr.acinq.phoenix.android.security.SeedManager +import fr.acinq.phoenix.android.utils.SystemNotificationHelper +import kotlinx.coroutines.flow.filterNotNull +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.launch + + +class NodeServiceGoogle : NodeService() { + + override fun onBind(intent: Intent?): IBinder { + isHeadless = false + notificationManager.cancel(SystemNotificationHelper.HEADLESS_NOTIF_ID) + return super.onBind(intent) + } + + override fun onUnbind(intent: Intent?): Boolean { + isHeadless = true + return super.onUnbind(intent) + } + + override suspend fun monitorFcmToken(business: PhoenixBusiness) { + val token = internalData.getFcmToken.filterNotNull().first() + business.connectionsManager.connections.first { it.peer == Connection.ESTABLISHED } + business.registerFcmToken(token) + } + + override fun refreshFcmToken() { + FirebaseMessaging.getInstance().token.addOnCompleteListener(OnCompleteListener { task -> + if (!task.isSuccessful) { + log.warn("fetching FCM registration token failed: ${task.exception?.localizedMessage}") + return@OnCompleteListener + } + task.result?.let { serviceScope.launch { internalData.saveFcmToken(it) } } + }) + } + + override fun deleteFcmToken() { + FirebaseMessaging.getInstance().deleteToken().addOnCompleteListener { task -> + if (task.isSuccessful) refreshFcmToken() + } + } + + override fun isFcmAvailable(context: Context): Boolean { + return GoogleApiAvailability.getInstance().isGooglePlayServicesAvailable(context) == ConnectionResult.SUCCESS + } + + // See [scheduleShutdown]. + private val shutdownHandler = Handler(Looper.getMainLooper()) + + /** + * Schedules business shutdown and removal of service from the foreground if needed. + * After receiving a FCM wake-up message, the service is put in the foreground and then keeps running + * for a while (typically 2 minutes). Then it shuts down automatically. + * + * @param delayMillis default is 2 minutes. Should always be less than 3 minutes, to avoid [onTimeout] since + * foreground mode is using the `SHORT_SERVICE` foreground type. + */ + private fun scheduleShutdown(delayMillis: Long = 2 * 60 * 1000L) { + shutdownHandler.postDelayed(Runnable { + if (isHeadless) { + log.debug("reached scheduled shutdown...") + if (receivedInBackground.isEmpty()) { + stopForeground(STOP_FOREGROUND_REMOVE) + } else { + stopForeground(STOP_FOREGROUND_DETACH) + } + notificationManager.cancel(SystemNotificationHelper.HEADLESS_NOTIF_ID) + shutdown() + } + }, delayMillis) + } + + @SuppressLint("InlinedApi") + override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int { + super.onStartCommand(intent, flags, startId) + val origin = intent?.getStringExtra(EXTRA_ORIGIN) + val reason = intent?.getStringExtra(EXTRA_REASON) + log.info("--- start service from intent [ intent=$intent, flag=$flags, startId=$startId ] reason=$reason origin=$origin in state=${state.value} ---") + + val encryptedSeed = SeedManager.loadSeedFromDisk(applicationContext) + val (notif, serviceType) = when (state.value) { + is NodeServiceState.Off -> { + try { + val seed = encryptedSeed!!.decrypt() + log.debug("successfully decrypted seed in the background, starting wallet...") + startBusiness(seed, requestCheckLegacyChannels = false) + SystemNotificationHelper.getHeadlessNotification(applicationContext) to ServiceInfo.FOREGROUND_SERVICE_TYPE_SHORT_SERVICE + } catch (e: Exception) { + log.error("failed to decrypt seed: {}", e.localizedMessage) + SystemNotificationHelper.getHeadlessFailureNotification(applicationContext) to ServiceInfo.FOREGROUND_SERVICE_TYPE_SHORT_SERVICE + } + } + else -> { + SystemNotificationHelper.getHeadlessNotification(applicationContext) to ServiceInfo.FOREGROUND_SERVICE_TYPE_SHORT_SERVICE + // TODO force reconnect if disconnected! + } + } + + startForeground(notif, serviceType) + shutdownHandler.removeCallbacksAndMessages(null) + scheduleShutdown() + + if (origin == ORIGIN_FCM) { + isHeadless = true + } else { + stopForeground(STOP_FOREGROUND_REMOVE) + } + return START_NOT_STICKY + } +} \ No newline at end of file diff --git a/phoenix-android/src/google/kotlin/fr/acinq/phoenix/android/settings/ExperimentalViewGoogle.kt b/phoenix-android/src/google/kotlin/fr/acinq/phoenix/android/settings/ExperimentalViewGoogle.kt new file mode 100644 index 000000000..723048e4f --- /dev/null +++ b/phoenix-android/src/google/kotlin/fr/acinq/phoenix/android/settings/ExperimentalViewGoogle.kt @@ -0,0 +1,7 @@ +package fr.acinq.phoenix.android.settings + +import androidx.compose.runtime.Composable +import fr.acinq.phoenix.android.AppViewModel + +@Composable +fun ManageHeadlessView(appViewModel: AppViewModel) {} \ No newline at end of file diff --git a/phoenix-android/src/main/AndroidManifest.xml b/phoenix-android/src/main/AndroidManifest.xml index e6518cdaa..3eef01ece 100644 --- a/phoenix-android/src/main/AndroidManifest.xml +++ b/phoenix-android/src/main/AndroidManifest.xml @@ -11,7 +11,6 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + \ No newline at end of file diff --git a/phoenix-android/src/main/kotlin/fr/acinq/phoenix/android/AppView.kt b/phoenix-android/src/main/kotlin/fr/acinq/phoenix/android/AppView.kt index c5af26631..70fa3c1be 100644 --- a/phoenix-android/src/main/kotlin/fr/acinq/phoenix/android/AppView.kt +++ b/phoenix-android/src/main/kotlin/fr/acinq/phoenix/android/AppView.kt @@ -58,7 +58,6 @@ import androidx.navigation.navOptions import com.google.accompanist.permissions.ExperimentalPermissionsApi import com.google.accompanist.permissions.isGranted import com.google.accompanist.permissions.rememberPermissionState -import com.google.firebase.messaging.FirebaseMessaging import fr.acinq.lightning.db.IncomingPayment import fr.acinq.lightning.utils.currentTimestampMillis import fr.acinq.phoenix.PhoenixBusiness @@ -136,7 +135,7 @@ fun LoadingAppView() { @Composable fun AppView( business: PhoenixBusiness, - appVM: AppViewModel, + appViewModel: AppViewModel, navController: NavHostController, ) { val log = logger("Navigation") @@ -172,7 +171,7 @@ fun AppView( ) MonitorNotices(vm = noticesViewModel) - val walletState by appVM.serviceState.observeAsState(null) + val walletState by appViewModel.serviceState.observeAsState(null) Box(modifier = Modifier.fillMaxSize()) { Column( @@ -202,7 +201,7 @@ fun AppView( null } StartupView( - appVM = appVM, + appVM = appViewModel, onShowIntro = { navController.navigate(Screen.Intro.route) }, onKeyAbsent = { navController.navigate(Screen.InitWallet.route) }, onBusinessStarted = { @@ -237,6 +236,7 @@ fun AppView( composable(Screen.Home.route) { RequireStarted(walletState) { HomeView( + appViewModel = appViewModel, paymentsViewModel = paymentsViewModel, noticesViewModel = noticesViewModel, onPaymentClick = { navigateToPaymentDetails(navController, id = it, isFromEvent = false) }, @@ -415,7 +415,7 @@ fun AppView( ) } composable(Screen.AppLock.route) { - AppAccessSettings(onBackClick = { navController.popBackStack() }, appViewModel = appVM) + AppAccessSettings(onBackClick = { navController.popBackStack() }, appViewModel = appViewModel) } composable(Screen.Logs.route) { LogsView() @@ -477,7 +477,7 @@ fun AppView( ) } composable(Screen.ResetWallet.route) { - appVM.service?.let { nodeService -> + appViewModel.service?.let { nodeService -> val application = application ResetWallet( onShutdownBusiness = application::shutdownBusiness, @@ -485,9 +485,7 @@ fun AppView( onPrefsClear = application::clearPreferences, onBusinessReset = { application.resetBusiness() - FirebaseMessaging.getInstance().deleteToken().addOnCompleteListener { task -> - if (task.isSuccessful) nodeService.refreshFcmToken() - } + nodeService.deleteFcmToken() }, onBackClick = { navController.popBackStack() } ) @@ -497,7 +495,7 @@ fun AppView( SettingsContactsView(onBackClick = { navController.popBackStack() }) } composable(Screen.Experimental.route) { - ExperimentalView(onBackClick = { navController.popBackStack() }) + ExperimentalView(onBackClick = { navController.popBackStack() }, appViewModel = appViewModel) } } @@ -507,7 +505,7 @@ fun AppView( } } - val isScreenLocked by appVM.isScreenLocked + val isScreenLocked by appViewModel.isScreenLocked val isBiometricLockEnabledState = userPrefs.getIsBiometricLockEnabled.collectAsState(initial = null) val isBiometricLockEnabled = isBiometricLockEnabledState.value val isCustomPinLockEnabledState = userPrefs.getIsCustomPinLockEnabled.collectAsState(initial = null) @@ -519,11 +517,11 @@ fun AppView( context.safeFindActivity()?.moveTaskToBack(false) } LockPrompt( - promptScreenLockImmediately = appVM.promptScreenLockImmediately.value, - onLock = { appVM.lockScreen() }, + promptScreenLockImmediately = appViewModel.promptScreenLockImmediately.value, + onLock = { appViewModel.lockScreen() }, onUnlock = { - appVM.unlockScreen() - appVM.promptScreenLockImmediately.value = false + appViewModel.unlockScreen() + appViewModel.promptScreenLockImmediately.value = false }, ) } @@ -539,7 +537,7 @@ fun AppView( if (isDataMigrationExpected == false) { if (payment is IncomingPayment && payment.origin is IncomingPayment.Origin.Offer) { SystemNotificationHelper.notifyPaymentsReceived( - context, userPrefs, paymentHash = payment.paymentHash, amount = payment.amount, rates = exchangeRates, isHeadless = false + context, userPrefs, paymentHash = payment.paymentHash, amount = payment.amount, rates = exchangeRates, isFromBackground = false ) } else { navigateToPaymentDetails(navController, id = payment.walletPaymentId(), isFromEvent = true) diff --git a/phoenix-android/src/main/kotlin/fr/acinq/phoenix/android/MainActivity.kt b/phoenix-android/src/main/kotlin/fr/acinq/phoenix/android/MainActivity.kt index dec556168..49a880b5a 100644 --- a/phoenix-android/src/main/kotlin/fr/acinq/phoenix/android/MainActivity.kt +++ b/phoenix-android/src/main/kotlin/fr/acinq/phoenix/android/MainActivity.kt @@ -31,7 +31,6 @@ import androidx.navigation.* import androidx.navigation.compose.rememberNavController import fr.acinq.lightning.io.PhoenixAndroidLegacyInfoEvent import fr.acinq.lightning.utils.Connection -import fr.acinq.phoenix.android.services.NodeService import fr.acinq.phoenix.android.utils.LegacyMigrationHelper import fr.acinq.phoenix.android.utils.PhoenixAndroidTheme import fr.acinq.phoenix.legacy.utils.* @@ -47,10 +46,10 @@ import org.slf4j.Logger import org.slf4j.LoggerFactory -class MainActivity : AppCompatActivity() { +abstract class MainActivity : AppCompatActivity() { val log: Logger = LoggerFactory.getLogger(MainActivity::class.java) - private val appViewModel: AppViewModel by viewModels { AppViewModel.Factory } + internal val appViewModel: AppViewModel by viewModels { AppViewModel.Factory } private var navController: NavHostController? = null @@ -151,9 +150,7 @@ class MainActivity : AppCompatActivity() { override fun onStart() { super.onStart() - Intent(this, NodeService::class.java).let { intent -> - applicationContext.bindService(intent, appViewModel.serviceConnection, Context.BIND_AUTO_CREATE or Context.BIND_ADJUST_WITH_ACTIVITY) - } + bindService() } override fun onResume() { @@ -161,6 +158,8 @@ class MainActivity : AppCompatActivity() { tryReconnect() } + abstract fun bindService() + private fun tryReconnect() { lifecycleScope.launch { val isDataMigrationExpected = LegacyPrefsDatastore.getDataMigrationExpected(applicationContext).filterNotNull().first() diff --git a/phoenix-android/src/main/kotlin/fr/acinq/phoenix/android/PhoenixApplication.kt b/phoenix-android/src/main/kotlin/fr/acinq/phoenix/android/PhoenixApplication.kt index 3bbd00d81..22857a479 100644 --- a/phoenix-android/src/main/kotlin/fr/acinq/phoenix/android/PhoenixApplication.kt +++ b/phoenix-android/src/main/kotlin/fr/acinq/phoenix/android/PhoenixApplication.kt @@ -18,6 +18,7 @@ package fr.acinq.phoenix.android import android.content.Intent import fr.acinq.phoenix.PhoenixBusiness +import fr.acinq.phoenix.android.services.NodeService import fr.acinq.phoenix.android.utils.Logging import fr.acinq.phoenix.android.utils.SystemNotificationHelper import fr.acinq.phoenix.android.utils.datastore.InternalDataRepository @@ -31,7 +32,7 @@ import fr.acinq.phoenix.utils.PlatformContext import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.asStateFlow -class PhoenixApplication : AppContext() { +abstract class PhoenixApplication : AppContext() { private val _business = MutableStateFlow(null) val business = _business.asStateFlow() @@ -39,6 +40,9 @@ class PhoenixApplication : AppContext() { lateinit var internalDataRepository: InternalDataRepository lateinit var userPrefs: UserPrefsRepository + abstract val mainActivityClass: Class + abstract val nodeServiceClass: Class + override fun onCreate() { super.onCreate() _business.value = PhoenixBusiness(PlatformContext(applicationContext)) @@ -49,7 +53,7 @@ class PhoenixApplication : AppContext() { } override fun onLegacyFinish() { - applicationContext.startActivity(Intent(applicationContext, MainActivity::class.java).apply { + applicationContext.startActivity(Intent(applicationContext, mainActivityClass).apply { addFlags(Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TOP) }) } @@ -59,6 +63,7 @@ class PhoenixApplication : AppContext() { business.value?.appConnectionsDaemon?.incrementDisconnectCount(AppConnectionsDaemon.ControlTarget.All) business.value?.stop() } + fun resetBusiness() { _business.value = PhoenixBusiness(PlatformContext(this)) log.info("business=$business") diff --git a/phoenix-android/src/main/kotlin/fr/acinq/phoenix/android/components/settings/SettingSwitch.kt b/phoenix-android/src/main/kotlin/fr/acinq/phoenix/android/components/settings/SettingSwitch.kt index e346e8d5c..a576e133f 100644 --- a/phoenix-android/src/main/kotlin/fr/acinq/phoenix/android/components/settings/SettingSwitch.kt +++ b/phoenix-android/src/main/kotlin/fr/acinq/phoenix/android/components/settings/SettingSwitch.kt @@ -19,6 +19,7 @@ package fr.acinq.phoenix.android.components.settings import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.RowScope import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height @@ -46,6 +47,32 @@ fun SettingSwitch( enabled: Boolean, isChecked: Boolean, onCheckChangeAttempt: ((Boolean) -> Unit) +) { + SettingSwitch( + modifier = modifier, + title = { + Text(text = title, style = if (enabled) MaterialTheme.typography.body2 else MaterialTheme.typography.caption, modifier = Modifier.weight(1f)) + }, + subtitle = description?.let { + { Text(text = description, style = MaterialTheme.typography.subtitle2, modifier = Modifier.weight(1f)) } + }, + icon = icon, + enabled = enabled, + isChecked = isChecked, + onCheckChangeAttempt = onCheckChangeAttempt + ) +} + + +@Composable +fun SettingSwitch( + modifier: Modifier = Modifier, + title: @Composable RowScope.() -> Unit, + subtitle: (@Composable RowScope.() -> Unit)? = null, + icon: Int? = null, + enabled: Boolean, + isChecked: Boolean, + onCheckChangeAttempt: ((Boolean) -> Unit) ) { Column( modifier @@ -58,17 +85,17 @@ fun SettingSwitch( PhoenixIcon(it, Modifier.size(ButtonDefaults.IconSize)) Spacer(Modifier.width(12.dp)) } - Text(text = title, style = if (enabled) MaterialTheme.typography.body2 else MaterialTheme.typography.caption, modifier = Modifier.weight(1f)) + title() Spacer(Modifier.width(16.dp)) Switch(checked = isChecked, onCheckedChange = null, enabled = enabled, modifier = Modifier.enableOrFade(enabled)) } - if (description != null) { + if (subtitle != null) { Spacer(modifier = Modifier.height(2.dp)) Row(Modifier.fillMaxWidth()) { icon?.let { Spacer(modifier = Modifier.width(30.dp)) } - Text(text = description, style = MaterialTheme.typography.subtitle2, modifier = Modifier.weight(1f)) + subtitle() Spacer(Modifier.width(48.dp)) } } diff --git a/phoenix-android/src/main/kotlin/fr/acinq/phoenix/android/home/HomeTopAndBottom.kt b/phoenix-android/src/main/kotlin/fr/acinq/phoenix/android/home/HomeTopAndBottom.kt index a178d829b..8f8fc145a 100644 --- a/phoenix-android/src/main/kotlin/fr/acinq/phoenix/android/home/HomeTopAndBottom.kt +++ b/phoenix-android/src/main/kotlin/fr/acinq/phoenix/android/home/HomeTopAndBottom.kt @@ -16,9 +16,22 @@ package fr.acinq.phoenix.android.home -import androidx.compose.animation.core.* +import androidx.compose.animation.core.RepeatMode +import androidx.compose.animation.core.animateFloat +import androidx.compose.animation.core.infiniteRepeatable +import androidx.compose.animation.core.keyframes +import androidx.compose.animation.core.rememberInfiniteTransition import androidx.compose.foundation.background -import androidx.compose.foundation.layout.* +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxHeight +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.layout.widthIn import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material.MaterialTheme @@ -40,12 +53,12 @@ import androidx.compose.ui.res.stringResource import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import fr.acinq.lightning.utils.Connection +import fr.acinq.phoenix.android.AppViewModel import fr.acinq.phoenix.android.R import fr.acinq.phoenix.android.components.BorderButton import fr.acinq.phoenix.android.components.Button import fr.acinq.phoenix.android.components.Dialog import fr.acinq.phoenix.android.components.FilledButton -import fr.acinq.phoenix.android.components.TextWithIcon import fr.acinq.phoenix.android.components.VSeparator import fr.acinq.phoenix.android.components.openLink import fr.acinq.phoenix.android.utils.isBadCertificate @@ -57,12 +70,12 @@ import fr.acinq.phoenix.managers.Connections @Composable fun TopBar( modifier: Modifier = Modifier, + appViewModel: AppViewModel, onConnectionsStateButtonClick: () -> Unit, connections: Connections, electrumBlockheight: Int, onTorClick: () -> Unit, isTorEnabled: Boolean?, - isFCMUnavailable: Boolean, isPowerSaverMode: Boolean, inFlightPaymentsCount: Int, showRequestLiquidity: Boolean, @@ -90,9 +103,9 @@ fun TopBar( } BackgroundRestrictionBadge( - isFCMUnavailable = isFCMUnavailable, + appViewModel = appViewModel, + isPowerSaverMode = isPowerSaverMode, isTorEnabled = isTorEnabled == true, - isPowerSaverMode = isPowerSaverMode ) Spacer(modifier = Modifier.weight(1f)) @@ -172,7 +185,7 @@ private fun ConnectionBadge( } @Composable -private fun TopBadgeButton( +fun TopBadgeButton( text: String?, icon: Int, onClick: () -> Unit, @@ -193,58 +206,6 @@ private fun TopBadgeButton( ) } -@Composable -private fun BackgroundRestrictionBadge( - isTorEnabled: Boolean, - isPowerSaverMode: Boolean, - isFCMUnavailable: Boolean, -) { - if (isTorEnabled || isPowerSaverMode || isFCMUnavailable) { - var showDialog by remember { mutableStateOf(false) } - - TopBadgeButton( - text = null, - icon = R.drawable.ic_alert_triangle, - iconTint = warningColor, - onClick = { showDialog = true }, - ) - Spacer(modifier = Modifier.width(4.dp)) - - if (showDialog) { - Dialog( - onDismiss = { showDialog = false }, - title = stringResource(id = R.string.home_background_restriction_title) - ) { - Column( - modifier = Modifier.padding(horizontal = 24.dp) - ) { - Text(text = stringResource(id = R.string.home_background_restriction_body_1)) - Spacer(modifier = Modifier.height(16.dp)) - Text(text = stringResource(id = R.string.home_background_restriction_body_2)) - Spacer(modifier = Modifier.height(8.dp)) - - Column(verticalArrangement = Arrangement.spacedBy(8.dp)) { - if (isTorEnabled) { - TextWithIcon(text = stringResource(id = R.string.home_background_restriction_tor), icon = R.drawable.ic_tor_shield_ok) - } - if (isPowerSaverMode) { - TextWithIcon(text = stringResource(id = R.string.home_background_restriction_powersaver), icon = R.drawable.ic_battery_charging) - } - if (isFCMUnavailable) { - TextWithIcon(text = stringResource(id = R.string.home_background_restriction_fcm), icon = R.drawable.ic_cloud_off) - Text( - text = stringResource(id = R.string.home_background_restriction_fcm_details), - style = MaterialTheme.typography.caption.copy(fontSize = 14.sp), - modifier = Modifier.padding(start = 26.dp) - ) - } - } - } - } - } - } -} - @Composable private fun InflightPaymentsBadge( count: Int, diff --git a/phoenix-android/src/main/kotlin/fr/acinq/phoenix/android/home/HomeView.kt b/phoenix-android/src/main/kotlin/fr/acinq/phoenix/android/home/HomeView.kt index 5303cd004..8683fd7ac 100644 --- a/phoenix-android/src/main/kotlin/fr/acinq/phoenix/android/home/HomeView.kt +++ b/phoenix-android/src/main/kotlin/fr/acinq/phoenix/android/home/HomeView.kt @@ -44,6 +44,7 @@ import androidx.constraintlayout.compose.Dimension import androidx.constraintlayout.compose.MotionLayout import androidx.constraintlayout.compose.MotionScene import androidx.constraintlayout.compose.layoutId +import fr.acinq.phoenix.android.AppViewModel import fr.acinq.phoenix.android.CF import fr.acinq.phoenix.android.NoticesViewModel import fr.acinq.phoenix.android.PaymentsViewModel @@ -53,7 +54,6 @@ import fr.acinq.phoenix.android.business import fr.acinq.phoenix.android.components.Dialog import fr.acinq.phoenix.android.components.PrimarySeparator import fr.acinq.phoenix.android.components.mvi.MVIView -import fr.acinq.phoenix.android.utils.FCMHelper import fr.acinq.phoenix.android.utils.annotatedStringResource import fr.acinq.phoenix.android.utils.datastore.HomeAmountDisplayMode import fr.acinq.phoenix.android.utils.findActivity @@ -67,6 +67,7 @@ import kotlinx.coroutines.launch @Composable fun HomeView( + appViewModel: AppViewModel, paymentsViewModel: PaymentsViewModel, noticesViewModel: NoticesViewModel, onPaymentClick: (WalletPaymentId) -> Unit, @@ -85,8 +86,6 @@ fun HomeView( val internalData = application.internalDataRepository val userPrefs = application.userPrefs val isPowerSaverModeOn = noticesViewModel.isPowerSaverModeOn - val fcmToken by internalData.getFcmToken.collectAsState(initial = "") - val isFCMAvailable = remember { FCMHelper.isFCMAvailable(context) } val torEnabledState = userPrefs.getIsTorEnabled.collectAsState(initial = null) val balanceDisplayMode by userPrefs.getHomeAmountDisplayMode.collectAsState(initial = HomeAmountDisplayMode.REDACTED) @@ -221,13 +220,13 @@ fun HomeView( ) {} TopBar( modifier = Modifier.layoutId("topBar"), + appViewModel = appViewModel, onConnectionsStateButtonClick = { showConnectionsDialog = true }, connections = connections, electrumBlockheight = electrumMessages?.blockHeight ?: 0, inFlightPaymentsCount = inFlightPaymentsCount, isTorEnabled = torEnabledState.value, onTorClick = onTorClick, - isFCMUnavailable = fcmToken == null || !isFCMAvailable, isPowerSaverMode = isPowerSaverModeOn, showRequestLiquidity = channels.canRequestLiquidity(), onRequestLiquidityClick = onRequestLiquidityClick, diff --git a/phoenix-android/src/main/kotlin/fr/acinq/phoenix/android/security/EncryptedSeed.kt b/phoenix-android/src/main/kotlin/fr/acinq/phoenix/android/security/EncryptedSeed.kt index 7bb4d4f49..13c8f4608 100644 --- a/phoenix-android/src/main/kotlin/fr/acinq/phoenix/android/security/EncryptedSeed.kt +++ b/phoenix-android/src/main/kotlin/fr/acinq/phoenix/android/security/EncryptedSeed.kt @@ -73,7 +73,7 @@ sealed class EncryptedSeed { private const val NO_AUTH_KEY_VERSION = 1 private const val REMOVED_DO_NOT_USE = 2 - fun deserialize(stream: ByteArrayInputStream): V2 { + fun deserialize(stream: ByteArrayInputStream): V2.NoAuth { val keyVersion = stream.read() val iv = ByteArray(IV_LENGTH) stream.read(iv, 0, IV_LENGTH) @@ -93,7 +93,7 @@ sealed class EncryptedSeed { const val SEED_FILE_VERSION_2: Byte = 2 /** Reads an array of byte and de-serializes it as an [EncryptedSeed] object. */ - fun deserialize(serialized: ByteArray): EncryptedSeed { + fun deserialize(serialized: ByteArray): EncryptedSeed.V2.NoAuth { val stream = ByteArrayInputStream(serialized) val version = stream.read() return when (version) { diff --git a/phoenix-android/src/main/kotlin/fr/acinq/phoenix/android/security/SeedManager.kt b/phoenix-android/src/main/kotlin/fr/acinq/phoenix/android/security/SeedManager.kt index c30bd38ca..db0802044 100644 --- a/phoenix-android/src/main/kotlin/fr/acinq/phoenix/android/security/SeedManager.kt +++ b/phoenix-android/src/main/kotlin/fr/acinq/phoenix/android/security/SeedManager.kt @@ -42,7 +42,7 @@ object SeedManager { } /** Extract the encrypted seed from app private dir. */ - fun loadSeedFromDisk(context: Context): EncryptedSeed? = loadSeedFromDir(getDatadir(context), SEED_FILE) + fun loadSeedFromDisk(context: Context): EncryptedSeed.V2.NoAuth? = loadSeedFromDir(getDatadir(context), SEED_FILE) fun getSeedState(context: Context): SeedFileState = try { when (val seed = loadSeedFromDisk(context)) { @@ -56,7 +56,7 @@ object SeedManager { } /** Extract an encrypted seed contained in a given file/folder. */ - private fun loadSeedFromDir(dir: File, seedFileName: String): EncryptedSeed? { + private fun loadSeedFromDir(dir: File, seedFileName: String): EncryptedSeed.V2.NoAuth? { val seedFile = File(dir, seedFileName) return if (!seedFile.exists()) { null diff --git a/phoenix-android/src/main/kotlin/fr/acinq/phoenix/android/services/InflightPaymentsWatcher.kt b/phoenix-android/src/main/kotlin/fr/acinq/phoenix/android/services/InflightPaymentsWatcher.kt index 39fa252e8..ef2439697 100644 --- a/phoenix-android/src/main/kotlin/fr/acinq/phoenix/android/services/InflightPaymentsWatcher.kt +++ b/phoenix-android/src/main/kotlin/fr/acinq/phoenix/android/services/InflightPaymentsWatcher.kt @@ -128,7 +128,8 @@ class InflightPaymentsWatcher(context: Context, workerParams: WorkerParameters) service.value = null } } - Intent(applicationContext, NodeService::class.java).let { intent -> + + Intent(applicationContext, application.nodeServiceClass).let { intent -> applicationContext.bindService(intent, serviceConnection, Context.BIND_AUTO_CREATE) } diff --git a/phoenix-android/src/main/kotlin/fr/acinq/phoenix/android/services/NodeService.kt b/phoenix-android/src/main/kotlin/fr/acinq/phoenix/android/services/NodeService.kt index c99dad40c..c9b5bf493 100644 --- a/phoenix-android/src/main/kotlin/fr/acinq/phoenix/android/services/NodeService.kt +++ b/phoenix-android/src/main/kotlin/fr/acinq/phoenix/android/services/NodeService.kt @@ -2,33 +2,30 @@ package fr.acinq.phoenix.android.services import android.app.Notification import android.app.Service +import android.content.Context import android.content.Intent -import android.content.pm.ServiceInfo import android.os.Binder import android.os.Build -import android.os.Handler import android.os.IBinder -import android.os.Looper import android.text.format.DateUtils +import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateListOf +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.setValue import androidx.core.app.NotificationManagerCompat import androidx.core.app.ServiceCompat import androidx.lifecycle.LiveData import androidx.lifecycle.MutableLiveData import androidx.work.WorkManager -import com.google.android.gms.tasks.OnCompleteListener -import com.google.firebase.messaging.FirebaseMessaging import fr.acinq.bitcoin.TxId import fr.acinq.lightning.LiquidityEvents import fr.acinq.lightning.MilliSatoshi -import fr.acinq.lightning.io.PaymentReceived -import fr.acinq.lightning.utils.Connection +import fr.acinq.lightning.PaymentEvents import fr.acinq.lightning.utils.currentTimestampMillis import fr.acinq.phoenix.PhoenixBusiness import fr.acinq.phoenix.android.BuildConfig import fr.acinq.phoenix.android.PhoenixApplication import fr.acinq.phoenix.android.security.EncryptedSeed -import fr.acinq.phoenix.android.security.SeedManager import fr.acinq.phoenix.android.utils.LegacyMigrationHelper import fr.acinq.phoenix.android.utils.SystemNotificationHelper import fr.acinq.phoenix.android.utils.datastore.InternalDataRepository @@ -44,30 +41,30 @@ import fr.acinq.phoenix.utils.MnemonicLanguage import kotlinx.coroutines.CoroutineExceptionHandler import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.Job import kotlinx.coroutines.SupervisorJob import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.filterNotNull import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.flatMapLatest import kotlinx.coroutines.launch import kotlinx.coroutines.withContext import org.slf4j.LoggerFactory import java.util.concurrent.locks.ReentrantLock import kotlin.time.Duration.Companion.hours -class NodeService : Service() { +abstract class NodeService : Service() { - private val log = LoggerFactory.getLogger(this::class.java) - private val serviceScope = CoroutineScope(Dispatchers.Main.immediate + SupervisorJob()) + internal val log = LoggerFactory.getLogger(this::class.java) + + internal val serviceScope = CoroutineScope(Dispatchers.Main.immediate + SupervisorJob()) lateinit var internalData: InternalDataRepository private val binder = NodeBinder() - /** True if the service is running headless (that is without a GUI). In that case we should show a notification */ - @Volatile - private var isHeadless = true + var isHeadless by mutableStateOf(false) - // Notifications - private lateinit var notificationManager: NotificationManagerCompat + lateinit var notificationManager: NotificationManagerCompat /** State of the wallet, provides access to the business when started. Private so that it's not mutated from the outside. */ private val _state = MutableLiveData(NodeServiceState.Off) @@ -77,7 +74,7 @@ class NodeService : Service() { private val stateLock = ReentrantLock() /** List of payments received while the app is in the background */ - private val receivedInBackground = mutableStateListOf() + val receivedInBackground = mutableStateListOf() // jobs monitoring events/payments after business start private var monitorPaymentsJob: Job? = null @@ -87,112 +84,70 @@ class NodeService : Service() { override fun onCreate() { super.onCreate() - log.debug("creating node service...") + log.info("creating node service...") internalData = (applicationContext as PhoenixApplication).internalDataRepository notificationManager = NotificationManagerCompat.from(this) refreshFcmToken() log.debug("service created") } - internal fun refreshFcmToken() { - FirebaseMessaging.getInstance().token.addOnCompleteListener(OnCompleteListener { task -> - if (!task.isSuccessful) { - log.warn("fetching FCM registration token failed: ${task.exception?.localizedMessage}") - return@OnCompleteListener - } - task.result?.let { serviceScope.launch { internalData.saveFcmToken(it) } } - }) - } + abstract fun refreshFcmToken() + abstract fun deleteFcmToken() + + abstract fun isFcmAvailable(context: Context): Boolean + + abstract suspend fun monitorFcmToken(business: PhoenixBusiness) // =========================================================== // // SERVICE LIFECYCLE // // =========================================================== // override fun onBind(intent: Intent?): IBinder { - log.debug("binding node service from intent=$intent") - // UI is binding to the service. The service is not headless anymore and we can remove the notification. - isHeadless = false + log.debug("binding node service from intent={}", intent) receivedInBackground.clear() - stopForeground(STOP_FOREGROUND_REMOVE) - notificationManager.cancel(SystemNotificationHelper.HEADLESS_NOTIF_ID) return binder } /** When unbound, the service is running headless. */ override fun onUnbind(intent: Intent?): Boolean { - isHeadless = true return false } - private val shutdownHandler = Handler(Looper.getMainLooper()) - private val shutdownRunnable: Runnable = Runnable { - if (isHeadless) { - log.debug("reached scheduled shutdown...") - if (receivedInBackground.isEmpty()) { - stopForeground(STOP_FOREGROUND_REMOVE) - } else { - stopForeground(STOP_FOREGROUND_DETACH) - } - shutdown() - } + override fun onTimeout(startId: Int) { + super.onTimeout(startId) + stopForeground(STOP_FOREGROUND_REMOVE) + notificationManager.cancel(SystemNotificationHelper.HEADLESS_NOTIF_ID) + shutdown() } - /** Shutdown the node, close connections and stop the service */ - fun shutdown() { - log.info("shutting down service in state=${_state.value?.name}") - monitorNodeEventsJob?.cancel() - stopSelf() - _state.postValue(NodeServiceState.Off) + override fun onDestroy() { + super.onDestroy() + log.info("service destroyed") } // =========================================================== // - // START COMMAND HANDLER // + // UTILITY // // =========================================================== // - /** Called when an intent is called for this service. */ - override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int { - super.onStartCommand(intent, flags, startId) - log.info("start service from intent [ intent=$intent, flag=$flags, startId=$startId ]") - val reason = intent?.getStringExtra(EXTRA_REASON) - - fun startForeground(notif: Notification) { - if (Build.VERSION.SDK_INT >= 34) { - ServiceCompat.startForeground(this, SystemNotificationHelper.HEADLESS_NOTIF_ID, notif, ServiceInfo.FOREGROUND_SERVICE_TYPE_SHORT_SERVICE) - } else { - startForeground(SystemNotificationHelper.HEADLESS_NOTIF_ID, notif) - } + /** Shutdown the node, close connections and stop the service */ + fun shutdown() { + log.info("shutting down service in state=${_state.value?.name}") + serviceScope.launch { + (application as? PhoenixApplication)?.business?.first()?.stop() + monitorNodeEventsJob?.cancel() + stopSelf() + _state.postValue(NodeServiceState.Off) + log.info("shutdown complete") } + } - val encryptedSeed = SeedManager.loadSeedFromDisk(applicationContext) - when { - _state.value is NodeServiceState.Running -> { - // NOTE: the notification will NOT be shown if the app is already running - val notif = SystemNotificationHelper.notifyRunningHeadless(applicationContext) - startForeground(notif) - } - encryptedSeed is EncryptedSeed.V2.NoAuth -> { - val seed = encryptedSeed.decrypt() - log.debug("successfully decrypted seed in the background, starting wallet...") - val notif = SystemNotificationHelper.notifyRunningHeadless(applicationContext) - startBusiness(seed, requestCheckLegacyChannels = false) - startForeground(notif) - } - else -> { - log.warn("unhandled incoming payment with seed=${encryptedSeed?.name()} reason=$reason") - val notif = when (reason) { - "IncomingPayment" -> SystemNotificationHelper.notifyPaymentMissedAppUnavailable(applicationContext) - "PendingSettlement" -> SystemNotificationHelper.notifyPendingSettlement(applicationContext) - else -> SystemNotificationHelper.notifyRunningHeadless(applicationContext) - } - startForeground(notif) - } - } - shutdownHandler.removeCallbacksAndMessages(null) - shutdownHandler.postDelayed(shutdownRunnable, 2 * 60 * 1000L) // service will shutdown in 2 minutes - if (!isHeadless) { - stopForeground(STOP_FOREGROUND_REMOVE) + internal fun startForeground(notif: Notification, foregroundServiceType: Int) { + log.info("--- starting service in foreground with type=$foregroundServiceType ---") + if (Build.VERSION.SDK_INT >= 34) { + ServiceCompat.startForeground(this, SystemNotificationHelper.HEADLESS_NOTIF_ID, notif, foregroundServiceType) + } else { + startForeground(SystemNotificationHelper.HEADLESS_NOTIF_ID, notif) } - return START_NOT_STICKY } // =========================================================== // @@ -216,10 +171,6 @@ class NodeService : Service() { serviceScope.launch(Dispatchers.IO + CoroutineExceptionHandler { _, e -> log.error("error when starting node: ", e) _state.postValue(NodeServiceState.Error(e)) - if (isHeadless) { - shutdown() - stopForeground(STOP_FOREGROUND_REMOVE) - } }) { log.info("cancel competing workers") val wm = WorkManager.getInstance(applicationContext) @@ -256,7 +207,7 @@ class NodeService : Service() { val trustedSwapInTxs = LegacyPrefsDatastore.getMigrationTrustedSwapInTxs(applicationContext).first() val preferredFiatCurrency = userPrefs.getFiatCurrency.first() - monitorPaymentsJob = serviceScope.launch { monitorPaymentsWhenHeadless(business.peerManager, business.currencyManager, userPrefs) } + monitorPaymentsJob = serviceScope.launch { monitorPaymentsWhenHeadless(business.nodeParamsManager, business.currencyManager, userPrefs) } monitorNodeEventsJob = serviceScope.launch { monitorNodeEvents(business.peerManager, business.nodeParamsManager) } monitorFcmTokenJob = serviceScope.launch { monitorFcmToken(business) } monitorInFlightPaymentsJob = serviceScope.launch { monitorInFlightPayments(business.peerManager) } @@ -285,21 +236,14 @@ class NodeService : Service() { } } - private suspend fun monitorFcmToken(business: PhoenixBusiness) { - val token = internalData.getFcmToken.filterNotNull().first() - business.connectionsManager.connections.first { it.peer == Connection.ESTABLISHED } - business.registerFcmToken(token) - } - private suspend fun monitorNodeEvents(peerManager: PeerManager, nodeParamsManager: NodeParamsManager) { val monitoringStartedAt = currentTimestampMillis() combine(peerManager.swapInNextTimeout, nodeParamsManager.nodeParams.filterNotNull().first().nodeEvents) { nextTimeout, nodeEvent -> nextTimeout to nodeEvent }.collect { (nextTimeout, event) -> - // TODO: click on notif must deeplink to the notification screen when (event) { is LiquidityEvents.Rejected -> { - log.debug("processing liquidity_event=$event") + log.debug("processing liquidity_event={}", event) if (event.source == LiquidityEvents.Source.OnChainWallet) { // Check the last time a rejected on-chain swap notification has been shown. If recent, we do not want to trigger a notification every time. val lastRejectedSwap = internalData.getLastRejectedOnchainSwap.first().takeIf { @@ -336,19 +280,20 @@ class NodeService : Service() { } } - private suspend fun monitorPaymentsWhenHeadless(peerManager: PeerManager, currencyManager: CurrencyManager, userPrefs: UserPrefsRepository) { - peerManager.getPeer().eventsFlow.collect { event -> + @OptIn(ExperimentalCoroutinesApi::class) + private suspend fun monitorPaymentsWhenHeadless(nodeParamsManager: NodeParamsManager, currencyManager: CurrencyManager, userPrefs: UserPrefsRepository) { + nodeParamsManager.nodeParams.filterNotNull().flatMapLatest { it.nodeEvents }.collect { event -> when (event) { - is PaymentReceived -> { + is PaymentEvents.PaymentReceived -> { if (isHeadless) { - receivedInBackground.add(event.received.amount) + receivedInBackground.add(event.amount) SystemNotificationHelper.notifyPaymentsReceived( context = applicationContext, userPrefs = userPrefs, - paymentHash = event.incomingPayment.paymentHash, - amount = event.received.amount, + paymentHash = event.paymentHash, + amount = event.amount, rates = currencyManager.ratesFlow.value, - isHeadless = isHeadless && receivedInBackground.size == 1 + isFromBackground = isHeadless ) } } @@ -375,5 +320,8 @@ class NodeService : Service() { companion object { const val EXTRA_REASON = "${BuildConfig.APPLICATION_ID}.SERVICE_SPAWN_REASON" + const val EXTRA_ORIGIN = "${BuildConfig.APPLICATION_ID}.SERVICE_SPAWN_ORIGIN" + const val ORIGIN_FCM = "fcm" + const val ORIGIN_HEADLESS = "fcm" } -} \ No newline at end of file +} diff --git a/phoenix-android/src/main/kotlin/fr/acinq/phoenix/android/services/NodeState.kt b/phoenix-android/src/main/kotlin/fr/acinq/phoenix/android/services/NodeState.kt index 82ac1b0ca..2195691e9 100644 --- a/phoenix-android/src/main/kotlin/fr/acinq/phoenix/android/services/NodeState.kt +++ b/phoenix-android/src/main/kotlin/fr/acinq/phoenix/android/services/NodeState.kt @@ -29,11 +29,11 @@ sealed class NodeServiceState { val name: String by lazy { this.javaClass.simpleName } /** Default state, the node is not started. */ - object Off : NodeServiceState() + data object Off : NodeServiceState() /** This is an utility state that is used when the binding between the service holding the state and the consumers of that state is disconnected. */ - object Disconnected : NodeServiceState() - object Init : NodeServiceState() - object Running : NodeServiceState() + data object Disconnected : NodeServiceState() + data object Init : NodeServiceState() + data object Running : NodeServiceState() data class Error(val cause: Throwable) : NodeServiceState() } \ No newline at end of file diff --git a/phoenix-android/src/main/kotlin/fr/acinq/phoenix/android/settings/ExperimentalView.kt b/phoenix-android/src/main/kotlin/fr/acinq/phoenix/android/settings/ExperimentalView.kt index fbc8f7a3f..45eb4a4ce 100644 --- a/phoenix-android/src/main/kotlin/fr/acinq/phoenix/android/settings/ExperimentalView.kt +++ b/phoenix-android/src/main/kotlin/fr/acinq/phoenix/android/settings/ExperimentalView.kt @@ -35,6 +35,7 @@ import androidx.lifecycle.ViewModelProvider import androidx.lifecycle.viewModelScope import androidx.lifecycle.viewmodel.CreationExtras import androidx.lifecycle.viewmodel.compose.viewModel +import fr.acinq.phoenix.android.AppViewModel import fr.acinq.phoenix.android.PhoenixApplication import fr.acinq.phoenix.android.R import fr.acinq.phoenix.android.business @@ -118,6 +119,7 @@ class ExperimentalViewModel(val peerManager: PeerManager, val internalDataReposi @Composable fun ExperimentalView( onBackClick: () -> Unit, + appViewModel: AppViewModel, ) { val vm = viewModel(factory = ExperimentalViewModel.Factory(business.peerManager)) @@ -131,6 +133,9 @@ fun ExperimentalView( Card(modifier = Modifier.fillMaxWidth()) { ClaimAddressButton(state = vm.claimAddressState, onClaim = { vm.claimAddress() }) } + + // flavored component + ManageHeadlessView(appViewModel = appViewModel) } } @@ -156,7 +161,7 @@ private fun ClaimAddressButton( leadingIcon = { PhoenixIcon(R.drawable.ic_arobase) }, subtitle = { Text(text = stringResource(id = R.string.bip353_subtitle)) - Spacer(modifier = Modifier.height(8.dp)) + Spacer(modifier = Modifier.height(16.dp)) FilledButton( text = stringResource(id = R.string.bip353_claim_button), onClick = onClaim, diff --git a/phoenix-android/src/main/kotlin/fr/acinq/phoenix/android/settings/ResetWallet.kt b/phoenix-android/src/main/kotlin/fr/acinq/phoenix/android/settings/ResetWallet.kt index 19574ecd6..a28210429 100644 --- a/phoenix-android/src/main/kotlin/fr/acinq/phoenix/android/settings/ResetWallet.kt +++ b/phoenix-android/src/main/kotlin/fr/acinq/phoenix/android/settings/ResetWallet.kt @@ -49,6 +49,7 @@ import fr.acinq.phoenix.android.LocalBitcoinUnit import fr.acinq.phoenix.android.LocalFiatCurrency import fr.acinq.phoenix.android.MainActivity import fr.acinq.phoenix.android.R +import fr.acinq.phoenix.android.application import fr.acinq.phoenix.android.business import fr.acinq.phoenix.android.components.BorderButton import fr.acinq.phoenix.android.components.Button @@ -328,6 +329,7 @@ private fun WalletDeleted() { verticalArrangement = Arrangement.spacedBy(2.dp), horizontalAlignment = Alignment.CenterHorizontally ) { + val application = application SuccessMessage(header = stringResource(id = R.string.reset_wallet_success)) Spacer(modifier = Modifier.height(16.dp)) BorderButton( @@ -335,7 +337,7 @@ private fun WalletDeleted() { icon = R.drawable.ic_check, onClick = { context.startActivity( - Intent(context, MainActivity::class.java).apply { + Intent(context, application.mainActivityClass).apply { flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TASK } ) diff --git a/phoenix-android/src/main/kotlin/fr/acinq/phoenix/android/utils/SystemNotificationHelper.kt b/phoenix-android/src/main/kotlin/fr/acinq/phoenix/android/utils/SystemNotificationHelper.kt index c4bd04a40..c8ce8a4a5 100644 --- a/phoenix-android/src/main/kotlin/fr/acinq/phoenix/android/utils/SystemNotificationHelper.kt +++ b/phoenix-android/src/main/kotlin/fr/acinq/phoenix/android/utils/SystemNotificationHelper.kt @@ -37,6 +37,7 @@ import fr.acinq.lightning.MilliSatoshi import fr.acinq.lightning.utils.currentTimestampMillis import fr.acinq.phoenix.android.BuildConfig import fr.acinq.phoenix.android.MainActivity +import fr.acinq.phoenix.android.PhoenixApplication import fr.acinq.phoenix.android.R import fr.acinq.phoenix.android.utils.Converter.toAbsoluteDateString import fr.acinq.phoenix.android.utils.Converter.toPrettyString @@ -72,6 +73,10 @@ object SystemNotificationHelper { /** If the remaining blocks count before a swap timeout is lower than this, we should mention it in the notification. */ private const val SWAP_TIMEOUT_THRESHOLD_IN_BLOCKS = 144 * 30 * 2 // ~2 months + private fun getActivityClass(context: Context): Class? { + return (context as? PhoenixApplication)?.mainActivityClass + } + fun registerNotificationChannels(context: Context) { // notification channels (android 8+) context.getSystemService(NotificationManager::class.java)?.createNotificationChannels( @@ -98,15 +103,18 @@ object SystemNotificationHelper { ) } - fun notifyRunningHeadless(context: Context): Notification { + fun getHeadlessNotification(context: Context): Notification { return NotificationCompat.Builder(context, HEADLESS_NOTIF_CHANNEL).apply { setContentTitle(context.getString(R.string.notif_headless_title_default)) setSmallIcon(R.drawable.ic_phoenix_outline) - }.build().also { - if (ActivityCompat.checkSelfPermission(context, Manifest.permission.POST_NOTIFICATIONS) == PackageManager.PERMISSION_GRANTED) { - NotificationManagerCompat.from(context).notify(HEADLESS_NOTIF_ID, it) - } - } + }.build() + } + + fun getHeadlessFailureNotification(context: Context): Notification { + return NotificationCompat.Builder(context, HEADLESS_NOTIF_CHANNEL).apply { + setContentTitle(context.getString(R.string.notif_headless_title_failure)) + setSmallIcon(R.drawable.ic_phoenix_outline) + }.build() } private fun notifyPaymentFailed(context: Context, title: String, message: String, deepLink: String?): Notification { @@ -116,8 +124,8 @@ object SystemNotificationHelper { setStyle(NotificationCompat.BigTextStyle().bigText(message)) setSmallIcon(R.drawable.ic_phoenix_outline) val intent = deepLink?.let { - Intent(Intent.ACTION_VIEW, it.toUri(), context, MainActivity::class.java) - } ?: Intent(context, MainActivity::class.java) + Intent(Intent.ACTION_VIEW, it.toUri(), context, getActivityClass(context)) + } ?: Intent(context, getActivityClass(context)) setContentIntent( TaskStackBuilder.create(context).run { addNextIntentWithParentStack(intent) @@ -218,7 +226,7 @@ object SystemNotificationHelper { setContentText(context.getString(R.string.notif_pending_settlement_message)) setStyle(NotificationCompat.BigTextStyle().bigText(context.getString(R.string.notif_pending_settlement_message))) setSmallIcon(R.drawable.ic_phoenix_outline) - setContentIntent(PendingIntent.getActivity(context, 0, Intent(context, MainActivity::class.java), PendingIntent.FLAG_IMMUTABLE)) + setContentIntent(PendingIntent.getActivity(context, 0, Intent(context, getActivityClass(context)), PendingIntent.FLAG_IMMUTABLE)) setAutoCancel(true) }.build().also { if (ActivityCompat.checkSelfPermission(context, Manifest.permission.POST_NOTIFICATIONS) == PackageManager.PERMISSION_GRANTED) { @@ -233,7 +241,7 @@ object SystemNotificationHelper { setContentText(context.getString(R.string.notif_inflight_payment_message)) setStyle(NotificationCompat.BigTextStyle().bigText(context.getString(R.string.notif_inflight_payment_message))) setSmallIcon(R.drawable.ic_phoenix_outline) - setContentIntent(PendingIntent.getActivity(context, 0, Intent(context, MainActivity::class.java), PendingIntent.FLAG_IMMUTABLE)) + setContentIntent(PendingIntent.getActivity(context, 0, Intent(context, getActivityClass(context)), PendingIntent.FLAG_IMMUTABLE)) setAutoCancel(true) }.build().also { if (ActivityCompat.checkSelfPermission(context, Manifest.permission.POST_NOTIFICATIONS) == PackageManager.PERMISSION_GRANTED) { @@ -248,7 +256,7 @@ object SystemNotificationHelper { paymentHash: ByteVector32, amount: MilliSatoshi, rates: List, - isHeadless: Boolean, + isFromBackground: Boolean, ): Notification { val isFiat = userPrefs.getIsAmountInFiat.first() && rates.isNotEmpty() val unit = if (isFiat) { @@ -277,14 +285,14 @@ object SystemNotificationHelper { return NotificationCompat.Builder(context, PAYMENT_RECEIVED_NOTIF_CHANNEL).apply { setContentTitle(context.getString(R.string.notif_headless_received, amount.toPrettyString(unit, rate, withUnit = true))) setSmallIcon(R.drawable.ic_phoenix_outline) - val intent = Intent(Intent.ACTION_VIEW,"phoenix:payments/${WalletPaymentId.DbType.INCOMING.value}/${paymentHash.toHex()}".toUri(), context, MainActivity::class.java).apply { + val intent = Intent(Intent.ACTION_VIEW,"phoenix:payments/${WalletPaymentId.DbType.INCOMING.value}/${paymentHash.toHex()}".toUri(), context, getActivityClass(context)).apply { Intent.FLAG_ACTIVITY_SINGLE_TOP } setContentIntent(PendingIntent.getActivity(context, 0, intent, PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT)) setAutoCancel(true) }.build().also { if (ActivityCompat.checkSelfPermission(context, Manifest.permission.POST_NOTIFICATIONS) == PackageManager.PERMISSION_GRANTED) { - NotificationManagerCompat.from(context).notify(if (isHeadless) HEADLESS_NOTIF_ID else Random().nextInt(), it) + NotificationManagerCompat.from(context).notify(if (isFromBackground) HEADLESS_NOTIF_ID else Random().nextInt(), it) } } } @@ -294,7 +302,7 @@ object SystemNotificationHelper { setContentTitle(context.getString(R.string.notif_watcher_revoked_commit_title)) setContentText(context.getString(R.string.notif_watcher_revoked_commit_message)) setSmallIcon(R.drawable.ic_phoenix_outline) - setContentIntent(PendingIntent.getActivity(context, 0, Intent(context, MainActivity::class.java), PendingIntent.FLAG_IMMUTABLE)) + setContentIntent(PendingIntent.getActivity(context, 0, Intent(context, getActivityClass(context)), PendingIntent.FLAG_IMMUTABLE)) setAutoCancel(true) }.let { if (ActivityCompat.checkSelfPermission(context, Manifest.permission.POST_NOTIFICATIONS) == PackageManager.PERMISSION_GRANTED) { diff --git a/phoenix-android/src/main/kotlin/fr/acinq/phoenix/android/utils/datastore/InternalDataRepository.kt b/phoenix-android/src/main/kotlin/fr/acinq/phoenix/android/utils/datastore/InternalDataRepository.kt index 759923057..88168ef9d 100644 --- a/phoenix-android/src/main/kotlin/fr/acinq/phoenix/android/utils/datastore/InternalDataRepository.kt +++ b/phoenix-android/src/main/kotlin/fr/acinq/phoenix/android/utils/datastore/InternalDataRepository.kt @@ -57,6 +57,8 @@ class InternalDataRepository(private val internalData: DataStore) { private val SHOW_SPLICEOUT_CAPACITY_DISCLAIMER = booleanPreferencesKey("SHOW_SPLICEOUT_CAPACITY_DISCLAIMER") private val REMOTE_WALLET_NOTICE_READ_INDEX = intPreferencesKey("REMOTE_WALLET_NOTICE_READ_INDEX") private val BIP_353_ADDRESS = stringPreferencesKey("BIP_353_ADDRESS") + private val BACKGROUND_SERVICE_MODE = booleanPreferencesKey("BACKGROUND_SERVICE_MODE") + private val BACKGROUND_SERVICE_MODE_WITH_WAKELOCK = booleanPreferencesKey("BACKGROUND_SERVICE_MODE_WITH_WAKELOCK") } val log = LoggerFactory.getLogger(this::class.java) @@ -147,4 +149,22 @@ class InternalDataRepository(private val internalData: DataStore) { val getBip353Address: Flow = safeData.map { it[BIP_353_ADDRESS] ?: "" } suspend fun saveBip353Address(address: String) = internalData.edit { it[BIP_353_ADDRESS] = address } + + val getBackgroundServiceMode: Flow?> = safeData.map { + val isEnabled = it[BACKGROUND_SERVICE_MODE] + val withWakeLock = it[BACKGROUND_SERVICE_MODE_WITH_WAKELOCK] ?: false + isEnabled?.let { it to withWakeLock } + } + suspend fun saveBackgroundServiceModeEnabled(withWakeLock: Boolean) { + internalData.edit { + it[BACKGROUND_SERVICE_MODE] = true + it[BACKGROUND_SERVICE_MODE_WITH_WAKELOCK] = withWakeLock + } + } + suspend fun saveBackgroundServiceModeDisabled() { + internalData.edit { + it[BACKGROUND_SERVICE_MODE] = false + it[BACKGROUND_SERVICE_MODE_WITH_WAKELOCK] = false + } + } } \ No newline at end of file diff --git a/phoenix-android/src/main/kotlin/fr/acinq/phoenix/android/utils/extensions.kt b/phoenix-android/src/main/kotlin/fr/acinq/phoenix/android/utils/extensions.kt index d5ea31466..00d74f166 100644 --- a/phoenix-android/src/main/kotlin/fr/acinq/phoenix/android/utils/extensions.kt +++ b/phoenix-android/src/main/kotlin/fr/acinq/phoenix/android/utils/extensions.kt @@ -17,22 +17,18 @@ package fr.acinq.phoenix.android.utils import android.content.* -import android.net.Uri import androidx.compose.runtime.Composable import androidx.compose.runtime.remember import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.res.stringResource -import androidx.core.content.FileProvider import fr.acinq.lightning.db.* import fr.acinq.lightning.utils.Connection -import fr.acinq.lightning.utils.currentTimestampMillis import fr.acinq.phoenix.android.* import fr.acinq.phoenix.android.R import fr.acinq.phoenix.android.utils.Converter.toPrettyString import fr.acinq.phoenix.data.BitcoinUnit import fr.acinq.phoenix.data.FiatCurrency import fr.acinq.phoenix.utils.extensions.desc -import java.io.File import java.security.cert.CertificateException import java.util.* import kotlin.contracts.ExperimentalContracts diff --git a/phoenix-android/src/main/res/values-b+es+419/important_strings.xml b/phoenix-android/src/main/res/values-b+es+419/important_strings.xml index 5e546b468..a7becc707 100644 --- a/phoenix-android/src/main/res/values-b+es+419/important_strings.xml +++ b/phoenix-android/src/main/res/values-b+es+419/important_strings.xml @@ -80,8 +80,7 @@ Ver detalles Procesamiento en segundo plano restringido. - Phoenix no podrá recibir pagos cuando esté en segundo plano, o cuando esté cerrado. - Esto ocurre porque: + Phoenix podría no recibir pagos cuando esté en segundo plano, o cuando esté cerrado. Tor está activado El dispositivo está en modo de ahorro de energía FCM notificaciones no disponibles diff --git a/phoenix-android/src/main/res/values-cs/important_strings.xml b/phoenix-android/src/main/res/values-cs/important_strings.xml index 9f4a465c7..c6bd3452b 100644 --- a/phoenix-android/src/main/res/values-cs/important_strings.xml +++ b/phoenix-android/src/main/res/values-cs/important_strings.xml @@ -83,8 +83,7 @@ Klikněte pro podrobnosti Omezeno zpracovávání na pozadí - Phoenix nebude moci přijímat platby, pokud je na pozadí nebo pokud je zavřený.. - Děje se tak, protože: + Phoenix možná nebude moci přijímat platby, pokud je na pozadí nebo pokud je zavřený.. Tor je povolen Zařízení je v úsporném režimu Oznámení FCM nejsou k dispozici diff --git a/phoenix-android/src/main/res/values-de/important_strings.xml b/phoenix-android/src/main/res/values-de/important_strings.xml index 08c413c06..479b4dc7f 100644 --- a/phoenix-android/src/main/res/values-de/important_strings.xml +++ b/phoenix-android/src/main/res/values-de/important_strings.xml @@ -80,8 +80,7 @@ Tippen Sie für Details Hintergrundverarbeitung eingeschränkt - Phoenix kann keine Zahlungen empfangen, wenn es im Hintergrund läuft oder geschlossen ist. - Dies geschieht, weil: + Phoenix wird möglicherweise keine Zahlungen erhalten können, wenn es im Hintergrund läuft oder geschlossen ist. Tor ist aktiviert Das Gerät ist im Energiesparmodus FCM-Benachrichtigungen nicht verfügbar diff --git a/phoenix-android/src/main/res/values-es/important_strings.xml b/phoenix-android/src/main/res/values-es/important_strings.xml index b77e6643a..ff49b79a9 100644 --- a/phoenix-android/src/main/res/values-es/important_strings.xml +++ b/phoenix-android/src/main/res/values-es/important_strings.xml @@ -75,8 +75,7 @@ Ver detalles Procesamiento en segundo plano restringido. - Phoenix no podrá recibir pagos cuando esté en segundo plano, o cuando esté cerrado. - Esto ocurre porque: + Phoenix podría no recibir pagos cuando esté en segundo plano, o cuando esté cerrado. Tor está activado El dispositivo está en modo de ahorro de energía FCM notificaciones no disponibles diff --git a/phoenix-android/src/main/res/values-fr/important_strings.xml b/phoenix-android/src/main/res/values-fr/important_strings.xml index 52c827bbe..7c95f722f 100644 --- a/phoenix-android/src/main/res/values-fr/important_strings.xml +++ b/phoenix-android/src/main/res/values-fr/important_strings.xml @@ -83,8 +83,7 @@ Voir les détails Fonctionnement en arrière plan restreint - Phoenix ne pourra pas recevoir de paiements quand l\'application est en arrière plan ou est fermée. - Cela vient du fait que: + Phoenix pourrait ne pas être en mesure de recevoir de paiements quand l\'application est en arrière plan ou est fermée. Tor est activé Le téléphone est en mode économie d\'énergie Les notifications FCM sont indisponibles diff --git a/phoenix-android/src/main/res/values-pt-rBR/important_strings.xml b/phoenix-android/src/main/res/values-pt-rBR/important_strings.xml index 75c5c5ed4..6d033c98d 100644 --- a/phoenix-android/src/main/res/values-pt-rBR/important_strings.xml +++ b/phoenix-android/src/main/res/values-pt-rBR/important_strings.xml @@ -83,8 +83,7 @@ Toque para obter detalhes Processamento em segundo plano restrito - O Phoenix não poderá receber pagamentos quando estiver em segundo plano ou quando estiver fechado. - Isso acontece porque: + A Phoenix pode não conseguir receber pagamentos quando estiver em segundo plano ou quando estiver fechado. Tor está ativado O dispositivo está no modo de economia de energia FCM notificações indisponíveis diff --git a/phoenix-android/src/main/res/values-sk/important_strings.xml b/phoenix-android/src/main/res/values-sk/important_strings.xml index 0b61efaa3..954ca880b 100644 --- a/phoenix-android/src/main/res/values-sk/important_strings.xml +++ b/phoenix-android/src/main/res/values-sk/important_strings.xml @@ -83,8 +83,7 @@ Kliknite pre podrobnosti Omedzené spracovanie pozadia - Fénix nebude môcť prijímať platby, keď je na pozadí alebo keď je zatvorený. - Deje sa tak, pretože: + Phoenix možno nebude môcť prijímať platby, keď je na pozadí alebo keď je zatvorený. Tor je povolený Zariadenie je v úspornom režime Oznamy FCM nie sú k dispozícii diff --git a/phoenix-android/src/main/res/values-sw/important_strings.xml b/phoenix-android/src/main/res/values-sw/important_strings.xml index 16b45bbdc..4a55d1a13 100644 --- a/phoenix-android/src/main/res/values-sw/important_strings.xml +++ b/phoenix-android/src/main/res/values-sw/important_strings.xml @@ -87,8 +87,7 @@ Bonyeza kwa maelezo Usindikaji wa nyuma umepunguzwa - Phoenix haitaweza kupokea malipo wakati iko nyuma, au wakati imefungwa. - Hii hutokea kwa sababu: + Huenda Phoenix isiweze kupokea malipo ikiwa chinichini, au inapofungwa. Tor imewashwa Kifaa kipo kwenye hali ya kuokoa nguvu Arifa za FCM hazipatikani diff --git a/phoenix-android/src/main/res/values-vi/important_strings.xml b/phoenix-android/src/main/res/values-vi/important_strings.xml index a8a7d5224..8a7cdd661 100644 --- a/phoenix-android/src/main/res/values-vi/important_strings.xml +++ b/phoenix-android/src/main/res/values-vi/important_strings.xml @@ -90,8 +90,7 @@ Nhấn để biết thêm chi tiết Hạn chế xử lý nền - Phoenix sẽ không thể nhận thanh toán khi nó ở chế độ nền hoặc khi nó bị đóng. - Điều này xảy ra vì: + Phoenix có thể không nhận được thanh toán khi đang ở chế độ nền hoặc khi đóng cửa. Tor đã được bật Thiết bị đang ở chế độ tiết kiệm năng lượng Thông báo FCM không khả dụng diff --git a/phoenix-android/src/main/res/values/important_strings.xml b/phoenix-android/src/main/res/values/important_strings.xml index a67f3efc9..fcf7b958d 100644 --- a/phoenix-android/src/main/res/values/important_strings.xml +++ b/phoenix-android/src/main/res/values/important_strings.xml @@ -29,6 +29,7 @@ Phoenix is running in the background + Background start failed Received +%1$s Please start Phoenix @@ -87,8 +88,7 @@ Tap for details Background processing restricted - Phoenix will not be able to receive payments when it is in the background, or when it is closed. - This happens because: + Phoenix may not be able to receive payments when it is in the background, or when it is closed. Tor is enabled The device is in power saving mode FCM notifications unavailable diff --git a/phoenix-android/src/main/res/values/strings.xml b/phoenix-android/src/main/res/values/strings.xml index 8b4251ed2..71eb8a341 100644 --- a/phoenix-android/src/main/res/values/strings.xml +++ b/phoenix-android/src/main/res/values/strings.xml @@ -235,6 +235,7 @@ Share OK Save + Start Confirm Cancel Close