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 = [