code:
package de.marmaro.krt.ffupdater.background

import android.content.Context
import android.util.Log
import androidx.annotation.MainThread
import androidx.work.*
import androidx.work.ExistingPeriodicWorkPolicy.REPLACE
import de.marmaro.krt.ffupdater.R
import de.marmaro.krt.ffupdater.app.App
import de.marmaro.krt.ffupdater.app.impl.exceptions.GithubRateLimitExceededException
import de.marmaro.krt.ffupdater.app.impl.exceptions.NetworkException
import de.marmaro.krt.ffupdater.download.AppCache
import de.marmaro.krt.ffupdater.download.DownloadManagerUtil
import de.marmaro.krt.ffupdater.download.DownloadManagerUtil.DownloadStatus.Status.FAILED
import de.marmaro.krt.ffupdater.download.DownloadManagerUtil.DownloadStatus.Status.SUCCESSFUL
import de.marmaro.krt.ffupdater.download.NetworkUtil
import de.marmaro.krt.ffupdater.download.StorageUtil
import de.marmaro.krt.ffupdater.settings.PreferencesHelper
import de.marmaro.krt.ffupdater.settings.SettingsHelper
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.delay
import java.time.LocalDateTime
import java.util.concurrent.TimeUnit.MINUTES

/**
 * This class will call the [WorkManager] to check regularly for app updates in the background.
 * When an app update is available, a notification will be displayed.
 *
 * doWork can be interrupted at any time and cause a CancellationException.
 */
class BackgroundJob(context: Context, workerParams: WorkerParameters) :
    CoroutineWorker(context, workerParams) {

    /**
     * If:
     * - airplane mode is enabled
     * - internet is not available
     * - the system download manager is currently downloading a file
     * then delay the background job execution by 30s, 1m, 2m, 4m, ...
     * https://developer.android.com/reference/androidx/work/BackoffPolicy?hl=en#EXPONENTIAL
     * https://developer.android.com/reference/androidx/work/WorkRequest#DEFAULT_BACKOFF_DELAY_MILLIS
     *
     * Result.failure will remove the scheduled job (and that's unwanted).
     * Result.success will execute the job in the next period.
     * Result.retry will retry the job with exponentially increased wait time (30s, 1m, 2m, ...).
     */
    @MainThread
    override suspend fun doWork(): Result {
        try {
            executeBackgroundJob()
            return Result.success()
        } catch (e: Exception) {
            when (e) {
                is CancellationException, is GithubRateLimitExceededException, is NetworkException -> {
                    if (runAttemptCount <= RUN_ATTEMPTS_FOR_63MIN_TOTAL) {
                        Log.i(LOG_TAG, "Retry background job.", e)
                        return Result.retry()
                    }
                }
            }
            showErrorNotification(applicationContext, e)
            return Result.success()
        }
    }

    private fun showErrorNotification(context: Context, e: Exception) {
        val message = context.getString(R.string.background_job_failure__notification_text)
        ErrorNotificationBuilder.showNotification(context, e, message)
    }

    @MainThread
    private suspend fun executeBackgroundJob(): Result {
        Log.i(LOG_TAG, "Execute background job for update check.")
        if (DownloadManagerUtil.isDownloadingFilesNow(applicationContext)) {
            Log.i(LOG_TAG, "Retry background job because other downloads are running.")
            return Result.retry()
        }

        val appsWithUpdates = findAppsWithUpdates()
        if (NetworkUtil.isActiveNetworkUnmetered(applicationContext) && StorageUtil.isEnoughStorageAvailable()) {
            downloadUpdatesInBackground(appsWithUpdates)
        }
        showUpdateNotification(appsWithUpdates)
        updateLastBackgroundCheckTimestamp()
        return Result.success()
    }

    /**
     * Returns apps which:
     *  - are installed
     *  - are not disabled (in the settings "excluded applications")
     *  - have an available update
     */
    private suspend fun findAppsWithUpdates(): List<App> {
        val disabledApps = SettingsHelper(applicationContext).disabledApps
        return App.values()
            .filter { it !in disabledApps }
            .filter { it.detail.isInstalled(applicationContext) }
            // nice side effect: check for updates by calling updateCheck()
            .filter { it.detail.updateCheck(applicationContext).isUpdateAvailable }
    }

    /**
     * If the current network is unmetered, then download the update for the given apps
     * with the DownloadManager in the background.
     */
    private suspend fun downloadUpdatesInBackground(appsWithUpdates: List<App>) {
        appsWithUpdates.forEach { downloadUpdateInBackground(it) }
    }

    /**
     * If the app update is not already been cached, then start the download and wait until the
     * download is finished.
     */
    @MainThread
    private suspend fun downloadUpdateInBackground(app: App) {
        val appCache = AppCache(app)
        val cachedUpdateChecker = app.detail.updateCheck(applicationContext)
        val availableResult = cachedUpdateChecker.availableResult
        if (appCache.isAvailable(applicationContext, availableResult)) {
            Log.i(LOG_TAG, "Skip $app download because it's already cached.")
            return
        }

        Log.i(LOG_TAG, "Download $app in the background.")
        val downloadId = DownloadManagerUtil.enqueue(
            applicationContext,
            app,
            availableResult,
            appCache.getFileName()
        )
        repeat(60 * 60) {
            val status = DownloadManagerUtil.getStatusAndProgress(applicationContext, downloadId).status
            if (status == SUCCESSFUL) {
                return
            }
            if (status == FAILED) {
                return@repeat
            }
            delay(1000)
        }
        DownloadManagerUtil.remove(applicationContext, downloadId)
        appCache.delete(applicationContext)
    }

    private fun updateLastBackgroundCheckTimestamp() {
        PreferencesHelper(applicationContext).lastBackgroundCheck = LocalDateTime.now()
    }

    private fun showUpdateNotification(appsWithUpdates: List<App>) {
        UpdateNotificationBuilder.showNotifications(appsWithUpdates, applicationContext)
    }

    companion object {
        private const val WORK_MANAGER_KEY = "update_checker"
        private const val LOG_TAG = "BackgroundJob"

        // waiting time = 0.5m + 1m + 2m + 4m + 8m + 16m + 32m = 63,5m
        private const val RUN_ATTEMPTS_FOR_63MIN_TOTAL = 7

        fun startOrStopBackgroundUpdateCheck(context: Context) {
            if (SettingsHelper(context).automaticCheck) {
                startBackgroundUpdateCheck(context)
            } else {
                stopBackgroundUpdateCheck(context)
            }
        }

        private fun startBackgroundUpdateCheck(context: Context) {
            val settingsHelper = SettingsHelper(context)
            val constraints = Constraints.Builder()
                .setRequiredNetworkType(NetworkType.CONNECTED)
                .setRequiresBatteryNotLow(true)
                .setRequiresStorageNotLow(true)
            if (settingsHelper.onlyUnmeteredNetwork) {
                constraints.setRequiredNetworkType(NetworkType.UNMETERED)
            }

            val workRequest = PeriodicWorkRequest.Builder(
                BackgroundJob::class.java,
                settingsHelper.checkInterval.toMinutes(),
                MINUTES
            )
                .setConstraints(constraints.build())
                .build()

            val workManager = WorkManager.getInstance(context)
            workManager.enqueueUniquePeriodicWork(WORK_MANAGER_KEY, REPLACE, workRequest)
        }

        private fun stopBackgroundUpdateCheck(context: Context) {
            WorkManager.getInstance(context).cancelUniqueWork(WORK_MANAGER_KEY)
        }
    }
}


code:
package de.marmaro.krt.ffupdater.background

import android.annotation.SuppressLint
import android.app.NotificationChannel
import android.app.NotificationManager
import android.app.PendingIntent
import android.app.PendingIntent.FLAG_IMMUTABLE
import android.app.PendingIntent.FLAG_UPDATE_CURRENT
import android.content.Context
import android.graphics.BitmapFactory
import android.os.Build
import androidx.annotation.RequiresApi
import androidx.core.app.NotificationCompat
import de.marmaro.krt.ffupdater.CrashReportActivity
import de.marmaro.krt.ffupdater.R
import de.marmaro.krt.ffupdater.R.mipmap
import de.marmaro.krt.ffupdater.device.DeviceEnvironment

object ErrorNotificationBuilder {
    private const val CHANNEL_ID = "error_notification_channel"
    private const val NOTIFICATION_ID = 300

    @SuppressLint("UnspecifiedImmutableFlag")
    fun showNotification(context: Context, exception: Exception, message: String) {
        val description = context.getString(R.string.crash_report__explain_text__background_job)
        val intent = CrashReportActivity.createIntent(context, exception, description)
        val updateAppIntent = if (DeviceEnvironment.supportsAndroid12()) {
            PendingIntent.getActivity(context, 0, intent, FLAG_UPDATE_CURRENT + FLAG_IMMUTABLE)
        } else {
            PendingIntent.getActivity(context, 0, intent, FLAG_UPDATE_CURRENT)
        }

        val notificationBuilder = if (DeviceEnvironment.supportsAndroidOreo()) {
            createNotificationChannel(context)
            NotificationCompat.Builder(context, CHANNEL_ID)
        } else {
            @Suppress("DEPRECATION")
            NotificationCompat.Builder(context)
        }
        val notification = notificationBuilder
            .setSmallIcon(mipmap.transparent, 0)
            .setLargeIcon(BitmapFactory.decodeResource(context.resources, mipmap.ic_launcher))
            .setContentTitle(context.getText(R.string.background_job_failure__notification_title))
            .setStyle(NotificationCompat.BigTextStyle().bigText(message))
            .setContentText(message)
            .setContentIntent(updateAppIntent)
            .setAutoCancel(true)
            .build()
        getNotificationManager(context).notify(NOTIFICATION_ID, notification)
    }

    @RequiresApi(api = Build.VERSION_CODES.O)
    private fun createNotificationChannel(context: Context) {
        val channel = NotificationChannel(
            CHANNEL_ID,
            context.getString(R.string.error_notification__channel_name),
            NotificationManager.IMPORTANCE_DEFAULT
        )
        channel.description = context.getString(R.string.error_notification__channel_description)
        getNotificationManager(context).createNotificationChannel(channel)
    }

    private fun getNotificationManager(context: Context): NotificationManager {
        return context.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
    }
}

repo_name: BadStarOne/ffupdater

license: mit


code:
package de.marmaro.krt.ffupdater.background

import android.annotation.SuppressLint
import android.app.NotificationChannel
import android.app.NotificationManager
import android.app.NotificationManager.IMPORTANCE_DEFAULT
import android.app.PendingIntent
import android.app.PendingIntent.FLAG_IMMUTABLE
import android.app.PendingIntent.FLAG_UPDATE_CURRENT
import android.content.Context
import android.content.Intent
import android.graphics.BitmapFactory
import android.os.Build
import androidx.annotation.RequiresApi
import androidx.core.app.NotificationCompat
import de.marmaro.krt.ffupdater.InstallActivity
import de.marmaro.krt.ffupdater.R
import de.marmaro.krt.ffupdater.app.App
import de.marmaro.krt.ffupdater.device.DeviceEnvironment

object UpdateNotificationBuilder {

    fun showNotifications(apps: List<App>, context: Context) {
        apps.forEach { showNotification(it, context) }
        App.values().filter { !apps.contains(it) }.forEach { hideNotification(it, context) }
    }

    @SuppressLint("UnspecifiedImmutableFlag")
    private fun showNotification(app: App, context: Context) {
        val intent = Intent(context, InstallActivity::class.java)
        intent.putExtra(InstallActivity.EXTRA_APP_NAME, app.name)
        val updateAppIntent = if (DeviceEnvironment.supportsAndroid12()) {
            PendingIntent.getActivity(context, 0, intent, FLAG_UPDATE_CURRENT + FLAG_IMMUTABLE)
        } else {
            PendingIntent.getActivity(context, 0, intent, FLAG_UPDATE_CURRENT)
        }
        val appTitle: String = context.getString(app.detail.displayTitle)

        val notificationBuilder = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
            createNotificationChannel(getChannelId(app), appTitle, context)
            NotificationCompat.Builder(context, getChannelId(app))
        } else {
            @Suppress("DEPRECATION")
            NotificationCompat.Builder(context)
        }
        val notification = notificationBuilder
            .setSmallIcon(R.mipmap.transparent, 0)
            .setLargeIcon(BitmapFactory.decodeResource(context.resources, R.mipmap.ic_launcher))
            .setContentTitle(context.getString(R.string.update_notification__title, appTitle))
            .setContentText(context.getText(R.string.update_notification__text))
            .setContentIntent(updateAppIntent)
            .setAutoCancel(true)
            .build()
        getNotificationManager(context).notify(getNotificationId(app), notification)
    }

    private fun hideNotification(app: App, context: Context) {
        getNotificationManager(context).cancel(getNotificationId(app))
    }

    private fun getChannelId(app: App): String {
        return when (app) {
            App.FIREFOX_RELEASE -> "update_notification_channel__firefox_release"
            App.FIREFOX_BETA -> "update_notification_channel__firefox_beta"
            App.FIREFOX_NIGHTLY -> "update_notification_channel__firefox_nightly"
            App.FIREFOX_FOCUS -> "update_notification_channel__firefox_focus"
            App.FIREFOX_KLAR -> "update_notification_channel__firefox_klar"
            App.LOCKWISE -> "update_notification_channel__lockwise"
            App.BRAVE -> "update_notification_channel__brave"
            App.ICERAVEN -> "update_notification_channel__iceraven"
            App.BROMITE -> "update_notification_channel__bromite"
            App.VIVALDI -> "update_notification_channel__vivaldi"
            App.UNGOOGLED_CHROMIUM -> "update_notification_channel__ungoogled_chromium"
        }
    }

    private fun getNotificationId(app: App): Int {
        return when (app) {
            App.FIREFOX_RELEASE -> 200
            App.FIREFOX_BETA -> 201
            App.FIREFOX_NIGHTLY -> 202
            App.FIREFOX_FOCUS -> 203
            App.FIREFOX_KLAR -> 204
            App.LOCKWISE -> 206
            App.BRAVE -> 207
            App.ICERAVEN -> 208
            App.BROMITE -> 209
            App.VIVALDI -> 210
            App.UNGOOGLED_CHROMIUM -> 212
        }
    }

    private fun getNotificationManager(context: Context): NotificationManager {
        return context.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
    }

    @RequiresApi(api = Build.VERSION_CODES.O)
    private fun createNotificationChannel(channelId: String, appTitle: String, context: Context) {
        val name = context.getString(R.string.update_notification__channel_name, appTitle)
        val channel = NotificationChannel(channelId, name, IMPORTANCE_DEFAULT)
        channel.description =
            context.getString(R.string.update_notification__channel_description, appTitle)
        getNotificationManager(context).createNotificationChannel(channel)
    }
}


code:
package de.marmaro.krt.ffupdater.crash

import android.content.Context
import de.marmaro.krt.ffupdater.CrashReportActivity
import de.marmaro.krt.ffupdater.R
import kotlin.system.exitProcess

class CrashListener private constructor(val context: Context) : Thread.UncaughtExceptionHandler {

    override fun uncaughtException(t: Thread, e: Throwable) {
        val description = context.getString(R.string.crash_report__explain_text__uncaught_throwable)
        val intent = CrashReportActivity.createIntent(context, e, description)
        context.startActivity(intent)
        exitProcess(1)
    }

    companion object {
        fun openCrashReporterForUncaughtExceptions(context: Context): CrashListener {
            val crashListener = CrashListener(context.applicationContext)
            Thread.setDefaultUncaughtExceptionHandler(crashListener)
            return crashListener
        }
    }
}


code:
package de.marmaro.krt.ffupdater.device

/**
 * All supported ABIs
 * "Note: Historically the NDK supported ARMv5 (armeabi), and 32-bit and 64-bit MIPS, but
 * support for these ABIs was removed in NDK r17." (r17c release in June 2018)
 *
 * @see [List of official supported ABIS](https://developer.android.com/ndk/guides/abis)
 * @see [ABI descriptions](https://pspdfkit.com/guides/android/current/faq/architectures/)
 */
enum class ABI {
    ARM64_V8A,   // 64-bit ARMv8
    ARMEABI_V7A, // 32-bit ARMv7
    ARMEABI,     // ARMv5/6 are old architectures that haven’t been used in years, since Android 2.3
    X86,         // 32-bit x86
    X86_64,      // 64-bit x86
    MIPS,        // MIPS is exclusively used for some rare embedded uses of Android.
    MIPS64       // MIPS is exclusively used for some rare embedded uses of Android.
}


code:
package de.marmaro.krt.ffupdater.device

import android.os.Build
import android.os.Build.VERSION.SDK_INT

/**
 * This class returns all supported ABIs and the API level.
 * This indirection with abis and supportAndroidXXX() is necessary, because Mockk can't mock/change the
 * Android classes.
 */
object DeviceEnvironment {
    val abis = findSupportedAbis()
    val sdkInt = SDK_INT

    /**
     * API level 23
     */
    fun supportsAndroidMarshmallow(): Boolean {
        return SDK_INT >= Build.VERSION_CODES.M
    }

    /**
     * API level 24
     */
    fun supportsAndroidNougat(): Boolean {
        return SDK_INT >= Build.VERSION_CODES.N
    }

    /**
     * API level 26
     */
    fun supportsAndroidOreo(): Boolean {
        return SDK_INT >= Build.VERSION_CODES.O
    }

    /**
     * API level 28
     */
    fun supportsAndroid9(): Boolean {
        return SDK_INT >= Build.VERSION_CODES.P
    }

    /**
     * API level 29
     */
    fun supportsAndroid10(): Boolean {
        return SDK_INT >= Build.VERSION_CODES.Q
    }

    /**
     * API level 30
     */
    fun supportsAndroid11(): Boolean {
        return SDK_INT >= Build.VERSION_CODES.R
    }

    /**
     * API level 31
     */
    fun supportsAndroid12(): Boolean {
        return SDK_INT >= Build.VERSION_CODES.S
    }

    private fun findSupportedAbis(): List<ABI> {
        val supportedAbis = Build.SUPPORTED_ABIS ?: return listOf()
        return supportedAbis.mapNotNull {
            when (it) {
                "arm64-v8a" -> ABI.ARM64_V8A
                "armeabi-v7a" -> ABI.ARMEABI_V7A
                "armeabi" -> ABI.ARMEABI
                "x86_64" -> ABI.X86_64
                "x86" -> ABI.X86
                "mips" -> ABI.MIPS
                "mips64" -> ABI.MIPS64
                else -> throw UnknownAbiException("unknown ABI '$it'")
            }
        }
    }

    class UnknownAbiException(message: String) : Exception(message)
}


code:
package de.marmaro.krt.ffupdater.dialog

import android.app.AlertDialog
import android.app.Dialog
import android.content.DialogInterface
import android.os.Bundle
import android.text.method.LinkMovementMethod
import android.widget.TextView
import androidx.fragment.app.DialogFragment
import androidx.fragment.app.FragmentManager
import de.marmaro.krt.ffupdater.R
import de.marmaro.krt.ffupdater.app.App

/**
 * Show a dialog with the app description.
 */
class AppInfoDialog : DialogFragment() {
    override fun onCreateDialog(savedInstanceState: Bundle?): Dialog {
        val app = App.valueOf(requireNotNull(requireArguments().getString(BUNDLE_APP_NAME)) {
            "$BUNDLE_APP_NAME is not set."
        })
        return AlertDialog.Builder(activity)
            .setTitle(app.detail.displayTitle)
            .setMessage(app.detail.displayDescription)
            .setPositiveButton(R.string.ok) { dialog: DialogInterface, _: Int ->
                dialog.dismiss()
            }
            .create()
    }

    override fun onStart() {
        super.onStart()
        dialog?.findViewById<TextView>(android.R.id.message)?.movementMethod =
            LinkMovementMethod.getInstance()
    }

    fun show(manager: FragmentManager) {
        show(manager, "app_info_dialog")
    }

    companion object {
        private const val BUNDLE_APP_NAME = "app_name"

        fun newInstance(app: App): AppInfoDialog {
            val bundle = Bundle()
            bundle.putString(BUNDLE_APP_NAME, app.name)
            val fragment = AppInfoDialog()
            fragment.arguments = bundle
            return fragment
        }
    }
}


code:
package de.marmaro.krt.ffupdater.dialog

import android.app.AlertDialog
import android.app.Dialog
import android.content.DialogInterface
import android.os.Bundle
import android.text.method.LinkMovementMethod
import android.widget.TextView
import androidx.fragment.app.DialogFragment
import androidx.fragment.app.FragmentManager
import de.marmaro.krt.ffupdater.R
import de.marmaro.krt.ffupdater.app.App
import de.marmaro.krt.ffupdater.device.DeviceEnvironment
import de.marmaro.krt.ffupdater.utils.AndroidVersionCodes

/**
 * Show the user that the app could not be installed because the operating system is too old.
 */
class DeviceTooOldDialog : DialogFragment() {
    override fun onCreateDialog(savedInstanceState: Bundle?): Dialog {
        val app = App.valueOf(requireNotNull(requireArguments().getString(BUNDLE_APP_NAME)) {
            "$BUNDLE_APP_NAME is not set."
        })
        val required = AndroidVersionCodes.getVersionForApiLevel(app.detail.minApiLevel)
        val actual = AndroidVersionCodes.getVersionForApiLevel(DeviceEnvironment.sdkInt)
        return AlertDialog.Builder(activity)
                .setTitle(R.string.device_too_old_dialog__title)
                .setMessage(getString(R.string.device_too_old_dialog__message, required, actual))
                .setNegativeButton(R.string.ok) { dialog: DialogInterface, _: Int -> dialog.dismiss() }
                .create()
    }

    override fun onStart() {
        super.onStart()
        dialog?.findViewById<TextView>(android.R.id.message)?.movementMethod = LinkMovementMethod.getInstance()
    }

    fun show(manager: FragmentManager) {
        show(manager, "device_too_old_dialog")
    }

    companion object {
        private const val BUNDLE_APP_NAME = "app_name"

        fun newInstance(app: App): DeviceTooOldDialog {
            val bundle = Bundle()
            bundle.putString(BUNDLE_APP_NAME, app.name)
            val fragment = DeviceTooOldDialog()
            fragment.arguments = bundle
            return fragment
        }
    }
}


code:
package de.marmaro.krt.ffupdater.dialog

import android.app.AlertDialog
import android.app.Dialog
import android.content.DialogInterface
import android.os.Bundle
import android.text.method.LinkMovementMethod
import android.widget.TextView
import androidx.fragment.app.DialogFragment
import androidx.fragment.app.FragmentManager
import de.marmaro.krt.ffupdater.R
import de.marmaro.krt.ffupdater.app.App
import de.marmaro.krt.ffupdater.device.DeviceEnvironment
import kotlinx.coroutines.runBlocking

/**
 * Allow the user to select an app from the dialog to install.
 * Show warning or error message (if ABI is not supported) if necessary.
 */
class InstallNewAppDialog : DialogFragment() {
    override fun onCreateDialog(savedInstanceState: Bundle?): Dialog {
        val context = requireContext()
        val apps = runBlocking {
            App.values().filterNot {
                it.detail.isInstalled(context)
            }
        }
        val names = apps.map { context.getString(it.detail.displayTitle) }.toTypedArray()
        return AlertDialog.Builder(activity)
                .setTitle(R.string.install_activity__retrigger_installation__button_title)
                .setItems(names) { _: DialogInterface, which: Int ->
                    triggerAppInstallation(apps[which])
                }
                .create()
    }

    private fun triggerAppInstallation(app: App) {
        // do not install an app which incompatible ABIs
        if (DeviceEnvironment.abis.intersect(app.detail.supportedAbis).isEmpty()) {
            UnsupportedAbiDialog.newInstance().show(parentFragmentManager)
            return
        }
        // do not install an app which require a newer Android version
        if (DeviceEnvironment.sdkInt < app.detail.minApiLevel) {
            DeviceTooOldDialog.newInstance(app).show(parentFragmentManager)
            return
        }
        ShowAppInfoBeforeInstallationDialog.newInstance(app).show(parentFragmentManager)
    }

    override fun onStart() {
        super.onStart()
        dialog?.findViewById<TextView>(android.R.id.message)?.movementMethod = LinkMovementMethod.getInstance()
    }

    fun show(manager: FragmentManager) {
        show(manager, "install_new_app_dialog")
    }

    companion object {
        fun newInstance(): InstallNewAppDialog {
            return InstallNewAppDialog()
        }
    }
}


code:
package de.marmaro.krt.ffupdater.dialog

import android.app.AlertDialog
import android.app.Dialog
import android.content.DialogInterface
import android.os.Bundle
import android.text.method.LinkMovementMethod
import android.widget.TextView
import androidx.fragment.app.DialogFragment
import androidx.fragment.app.FragmentManager
import de.marmaro.krt.ffupdater.MainActivity
import de.marmaro.krt.ffupdater.R
import de.marmaro.krt.ffupdater.app.App

class InstallSameVersionDialog : DialogFragment() {
    override fun onCreateDialog(savedInstanceState: Bundle?): Dialog {
        val app = App.valueOf(requireNotNull(requireArguments().getString(BUNDLE_APP_NAME)) {
            "$BUNDLE_APP_NAME is not set."
        })
        val mainActivity = activity as MainActivity
        return AlertDialog.Builder(activity)
            .setTitle(R.string.install_same_version_dialog__title)
            .setMessage(R.string.install_same_version_dialog__message)
            .setPositiveButton(R.string.dialog_button__yes) { dialog: DialogInterface, _: Int ->
                dialog.dismiss()
                mainActivity.installAppButCheckForCurrentDownloads(app)
            }
            .setNegativeButton(R.string.dialog_button__do_not_install) { dialog: DialogInterface, _: Int ->
                dialog.dismiss()
            }
            .create()
    }

    override fun onStart() {
        super.onStart()
        dialog?.findViewById<TextView>(android.R.id.message)?.movementMethod = LinkMovementMethod.getInstance()
    }

    fun show(manager: FragmentManager) {
        show(manager, "install_same_version_dialog")
    }

    companion object {
        private const val BUNDLE_APP_NAME = "app_name"

        fun newInstance(app: App): InstallSameVersionDialog {
            val bundle = Bundle()
            bundle.putString(BUNDLE_APP_NAME, app.name)
            val fragment = InstallSameVersionDialog()
            fragment.arguments = bundle
            return fragment
        }
    }
}


code:
package de.marmaro.krt.ffupdater.dialog

import android.app.AlertDialog
import android.app.Dialog
import android.content.DialogInterface
import android.os.Bundle
import android.text.method.LinkMovementMethod
import android.widget.TextView
import androidx.fragment.app.DialogFragment
import androidx.fragment.app.FragmentManager
import de.marmaro.krt.ffupdater.MainActivity
import de.marmaro.krt.ffupdater.R
import de.marmaro.krt.ffupdater.app.App

class RunningDownloadsDialog : DialogFragment() {
    override fun onCreateDialog(savedInstanceState: Bundle?): Dialog {
        val app = App.valueOf(requireNotNull(requireArguments().getString(BUNDLE_APP_NAME)) {
            "$BUNDLE_APP_NAME is not set."
        })
        val mainActivity = activity as MainActivity
        return AlertDialog.Builder(activity)
                .setTitle(R.string.running_downloads_dialog__title)
                .setMessage(R.string.running_downloads_dialog__message)
                .setPositiveButton(R.string.running_downloads_dialog__yes) { dialog: DialogInterface, _: Int ->
                    dialog.dismiss()
                    mainActivity.installApp(app)
                }
                .setNegativeButton(R.string.running_downloads_dialog__negative) { dialog: DialogInterface, _: Int ->
                    dialog.dismiss()
                }
                .create()
    }

    override fun onStart() {
        super.onStart()
        dialog?.findViewById<TextView>(android.R.id.message)?.movementMethod = LinkMovementMethod.getInstance()
    }

    fun show(manager: FragmentManager) {
        show(manager, "downloads_are_running_dialog")
    }

    companion object {
        private const val BUNDLE_APP_NAME = "app_name"

        fun newInstance(app: App): RunningDownloadsDialog {
            val bundle = Bundle()
            bundle.putString(BUNDLE_APP_NAME, app.name)
            val fragment = RunningDownloadsDialog()
            fragment.arguments = bundle
            return fragment
        }
    }
}


code:
package de.marmaro.krt.ffupdater.dialog

import android.app.AlertDialog
import android.app.Dialog
import android.content.DialogInterface
import android.os.Bundle
import android.text.method.LinkMovementMethod
import android.widget.TextView
import androidx.fragment.app.DialogFragment
import androidx.fragment.app.FragmentManager
import de.marmaro.krt.ffupdater.MainActivity
import de.marmaro.krt.ffupdater.R
import de.marmaro.krt.ffupdater.app.App

/**
 * Show a dialog with the app description.
 */
class ShowAppInfoBeforeInstallationDialog : DialogFragment() {
    override fun onCreateDialog(savedInstanceState: Bundle?): Dialog {
        val app = App.valueOf(requireNotNull(requireArguments().getString(BUNDLE_APP_NAME)) {
            "$BUNDLE_APP_NAME is not set."
        })
        val mainActivity = activity as MainActivity
        return AlertDialog.Builder(activity)
            .setTitle(app.detail.displayTitle)
            .setMessage(app.detail.displayDescription)
            .setPositiveButton(R.string.install_app) { dialog: DialogInterface, _: Int ->
                dialog.dismiss()
                if (app.detail.displayWarning != null) {
                    ShowWarningBeforeInstallationDialog.newInstance(app).show(parentFragmentManager)
                } else {
                    mainActivity.installAppButCheckForCurrentDownloads(app)
                }
            }
            .setNegativeButton(R.string.go_back) { dialog: DialogInterface, _: Int ->
                dialog.dismiss()
            }
            .create()
    }

    override fun onStart() {
        super.onStart()
        dialog?.findViewById<TextView>(android.R.id.message)?.movementMethod = LinkMovementMethod.getInstance()
    }

    fun show(manager: FragmentManager) {
        show(manager, "show_app_info_before_installation_dialog")
    }

    companion object {
        private const val BUNDLE_APP_NAME = "app_name"

        fun newInstance(app: App): ShowAppInfoBeforeInstallationDialog {
            val bundle = Bundle()
            bundle.putString(BUNDLE_APP_NAME, app.name)
            val fragment = ShowAppInfoBeforeInstallationDialog()
            fragment.arguments = bundle
            return fragment
        }
    }
}


code:
package de.marmaro.krt.ffupdater.dialog

import android.app.AlertDialog
import android.app.Dialog
import android.content.DialogInterface
import android.os.Bundle
import android.text.method.LinkMovementMethod
import android.widget.TextView
import androidx.fragment.app.DialogFragment
import androidx.fragment.app.FragmentManager
import de.marmaro.krt.ffupdater.MainActivity
import de.marmaro.krt.ffupdater.R
import de.marmaro.krt.ffupdater.app.App

/**
 * Ask the user with this dialog if he really want to install the app.
 */
class ShowWarningBeforeInstallationDialog : DialogFragment() {
    override fun onCreateDialog(savedInstanceState: Bundle?): Dialog {
        val app = App.valueOf(requireNotNull(requireArguments().getString(BUNDLE_APP_NAME)) {
            "$BUNDLE_APP_NAME is not set."
        })
        val mainActivity = activity as MainActivity
        return AlertDialog.Builder(activity)
            .setTitle(R.string.show_warning_before_installation_dialog__title)
            .setMessage(requireNotNull(app.detail.displayWarning) {
                "$app must have a warning message."
            })
            .setPositiveButton(R.string.dialog_button__yes) { dialog: DialogInterface, _: Int ->
                dialog.dismiss()
                mainActivity.installAppButCheckForCurrentDownloads(app)
            }
            .setNegativeButton(R.string.dialog_button__do_not_install) { dialog: DialogInterface, _: Int ->
                dialog.dismiss()
            }
            .create()
    }

    override fun onStart() {
        super.onStart()
        dialog?.findViewById<TextView>(android.R.id.message)?.movementMethod = LinkMovementMethod.getInstance()
    }

    fun show(manager: FragmentManager) {
        show(manager, "show_warning_before_installation_dialog")
    }

    companion object {
        private const val BUNDLE_APP_NAME = "app_name"

        fun newInstance(app: App): ShowWarningBeforeInstallationDialog {
            val bundle = Bundle()
            bundle.putString(BUNDLE_APP_NAME, app.name)
            val fragment = ShowWarningBeforeInstallationDialog()
            fragment.arguments = bundle
            return fragment
        }
    }
}


code:
package de.marmaro.krt.ffupdater.dialog

import android.app.AlertDialog
import android.app.Dialog
import android.content.DialogInterface
import android.os.Bundle
import android.text.method.LinkMovementMethod
import android.widget.TextView
import androidx.fragment.app.DialogFragment
import androidx.fragment.app.FragmentManager
import de.marmaro.krt.ffupdater.R

/**
 * Show the error that the app is not supported on this smartphone.
 */
class UnsupportedAbiDialog : DialogFragment() {
    override fun onCreateDialog(savedInstanceState: Bundle?): Dialog {
        return AlertDialog.Builder(activity)
                .setTitle(R.string.unsupported_abi_dialog__title)
                .setMessage(R.string.unsupported_abi_dialog__message)
                .setNegativeButton(R.string.ok) { dialog: DialogInterface, _: Int -> dialog.dismiss() }
                .create()
    }

    override fun onStart() {
        super.onStart()
        dialog?.findViewById<TextView>(android.R.id.message)?.movementMethod = LinkMovementMethod.getInstance()
    }

    fun show(manager: FragmentManager) {
        show(manager, "unsupported_abi_dialog")
    }

    companion object {
        fun newInstance(): UnsupportedAbiDialog {
            return UnsupportedAbiDialog()
        }
    }
}


code:
package de.marmaro.krt.ffupdater.download

import android.content.Context
import android.os.Environment
import de.marmaro.krt.ffupdater.app.App
import de.marmaro.krt.ffupdater.app.AvailableVersionResult
import java.io.File

class AppCache(val app: App) {
    fun getFileName(): String {
        return "${app.detail.packageName}.apk"
    }

    fun getFile(context: Context): File {
        return File(getCacheFolder(context), getFileName())
    }

    fun fixFileName(context: Context) {
        if (getFile(context).exists()) {
            return
        }

        // sometimes the DownloadManager forgets to add the file suffix "apk" to the downloaded file: notabug#79
        val fileWithoutSuffix = File(getCacheFolder(context), app.detail.packageName)
        if (fileWithoutSuffix.exists()) {
            val normalFile = getFile(context)
            check(!normalFile.exists()) { "Normal cache file should not exists." }
            val success = fileWithoutSuffix.renameTo(normalFile)
            check(success) { "Renaming file to the correct file name was not successful." }
        }
    }

    suspend fun isAvailable(context: Context, available: AvailableVersionResult): Boolean {
        val file = getFile(context)
        if (file.exists()) {
            return app.detail.isAvailableVersionEqualToArchive(context, file, available)
        }
        return false
    }

    private fun getCacheFolder(context: Context): File {
        val downloadFolder = context.getExternalFilesDir(Environment.DIRECTORY_DOWNLOADS)
        return checkNotNull(downloadFolder) { "External download folder should exists." }
    }

    /**
     * If the file already exists, the download manager will append some text to the file name of the next
     * downloaded file.
     * That's why there could the files "org.mozilla.firefox.apk", "org.mozilla.firefox-1.apk" ...
     * And all files for an app mus be deleted.
     */
    fun delete(context: Context) {
        val allFiles = getCacheFolder(context).listFiles()
        checkNotNull(allFiles) { "Array of files in download folder should exists." }
        val appFiles = allFiles.filter { it.name.startsWith(app.detail.packageName) }
        appFiles.forEach {
            val success = it.delete()
            check(success) { "Fail to delete file '${it.name}'." }
        }
    }
}


code:
package de.marmaro.krt.ffupdater.download

import android.app.DownloadManager
import android.app.DownloadManager.*
import android.content.Context
import android.database.CursorIndexOutOfBoundsException
import android.net.Uri
import android.os.Environment.DIRECTORY_DOWNLOADS
import de.marmaro.krt.ffupdater.R
import de.marmaro.krt.ffupdater.app.App
import de.marmaro.krt.ffupdater.app.AvailableVersionResult

/**
 * This class helps to use the `android.app.DownloadManager` more easily.
 */
object DownloadManagerUtil {
    private const val HUNDRED_PERCENT = 100

    /**
     * Enqueue a new download.
     */
    fun enqueue(
        context: Context,
        app: App,
        availableVersionResult: AvailableVersionResult,
        fileName: String
    ): Long {
        require(availableVersionResult.downloadUrl.startsWith("https://"))
        val downloadUri = Uri.parse(availableVersionResult.downloadUrl)
        val notificationTitle = "FFUpdater: ${context.getString(app.detail.displayTitle)}"
        val request = Request(downloadUri)
            .setTitle(notificationTitle)
            .setDestinationInExternalFilesDir(context, DIRECTORY_DOWNLOADS, fileName)
            .setNotificationVisibility(Request.VISIBILITY_VISIBLE)

        val downloadManager = getDownloadManager(context)
        return downloadManager.enqueue(request)
    }

    /**
     * Return the status and percent for a download.
     * This method is simple to use then `android.app.DownloadManager.query()`
     *
     * @param id id
     * @return status (constants from `android.app.DownloadManager`) and percent (0-100)
     */
    fun getStatusAndProgress(context: Context, id: Long): DownloadStatus {
        val query = Query()
        query.setFilterById(id)
        try {
            val downloadManager = getDownloadManager(context)
            downloadManager.query(query).use { cursor ->
                if (!cursor.moveToFirst()) {
                    return DownloadStatus(DownloadStatus.Status.FAILED, 0)
                }

                val totalBytesIndex = cursor.getColumnIndex(COLUMN_TOTAL_SIZE_BYTES)
                val totalBytes = cursor.getInt(totalBytesIndex).toDouble()
                val actualBytesIndex = cursor.getColumnIndex(COLUMN_BYTES_DOWNLOADED_SO_FAR)
                val actualBytes = cursor.getInt(actualBytesIndex).toDouble()
                val progress = (actualBytes / totalBytes * HUNDRED_PERCENT).toInt()

                val statusIndex = cursor.getColumnIndex(COLUMN_STATUS)
                val status = when (cursor.getInt(statusIndex)) {
                    STATUS_RUNNING -> DownloadStatus.Status.RUNNING
                    STATUS_SUCCESSFUL -> DownloadStatus.Status.SUCCESSFUL
                    STATUS_FAILED -> DownloadStatus.Status.FAILED
                    STATUS_PAUSED -> DownloadStatus.Status.PAUSED
                    STATUS_PENDING -> DownloadStatus.Status.PENDING
                    else -> DownloadStatus.Status.UNKNOWN
                }
                return DownloadStatus(status, progress)
            }
        } catch (e: CursorIndexOutOfBoundsException) {
            return DownloadStatus(DownloadStatus.Status.UNKNOWN, 0)
        }
    }

    fun getStatusText(context: Context, downloadStatus: DownloadStatus): String {
        return when (downloadStatus.status) {
            DownloadStatus.Status.RUNNING -> context.getString(R.string.install_activity__download_status_running)
            DownloadStatus.Status.SUCCESSFUL -> context.getString(R.string.install_activity__download_status_success)
            DownloadStatus.Status.FAILED -> context.getString(R.string.install_activity__download_status_failed)
            DownloadStatus.Status.PAUSED -> context.getString(R.string.install_activity__download_status_paused)
            DownloadStatus.Status.PENDING -> context.getString(R.string.install_activity__download_status_pending)
            DownloadStatus.Status.UNKNOWN -> "unknown"
        }
    }

    fun isDownloadingFilesNow(context: Context): Boolean {
        val query = Query()
        query.setFilterByStatus(STATUS_RUNNING)
        val downloadManager = getDownloadManager(context)
        downloadManager.query(query).use { cursor ->
            //cursor will be null if "Download Manager" is disabled
            return cursor?.moveToFirst() ?: false
        }
    }

    fun remove(context: Context, id: Long): Int {
        val downloadManager = getDownloadManager(context)
        return downloadManager.remove(id)
    }

    private fun getDownloadManager(context: Context): DownloadManager {
        return context.getSystemService(Context.DOWNLOAD_SERVICE)!! as DownloadManager
    }

    data class DownloadStatus(val status: Status, val progressInPercentage: Int) {
        enum class Status {
            UNKNOWN, PENDING, RUNNING, PAUSED, SUCCESSFUL, FAILED
        }
    }
}


code:
package de.marmaro.krt.ffupdater.download

import android.content.Context
import android.net.ConnectivityManager
import android.net.NetworkCapabilities
import android.os.Build
import androidx.annotation.AnyThread
import androidx.annotation.RequiresApi
import de.marmaro.krt.ffupdater.device.DeviceEnvironment

object NetworkUtil {
    @AnyThread
    fun isInternetUnavailable(context: Context): Boolean {
        val cm = context.getSystemService(Context.CONNECTIVITY_SERVICE) as ConnectivityManager
        return !isInternetAvailable(cm)
    }

    private fun isInternetAvailable(cm: ConnectivityManager): Boolean {
        return if (DeviceEnvironment.supportsAndroid10()) {
            isNetworkConnected(cm)
        } else {
            isNetworkConnectedOldWay(cm)
        }
    }

    /**
     * https://gist.github.com/Farbklex/f84029889444ee9c52a331a7e2bd10d2
     */
    @RequiresApi(Build.VERSION_CODES.Q)
    private fun isNetworkConnected(cm: ConnectivityManager): Boolean {
        val activeNetwork = cm.activeNetwork ?: return false
        val capabilities = cm.getNetworkCapabilities(activeNetwork) ?: return false
        return capabilities.hasCapability(NetworkCapabilities.NET_CAPABILITY_INTERNET) &&
                capabilities.hasCapability(NetworkCapabilities.NET_CAPABILITY_VALIDATED)
    }

    /**
     * How AndroidX WorkManager checks for internet access:
     * https://android.googlesource.com/platform/frameworks/support.git/+/refs/heads/androidx-main/
     * work/work-runtime/src/main/java/androidx/work/impl/constraints/trackers/NetworkStateTracker.java#135
     */
    @Suppress("DEPRECATION")
    private fun isNetworkConnectedOldWay(cm: ConnectivityManager): Boolean {
        return cm.activeNetworkInfo?.isConnected == true
    }

    fun isActiveNetworkUnmetered(context: Context): Boolean {
        val cm = context.getSystemService(Context.CONNECTIVITY_SERVICE) as ConnectivityManager
        return !cm.isActiveNetworkMetered
    }
}


code:
package de.marmaro.krt.ffupdater.download

import android.annotation.SuppressLint
import android.content.pm.PackageInfo
import android.content.pm.PackageManager
import android.content.pm.PackageManager.GET_SIGNATURES
import android.content.pm.PackageManager.GET_SIGNING_CERTIFICATES
import android.content.pm.Signature
import androidx.annotation.MainThread
import de.marmaro.krt.ffupdater.app.App
import de.marmaro.krt.ffupdater.device.DeviceEnvironment
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext

object PackageManagerUtil {

    @MainThread
    suspend fun getPackageArchiveInfo(packageManager: PackageManager, path: String): Signature {
        return withContext(Dispatchers.IO) {
            val packageInfo = if (DeviceEnvironment.supportsAndroid11()) {
                // GET_SIGNING_CERTIFICATES does not work on Android 9/10
                val packageInfo = packageManager.getPackageArchiveInfo(path, GET_SIGNING_CERTIFICATES)
                checkNotNull(packageInfo) { "PackageInfo for file '$path' is null." }
            } else {
                val packageInfo = packageManager.getPackageArchiveInfo(path, GET_SIGNATURES)
                checkNotNull(packageInfo) { "PackageInfo for file '$path' is null." }
            }
            extractSignature(packageInfo)
        }
    }

    @MainThread
    suspend fun getPackageArchiveVersionNameOrNull(packageManager: PackageManager, path: String): String? {
        return withContext(Dispatchers.IO) {
            packageManager.getPackageArchiveInfo(path, 0)?.versionName
        }
    }

    fun getInstalledAppInfo(packageManager: PackageManager, app: App): Signature {
        val packageInfo = if (DeviceEnvironment.supportsAndroid9()) {
            val packageInfo = packageManager.getPackageInfo(app.detail.packageName, GET_SIGNING_CERTIFICATES)
            checkNotNull(packageInfo) { "PackageInfo for package ${app.detail.packageName} is null." }
        } else {
            @SuppressLint("PackageManagerGetSignatures")
            // because GET_SIGNATURES is dangerous on Android 4.4 or lower https://stackoverflow.com/a/39348300
            val packageInfo = packageManager.getPackageInfo(app.detail.packageName, GET_SIGNATURES)
            checkNotNull(packageInfo) { "PackageInfo for package ${app.detail.packageName} is null." }
        }
        return extractSignature(packageInfo)
    }

    fun getInstalledAppVersionName(packageManager: PackageManager, packageName: String): String? {
        return try {
            packageManager.getPackageInfo(packageName, 0)?.versionName
        } catch (e: PackageManager.NameNotFoundException) {
            null
        }
    }

    fun isAppInstalled(packageManager: PackageManager, packageName: String): Boolean {
        return try {
            packageManager.getPackageInfo(packageName, 0)
            true
        } catch (e: PackageManager.NameNotFoundException) {
            false
        }
    }

    private fun extractSignature(packageInfo: PackageInfo): Signature {
        if (DeviceEnvironment.supportsAndroid9() && packageInfo.signingInfo != null) {
            val signingInfo = packageInfo.signingInfo
            check(!signingInfo.hasMultipleSigners()) { "App ${packageInfo.packageName} has multiple signers" }
            check(signingInfo.signingCertificateHistory.size == 1) {
                "App ${packageInfo.packageName} has ${signingInfo.signingCertificateHistory.size} certificates."
            }
            return signingInfo.signingCertificateHistory[0]
        }
        if (packageInfo.signatures != null) {
            val signatures = packageInfo.signatures
            check(signatures.size == 1) {
                "App ${packageInfo.packageName} has ${signatures.size} signatures."
            }
            return signatures[0]
        }
        throw IllegalArgumentException("PackageInfo has no signingInfo and no signatures.")
    }
}


code:
package de.marmaro.krt.ffupdater.download

import android.os.Environment
import android.os.StatFs

object StorageUtil {
    private const val REQUIRED_MEBIBYTES = 500
    private const val BYTES_IN_MEBIBYTE = 1024 * 1024

    fun isEnoughStorageAvailable(): Boolean {
        return getFreeStorageInMebibytes() > REQUIRED_MEBIBYTES
    }

    fun getFreeStorageInMebibytes(): Long {
        val path = Environment.getDataDirectory().absolutePath
        return StatFs(path).availableBytes / BYTES_IN_MEBIBYTE
    }
}


code:
package de.marmaro.krt.ffupdater.installer

import android.app.Activity
import android.content.Context
import android.content.Intent
import de.marmaro.krt.ffupdater.device.DeviceEnvironment
import java.io.File

interface AppInstaller {
    fun onNewIntentCallback(intent: Intent, context: Context) {}
    fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?) {}
    fun install(activity: Activity, downloadedFile: File) {}

    companion object {
        fun create(
                successfulInstallationCallback: () -> Any,
                failedInstallationCallback: (errorMessage: String?) -> Any,
        ): AppInstaller {
            return if (DeviceEnvironment.supportsAndroidNougat()) {
                SessionInstaller(successfulInstallationCallback, failedInstallationCallback)
            } else {
                IntentInstaller(successfulInstallationCallback, failedInstallationCallback)
            }
        }
    }
}


code:
package de.marmaro.krt.ffupdater.installer

import android.app.Activity
import android.content.Intent
import android.net.Uri
import java.io.File
import java.io.IOException

//for API < 24 (Nougat 7.0)
class IntentInstaller(
        private val appInstalledCallback: () -> Any,
        private val appNotInstalledCallback: (errorMessage: String) -> Any,
) : AppInstaller {

    override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?) {
        if (requestCode == REQUEST_CODE_INSTALL) {
            if (resultCode == Activity.RESULT_OK) {
                appInstalledCallback()
            } else {
                appNotInstalledCallback("resultCode is '$resultCode'")
            }
        }
    }

    override fun install(activity: Activity, downloadedFile: File) {
        require(downloadedFile.exists()) { "File does not exists." }
        try {
            return installInternal(activity, downloadedFile)
        } catch (e: IOException) {
            throw IntentInstallerException("fail to install app", e)
        }
    }

    /**
     * See org.fdroid.fdroid.installer.DefaultInstallerActivity.java from
     * https://github.com/f-droid/fdroidclient
     */
    private fun installInternal(activity: Activity, downloadedFile: File) {
        @Suppress("DEPRECATION")
        val intent = Intent(Intent.ACTION_INSTALL_PACKAGE)
        intent.data = Uri.fromFile(downloadedFile)
        intent.putExtra(Intent.EXTRA_RETURN_RESULT, true)
        intent.putExtra(Intent.EXTRA_NOT_UNKNOWN_SOURCE, true)
        activity.startActivityForResult(intent, REQUEST_CODE_INSTALL)
    }

    class IntentInstallerException(message: String, throwable: Throwable) : Exception(message, throwable)

    companion object {
        private const val REQUEST_CODE_INSTALL = 0
    }
}


code:
package de.marmaro.krt.ffupdater.installer

import android.annotation.SuppressLint
import android.app.Activity
import android.app.PendingIntent
import android.content.ActivityNotFoundException
import android.content.Context
import android.content.Intent
import android.content.IntentSender
import android.content.pm.PackageInstaller.*
import android.content.pm.PackageInstaller.SessionParams.MODE_FULL_INSTALL
import android.content.pm.PackageInstaller.SessionParams.USER_ACTION_NOT_REQUIRED
import de.marmaro.krt.ffupdater.InstallActivity
import de.marmaro.krt.ffupdater.R
import de.marmaro.krt.ffupdater.device.DeviceEnvironment
import java.io.File
import java.io.IOException

//for API >= 24 (Nougat 7.0)
class SessionInstaller(
    private val appInstalledCallback: () -> Any,
    private val appNotInstalledCallback: (errorMessage: String?) -> Any,
) : AppInstaller {
    override fun onNewIntentCallback(intent: Intent, context: Context) {
        if (intent.action != PACKAGE_INSTALLED_ACTION) return
        val bundle = intent.extras ?: let {
            appNotInstalledCallback("intent.extras is null")
            return@onNewIntentCallback
        }

        when (val status = bundle.getInt(EXTRA_STATUS)) {
            STATUS_PENDING_USER_ACTION -> {
                try {
                    //FFUpdater isn't privileged, so the user has to confirm the install.
                    context.startActivity(bundle.get(Intent.EXTRA_INTENT) as Intent)
                } catch (e: ActivityNotFoundException) {
                    val tip = context.getString(
                        R.string.install_activity__try_disable_miui_optimization
                    )
                    appNotInstalledCallback("${e.message}\n\n$tip")
                }
            }
            STATUS_SUCCESS -> {
                appInstalledCallback()
            }
            STATUS_FAILURE -> {
                appNotInstalledCallback(context.getString(R.string.session_installer__status_failure))
            }
            STATUS_FAILURE_ABORTED -> {
                appNotInstalledCallback(context.getString(R.string.session_installer__status_failure_aborted))
            }
            STATUS_FAILURE_BLOCKED -> {
                appNotInstalledCallback(context.getString(R.string.session_installer__status_failure_blocked))
            }
            STATUS_FAILURE_CONFLICT -> {
                appNotInstalledCallback(context.getString(R.string.session_installer__status_failure_conflict))
            }
            STATUS_FAILURE_INCOMPATIBLE -> {
                appNotInstalledCallback(context.getString(R.string.session_installer__status_failure_incompatible))
            }
            STATUS_FAILURE_INVALID -> {
                appNotInstalledCallback(context.getString(R.string.session_installer__status_failure_invalid))
            }
            STATUS_FAILURE_STORAGE -> {
                appNotInstalledCallback(context.getString(R.string.session_installer__status_failure_storage))
            }
            else -> {
                val errorMessage = bundle.getString(EXTRA_STATUS_MESSAGE)
                appNotInstalledCallback("($status) $errorMessage")
            }
        }
    }

    override fun install(activity: Activity, downloadedFile: File) {
        require(downloadedFile.exists()) { "File does not exists." }
        try {
            return installInternal(activity, downloadedFile)
        } catch (e: IOException) {
            throw SessionInstallerException("Fail to install app.", e)
        }
    }

    @SuppressLint("UnspecifiedImmutableFlag")
    private fun installInternal(activity: Activity, downloadedFile: File) {
        val installer = activity.packageManager.packageInstaller
        val params = createSessionParams()
        val id = installer.createSession(params)

        installer.registerSessionCallback(object : SessionSuccessCallback(id) {
            override fun onFinishedForThisSession(success: Boolean) {
                installer.unregisterSessionCallback(this)
                if (success) {
                    appInstalledCallback()
                } else {
                    appNotInstalledCallback(null)
                }
            }
        })

        installer.openSession(id).use { session ->
            try {
                copyApkToSession(downloadedFile, session)
            } catch (e: IOException) {
                session.abandon()
                val error = activity.getString(R.string.session_installer__not_enough_storage, e.message)
                appNotInstalledCallback(error)
                return
            }
            val intentSender = createSessionChangeReceiver(activity)
            session.commit(intentSender)
        }
    }

    private fun createSessionParams(): SessionParams {
        val params = SessionParams(MODE_FULL_INSTALL)
        if (DeviceEnvironment.supportsAndroid12()) {
            params.setRequireUserAction(USER_ACTION_NOT_REQUIRED)
        }
        return params
    }

    private fun copyApkToSession(downloadedFile: File, session: Session) {
        session.openWrite("package", 0, downloadedFile.length()).use { sessionStream ->
            downloadedFile.inputStream().use { downloadedFileStream ->
                downloadedFileStream.copyTo(sessionStream)
            }
        }
    }

    private fun createSessionChangeReceiver(activity: Activity): IntentSender {
        val intent = Intent(activity, InstallActivity::class.java)
        intent.action = PACKAGE_INSTALLED_ACTION
        val pendingIntent = if (DeviceEnvironment.supportsAndroid12()) {
            PendingIntent.getActivity(activity, 0, intent, PendingIntent.FLAG_MUTABLE)
        } else {
            PendingIntent.getActivity(activity, 0, intent, 0)
        }
        return pendingIntent.intentSender
    }

    class SessionInstallerException(message: String, throwable: Throwable) :
        Exception(message, throwable)

    companion object {
        private const val PACKAGE_INSTALLED_ACTION =
            "de.marmaro.krt.ffupdater.installer.SessionInstaller.SESSION_API_PACKAGE_INSTALLED"
    }
}


code:
package de.marmaro.krt.ffupdater.installer

import android.content.pm.PackageInstaller.SessionCallback

abstract class SessionSuccessCallback(private val sessionId: Int) : SessionCallback() {
    override fun onCreated(sessionId: Int) {}
    override fun onBadgingChanged(sessionId: Int) {}
    override fun onActiveChanged(sessionId: Int, active: Boolean) {}
    override fun onProgressChanged(sessionId: Int, progress: Float) {}
    override fun onFinished(sessionId: Int, success: Boolean) {
        if (this.sessionId == sessionId) {
            onFinishedForThisSession(success)
        }
    }
    open fun onFinishedForThisSession(success: Boolean) {}
}


code:
package de.marmaro.krt.ffupdater.security

import androidx.annotation.MainThread
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import java.io.BufferedInputStream
import java.io.File
import java.io.FileInputStream
import java.security.MessageDigest

object FileHashCalculator {

    @MainThread
    suspend fun getSHA256ofFile(file: File): Sha256Hash {
        //https://stackoverflow.com/a/14922433
        return withContext(Dispatchers.IO) {
            val digest = MessageDigest.getInstance("SHA-256")
            val buffer = ByteArray(4096)
            createInputStream(file).use { bufferedInputStream ->
                while (true) {
                    val bytes = read(bufferedInputStream, buffer)
                    if (bytes <= 0) {
                        break
                    }
                    digest.update(buffer, 0, bytes)
                }
            }
            val hash = digest.digest().joinToString("") { "%02x".format(it) }
            Sha256Hash(hash)
        }
    }

    /**
     * Prevent false-positive "Inappropriate blocking method call"
     */
    private fun createInputStream(file: File): BufferedInputStream {
        return BufferedInputStream(FileInputStream(file))
    }

    /**
     * Prevent false-positive "Inappropriate blocking method call"
     */
    private fun read(bufferedInputStream: BufferedInputStream, buffer: ByteArray): Int {
        return bufferedInputStream.read(buffer)
    }
}


code:
package de.marmaro.krt.ffupdater.security

import android.content.pm.PackageManager
import android.content.pm.Signature
import androidx.annotation.MainThread
import de.marmaro.krt.ffupdater.app.App
import de.marmaro.krt.ffupdater.download.PackageManagerUtil
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import java.io.ByteArrayInputStream
import java.io.File
import java.security.MessageDigest
import java.security.NoSuchAlgorithmException
import java.security.cert.CertificateException
import java.security.cert.CertificateFactory

/**
 * Validation of downloaded and installed application.
 */
class FingerprintValidator(private val packageManager: PackageManager) {

    /**
     * Validate the SHA256 fingerprint of the certificate of the downloaded application as APK file.
     * Takes about 1s -> blocking UI thread is not ok -> suspend
     *
     * @param file APK file
     * @param app  app
     * @return the fingerprint of the app and if it matched with the stored fingerprint
     */
    @MainThread
    suspend fun checkApkFile(file: File, app: App): CertificateValidationResult {
        require(file.exists()) { "File '${file.absoluteFile}' must exists." }
        val path = file.absolutePath
        val signature = PackageManagerUtil.getPackageArchiveInfo(packageManager, path)
        checkNotNull(signature) { "Fail to parse APK file '${file.absoluteFile}'." }
        return try {
            verifyPackageInfo(signature, app)
        } catch (e: CertificateException) {
            throw UnableCheckApkException("Certificate of APK file is invalid.", e)
        } catch (e: NoSuchAlgorithmException) {
            throw UnableCheckApkException("Unknown algorithm for checking APK file.", e)
        }
    }

    /**
     * Validate the SHA256 fingerprint of the certificate of the installed application.
     * Takes about 1ms -> blocking UI thread is ok because thread switching is expensive
     *
     * @param app app
     * @return the fingerprint of the app and if it matched with the stored fingerprint
     * @see [Example on how to generate the certificate fingerprint](https://stackoverflow.com/a/22506133)
     *
     * @see [Another example](https://gist.github.com/scottyab/b849701972d57cf9562e)
     */

    @MainThread
    suspend fun checkInstalledApp(app: App): CertificateValidationResult {
        return try {
            val signature = PackageManagerUtil.getInstalledAppInfo(packageManager, app)
            checkNotNull(signature) { "Fail to get signature of app '${app.detail.packageName}'." }
            verifyPackageInfo(signature, app)
        } catch (e: CertificateException) {
            throw UnableCheckApkException("certificate of APK file is invalid", e)
        } catch (e: NoSuchAlgorithmException) {
            throw UnableCheckApkException("unknown algorithm for checking APK file", e)
        } catch (e: PackageManager.NameNotFoundException) {
            CertificateValidationResult(false, "")
        }
    }

    @MainThread
    @Throws(CertificateException::class, NoSuchAlgorithmException::class)
    private suspend fun verifyPackageInfo(
        signature: Signature,
        appDetail: App
    ): CertificateValidationResult {
        return withContext(Dispatchers.IO) {
            val signatureStream = ByteArrayInputStream(signature.toByteArray())
            val certificateFactory = CertificateFactory.getInstance("X509")
            val certificate = certificateFactory.generateCertificate(signatureStream)

            val messageDigestSha256 = MessageDigest.getInstance("SHA-256")
            val certificateFingerprint = messageDigestSha256.digest(certificate.encoded)
            val certificateFingerprintHexString = certificateFingerprint.joinToString("") {
                String.format("%02x", (it.toInt() and 0xFF))
            }

            val isValid = certificateFingerprintHexString == appDetail.detail.signatureHash
            CertificateValidationResult(
                isValid = isValid,
                hexString = certificateFingerprintHexString
            )
        }
    }

    class CertificateValidationResult(val isValid: Boolean, val hexString: String)

    class UnableCheckApkException(message: String, throwable: Throwable) :
        Exception(message, throwable)
}


code:
package de.marmaro.krt.ffupdater.security

data class Sha256Hash(
        val hexValue: String,
)


code:
package de.marmaro.krt.ffupdater.security

import android.os.StrictMode
import android.os.StrictMode.VmPolicy
import de.marmaro.krt.ffupdater.device.DeviceEnvironment

/**
 * Configure StrictMode to find bugs or other problems.
 */
object StrictModeSetup {
    fun enableStrictMode() {
        StrictMode.setThreadPolicy(
            StrictMode.ThreadPolicy.Builder()
                .detectAll()
                .permitDiskReads() // for storing preferences
                .permitDiskWrites() // for downloading apps which will be installed
                .penaltyLog()
                //.penaltyDeath()
                .build()
        )

        val vmPolicyBuilder = VmPolicy.Builder()
        //vmPolicyBuilder.detectAll() //because of the always present LeakedClosableViolation
        if (DeviceEnvironment.supportsAndroid12()) {
            vmPolicyBuilder.detectIncorrectContextUse()
            vmPolicyBuilder.detectUnsafeIntentLaunch()
        }
        if (DeviceEnvironment.supportsAndroidMarshmallow()) {
            vmPolicyBuilder.detectActivityLeaks()
            vmPolicyBuilder.detectCleartextNetwork()
            vmPolicyBuilder.detectFileUriExposure()
            //vmPolicyBuilder.detectLeakedClosableObjects() because of the always present LeakedClosableViolation
            vmPolicyBuilder.detectLeakedRegistrationObjects()
            vmPolicyBuilder.detectLeakedSqlLiteObjects()
        }
        if (DeviceEnvironment.supportsAndroidOreo()) {
            vmPolicyBuilder.detectContentUriWithoutPermission()
            vmPolicyBuilder.detectUntaggedSockets()
        }
        if (DeviceEnvironment.supportsAndroid9()) {
            vmPolicyBuilder.detectNonSdkApiUsage()
        }
        if (DeviceEnvironment.supportsAndroid10()) {
            vmPolicyBuilder.detectImplicitDirectBoot()
            vmPolicyBuilder.detectCredentialProtectedWhileLocked()
        }
        StrictMode.setVmPolicy(vmPolicyBuilder
            .penaltyLog()
            //.penaltyDeath()
            .build())
    }
}


code:
package de.marmaro.krt.ffupdater.settings

import android.content.Context
import androidx.preference.PreferenceManager
import java.time.LocalDateTime
import java.time.format.DateTimeFormatter
import java.time.format.DateTimeParseException

class PreferencesHelper(context: Context) {
    private val preferences = PreferenceManager.getDefaultSharedPreferences(context)

    var lastBackgroundCheck: LocalDateTime?
        get() {
            val timestamp = preferences.getString(LAST_BACKGROUND_CHECK_TIMESTAMP, null)
                    ?: return null
            return try {
                LocalDateTime.parse(timestamp, DateTimeFormatter.ISO_LOCAL_DATE_TIME)
            } catch (e: DateTimeParseException) {
                null
            }
        }
        set(value) {
            val timestamp = value?.format(DateTimeFormatter.ISO_LOCAL_DATE_TIME)
            preferences.edit()
                    .putString(LAST_BACKGROUND_CHECK_TIMESTAMP, timestamp)
                    .apply()
        }

    companion object {
        const val LAST_BACKGROUND_CHECK_TIMESTAMP = "lastBackgroundCheckTimestamp"
    }
}


code:
package de.marmaro.krt.ffupdater.settings

import android.content.Context
import androidx.appcompat.app.AppCompatDelegate.*
import androidx.preference.PreferenceManager
import de.marmaro.krt.ffupdater.app.App
import de.marmaro.krt.ffupdater.device.DeviceEnvironment
import java.time.Duration

/**
 * This class is a helper to access the settings more easily by checking und converting the raw values from
 * SharedPreferences instance.
 */
class SettingsHelper(context: Context) {
    private val preferences = PreferenceManager.getDefaultSharedPreferences(context)

    /**
     * @return should the regular background update check be enabled?
     */
    val automaticCheck: Boolean
        get() {
            return preferences.getBoolean("automaticCheck", true)
        }

    /**
     * @return how long should be the time span between check background update check?
     */
    val checkInterval: Duration
        get() {
            val default = Duration.ofHours(6)
            val rawValue = preferences.getString("checkInterval", null) ?: return default
            val checkInterval: Long
            try {
                checkInterval = rawValue.toLong()
            } catch (_: NumberFormatException) {
                return default
            }
            if (checkInterval < 0 || checkInterval > Duration.ofDays(35).toMinutes()) {
                return default
            }
            return Duration.ofMinutes(checkInterval)
        }

    /**
     * This setting is necessary to deactivate apps when e.g. their update checks are broken.
     *
     * @return the regular background update check should ignore these apps
     */
    val disabledApps: List<App>
        get() {
            val disableApps = preferences.getStringSet("disableApps", null) ?: setOf()
            return disableApps.mapNotNull {
                try {
                    App.valueOf(it)
                } catch (e: IllegalArgumentException) {
                    null
                }
            }
        }

    /**
     * If the app is started for the first time, the method will return AppCompatDelegate.MODE_NIGHT_NO.
     * If not, then the default value from R.string.default_theme_preference or the user setting will be returned.
     *
     * @return AppCompatDelegate.MODE_NIGHT_NO, AppCompatDelegate.MODE_NIGHT_YES, ...
     */
    fun getThemePreference(): Int {
        val default = if (DeviceEnvironment.supportsAndroid10()) {
            MODE_NIGHT_FOLLOW_SYSTEM
        } else {
            MODE_NIGHT_AUTO_BATTERY
        }

        val rawValue = preferences.getString("themePreference", null)
        val themePreference: Int
        try {
            themePreference = rawValue?.toInt() ?: return default
        } catch (_: NumberFormatException) {
            return default
        }

        val validThemes = listOf(MODE_NIGHT_FOLLOW_SYSTEM,
                MODE_NIGHT_AUTO_BATTERY,
                MODE_NIGHT_YES,
                MODE_NIGHT_NO)
        return if (validThemes.contains(themePreference)) {
            themePreference
        } else default
    }

    val onlyUnmeteredNetwork: Boolean
        get() {
            return preferences.getBoolean("onlyUnmeteredNetwork", false)
        }
}


code:
package de.marmaro.krt.ffupdater.utils

import android.os.Build.VERSION_CODES.*

/**
 * Class with useful helper methods.
 */
object AndroidVersionCodes {

    /**
     * @param apiLevel API Level
     * @return the Android version an its codename for the associated API Level
     */
    @JvmStatic
    fun getVersionForApiLevel(apiLevel: Int): String {
        return when (apiLevel) {
            LOLLIPOP -> "5.0 (Lollipop)"
            LOLLIPOP_MR1 -> "5.1 (Lollipop)"
            M -> "6.0 (Marshmallow)"
            N -> "7.0 (Nougat)"
            N_MR1 -> "7.1 (Nougat)"
            O -> "8.0.0 (Oreo)"
            O_MR1 -> "8.1.0 (Oreo)"
            P -> "9 (Pie)"
            Q -> "10 (Q)"
            R -> "11 (R)"
            R + 1 -> throw Exception("missing entry for Android 12")
            else -> throw Exception("invalid API level")
        }
    }
}


code:
<vector android:height="36dp" android:viewportHeight="24.0"
    android:viewportWidth="24.0" android:width="36dp" xmlns:android="http://schemas.android.com/apk/res/android">
    <path android:fillColor="#FF000000" android:pathData="M19,13h-6v6h-2v-6H5v-2h6V5h2v6h6v2z"/>
</vector>


code:
<vector android:height="36dp" android:tint="#BDBDBD"
    android:viewportHeight="24.0" android:viewportWidth="24.0"
    android:width="36dp" xmlns:android="http://schemas.android.com/apk/res/android">
    <path android:fillColor="#FF000000" android:pathData="M19,9h-4V3H9v6H5l7,7 7,-7zM5,18v2h14v-2H5z"/>
</vector>


code:
<vector android:height="36dp" android:tint="#FF594F"
    android:viewportHeight="24.0" android:viewportWidth="24.0"
    android:width="36dp" xmlns:android="http://schemas.android.com/apk/res/android">
    <path android:fillColor="#FF000000" android:pathData="M19,9h-4V3H9v6H5l7,7 7,-7zM5,18v2h14v-2H5z"/>
</vector>


code:
<vector android:height="24dp" android:tint="#FFFFFF"
    android:viewportHeight="24.0" android:viewportWidth="24.0"
    android:width="24dp" xmlns:android="http://schemas.android.com/apk/res/android">
    <path android:fillColor="#FF000000" android:pathData="M12,2C6.48,2 2,6.48 2,12s4.48,10 10,10 10,-4.48 10,-10S17.52,2 12,2zM13,17h-2v-6h2v6zM13,9h-2L11,7h2v2z"/>
</vector>


code:
<androidx.cardview.widget.CardView xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:app="http://schemas.android.com/apk/res-auto"
    android:id="@+id/appCard"
    style="@style/AppCard"
    app:cardUseCompatPadding="true">

    <androidx.constraintlayout.widget.ConstraintLayout style="@style/AppCardLayout">

        <TextView
            style="@style/AppCardTitle"
            android:tag="appCardTitle"
            app:layout_constraintStart_toEndOf="@+id/appIcon"
            app:layout_constraintTop_toTopOf="parent" />

        <TextView
            android:id="@+id/appInstalledVersion"
            style="@style/AppCardInstalledVersion"
            android:tag="appInstalledVersion"
            app:layout_constraintStart_toStartOf="parent"
            app:layout_constraintTop_toBottomOf="@id/appIcon" />

        <TextView
            android:id="@+id/appAvailableVersion"
            style="@style/AppCardVersion"
            android:tag="appAvailableVersion"
            app:layout_constraintStart_toStartOf="parent"
            app:layout_constraintTop_toBottomOf="@id/appInstalledVersion" />

        <ImageButton
            android:id="@+id/appInfoButton"
            style="@style/AppCardInfoButton"
            android:contentDescription="@string/information_about_the_app"
            android:tag="appInfoButton"
            app:layout_constraintBottom_toBottomOf="parent"
            app:layout_constraintEnd_toStartOf="@+id/appDownloadButton"
            app:srcCompat="@drawable/ic_info_outline_blue_36dp" />

        <ImageButton
            android:id="@+id/appDownloadButton"
            style="@style/AppCardDownloadButton"
            android:contentDescription="@string/download_the_app"
            android:tag="appDownloadButton"
            app:layout_constraintBottom_toBottomOf="parent"
            app:layout_constraintEnd_toEndOf="parent"
            app:srcCompat="@drawable/ic_file_download_grey" />

        <ImageView
            android:id="@+id/appIcon"
            style="@style/AppCardIcon"
            android:contentDescription="@string/app_icon"
            android:tag="appIcon"
            app:layout_constraintStart_toStartOf="parent"
            app:layout_constraintTop_toTopOf="parent" />

    </androidx.constraintlayout.widget.ConstraintLayout>

</androidx.cardview.widget.CardView>


code:
<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:tools="http://schemas.android.com/tools"
    android:layout_width="match_parent"
    android:layout_height="match_parent"
    android:fitsSystemWindows="true"
    tools:context=".CrashReportActivity"
    tools:ignore="PrivateResource">

    <androidx.core.widget.NestedScrollView
        android:layout_width="match_parent"
        android:layout_height="match_parent">

        <LinearLayout
            android:layout_width="match_parent"
            android:layout_height="match_parent"
            android:layout_marginTop="10dp"
            android:orientation="vertical">

            <TextView
                android:id="@+id/crash_report__explanation_textview"
                android:layout_width="match_parent"
                android:layout_height="wrap_content"
                android:layout_marginLeft="16dp"
                android:layout_marginRight="16dp"
                android:paddingTop="8dp"
                android:paddingBottom="8dp"
                android:text="@string/crash_report__explain_text__uncaught_throwable"/>

            <Button
                android:id="@+id/crash_report__copy_error_message_to_clipboard_button"
                android:layout_width="wrap_content"
                android:layout_height="wrap_content"
                android:layout_marginLeft="16dp"
                android:layout_marginRight="16dp"
                android:text="@string/crash_report__copy_crash_report_to_clipboard"/>

            <Button
                android:id="@+id/crash_report__got_to_notabug_org_button"
                android:layout_width="wrap_content"
                android:layout_height="wrap_content"
                android:layout_marginLeft="16dp"
                android:layout_marginRight="16dp"
                android:text="@string/crash_report__got_to_notabug_org" />

            <Button
                android:id="@+id/crash_report__got_to_github_button"
                android:layout_width="wrap_content"
                android:layout_height="wrap_content"
                android:layout_marginLeft="16dp"
                android:layout_marginRight="16dp"
                android:text="@string/crash_report__go_to_github" />

            <Button
                android:id="@+id/crash_report__got_to_gitlab_button"
                android:layout_width="wrap_content"
                android:layout_height="wrap_content"
                android:layout_marginLeft="16dp"
                android:layout_marginRight="16dp"
                android:text="@string/crash_report__go_to_gitlab" />

            <View
                android:layout_width="match_parent"
                android:layout_height="1dip"
                android:layout_marginTop="16dp"
                android:layout_marginBottom="16dp"
                android:background="#1E000000" />

            <HorizontalScrollView
                android:layout_width="wrap_content"
                android:layout_height="wrap_content"
                android:clipToPadding="false"
                android:paddingBottom="8dp"
                android:paddingLeft="16dp"
                android:paddingRight="16dp"
                android:paddingTop="8dp">

                <TextView
                    android:id="@+id/crash_report__exception_stack_trace"
                    android:layout_width="wrap_content"
                    android:layout_height="wrap_content"
                    android:textIsSelectable="true"/>

            </HorizontalScrollView>

        </LinearLayout>

    </androidx.core.widget.NestedScrollView>

</LinearLayout>


code:
<?xml version="1.0" encoding="utf-8"?>
<androidx.core.widget.NestedScrollView xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:tools="http://schemas.android.com/tools"
    android:layout_width="match_parent"
    android:layout_height="match_parent"
    android:scrollbarFadeDuration="0"
    android:scrollbars="vertical"
    tools:context=".InstallActivity">

    <LinearLayout
        android:layout_width="match_parent"
        android:layout_height="wrap_content"
        android:layout_marginLeft="16dp"
        android:layout_marginRight="16dp"
        android:layout_marginBottom="16dp"
        android:orientation="vertical">

        <LinearLayout
            android:id="@+id/tooLowMemory"
            style="@style/DownloadActivitySegment"
            android:background="@color/warningOrange"
            android:padding="8dp">

            <TextView
                style="@style/BoldTextView"
                android:text="@string/install_activity__too_low_memory_title" />

            <TextView
                android:id="@+id/tooLowMemoryDescription"
                style="@style/DefaultTextView" />

        </LinearLayout>

        <LinearLayout
            android:id="@+id/unknownSignatureOfInstalledApp"
            style="@style/DownloadActivitySegment"
            android:background="@color/errorRed"
            android:padding="8dp">

            <TextView
                style="@style/BoldTextView"
                android:text="@string/install_activity__unknown_signature_of_installed_app_text1" />

            <TextView
                style="@style/BoldTextView"
                android:text="@string/install_activity__unknown_signature_of_installed_app_text2" />

        </LinearLayout>

        <LinearLayout
            android:id="@+id/externalStorageNotAccessible"
            style="@style/DownloadActivitySegment"
            android:background="@color/errorRed"
            android:padding="8dp">

            <TextView
                style="@style/BoldTextView"
                android:text="@string/install_activity__external_storage_not_accessible" />

            <TextView
                style="@style/MonospaceSelectableTextView"
                android:id="@+id/externalStorageNotAccessible_state"
                android:text="" />

        </LinearLayout>

        <LinearLayout
            android:id="@+id/downloadAppIsDisabled"
            style="@style/DownloadActivitySegment"
            android:background="@color/errorRed"
            android:padding="8dp">

            <TextView
                style="@style/BoldTextView"
                android:text="@string/install_activity__download_app_is_disabled" />

        </LinearLayout>

        <LinearLayout
            android:id="@+id/fetchUrl"
            style="@style/DownloadActivitySegment">

            <TextView
                android:id="@+id/fetchUrlTextView"
                style="@style/BoldTextView"
                android:text="@string/install_activity__fetch_url_for_download" />

            <ProgressBar
                style="?android:attr/progressBarStyle"
                android:layout_width="60dp"
                android:layout_height="60dp"
                android:layout_gravity="center" />

        </LinearLayout>

        <LinearLayout
            android:id="@+id/fetchedUrlSuccess"
            style="@style/DownloadActivitySegment">

            <TextView
                android:id="@+id/fetchedUrlSuccessTextView"
                style="@style/BoldTextView"
                android:text="@string/install_activity__fetched_url_for_download_successfully" />

        </LinearLayout>

        <LinearLayout
            android:id="@+id/downloadingFile"
            style="@style/DownloadActivitySegment">

            <TextView
                android:id="@+id/downloadingFileText"
                style="@style/BoldTextView"
                android:text="@string/install_activity__download_application_from_with_status" />

            <TextView
                android:id="@+id/downloadingFileUrl"
                style="@style/MonospaceSelectableTextView" />

            <ProgressBar
                android:id="@+id/downloadingFileProgressBar"
                style="?android:attr/progressBarStyleHorizontal"
                android:layout_width="match_parent"
                android:layout_height="wrap_content"
                android:layout_gravity="center" />

        </LinearLayout>

        <LinearLayout
            android:id="@+id/downloadedFile"
            style="@style/DownloadActivitySegment">

            <TextView
                style="@style/BoldTextView"
                android:text="@string/install_activity__downloaded_application_from" />

            <TextView
                android:id="@+id/downloadedFileUrl"
                style="@style/MonospaceSelectableTextView" />

        </LinearLayout>

        <LinearLayout
            android:id="@+id/useCachedDownloadedApk"
            style="@style/DownloadActivitySegment">

            <TextView
                style="@style/BoldTextView"
                android:text="@string/install_activity__use_cached_downloaded_apk" />

            <TextView
                android:id="@+id/useCachedDownloadedApk__path"
                style="@style/MonospaceSelectableTextView" />

        </LinearLayout>

        <LinearLayout
            android:id="@+id/downloadFileFailed"
            style="@style/DownloadActivitySegment"
            android:background="@color/errorRed"
            android:padding="8dp">

            <TextView
                style="@style/BoldTextView"
                android:text="@string/install_activity__download_failed" />

            <TextView
                android:id="@+id/downloadFileFailedUrl"
                style="@style/MonospaceSelectableTextView" />

        </LinearLayout>

        <LinearLayout
            android:id="@+id/verifyDownloadFingerprint"
            style="@style/DownloadActivitySegment">

            <TextView
                style="@style/BoldTextView"
                android:text="@string/install_activity__verifying_downloaded_application_certificate" />

            <ProgressBar
                style="?android:attr/progressBarStyle"
                android:layout_width="60dp"
                android:layout_height="60dp"
                android:layout_gravity="center" />

        </LinearLayout>

        <LinearLayout
            android:id="@+id/fingerprintDownloadGood"
            style="@style/DownloadActivitySegment"
            android:background="@color/successGreen"
            android:padding="8dp">

            <TextView
                style="@style/BoldTextView"
                android:text="@string/install_activity__downloaded_application_is_verified" />

            <TextView
                style="@style/DefaultTextView"
                android:text="@string/install_activity__sha_256_certificate_fingerprint" />

            <TextView
                android:id="@+id/fingerprintDownloadGoodHash"
                style="@style/MonospaceSelectableTextView" />

        </LinearLayout>

        <LinearLayout
            android:id="@+id/fingerprintDownloadBad"
            style="@style/DownloadActivitySegment"
            android:background="@color/errorRed"
            android:padding="8dp">

            <TextView
                style="@style/BoldTextView"
                android:text="@string/install_activity__downloaded_application_is_not_verified" />

            <TextView
                style="@style/DefaultTextView"
                android:text="@string/install_activity__expected_sha_256_certificate_fingerprint" />

            <TextView
                android:id="@+id/fingerprintDownloadBadHashExpected"
                style="@style/MonospaceSelectableTextView" />

            <TextView
                style="@style/DefaultTextView"
                android:text="@string/but_was" />

            <TextView
                android:id="@+id/fingerprintDownloadBadHashActual"
                style="@style/MonospaceSelectableTextView" />

            <TextView
                style="@style/DefaultTextView"
                android:layout_marginTop="8dp"
                android:text="@string/install_activity__invalid_downloaded_certificate_fingerprint_explanation"
                android:textIsSelectable="true" />

        </LinearLayout>

        <LinearLayout
            android:id="@+id/installingApplication"
            style="@style/DownloadActivitySegment">

            <ProgressBar
                style="?android:attr/progressBarStyle"
                android:layout_width="60dp"
                android:layout_height="60dp"
                android:layout_gravity="center" />
        </LinearLayout>

        <LinearLayout
            android:id="@+id/install_activity__retrigger_installation"
            style="@style/DownloadActivitySegment">

            <Button
                android:id="@+id/install_activity__retrigger_installation__button"
                style="@style/DefaultButton"
                android:text="@string/install_activity__retrigger_installation__button_title" />

        </LinearLayout>

        <LinearLayout
            android:id="@+id/verifyInstalledFingerprint"
            style="@style/DownloadActivitySegment">

            <TextView
                style="@style/BoldTextView"
                android:text="@string/install_activity__verifying_downloaded_application_certificate" />

            <ProgressBar
                style="?android:attr/progressBarStyle"
                android:layout_width="60dp"
                android:layout_height="60dp"
                android:layout_gravity="center" />

        </LinearLayout>

        <LinearLayout
            android:id="@+id/fingerprintInstalledGood"
            style="@style/DownloadActivitySegment"
            android:background="@color/successGreen"
            android:padding="8dp">

            <TextView
                style="@style/BoldTextView"
                android:text="@string/installed_application_is_verified" />

            <TextView
                style="@style/DefaultTextView"
                android:text="@string/install_activity__sha_256_certificate_fingerprint" />

            <TextView
                android:id="@+id/fingerprintInstalledGoodHash"
                style="@style/MonospaceSelectableTextView" />

        </LinearLayout>

        <LinearLayout
            android:id="@+id/fingerprintInstalledBad"
            style="@style/DownloadActivitySegment"
            android:background="@color/errorRed"
            android:padding="8dp">

            <TextView
                style="@style/BoldTextView"
                android:text="@string/installed_app_is_not_verified" />

            <TextView
                style="@style/DefaultTextView"
                android:text="@string/install_activity__expected_sha_256_certificate_fingerprint" />

            <TextView
                android:id="@+id/fingerprintInstalledBadHashExpected"
                style="@style/MonospaceSelectableTextView" />

            <TextView
                style="@style/DefaultTextView"
                android:text="@string/but_was" />

            <TextView
                android:id="@+id/fingerprintInstalledBadHashActual"
                style="@style/MonospaceSelectableTextView" />

            <TextView
                style="@style/DefaultTextView"
                android:layout_marginTop="8dp"
                android:text="@string/install_activity__invalid_installed_certificate_fingerprint_explanation"
                android:textIsSelectable="true" />

        </LinearLayout>

        <LinearLayout
            android:id="@+id/installerSuccess"
            style="@style/DownloadActivitySegment"
            android:background="@color/successGreen"
            android:padding="8dp">

            <TextView
                style="@style/BoldTextView"
                android:text="@string/application_successfully_installed" />

            <TextView
                style="@style/DefaultTextView"
                android:text="@string/install_activity__you_can_now_close_this_window" />

        </LinearLayout>

        <LinearLayout
            android:id="@+id/installerFailed"
            style="@style/DownloadActivitySegment"
            android:background="@color/errorRed"
            android:padding="8dp">

            <TextView
                style="@style/BoldTextView"
                android:text="@string/application_installation_was_not_successful" />

            <TextView
                android:id="@+id/installerFailedReason"
                style="@style/MonospaceSelectableTextView" />

            <TextView
                style="@style/DefaultTextView"
                android:text="@string/install_activity__you_can_now_close_this_window" />

        </LinearLayout>

        <LinearLayout
            android:id="@+id/install_activity__exception"
            style="@style/DownloadActivitySegment"
            android:background="@color/errorRed"
            android:padding="8dp">

            <TextView
                android:id="@+id/install_activity__exception__text"
                style="@style/BoldTextView" />

            <TextView
                android:id="@+id/install_activity__exception__show_button"
                android:text="@string/install_activity__exception__show_button"
                style="@style/DefaultTextView" />

            <TextView
                style="@style/DefaultTextView"
                android:text="@string/install_activity__you_can_now_close_this_window" />

        </LinearLayout>

        <LinearLayout
            android:id="@+id/install_activity__delete_cache"
            style="@style/DownloadActivitySegment"
            android:padding="8dp">

            <TextView
                style="@style/DefaultTextView"
                android:text="@string/install_activity__delete_cache_explanation" />

            <Button
                android:id="@+id/install_activity__delete_cache_button"
                style="@style/DefaultButton"
                android:text="@string/install_activity__delete_cache_button_text" />

        </LinearLayout>

        <LinearLayout
            android:id="@+id/install_activity__open_cache_folder"
            style="@style/DownloadActivitySegment"
            android:padding="8dp">

            <TextView
                style="@style/DefaultTextView"
                android:text="@string/install_activity__open_cache_explanation" />

            <Button
                android:id="@+id/install_activity__open_cache_folder_button"
                style="@style/DefaultButton"
                android:text="@string/install_activity__open_cache_folder_button_text" />

        </LinearLayout>

    </LinearLayout>

</androidx.core.widget.NestedScrollView>


code:
<?xml version="1.0" encoding="utf-8"?>
<androidx.coordinatorlayout.widget.CoordinatorLayout xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:app="http://schemas.android.com/apk/res-auto"
    xmlns:tools="http://schemas.android.com/tools"
    android:id="@+id/coordinatorLayout"
    android:layout_width="match_parent"
    android:layout_height="match_parent"
    android:fitsSystemWindows="true"
    tools:context=".MainActivity"
    tools:ignore="PrivateResource">

    <com.google.android.material.appbar.AppBarLayout
        android:id="@+id/app_bar"
        android:layout_width="match_parent"
        android:layout_height="250dp"
        android:fitsSystemWindows="true"
        android:theme="@style/ThemeOverlay.AppCompat.DayNight.ActionBar">

        <com.google.android.material.appbar.CollapsingToolbarLayout
            android:layout_width="match_parent"
            android:layout_height="match_parent"
            android:fitsSystemWindows="true"
            app:contentScrim="?attr/colorPrimary"
            app:collapsedTitleTextAppearance="@style/MainActivityTheme.MainTitle.Small"
            app:expandedTitleMarginBottom="70dp"
            app:expandedTitleMarginStart="32dp"
            app:expandedTitleTextAppearance="@style/MainActivityTheme.MainTitle.Big"
            app:layout_scrollFlags="scroll|exitUntilCollapsed|snap">

            <TextView
                android:layout_width="match_parent"
                android:layout_height="wrap_content"
                android:layout_gravity="bottom"
                android:layout_marginStart="32dp"
                android:layout_marginBottom="32dp"
                android:singleLine="true"
                android:text="@string/main_activity__subtitle"
                android:textAppearance="@style/MainActivityTheme.SubTitle"
                app:layout_collapseMode="parallax" />

            <androidx.appcompat.widget.Toolbar
                android:id="@+id/toolbar"
                android:layout_width="match_parent"
                android:layout_height="?attr/actionBarSize"
                app:layout_collapseMode="pin"
                app:popupTheme="@style/ThemeOverlay.AppCompat.DayNight" />

        </com.google.android.material.appbar.CollapsingToolbarLayout>
    </com.google.android.material.appbar.AppBarLayout>

    <androidx.swiperefreshlayout.widget.SwipeRefreshLayout
        android:id="@+id/swipeContainer"
        android:layout_width="match_parent"
        android:layout_height="match_parent"
        app:layout_behavior="@string/appbar_scrolling_view_behavior"
        tools:context=".MainActivity"
        tools:showIn="@layout/main_activity">

        <androidx.core.widget.NestedScrollView
            android:layout_width="match_parent"
            android:layout_height="match_parent">

            <LinearLayout
                android:id="@+id/mainLinearLayout"
                android:layout_width="match_parent"
                android:layout_height="match_parent"
                android:layout_marginTop="10dp"
                android:orientation="vertical">

            </LinearLayout>

        </androidx.core.widget.NestedScrollView>

    </androidx.swiperefreshlayout.widget.SwipeRefreshLayout>

    <com.google.android.material.floatingactionbutton.FloatingActionButton
        android:id="@+id/installAppButton"
        android:layout_width="wrap_content"
        android:layout_height="94dp"
        android:layout_gravity="end|center"
        android:layout_margin="10dp"
        android:visibility="visible"
        app:layout_anchor="@id/app_bar"
        app:layout_anchorGravity="end|bottom"
        app:srcCompat="@drawable/ic_add_black"
        android:contentDescription="@string/install_new_app" />

</androidx.coordinatorlayout.widget.CoordinatorLayout>


code:
<?xml version="1.0" encoding="utf-8"?>
<androidx.core.widget.NestedScrollView xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:tools="http://schemas.android.com/tools"
    android:layout_width="match_parent"
    android:layout_height="match_parent"
    android:scrollbarFadeDuration="0"
    android:scrollbars="vertical"
    tools:context=".ManageStorageActivity">

    <LinearLayout
        android:layout_width="match_parent"
        android:layout_height="wrap_content"
        android:layout_marginLeft="16dp"
        android:layout_marginTop="16dp"
        android:layout_marginRight="16dp"
        android:layout_marginBottom="16dp"
        android:orientation="vertical">

        <TextView
            style="@style/BoldTextView"
            android:layout_marginBottom="8dp"
            android:text="@string/manage_storage_activity__explanation" />

        <Button
            android:id="@+id/manage_storage_activity__delete_cached_apk_files"
            style="@style/DefaultButton"
            android:text="@string/manage_storage_activity__delete_button" />

    </LinearLayout>

</androidx.core.widget.NestedScrollView>


code:
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
    android:layout_width="match_parent"
    android:layout_height="match_parent">

    <FrameLayout
        android:id="@+id/settings"
        android:layout_width="match_parent"
        android:layout_height="match_parent" />
</LinearLayout>


code:
<?xml version="1.0" encoding="utf-8"?>

<menu xmlns:android="http://schemas.android.com/apk/res/android"
      xmlns:app="http://schemas.android.com/apk/res-auto"
      xmlns:tools="http://schemas.android.com/tools"
      tools:context=".activities.MainActivity">

    <item
        android:id="@+id/action_settings"
        android:icon="@drawable/ic_settings_white_24dp"
        android:title="@string/action_settings_title"
        app:showAsAction="ifRoom"/>
    <item
        android:id="@+id/action_about"
        android:icon="@drawable/ic_info_white_24dp"
        android:title="@string/action_about_title"
        app:showAsAction="ifRoom"/>
</menu>