diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 42f1ae2..a213a2b 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -4,6 +4,7 @@ + @@ -41,6 +42,7 @@ diff --git a/app/src/main/kotlin/com/aliucord/manager/installer/steps/StepRunner.kt b/app/src/main/kotlin/com/aliucord/manager/installer/steps/StepRunner.kt index bac13c9..929368e 100644 --- a/app/src/main/kotlin/com/aliucord/manager/installer/steps/StepRunner.kt +++ b/app/src/main/kotlin/com/aliucord/manager/installer/steps/StepRunner.kt @@ -1,7 +1,12 @@ package com.aliucord.manager.installer.steps +import android.content.Context +import androidx.lifecycle.Lifecycle +import androidx.lifecycle.ProcessLifecycleOwner +import com.aliucord.manager.R import com.aliucord.manager.installer.steps.base.Step import com.aliucord.manager.manager.PreferencesManager +import com.aliucord.manager.ui.util.InstallNotifications import kotlinx.collections.immutable.ImmutableList import kotlinx.coroutines.delay import org.koin.core.component.KoinComponent @@ -14,7 +19,10 @@ import org.koin.core.component.inject */ const val MINIMUM_STEP_DELAY: Long = 600L +const val ERROR_NOTIF_ID = 200002 + abstract class StepRunner : KoinComponent { + private val context: Context by inject() private val preferences: PreferencesManager by inject() abstract val steps: ImmutableList @@ -39,7 +47,10 @@ abstract class StepRunner : KoinComponent { suspend fun executeAll(): Throwable? { for (step in steps) { val error = step.executeCatching(this@StepRunner) - if (error != null) return error + if (error != null) { + showErrorNotification() + return error + } // Skip minimum run time when in dev mode if (!preferences.devMode && step.durationMs < MINIMUM_STEP_DELAY) { @@ -49,4 +60,16 @@ abstract class StepRunner : KoinComponent { return null } + + private fun showErrorNotification() { + // If app backgrounded + if (ProcessLifecycleOwner.get().lifecycle.currentState == Lifecycle.State.CREATED) { + InstallNotifications.createNotification( + context = context, + id = ERROR_NOTIF_ID, + title = R.string.notif_install_fail_title, + description = R.string.notif_install_fail_desc, + ) + } + } } diff --git a/app/src/main/kotlin/com/aliucord/manager/installer/steps/install/InstallStep.kt b/app/src/main/kotlin/com/aliucord/manager/installer/steps/install/InstallStep.kt index 190b58a..d1b21be 100644 --- a/app/src/main/kotlin/com/aliucord/manager/installer/steps/install/InstallStep.kt +++ b/app/src/main/kotlin/com/aliucord/manager/installer/steps/install/InstallStep.kt @@ -1,5 +1,7 @@ package com.aliucord.manager.installer.steps.install +import android.content.Context +import androidx.lifecycle.* import com.aliucord.manager.R import com.aliucord.manager.installer.steps.StepGroup import com.aliucord.manager.installer.steps.StepRunner @@ -9,13 +11,17 @@ import com.aliucord.manager.installer.steps.patch.CopyDependenciesStep import com.aliucord.manager.installers.InstallerResult import com.aliucord.manager.manager.InstallerManager import com.aliucord.manager.manager.PreferencesManager +import com.aliucord.manager.ui.util.InstallNotifications import org.koin.core.component.KoinComponent import org.koin.core.component.inject +private const val READY_NOTIF_ID = 200001 + /** * Install the final APK with the system's PackageManager. */ class InstallStep : Step(), KoinComponent { + private val context: Context by inject() private val installers: InstallerManager by inject() private val prefs: PreferencesManager by inject() @@ -25,6 +31,19 @@ class InstallStep : Step(), KoinComponent { override suspend fun execute(container: StepRunner) { val apk = container.getStep().patchedApk + // If app backgrounded, show notification + if (ProcessLifecycleOwner.get().lifecycle.currentState == Lifecycle.State.CREATED) { + InstallNotifications.createNotification( + context = context, + id = READY_NOTIF_ID, + title = R.string.notif_install_ready_title, + description = R.string.notif_install_ready_desc, + ) + } + + // Wait until app resumed + ProcessLifecycleOwner.get().lifecycle.withResumed {} + val result = installers.getActiveInstaller().waitInstall( apks = listOf(apk), silent = !prefs.devMode, diff --git a/app/src/main/kotlin/com/aliucord/manager/ui/components/Wakelock.kt b/app/src/main/kotlin/com/aliucord/manager/ui/components/Wakelock.kt index 69de57a..93847c1 100644 --- a/app/src/main/kotlin/com/aliucord/manager/ui/components/Wakelock.kt +++ b/app/src/main/kotlin/com/aliucord/manager/ui/components/Wakelock.kt @@ -1,12 +1,10 @@ package com.aliucord.manager.ui.components -import android.app.Activity -import android.content.Context -import android.content.ContextWrapper import android.view.WindowManager import androidx.compose.runtime.Composable import androidx.compose.runtime.DisposableEffect import androidx.compose.ui.platform.LocalContext +import com.aliucord.manager.util.findActivity /** * Maintain an active screen wakelock as long as [active] is true and this component is in scope. @@ -28,12 +26,3 @@ fun Wakelock(active: Boolean = false) { } } } - -private fun Context.findActivity(): Activity? { - var context = this - while (context is ContextWrapper) { - if (context is Activity) return context - context = context.baseContext - } - return null -} diff --git a/app/src/main/kotlin/com/aliucord/manager/ui/screens/installopts/InstallOptionsScreen.kt b/app/src/main/kotlin/com/aliucord/manager/ui/screens/installopts/InstallOptionsScreen.kt index 4d509bc..3155155 100644 --- a/app/src/main/kotlin/com/aliucord/manager/ui/screens/installopts/InstallOptionsScreen.kt +++ b/app/src/main/kotlin/com/aliucord/manager/ui/screens/installopts/InstallOptionsScreen.kt @@ -8,6 +8,7 @@ import androidx.compose.runtime.* import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.alpha +import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.style.TextAlign @@ -33,9 +34,14 @@ class InstallOptionsScreen( @Composable override fun Content() { + val context = LocalContext.current val navigator = LocalNavigator.currentOrThrow val model = getScreenModel() + LaunchedEffect(Unit) { + InstallNotifications.requestPermissions(context) + } + Scaffold( topBar = { InstallOptionsAppBar() }, ) { paddingValues -> diff --git a/app/src/main/kotlin/com/aliucord/manager/ui/util/InstallNotifications.kt b/app/src/main/kotlin/com/aliucord/manager/ui/util/InstallNotifications.kt new file mode 100644 index 0000000..73a67a4 --- /dev/null +++ b/app/src/main/kotlin/com/aliucord/manager/ui/util/InstallNotifications.kt @@ -0,0 +1,87 @@ +package com.aliucord.manager.ui.util + +import android.Manifest +import android.app.* +import android.content.Context +import android.content.Intent +import android.content.pm.PackageManager +import android.os.Build +import android.util.Log +import androidx.annotation.StringRes +import androidx.core.app.* +import androidx.core.content.ContextCompat +import com.aliucord.manager.* +import com.aliucord.manager.util.findActivity + +object InstallNotifications { + private const val CHANNEL_ID = "installation" + + /** + * Creates or replaces a notification with id [id] that brings + * up the existing [MainActivity] when clicked upon. + * + * @param id A unique notification ID for different notifications + * @param title Main notification title + * @param description Notification description + */ + fun createNotification( + context: Context, + id: Int, + @StringRes title: Int, + @StringRes description: Int, + ) { + val manager = NotificationManagerCompat.from(context) + + // Create the target notification channel + if (Build.VERSION.SDK_INT >= 26 && manager.getNotificationChannel(CHANNEL_ID) == null) { + val channel = NotificationChannelCompat.Builder(CHANNEL_ID, NotificationManager.IMPORTANCE_HIGH) + .setName(context.getString(R.string.notif_group_install_title)) + .setDescription(context.getString(R.string.notif_group_install_desc)) + .build() + + manager.createNotificationChannel(channel) + } + + val notification = NotificationCompat.Builder(context, CHANNEL_ID) + .setDefaults(Notification.DEFAULT_LIGHTS or Notification.DEFAULT_SOUND) + .setAutoCancel(true) + .setSmallIcon(R.drawable.ic_aliucord_logo) + .setContentTitle(context.getString(title)) + .setContentText(context.getString(description)) + .setContentIntent( + PendingIntent.getActivity( + /* context = */ context, + /* requestCode = */ 0, + /* intent = */ + Intent(context, MainActivity::class.java) + .addFlags(Intent.FLAG_ACTIVITY_BROUGHT_TO_FRONT), + /* flags = */ PendingIntent.FLAG_IMMUTABLE, + ) + ) + .build() + + try { + manager.notify(id, notification) + } catch (e: SecurityException) { + Log.w(BuildConfig.TAG, "Failed to send install notification", e) + } + } + + /** + * Request the `POST_NOTIFICATIONS` permission if needed. + */ + fun requestPermissions(context: Context) { + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.TIRAMISU) return + + val granted = ContextCompat.checkSelfPermission(context, Manifest.permission.POST_NOTIFICATIONS) + val activity = context.findActivity() ?: return + + if (granted != PackageManager.PERMISSION_GRANTED) { + ActivityCompat.requestPermissions( + activity, + arrayOf(Manifest.permission.POST_NOTIFICATIONS), + 0, + ) + } + } +} diff --git a/app/src/main/kotlin/com/aliucord/manager/util/Util.kt b/app/src/main/kotlin/com/aliucord/manager/util/Context.kt similarity index 85% rename from app/src/main/kotlin/com/aliucord/manager/util/Util.kt rename to app/src/main/kotlin/com/aliucord/manager/util/Context.kt index 6150fe2..1e54fe7 100644 --- a/app/src/main/kotlin/com/aliucord/manager/util/Util.kt +++ b/app/src/main/kotlin/com/aliucord/manager/util/Context.kt @@ -1,5 +1,6 @@ package com.aliucord.manager.util +import android.app.Activity import android.content.* import android.os.Environment import android.util.Log @@ -44,3 +45,12 @@ fun Context.getPackageVersion(pkg: String): Pair { return packageManager.getPackageInfo(pkg, 0) .let { it.versionName to it.versionCode } } + +fun Context.findActivity(): Activity? { + var context = this + while (context is ContextWrapper) { + if (context is Activity) return context + context = context.baseContext + } + return null +} diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index a33a044..c472573 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -146,4 +146,11 @@ Changes the app\'s icon to use Aliucord branding instead of Discord\'s own. Basic Advanced + + Active installation + Progress notifications sent during a minimized installation + Aliucord installation ready! + Click to install… + Aliucord failed to install + Click to view more info… diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 8870c82..077bfef 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -29,6 +29,7 @@ accompanist-systemUiController = { module = "com.google.accompanist:accompanist- # AndroidX androidx-activity = { module = "androidx.activity:activity-compose", version.ref = "androidx-activity" } androidx-lifecycle = { module = "androidx.lifecycle:lifecycle-runtime-compose", version.ref = "androidx-lifecycle" } +androidx-lifecycle-process = { module = "androidx.lifecycle:lifecycle-process", version.ref = "androidx-lifecycle" } androidx-core = { module = "androidx.core:core-ktx", version.ref = "androidx-core" } androidx-splashscreen = { module = "androidx.core:core-splashscreen", version.ref = "androidx-splashscreen" } @@ -80,6 +81,7 @@ androidx = [ "androidx-core", "androidx-activity", "androidx-lifecycle", + "androidx-lifecycle-process", "androidx-splashscreen", ] compose = [