code:
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:tools="http://schemas.android.com/tools"
    package="de.marmaro.krt.ffupdater"
    android:installLocation="auto">
    <!-- Touchscreen is enabled by default but not always needed -->
    <uses-feature
        android:name="android.hardware.touchscreen"
        android:required="false" />

    <uses-permission android:name="android.permission.INTERNET" />
    <uses-permission android:name="android.permission.ACCESS_WIFI_STATE" />
    <uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
    <uses-permission android:name="android.permission.REQUEST_INSTALL_PACKAGES" />
    <uses-permission android:name="android.permission.UPDATE_PACKAGES_WITHOUT_USER_ACTION" />

    <!-- Necessary for gaining read/write access to "Android/data/de.marmaro.krt.ffupdater/files/"
         on older devices. For Amazon devices, I have to download the APK files into this folder
         or I'll get a "Permission Denial: reading ... DownloadProvider uri ... requires
         android.permission.ACCESS_ALL_DOWNLOADS or grantUriPermission()" exception.
         -->
    <uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE" />
    <uses-permission
        android:name="android.permission.WRITE_EXTERNAL_STORAGE"
        tools:ignore="ScopedStorage" />

    <!-- If FFUpdater wants to check if an app is installed, this app must be listed here -->
    <!-- https://developer.android.com/about/versions/11/privacy/package-visibility -->
    <queries>
        <package android:name="org.mozilla.firefox" />
        <package android:name="org.mozilla.firefox_beta" />
        <package android:name="org.mozilla.fenix" />
        <package android:name="org.mozilla.focus" />
        <package android:name="org.mozilla.klar" />
        <package android:name="org.mozilla.rocket" />
        <package android:name="mozilla.lockbox" />
        <package android:name="com.brave.browser" />
        <package android:name="io.github.forkmaintainers.iceraven" />
        <package android:name="org.bromite.bromite" />
        <package android:name="com.vivaldi.browser" />
        <package android:name="org.ungoogled.chromium.stable" />
    </queries>

    <application
        android:allowBackup="true"
        android:fullBackupContent="@xml/backup_descriptor"
        android:icon="@mipmap/ic_launcher"
        android:label="@string/app_name"
        android:networkSecurityConfig="@xml/network_security_config"
        android:roundIcon="@mipmap/ic_launcher"
        android:supportsRtl="true"
        android:usesCleartextTraffic="false"
        android:manageSpaceActivity=".ManageStorageActivity"
        tools:targetApi="Q">
        <activity
            android:name=".ManageStorageActivity"
            android:label="Manage Storage"
            android:theme="@style/MainActivityTheme"/>
        <activity
            android:name=".CrashReportActivity"
            android:label="FFUpdater Crash Reporter"
            android:theme="@style/CrashReportTheme" />
        <activity
            android:name=".SettingsActivity"
            android:label="@string/settings__activity_label"
            android:theme="@style/Theme.AppCompat.DayNight.DarkActionBar" />
        <activity
            android:name=".InstallActivity"
            android:label="@string/install_activity_label"
            android:launchMode="singleTop"
            android:theme="@style/Theme.AppCompat.DayNight.DarkActionBar" />
        <activity
            android:name=".MainActivity"
            android:exported="true"
            android:label="@string/app_name"
            android:theme="@style/MainActivityTheme">
            <intent-filter>
                <action android:name="android.intent.action.MAIN" />
                <category android:name="android.intent.category.LAUNCHER" />
            </intent-filter>
        </activity>
    </application>

</manifest>


code:
package de.marmaro.krt.ffupdater

import android.content.ClipData
import android.content.ClipboardManager
import android.content.Context
import android.content.Intent
import android.net.Uri
import android.os.Build
import android.os.Bundle
import android.widget.Button
import android.widget.TextView
import android.widget.Toast
import androidx.appcompat.app.AppCompatActivity
import de.marmaro.krt.ffupdater.device.DeviceEnvironment

class CrashReportActivity : AppCompatActivity() {

    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        setContentView(R.layout.crash_report_layout)

        findViewById<TextView>(R.id.crash_report__explanation_textview).text =
            intent.extras?.getString(EXTRA_EXCEPTION_EXPLANATION, "/")
        findViewById<Button>(R.id.crash_report__copy_error_message_to_clipboard_button).setOnClickListener {
            copyErrorMessageToClipboard()
        }
        findViewById<Button>(R.id.crash_report__got_to_notabug_org_button).setOnClickListener {
            startActivity(Intent(Intent.ACTION_VIEW, NOTABUG_URI))
        }
        findViewById<Button>(R.id.crash_report__got_to_github_button).setOnClickListener {
            startActivity(Intent(Intent.ACTION_VIEW, GITHUB_URI))
        }
        findViewById<Button>(R.id.crash_report__got_to_gitlab_button).setOnClickListener {
            startActivity(Intent(Intent.ACTION_VIEW, GITLAB_URI))
        }

        findViewById<TextView>(R.id.crash_report__exception_stack_trace).text =
            intent.extras?.getString(EXTRA_EXCEPTION_STACK_TRACE) ?: "/"
    }

    private fun copyErrorMessageToClipboard() {
        val clipboardManager = getSystemService(CLIPBOARD_SERVICE) as ClipboardManager
        val crashReport = getCrashReport()
        val clip = ClipData.newPlainText("FFUpdater crash report", crashReport)
        clipboardManager.setPrimaryClip(clip)
        Toast.makeText(this, "Crash report is copied to your clipboard", Toast.LENGTH_LONG)
            .show()
    }

    private fun getCrashReport(): String {
        return """
            |Stacktrace:
            |```
            |${intent.extras?.getString(EXTRA_EXCEPTION_STACK_TRACE)}
            |```
            |Device information:
            || key | value |
            || --- | --- |
            || version name | ${BuildConfig.VERSION_NAME} |
            || version code | ${BuildConfig.VERSION_CODE} |
            || build type | ${BuildConfig.BUILD_TYPE} |
            || SDK version | ${Build.VERSION.SDK_INT} |
            || android release | ${Build.VERSION.RELEASE} |
            || board | ${Build.BOARD} |
            || brand | ${Build.BRAND} |
            || device | ${Build.DEVICE} |
            || manufacturer | ${Build.MANUFACTURER} |
            || model | ${Build.MODEL} |
            || product | ${Build.PRODUCT} |
            || SOC manufacturer | ${if (DeviceEnvironment.supportsAndroid12()) Build.SOC_MANUFACTURER else "/"} |
            || SOC model | ${if (DeviceEnvironment.supportsAndroid12()) Build.SOC_MODEL else "/"} |
            || supported ABIs | ${Build.SUPPORTED_ABIS.joinToString()} |
            || tags | ${Build.TAGS} |
            || build time | ${Build.TIME} |
        """.trimMargin()
    }

    companion object {
        const val EXTRA_EXCEPTION_STACK_TRACE = "exception_stack_trace"
        const val EXTRA_EXCEPTION_EXPLANATION = "exception_explanation"
        val NOTABUG_URI: Uri = Uri.parse("https://notabug.org/Tobiwan/ffupdater/issues")
        val GITHUB_URI: Uri = Uri.parse("https://github.com/Tobi823/ffupdater/issues")
        val GITLAB_URI: Uri = Uri.parse("https://gitlab.com/Tobiwan/ffupdater_gitlab/-/issues")

        fun createIntent(context: Context, throwable: Throwable, description: String): Intent {
            val intent = Intent(context, CrashReportActivity::class.java)
            intent.flags = Intent.FLAG_ACTIVITY_NEW_TASK
            intent.putExtra(EXTRA_EXCEPTION_STACK_TRACE, throwable.stackTraceToString().trim())
            intent.putExtra(EXTRA_EXCEPTION_EXPLANATION, description)
            return intent
        }
    }
}

repo_name: BadStarOne/ffupdater

license: mit


code:
package de.marmaro.krt.ffupdater

import android.app.DownloadManager.*
import android.content.*
import android.content.pm.PackageManager
import android.net.Uri
import android.os.Bundle
import android.os.Environment
import android.view.MenuItem
import android.view.View
import android.widget.Button
import android.widget.ProgressBar
import android.widget.TextView
import androidx.annotation.MainThread
import androidx.appcompat.app.AppCompatActivity
import androidx.appcompat.app.AppCompatDelegate
import androidx.lifecycle.ViewModel
import androidx.lifecycle.ViewModelProvider
import androidx.lifecycle.lifecycleScope
import de.marmaro.krt.ffupdater.InstallActivity.State.*
import de.marmaro.krt.ffupdater.R.id.install_activity__exception__show_button
import de.marmaro.krt.ffupdater.R.string.crash_report__explain_text__install_activity_fetching_url
import de.marmaro.krt.ffupdater.app.App
import de.marmaro.krt.ffupdater.app.UpdateCheckResult
import de.marmaro.krt.ffupdater.app.impl.exceptions.GithubRateLimitExceededException
import de.marmaro.krt.ffupdater.app.impl.exceptions.NetworkException
import de.marmaro.krt.ffupdater.crash.CrashListener
import de.marmaro.krt.ffupdater.download.AppCache
import de.marmaro.krt.ffupdater.download.DownloadManagerUtil
import de.marmaro.krt.ffupdater.download.DownloadManagerUtil.DownloadStatus.Status.*
import de.marmaro.krt.ffupdater.download.StorageUtil
import de.marmaro.krt.ffupdater.installer.AppInstaller
import de.marmaro.krt.ffupdater.security.FingerprintValidator
import de.marmaro.krt.ffupdater.security.FingerprintValidator.CertificateValidationResult
import de.marmaro.krt.ffupdater.settings.SettingsHelper
import kotlinx.coroutines.*
import java.util.*
import java.util.concurrent.*

/**
 * Activity for downloading and installing apps on devices with API Level >= 24/Nougat.
 * Reason: If have to use the DownloadManager because this is the easiest way to download the app and access it with
 * the scheme format (for example: content://downloads/all_downloads/20).
 * The DownloadManager is more difficult to use then the default java way, but the DownloadManager offers more features
 * like restarting downloads, showing the current download status etc.
 */
class InstallActivity : AppCompatActivity() {
    private lateinit var viewModel: InstallActivityViewModel
    private lateinit var fingerprintValidator: FingerprintValidator
    private lateinit var appInstaller: AppInstaller
    private lateinit var appCache: AppCache

    // necessary for communication with State enums
    private lateinit var app: App
    private var state = SUCCESS_PAUSE
    private var stateJob: Job? = null
    private lateinit var fileFingerprint: CertificateValidationResult
    private lateinit var appFingerprint: CertificateValidationResult
    private var appInstallationFailedErrorMessage: String? = null

    // persistent data across orientation changes
    class InstallActivityViewModel : ViewModel() {
        var app: App? = null
        var downloadId: Long? = null
        var updateCheckResult: UpdateCheckResult? = null
        var fetchUrlException: Exception? = null
        var fetchUrlExceptionText: String? = null
    }

    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        setContentView(R.layout.install_activity)
        CrashListener.openCrashReporterForUncaughtExceptions(this)
        AppCompatDelegate.setDefaultNightMode(SettingsHelper(this).getThemePreference())
        supportActionBar?.setDisplayHomeAsUpEnabled(true)

        val passedAppName = intent.extras?.getString(EXTRA_APP_NAME)
        if (passedAppName == null) {
            //InstallActivity was unintentionally started again after finishing the download
            finish()
            return
        }
        app = App.valueOf(passedAppName)

        fingerprintValidator = FingerprintValidator(packageManager)
        appInstaller = AppInstaller.create(
            successfulInstallationCallback = {
                restartStateMachine(USER_HAS_INSTALLED_APP_SUCCESSFUL)
            },
            failedInstallationCallback = { errorMessage ->
                if (errorMessage != null) {
                    appInstallationFailedErrorMessage = errorMessage
                }
                restartStateMachine(FAILURE_APP_INSTALLATION)
            })
        appCache = AppCache(app)
        findViewById<View>(R.id.install_activity__retrigger_installation__button).setOnClickListener {
            restartStateMachine(TRIGGER_INSTALLATION_PROCESS)
        }
        findViewById<Button>(R.id.install_activity__delete_cache_button).setOnClickListener {
            appCache.delete(this)
            hide(R.id.install_activity__delete_cache)
        }
        findViewById<Button>(R.id.install_activity__open_cache_folder_button).setOnClickListener {
            val intent = Intent(Intent.ACTION_VIEW)
            val parentFolder = appCache.getFile(this).parentFile ?: return@setOnClickListener
            val uri = Uri.parse("file://${parentFolder.absolutePath}")
            intent.setDataAndType(uri, "resource/folder")
            startActivity(Intent.createChooser(intent, "Open folder"))
        }

        //make sure that the ViewModel is correct for the current app
        viewModel = ViewModelProvider(this).get(InstallActivityViewModel::class.java)
        if (viewModel.app != null) {
            check(viewModel.app == app)
        }
        viewModel.app = app
        //recover from an orientation change - is the download already running/finished?
        if (viewModel.downloadId != null) {
            restartStateMachine(DOWNLOAD_IS_ENQUEUED)
            return
        }
        restartStateMachine(START)
    }

    override fun onStop() {
        super.onStop()
        //finish activity when it's finished and the user switch to another app
        if (state == SUCCESS_STOP || state == ERROR_STOP) {
            finish()
        }
    }

    override fun onDestroy() {
        super.onDestroy()
        stateJob?.cancel()
    }

    override fun onOptionsItemSelected(item: MenuItem): Boolean {
        if (item.itemId == android.R.id.home) {
            onBackPressed()
            return true
        }
        return super.onOptionsItemSelected(item)
    }

    override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?) {
        @Suppress("DEPRECATION")
        super.onActivityResult(requestCode, resultCode, data)
        appInstaller.onActivityResult(requestCode, resultCode, data)
    }

    private fun restartStateMachine(jumpDestination: State) {
        // security check to prevent a illegal restart
        if (state != SUCCESS_PAUSE) {
            return
        }
        stateJob?.cancel()
        state = jumpDestination
        stateJob = lifecycleScope.launch(Dispatchers.Main) {
            while (state != ERROR_STOP
                && state != SUCCESS_PAUSE
                && state != SUCCESS_STOP
            ) {
                state = state.action(this@InstallActivity)
            }
        }
    }

    /**
     * This method will be called when the app installation is completed.
     * @param intent intent
     */
    override fun onNewIntent(intent: Intent) {
        super.onNewIntent(intent)
        appInstaller.onNewIntentCallback(intent, this)
    }

    private fun show(viewId: Int) {
        findViewById<View>(viewId).visibility = View.VISIBLE
    }

    private fun hide(viewId: Int) {
        findViewById<View>(viewId).visibility = View.GONE
    }

    private fun setText(textId: Int, text: String) {
        findViewById<TextView>(textId).text = text
    }

    @MainThread
    enum class State(val action: suspend (InstallActivity) -> State) {
        START(InstallActivity::start),
        INSTALLED_APP_SIGNATURE_CHECKED(InstallActivity::installedAppSignatureChecked),
        EXTERNAL_STORAGE_IS_ACCESSIBLE(InstallActivity::externalStorageIsAccessible),
        DOWNLOAD_MANAGER_IS_ENABLED(InstallActivity::downloadManagerIsEnabled),
        PRECONDITIONS_ARE_CHECKED(InstallActivity::preconditionsAreChecked),
        ENQUEUING_DOWNLOAD(InstallActivity::enqueuingDownload),
        DOWNLOAD_IS_ENQUEUED(InstallActivity::downloadIsEnqueued),
        DOWNLOAD_WAS_SUCCESSFUL(InstallActivity::downloadWasSuccessful),
        USE_CACHED_DOWNLOADED_APK(InstallActivity::useCachedDownloadedApk),
        FINGERPRINT_OF_DOWNLOADED_FILE_OK(InstallActivity::fingerprintOfDownloadedFileOk),
        TRIGGER_INSTALLATION_PROCESS(InstallActivity::triggerInstallationProcess),
        USER_HAS_INSTALLED_APP_SUCCESSFUL(InstallActivity::userHasInstalledAppSuccessful),
        APP_INSTALLATION_HAS_BEEN_REGISTERED(InstallActivity::appInstallationHasBeenRegistered),
        FINGERPRINT_OF_INSTALLED_APP_OK(InstallActivity::fingerprintOfInstalledAppOk),

        //===============================================

        FAILURE_UNKNOWN_SIGNATURE_OF_INSTALLED_APP(InstallActivity::failureUnknownSignatureOfInstalledApp),
        FAILURE_EXTERNAL_STORAGE_NOT_ACCESSIBLE(InstallActivity::failureExternalStorageNotAccessible),
        FAILURE_DOWNLOAD_MANAGER_DISABLED(InstallActivity::failureDownloadManagerDisabled),
        FAILURE_DOWNLOAD_UNSUCCESSFUL(InstallActivity::failureDownloadUnsuccessful),
        FAILURE_INVALID_FINGERPRINT_OF_DOWNLOADED_FILE(InstallActivity::failureInvalidFingerprintOfDownloadedFile),
        FAILURE_APP_INSTALLATION(InstallActivity::failureAppInstallation),
        FAILURE_FINGERPRINT_OF_INSTALLED_APP_INVALID(InstallActivity::failureFingerprintOfInstalledAppInvalid),
        FAILURE_SHOW_FETCH_URL_EXCEPTION(InstallActivity::failureShowFetchUrlException),

        // SUCCESS_PAUSE => state machine will be restarted externally
        SUCCESS_PAUSE({ SUCCESS_PAUSE }),
        SUCCESS_STOP({ SUCCESS_STOP }),
        ERROR_STOP({ ERROR_STOP });
    }

    companion object {
        const val EXTRA_APP_NAME = "app_name"

        @MainThread
        suspend fun start(ia: InstallActivity): State {
            if (!ia.app.detail.isInstalled(ia) || ia.fingerprintValidator.checkInstalledApp(ia.app).isValid) {
                return INSTALLED_APP_SIGNATURE_CHECKED
            }
            return FAILURE_UNKNOWN_SIGNATURE_OF_INSTALLED_APP
        }

        @MainThread
        fun installedAppSignatureChecked(ia: InstallActivity): State {
            if (Environment.getExternalStorageState() == Environment.MEDIA_MOUNTED) {
                return EXTERNAL_STORAGE_IS_ACCESSIBLE
            }
            return FAILURE_EXTERNAL_STORAGE_NOT_ACCESSIBLE
        }

        @MainThread
        fun externalStorageIsAccessible(ia: InstallActivity): State {
            val downloadManager = "com.android.providers.downloads"
            return try {
                if (ia.packageManager.getApplicationInfo(downloadManager, 0).enabled) {
                    DOWNLOAD_MANAGER_IS_ENABLED
                } else {
                    FAILURE_DOWNLOAD_MANAGER_DISABLED
                }
            } catch (e: PackageManager.NameNotFoundException) {
                FAILURE_DOWNLOAD_MANAGER_DISABLED
            }
        }

        @MainThread
        fun downloadManagerIsEnabled(ia: InstallActivity): State {
            if (StorageUtil.isEnoughStorageAvailable()) {
                return PRECONDITIONS_ARE_CHECKED
            }
            ia.show(R.id.tooLowMemory)
            val mbs = StorageUtil.getFreeStorageInMebibytes()
            val message = ia.getString(R.string.install_activity__too_low_memory_description, mbs)
            ia.setText(R.id.tooLowMemoryDescription, message)
            return PRECONDITIONS_ARE_CHECKED
        }

        @MainThread
        suspend fun preconditionsAreChecked(ia: InstallActivity): State {
            val app = ia.app
            ia.show(R.id.fetchUrl)
            val downloadSource = ia.getString(app.detail.displayDownloadSource)
            val runningText = ia.getString(
                R.string.install_activity__fetch_url_for_download,
                downloadSource
            )
            ia.setText(R.id.fetchUrlTextView, runningText)

            val updateCheckResult = try {
                app.detail.updateCheck(ia)
            } catch (e: GithubRateLimitExceededException) {
                ia.viewModel.fetchUrlException = e
                ia.viewModel.fetchUrlExceptionText =
                    ia.getString(R.string.install_activity__github_rate_limit_exceeded)
                return FAILURE_SHOW_FETCH_URL_EXCEPTION
            } catch (e: NetworkException) {
                ia.viewModel.fetchUrlException = e
                ia.viewModel.fetchUrlExceptionText =
                    ia.getString(R.string.install_activity__temporary_network_issue)
                return FAILURE_SHOW_FETCH_URL_EXCEPTION
            }

            ia.viewModel.updateCheckResult = updateCheckResult
            ia.hide(R.id.fetchUrl)
            ia.show(R.id.fetchedUrlSuccess)
            val finishedText = ia.getString(
                R.string.install_activity__fetched_url_for_download_successfully,
                downloadSource
            )
            ia.setText(R.id.fetchedUrlSuccessTextView, finishedText)
            if (ia.appCache.isAvailable(ia, updateCheckResult.availableResult)) {
                return USE_CACHED_DOWNLOADED_APK
            }
            return ENQUEUING_DOWNLOAD
        }

        @MainThread
        fun enqueuingDownload(ia: InstallActivity): State {
            val updateCheckResult = requireNotNull(ia.viewModel.updateCheckResult)
            ia.show(R.id.downloadingFile)
            ia.setText(R.id.downloadingFileUrl, updateCheckResult.downloadUrl)
            ia.appCache.delete(ia)
            ia.viewModel.downloadId = DownloadManagerUtil.enqueue(
                context = ia,
                app = ia.app,
                availableVersionResult = updateCheckResult.availableResult,
                fileName = ia.appCache.getFileName()
            )
            return DOWNLOAD_IS_ENQUEUED
        }

        @MainThread
        suspend fun downloadIsEnqueued(ia: InstallActivity): State {
            val downloadId = requireNotNull(ia.viewModel.downloadId)
            do {
                val downloadStatus = DownloadManagerUtil.getStatusAndProgress(ia, downloadId)
                val downloadStatusText = DownloadManagerUtil.getStatusText(ia, downloadStatus)

                ia.setText(
                    R.id.downloadingFileText,
                    ia.getString(
                        R.string.install_activity__download_application_from_with_status,
                        downloadStatusText
                    )
                )
                ia.findViewById<ProgressBar>(R.id.downloadingFileProgressBar).progress =
                    downloadStatus.progressInPercentage

                if (downloadStatus.status == SUCCESSFUL) {
                    return DOWNLOAD_WAS_SUCCESSFUL
                }
                delay(500L)
            } while (downloadStatus.status != FAILED)
            return FAILURE_DOWNLOAD_UNSUCCESSFUL
        }

        @MainThread
        suspend fun downloadWasSuccessful(ia: InstallActivity): State {
            val updateCheckResult = requireNotNull(ia.viewModel.updateCheckResult)

            ia.show(R.id.downloadedFile)
            val app = ia.app
            ia.hide(R.id.downloadingFile)
            ia.setText(R.id.downloadedFileUrl, updateCheckResult.downloadUrl)
            ia.show(R.id.verifyDownloadFingerprint)

            ia.appCache.fixFileName(ia)
            val fingerprint = ia.fingerprintValidator.checkApkFile(ia.appCache.getFile(ia), app)
            ia.fileFingerprint = fingerprint
            return if (fingerprint.isValid) {
                FINGERPRINT_OF_DOWNLOADED_FILE_OK
            } else {
                ia.appCache.delete(ia)
                FAILURE_INVALID_FINGERPRINT_OF_DOWNLOADED_FILE
            }
        }

        @MainThread
        suspend fun useCachedDownloadedApk(ia: InstallActivity): State {
            val app = ia.app
            ia.show(R.id.useCachedDownloadedApk)
            ia.setText(R.id.useCachedDownloadedApk__path, ia.appCache.getFile(ia).absolutePath)
            ia.show(R.id.verifyDownloadFingerprint)

            val fingerprint = ia.fingerprintValidator.checkApkFile(ia.appCache.getFile(ia), app)
            ia.fileFingerprint = fingerprint
            return if (fingerprint.isValid) {
                FINGERPRINT_OF_DOWNLOADED_FILE_OK
            } else {
                ia.appCache.delete(ia)
                FAILURE_INVALID_FINGERPRINT_OF_DOWNLOADED_FILE
            }
        }

        @MainThread
        fun fingerprintOfDownloadedFileOk(ia: InstallActivity): State {
            ia.hide(R.id.verifyDownloadFingerprint)
            ia.show(R.id.fingerprintDownloadGood)
            ia.show(R.id.install_activity__retrigger_installation)
            ia.setText(R.id.fingerprintDownloadGoodHash, ia.fileFingerprint.hexString)
            return TRIGGER_INSTALLATION_PROCESS
        }

        @MainThread
        fun triggerInstallationProcess(ia: InstallActivity): State {
            ia.show(R.id.installingApplication)
            ia.appInstaller.install(ia, ia.appCache.getFile(ia))
            return SUCCESS_PAUSE
        }

        @MainThread
        fun userHasInstalledAppSuccessful(ia: InstallActivity): State {
            ia.hide(R.id.installingApplication)
            ia.hide(R.id.install_activity__retrigger_installation)
            ia.show(R.id.installerSuccess)
            return APP_INSTALLATION_HAS_BEEN_REGISTERED
        }

        @MainThread
        suspend fun appInstallationHasBeenRegistered(ia: InstallActivity): State {
            ia.show(R.id.verifyInstalledFingerprint)
            val fingerprint = ia.fingerprintValidator.checkInstalledApp(ia.app)
            ia.appFingerprint = fingerprint
            ia.hide(R.id.verifyInstalledFingerprint)
            return if (fingerprint.isValid) {
                FINGERPRINT_OF_INSTALLED_APP_OK
            } else {
                FAILURE_FINGERPRINT_OF_INSTALLED_APP_INVALID
            }
        }

        @MainThread
        fun fingerprintOfInstalledAppOk(ia: InstallActivity): State {
            val updateCheckResult = requireNotNull(ia.viewModel.updateCheckResult)
            ia.show(R.id.fingerprintInstalledGood)
            ia.setText(R.id.fingerprintInstalledGoodHash, ia.appFingerprint.hexString)
            val available = updateCheckResult.availableResult
            ia.app.detail.appInstallationCallback(ia, available)
            deleteDownloadedCacheFile(ia)
            return SUCCESS_STOP
        }

        //===============================================

        @MainThread
        fun failureUnknownSignatureOfInstalledApp(ia: InstallActivity): State {
            ia.show(R.id.unknownSignatureOfInstalledApp)
            return ERROR_STOP
        }

        @MainThread
        fun failureExternalStorageNotAccessible(ia: InstallActivity): State {
            ia.show(R.id.externalStorageNotAccessible)
            ia.setText(
                R.id.externalStorageNotAccessible_state,
                Environment.getExternalStorageState()
            )
            return ERROR_STOP
        }

        @MainThread
        fun failureDownloadManagerDisabled(ia: InstallActivity): State {
            ia.show(R.id.downloadAppIsDisabled)
            return ERROR_STOP
        }

        @MainThread
        fun failureDownloadUnsuccessful(ia: InstallActivity): State {
            val updateCheckResult = requireNotNull(ia.viewModel.updateCheckResult)
            ia.hide(R.id.downloadingFile)
            ia.show(R.id.downloadFileFailed)
            ia.setText(R.id.downloadFileFailedUrl, updateCheckResult.downloadUrl)
            ia.show(R.id.installerFailed)
            deleteDownloadedCacheFile(ia)
            return ERROR_STOP
        }

        @MainThread
        fun failureInvalidFingerprintOfDownloadedFile(ia: InstallActivity): State {
            ia.hide(R.id.verifyDownloadFingerprint)
            ia.show(R.id.fingerprintDownloadBad)
            ia.setText(R.id.fingerprintDownloadBadHashActual, ia.fileFingerprint.hexString)
            ia.setText(R.id.fingerprintDownloadBadHashExpected, ia.app.detail.signatureHash)
            ia.show(R.id.installerFailed)
            deleteDownloadedCacheFile(ia)
            return ERROR_STOP
        }

        @MainThread
        fun failureAppInstallation(ia: InstallActivity): State {
            ia.hide(R.id.installingApplication)
            ia.hide(R.id.install_activity__retrigger_installation)
            ia.show(R.id.installerFailed)
            ia.show(R.id.install_activity__delete_cache)
            ia.show(R.id.install_activity__open_cache_folder)
            var error = ia.appInstallationFailedErrorMessage
            if (error != null) {
                if (error.contains("INSTALL_FAILED_INTERNAL_ERROR") &&
                    error.contains("Permission Denied")
                ) {
                    error += "\n\n" + ia.getString(R.string.install_activity__try_disable_miui_optimization)
                }
                ia.setText(R.id.installerFailedReason, error)
            }
            return ERROR_STOP
        }

        @MainThread
        fun failureFingerprintOfInstalledAppInvalid(ia: InstallActivity): State {
            ia.show(R.id.fingerprintInstalledBad)
            ia.setText(R.id.fingerprintInstalledBadHashActual, ia.appFingerprint.hexString)
            ia.setText(R.id.fingerprintInstalledBadHashExpected, ia.app.detail.signatureHash)
            deleteDownloadedCacheFile(ia)
            return ERROR_STOP
        }

        @MainThread
        fun failureShowFetchUrlException(ia: InstallActivity): State {
            ia.hide(R.id.fetchUrl)
            ia.show(R.id.install_activity__exception)
            val text = ia.viewModel.fetchUrlExceptionText ?: "/"
            ia.setText(R.id.install_activity__exception__text, text)
            val exception = ia.viewModel.fetchUrlException
            if (exception != null) {
                ia.findViewById<TextView>(install_activity__exception__show_button)
                    .setOnClickListener {
                        val description =
                            ia.getString(crash_report__explain_text__install_activity_fetching_url)
                        val intent = CrashReportActivity.createIntent(ia, exception, description)
                        ia.startActivity(intent)
                    }
            }
            return ERROR_STOP
        }

        //===============================================

        private fun deleteDownloadedCacheFile(ia: InstallActivity) {
            ia.viewModel.downloadId?.let { id -> DownloadManagerUtil.remove(ia, id) }
            ia.appCache.delete(ia)
        }
    }
}


code:
package de.marmaro.krt.ffupdater

import android.app.AlertDialog
import android.content.DialogInterface
import android.content.Intent
import android.os.Bundle
import android.util.Log
import android.view.Menu
import android.view.MenuItem
import android.view.View
import android.widget.ImageButton
import android.widget.ImageView
import android.widget.LinearLayout
import android.widget.TextView
import androidx.annotation.MainThread
import androidx.annotation.UiThread
import androidx.appcompat.app.AppCompatActivity
import androidx.appcompat.app.AppCompatDelegate
import androidx.lifecycle.lifecycleScope
import androidx.swiperefreshlayout.widget.SwipeRefreshLayout
import com.google.android.material.snackbar.Snackbar
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.background.BackgroundJob
import de.marmaro.krt.ffupdater.crash.CrashListener
import de.marmaro.krt.ffupdater.dialog.AppInfoDialog
import de.marmaro.krt.ffupdater.dialog.InstallNewAppDialog
import de.marmaro.krt.ffupdater.dialog.InstallSameVersionDialog
import de.marmaro.krt.ffupdater.dialog.RunningDownloadsDialog
import de.marmaro.krt.ffupdater.download.DownloadManagerUtil
import de.marmaro.krt.ffupdater.download.NetworkUtil
import de.marmaro.krt.ffupdater.security.StrictModeSetup
import de.marmaro.krt.ffupdater.settings.PreferencesHelper
import de.marmaro.krt.ffupdater.settings.SettingsHelper
import kotlinx.coroutines.*
import java.time.format.DateTimeFormatter
import java.util.*
import java.util.concurrent.*

class MainActivity : AppCompatActivity() {
    private val sameAppVersionAlreadyInstalled: EnumMap<App, Boolean> = EnumMap(App::class.java)
    private val availableVersions: EnumMap<App, TextView> = EnumMap(App::class.java)
    private val downloadButtons: EnumMap<App, ImageButton> = EnumMap(App::class.java)
    private val errorsDuringUpdateCheck: EnumMap<App, Exception?> = EnumMap(App::class.java)

    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        CrashListener.openCrashReporterForUncaughtExceptions(this)

        setContentView(R.layout.main_activity)
        setSupportActionBar(findViewById(R.id.toolbar))
        if (BuildConfig.BUILD_TYPE == "debug") {
            StrictModeSetup.enableStrictMode()
        }
        AppCompatDelegate.setDefaultNightMode(SettingsHelper(this).getThemePreference())
        Migrator().migrate(this)

        findViewById<View>(R.id.installAppButton).setOnClickListener {
            InstallNewAppDialog.newInstance().show(supportFragmentManager)
        }
        findViewById<SwipeRefreshLayout>(R.id.swipeContainer).setOnRefreshListener {
            lifecycleScope.launch(Dispatchers.Main) {
                checkForUpdates()
            }
        }
    }

    @MainThread
    private fun userTriggersAppDownload(app: App) {
        if (NetworkUtil.isInternetUnavailable(this)) {
            showInternetUnavailableToast()
            return
        }
        // true: same version is already installed -> show warning
        // false: an other version is installed (probably older) ->
        //          install new version without warning
        // null: due to e.g. network issues it isn't known if the same version is installed ->
        //          install new version without warning
        if (sameAppVersionAlreadyInstalled[app] == true) {
            InstallSameVersionDialog.newInstance(app).show(supportFragmentManager)
        } else {
            installAppButCheckForCurrentDownloads(app)
        }
    }

    @MainThread
    fun installAppButCheckForCurrentDownloads(app: App) {
        if (DownloadManagerUtil.isDownloadingFilesNow(this)) {
            RunningDownloadsDialog.newInstance(app).show(supportFragmentManager)
        } else {
            installApp(app)
        }
    }

    @MainThread
    override fun onResume() {
        super.onResume()
        initUI()
        lifecycleScope.launch(Dispatchers.Main) {
            checkForUpdates()
            BackgroundJob.startOrStopBackgroundUpdateCheck(this@MainActivity)
        }
    }

    override fun onPause() {
        super.onPause()
        cleanUpObjects()
    }

    override fun onCreateOptionsMenu(menu: Menu): Boolean {
        menuInflater.inflate(R.menu.menu, menu)
        return true
    }

    override fun onOptionsItemSelected(item: MenuItem): Boolean {
        val itemId = item.itemId
        if (itemId == R.id.action_about) {
            val timestamp = PreferencesHelper(this).lastBackgroundCheck
                ?.let { DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm").format(it) }
                ?: "/"
            AlertDialog.Builder(this@MainActivity)
                .setTitle(R.string.action_about_title)
                .setMessage(getString(R.string.infobox, timestamp))
                .setNeutralButton(R.string.ok) { dialog: DialogInterface, _: Int ->
                    dialog.dismiss()
                }
                .create()
                .show()
        } else if (itemId == R.id.action_settings) {
            //start settings activity where we use select firefox product and release type;
            startActivity(Intent(this, SettingsActivity::class.java))
        }
        return super.onOptionsItemSelected(item)
    }

    @UiThread
    private fun cleanUpObjects() {
        availableVersions.clear()
        downloadButtons.clear()
        errorsDuringUpdateCheck.clear()
    }

    @UiThread
    private fun initUI() {
        val mainLayout = findViewById<LinearLayout>(R.id.mainLinearLayout)
        mainLayout.removeAllViews()
        cleanUpObjects()
        val installedApps = App.values().filter { it.detail.isInstalled(this) }
        installedApps.forEach { app ->
            val newCardView = layoutInflater.inflate(R.layout.app_card_layout, mainLayout, false)

            val installedVersion: TextView = newCardView.findViewWithTag("appInstalledVersion")
            installedVersion.text = app.detail.getDisplayInstalledVersion(this)

            val availableVersion: TextView = newCardView.findViewWithTag("appAvailableVersion")
            availableVersions[app] = availableVersion
            availableVersion.setOnClickListener {
                val exception = errorsDuringUpdateCheck[app]
                Log.e("MainActivity", exception.toString())
                if (exception != null) {
                    val description =
                        getString(R.string.crash_report__explain_text__main_activity_update_check)
                    val intent = CrashReportActivity.createIntent(this, exception, description)
                    startActivity(intent)
                }
            }

            val downloadButton: ImageButton = newCardView.findViewWithTag("appDownloadButton")
            downloadButton.setOnClickListener { userTriggersAppDownload(app) }
            downloadButtons[app] = downloadButton
            disableDownloadButton(app)

            val infoButton: ImageButton = newCardView.findViewWithTag("appInfoButton")
            infoButton.setOnClickListener {
                AppInfoDialog.newInstance(app).show(supportFragmentManager)
            }

            val cardTitle: TextView = newCardView.findViewWithTag("appCardTitle")
            cardTitle.setText(app.detail.displayTitle)

            val icon = newCardView.findViewWithTag<ImageView>("appIcon")
            icon.setImageResource(app.detail.displayIcon)

            mainLayout.addView(newCardView)
        }
    }

    @MainThread
    private suspend fun checkForUpdates() {
        val installedApps = App.values().filter { app ->
            app.detail.isInstalled(this@MainActivity)
        }
        if (NetworkUtil.isInternetUnavailable(this)) {
            installedApps.forEach { app ->
                val message = getString(R.string.main_activity__no_internet_connection)
                setAvailableVersion(app, message)
            }
            hideLoadAnimation()
            showInternetUnavailableToast()
            return
        }

        showLoadAnimation()
        installedApps.forEach { app ->
            setAvailableVersion(app, getString(R.string.available_version_loading))
        }
        installedApps.forEach { app ->
            checkForAppUpdateInIOThread(app)
        }
        hideLoadAnimation()
    }

    @MainThread
    private suspend fun checkForAppUpdateInIOThread(app: App) {
        try {
            val updateResult = app.detail.updateCheck(applicationContext)
            setAvailableVersion(app, updateResult.displayVersion)
            sameAppVersionAlreadyInstalled[app] = !updateResult.isUpdateAvailable
            if (updateResult.isUpdateAvailable) {
                enableDownloadButton(app)
            } else {
                disableDownloadButton(app)
            }
        } catch (e: GithubRateLimitExceededException) {
            showUpdateCheckError(app, R.string.main_activity__github_api_limit_exceeded, e)
        } catch (e: NetworkException) {
            showUpdateCheckError(app, R.string.main_activity__temporary_network_issue, e)
        } catch (e: Exception) {
            showUpdateCheckError(app, R.string.available_version_error, e)
        }
    }

    @MainThread
    private fun setAvailableVersion(app: App, message: String) {
        //it's possible that MainActivity is destroyed but the coroutine wants to update
        //the "available version" text field (which does not longer exists)
        availableVersions[app]?.text = message
        errorsDuringUpdateCheck[app] = null
    }

    @UiThread
    private fun showUpdateCheckError(app: App, message: Int, exception: Exception) {
        errorsDuringUpdateCheck[app] = exception
        availableVersions[app]?.setText(message)
        disableDownloadButton(app)
    }

    @MainThread
    fun installApp(app: App) {
        if (NetworkUtil.isInternetUnavailable(this)) {
            showInternetUnavailableToast()
            return
        }
        val intent = Intent(this@MainActivity, InstallActivity::class.java)
        intent.putExtra(InstallActivity.EXTRA_APP_NAME, app.name)
        startActivity(intent)
    }

    @UiThread
    private fun showInternetUnavailableToast() {
        val layout = findViewById<View>(R.id.coordinatorLayout)
        val message = R.string.main_activity__no_internet_connection
        Snackbar.make(layout, message, Snackbar.LENGTH_LONG).show()
    }

    @UiThread
    private fun enableDownloadButton(app: App) {
        downloadButtons[app]?.setImageResource(R.drawable.ic_file_download_orange)
    }

    @UiThread
    private fun disableDownloadButton(app: App) {
        downloadButtons[app]?.setImageResource(R.drawable.ic_file_download_grey)
    }

    @UiThread
    private fun showLoadAnimation() {
        findViewById<SwipeRefreshLayout>(R.id.swipeContainer).isRefreshing = true
    }

    @UiThread
    private fun hideLoadAnimation() {
        findViewById<SwipeRefreshLayout>(R.id.swipeContainer).isRefreshing = false
    }
}


code:
package de.marmaro.krt.ffupdater

import android.os.Bundle
import android.os.Environment
import android.widget.Button
import androidx.appcompat.app.AppCompatActivity
import de.marmaro.krt.ffupdater.crash.CrashListener

class ManageStorageActivity : AppCompatActivity() {
    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        CrashListener.openCrashReporterForUncaughtExceptions(this)
        setContentView(R.layout.manage_storage_activity)

        findViewById<Button>(R.id.manage_storage_activity__delete_cached_apk_files).setOnClickListener {
            val folder = getExternalFilesDir(Environment.DIRECTORY_DOWNLOADS)
            checkNotNull(folder) { "External download folder should exists." }
            val files = folder.listFiles()
            checkNotNull(files) { "Array of files in download folder should exists." }
            files.forEach {
                check(it.delete()) { "Fail to delete file '${it.name}'." }
            }
            finish()
        }
    }
}


code:
package de.marmaro.krt.ffupdater

import android.content.Context
import android.os.Environment
import android.util.Log
import androidx.preference.PreferenceManager

class Migrator(private val currentVersionCode: Int = BuildConfig.VERSION_CODE) {

    fun migrate(context: Context) {
        val preferences = PreferenceManager.getDefaultSharedPreferences(context)
        val lastVersionCode = preferences.getInt(FFUPDATER_VERSION_CODE, 0)

        if (lastVersionCode < 87 /* 74.5.0 */) {
            deleteOldCacheData(context)
        }

        preferences.edit().putInt(FFUPDATER_VERSION_CODE, currentVersionCode).apply()
    }

    private fun deleteOldCacheData(context: Context) {
        Log.i(LOG_TAG, "Delete old data from cache.")
        context.getExternalFilesDir(Environment.DIRECTORY_DOWNLOADS)
            ?.listFiles()
            ?.forEach { it.delete() }
        context.externalCacheDir
            ?.listFiles()
            ?.forEach { it.delete() }
    }

    companion object {
        const val FFUPDATER_VERSION_CODE = "migrator_ffupdater_version_code"
        const val LOG_TAG = "Migrator"
    }
}


code:
package de.marmaro.krt.ffupdater

import android.os.Bundle
import android.view.MenuItem
import androidx.appcompat.app.AppCompatActivity
import androidx.appcompat.app.AppCompatDelegate
import androidx.preference.ListPreference
import androidx.preference.PreferenceFragmentCompat
import de.marmaro.krt.ffupdater.background.BackgroundJob
import de.marmaro.krt.ffupdater.crash.CrashListener
import de.marmaro.krt.ffupdater.settings.SettingsHelper

/**
 * Activity for displaying the settings view.
 */
class SettingsActivity : AppCompatActivity() {
    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        CrashListener.openCrashReporterForUncaughtExceptions(this)
        setContentView(R.layout.settings_activity)
        AppCompatDelegate.setDefaultNightMode(SettingsHelper(this).getThemePreference())
        if (savedInstanceState == null) { //https://stackoverflow.com/a/60348385
            supportFragmentManager
                    .beginTransaction()
                    .replace(R.id.settings, SettingsFragment())
                    .commit()
        }
        supportActionBar?.setDisplayHomeAsUpEnabled(true)
    }

    override fun onPause() {
        super.onPause()
        BackgroundJob.startOrStopBackgroundUpdateCheck(this)
    }

    override fun onOptionsItemSelected(item: MenuItem): Boolean {
        if (item.itemId == android.R.id.home) {
            onBackPressed()
            return true
        }
        return super.onOptionsItemSelected(item)
    }

    class SettingsFragment : PreferenceFragmentCompat() {
        override fun onCreatePreferences(savedInstanceState: Bundle?, rootKey: String?) {
            setPreferencesFromResource(R.xml.root_preferences, rootKey)
            findPreference<ListPreference>("themePreference")?.setOnPreferenceChangeListener()
            { _, newValue ->
                AppCompatDelegate.setDefaultNightMode((newValue as String).toInt())
                true
            }
        }
    }
}


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

import de.marmaro.krt.ffupdater.app.impl.*

enum class App(appDetail: BaseApp) {
    FIREFOX_RELEASE(FirefoxRelease()),
    FIREFOX_BETA(FirefoxBeta()),
    FIREFOX_NIGHTLY(FirefoxNightly()),
    FIREFOX_FOCUS(FirefoxFocus()),
    FIREFOX_KLAR(FirefoxKlar()),
    LOCKWISE(Lockwise()),
    BRAVE(Brave()),
    ICERAVEN(Iceraven()),
    BROMITE(Bromite()),
    VIVALDI(Vivaldi()),
    UNGOOGLED_CHROMIUM(UngoogledChromium());

    val detail: BaseApp = appDetail
}


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

import de.marmaro.krt.ffupdater.security.Sha256Hash
import java.time.ZonedDateTime

data class AvailableVersionResult(
        val downloadUrl: String,
        val version: String,
        val publishDate: ZonedDateTime?,
        val fileSizeBytes: Long?,
        val fileHash: Sha256Hash?
)


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

import android.content.Context
import androidx.annotation.AnyThread
import androidx.annotation.MainThread
import de.marmaro.krt.ffupdater.R
import de.marmaro.krt.ffupdater.app.impl.exceptions.InvalidApiResponseException
import de.marmaro.krt.ffupdater.app.impl.exceptions.NetworkException
import de.marmaro.krt.ffupdater.device.ABI
import de.marmaro.krt.ffupdater.download.PackageManagerUtil
import java.io.File

interface BaseApp {
    val packageName: String
    val displayTitle: Int
    val displayDescription: Int
    val displayWarning: Int?
    val displayDownloadSource: Int
    val displayIcon: Int
    val minApiLevel: Int
    val supportedAbis: List<ABI>
    val signatureHash: String

    @AnyThread
    fun isInstalled(context: Context): Boolean {
        return PackageManagerUtil.isAppInstalled(context.packageManager, packageName)
    }

    @AnyThread
    fun getInstalledVersion(context: Context): String? {
        return PackageManagerUtil.getInstalledAppVersionName(context.packageManager, packageName)
    }

    @AnyThread
    fun getDisplayInstalledVersion(context: Context): String {
        return context.getString(R.string.installed_version, getInstalledVersion(context))
    }

    @AnyThread
    fun getDisplayAvailableVersion(context: Context, availableVersionResult: AvailableVersionResult): String {
        return context.getString(R.string.available_version, availableVersionResult.version)
    }

    @AnyThread
    fun appInstallationCallback(context: Context, available: AvailableVersionResult) {
    }

    /**
     * 2min timeout
     * Exception will not cause CancellationExceptions
     * @throws InvalidApiResponseException
     * @throws NetworkException
     */
    @MainThread
    suspend fun updateCheck(context: Context): UpdateCheckResult

    @MainThread
    suspend fun isAvailableVersionEqualToArchive(
        context: Context,
        file: File,
        available: AvailableVersionResult
    ): Boolean {
        val archiveVersion = PackageManagerUtil.getPackageArchiveVersionNameOrNull(
            context.packageManager,
            file.absolutePath
        ) ?: return false
        return VersionCompareHelper.isAvailableVersionEqual(archiveVersion, available.version)
    }

    @AnyThread
    fun isAvailableVersionHigherThanInstalled(context: Context, available: AvailableVersionResult): Boolean {
        val installedVersion = getInstalledVersion(context) ?: return true
        return VersionCompareHelper.isAvailableVersionHigher(installedVersion, available.version)
    }
}


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

import android.content.Context
import androidx.annotation.MainThread
import de.marmaro.krt.ffupdater.device.ABI
import de.marmaro.krt.ffupdater.device.DeviceEnvironment
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock

abstract class BaseAppWithCachedUpdateCheck : BaseApp {
    private var cache: CachedAvailableVersionResult? = null
    private val mutex = Mutex()

    @MainThread
    protected abstract suspend fun updateCheckWithoutCaching(): AvailableVersionResult

    /**
     * This method must not be called from the main thread or a android.os.NetworkOnMainThreadException
     * will be thrown
     */
    override suspend fun updateCheck(context: Context): UpdateCheckResult {
        // - use mutex lock to prevent multiple simultaneously update check for a single app
        // - it's useless to start a new update check for an app when a different update check
        // for the same app is already running
        mutex.withLock {
            val cacheAge = System.currentTimeMillis() - (cache?.cacheTimestamp ?: 0)
            if (cache == null || cacheAge > CACHE_TIME) {
                val availableVersionResult = updateCheckWithoutCaching()
                val time = System.currentTimeMillis()
                cache = CachedAvailableVersionResult(availableVersionResult, time)
            }
        }

        val availableVersionResult = checkNotNull(cache).availableVersionResult
        return UpdateCheckResult(
            availableResult = availableVersionResult,
            isUpdateAvailable = isAvailableVersionHigherThanInstalled(context, availableVersionResult),
            displayVersion = getDisplayAvailableVersion(context, availableVersionResult)
        )
    }

    /**
     * Helper function for getting the correct ABI name (these names are app specific)
     */
    protected fun getStringForCurrentAbi(
        arm: String? = null,
        arm64: String? = null,
        x86: String? = null,
        x64: String? = null,
    ): String {
        val mapping = DeviceEnvironment.abis.mapNotNull {
            when (it) {
                ABI.ARM64_V8A -> arm64
                ABI.ARMEABI_V7A -> arm
                ABI.X86 -> x86
                ABI.X86_64 -> x64
                ABI.ARMEABI, ABI.MIPS, ABI.MIPS64 -> null
            }
        }
        check(mapping.isNotEmpty()) {
            "There was no matches for the device ABIs [${DeviceEnvironment.abis.joinToString(", ")}]."
        }
        return mapping.first()
    }

    companion object {
        const val CACHE_TIME = 600_000L // 10 minutes
    }

    data class CachedAvailableVersionResult(
        val availableVersionResult: AvailableVersionResult,
        val cacheTimestamp: Long
    )
}


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

import de.marmaro.krt.ffupdater.security.Sha256Hash
import java.time.ZonedDateTime

data class UpdateCheckResult(
        val availableResult: AvailableVersionResult,
        val isUpdateAvailable: Boolean,
        val displayVersion: String,
) {

    val downloadUrl: String
        get() = availableResult.downloadUrl

    val version: String
        get() = availableResult.version

    val publishDate: ZonedDateTime?
        get() = availableResult.publishDate

    val fileSizeBytes: Long?
        get() = availableResult.fileSizeBytes

    val fileHash: Sha256Hash?
        get() = availableResult.fileHash
}


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

import io.github.g00fy2.versioncompare.Version

object VersionCompareHelper {
    fun isAvailableVersionHigher(installedVersion: String, availableVersion: String): Boolean {
        return try {
            val installed = Version(installedVersion, true)
            val available = Version(availableVersion, true)
            available.isHigherThan(installed)
        } catch (e: IllegalArgumentException) {
            installedVersion != availableVersion
        }
    }

    fun isAvailableVersionEqual(installedVersion: String, availableVersion: String): Boolean {
        return try {
            val installed = Version(installedVersion, true)
            val available = Version(availableVersion, true)
            available.isEqual(installed)
        } catch (e: IllegalArgumentException) {
            installedVersion == availableVersion
        }
    }
}


code:
package de.marmaro.krt.ffupdater.app.impl

import android.os.Build
import de.marmaro.krt.ffupdater.R
import de.marmaro.krt.ffupdater.app.AvailableVersionResult
import de.marmaro.krt.ffupdater.app.BaseAppWithCachedUpdateCheck
import de.marmaro.krt.ffupdater.app.impl.fetch.github.GithubConsumer
import de.marmaro.krt.ffupdater.app.impl.fetch.github.GithubConsumer.Asset
import de.marmaro.krt.ffupdater.app.impl.fetch.github.GithubConsumer.Release
import de.marmaro.krt.ffupdater.device.ABI

/**
 * https://github.com/brave/brave-browser/releases
 * https://api.github.com/repos/brave/brave-browser/releases
 * https://www.apkmirror.com/apk/brave-software/brave-browser/
 */
class Brave : BaseAppWithCachedUpdateCheck() {
    override val packageName = "com.brave.browser"
    override val displayTitle = R.string.brave__title
    override val displayDescription = R.string.brave__description
    override val displayWarning = R.string.brave__warning
    override val displayDownloadSource = R.string.github
    override val displayIcon = R.mipmap.ic_logo_brave
    override val minApiLevel = Build.VERSION_CODES.N
    override val supportedAbis = listOf(ABI.ARM64_V8A, ABI.ARMEABI_V7A, ABI.X86_64, ABI.X86)

    @Suppress("SpellCheckingInspection")
    override val signatureHash = "9c2db70513515fdbfbbc585b3edf3d7123d4dc67c94ffd306361c1d79bbf18ac"

    override suspend fun updateCheckWithoutCaching(): AvailableVersionResult {
        val fileName = getStringForCurrentAbi(
            "BraveMonoarm.apk", "BraveMonoarm64.apk",
            "BraveMonox86.apk", "BraveMonox64.apk"
        )
        val githubConsumer = GithubConsumer(
            repoOwner = "brave",
            repoName = "brave-browser",
            resultsPerPage = 20,
            validReleaseTester = { release: Release ->
                release.name.startsWith("Release v") &&
                        release.assets.any { it.name == fileName }
            },
            correctAssetTester = { asset: Asset -> asset.name == fileName })
        val result = githubConsumer.updateCheckAllReleases()
        val version = result.tagName.replace("v", "")
        return AvailableVersionResult(
            downloadUrl = result.url,
            version = version,
            publishDate = result.releaseDate,
            fileSizeBytes = result.fileSizeBytes,
            fileHash = null
        )
    }
}


code:
package de.marmaro.krt.ffupdater.app.impl

import android.os.Build
import de.marmaro.krt.ffupdater.R
import de.marmaro.krt.ffupdater.app.AvailableVersionResult
import de.marmaro.krt.ffupdater.app.BaseAppWithCachedUpdateCheck
import de.marmaro.krt.ffupdater.app.impl.fetch.github.GithubConsumer
import de.marmaro.krt.ffupdater.app.impl.fetch.github.GithubConsumer.Asset
import de.marmaro.krt.ffupdater.app.impl.fetch.github.GithubConsumer.Release
import de.marmaro.krt.ffupdater.device.ABI

/**
 * https://github.com/bromite/bromite/releases
 * https://api.github.com/repos/bromite/bromite/releases
 * https://www.apkmirror.com/apk/bromite/bromite/
 */
class Bromite : BaseAppWithCachedUpdateCheck() {
    override val packageName = "org.bromite.bromite"
    override val displayTitle = R.string.bromite__title
    override val displayDescription = R.string.bromite__description
    override val displayWarning = R.string.bromite__warning
    override val displayDownloadSource = R.string.github
    override val displayIcon = R.mipmap.ic_logo_bromite
    override val minApiLevel = Build.VERSION_CODES.LOLLIPOP
    override val supportedAbis = listOf(ABI.ARM64_V8A, ABI.ARMEABI_V7A, ABI.X86)

    @Suppress("SpellCheckingInspection")
    override val signatureHash = "e1ee5cd076d7b0dc84cb2b45fb78b86df2eb39a3b6c56ba3dc292a5e0c3b9504"

    override suspend fun updateCheckWithoutCaching(): AvailableVersionResult {
        val fileName = getStringForCurrentAbi(
            "arm_ChromePublic.apk",
            "arm64_ChromePublic.apk", "x86_ChromePublic.apk", null
        )
        val githubConsumer = GithubConsumer(
            repoOwner = "bromite",
            repoName = "bromite",
            resultsPerPage = 5,
            validReleaseTester = { release: Release ->
                !release.isPreRelease && release.assets.any { it.name == fileName }
            },
            correctAssetTester = { asset: Asset -> asset.name == fileName })
        val result = githubConsumer.updateCheck()
        // tag name can be "90.0.4430.59"
        return AvailableVersionResult(
            downloadUrl = result.url,
            version = result.tagName,
            publishDate = result.releaseDate,
            fileSizeBytes = result.fileSizeBytes,
            fileHash = null
        )
    }
}


code:
package de.marmaro.krt.ffupdater.app.impl

import android.os.Build
import de.marmaro.krt.ffupdater.R
import de.marmaro.krt.ffupdater.app.AvailableVersionResult
import de.marmaro.krt.ffupdater.app.BaseAppWithCachedUpdateCheck
import de.marmaro.krt.ffupdater.app.impl.fetch.mozillaci.MozillaCiLogConsumer
import de.marmaro.krt.ffupdater.device.ABI

/**
 * https://firefox-ci-tc.services.mozilla.com/tasks/index/mobile.v2.fenix.beta.latest
 * https://www.apkmirror.com/apk/mozilla/firefox-beta/
 */
class FirefoxBeta : BaseAppWithCachedUpdateCheck() {
    override val packageName = "org.mozilla.firefox_beta"
    override val displayTitle = R.string.firefox_beta__title
    override val displayDescription = R.string.firefox_beta__description
    override val displayWarning = R.string.firefox_beta__warning
    override val displayDownloadSource = R.string.mozilla_ci
    override val displayIcon = R.mipmap.ic_logo_firefox_beta
    override val minApiLevel = Build.VERSION_CODES.LOLLIPOP
    override val supportedAbis = listOf(ABI.ARM64_V8A, ABI.ARMEABI_V7A, ABI.X86_64, ABI.X86)

    @Suppress("SpellCheckingInspection")
    override val signatureHash = "a78b62a5165b4494b2fead9e76a280d22d937fee6251aece599446b2ea319b04"

    override suspend fun updateCheckWithoutCaching(): AvailableVersionResult {
        val abiString = getStringForCurrentAbi(
            "armeabi-v7a", "arm64-v8a", "x86",
            "x86_64"
        )
        val mozillaCiConsumer = MozillaCiLogConsumer(
            task = "mobile.v2.fenix.beta.latest.$abiString",
            apkArtifact = "public/build/$abiString/target.apk",
        )
        val result = mozillaCiConsumer.updateCheck()
        return AvailableVersionResult(
            downloadUrl = result.url,
            version = result.version,
            publishDate = result.releaseDate,
            fileSizeBytes = null,
            fileHash = null
        )
    }
}


code:
package de.marmaro.krt.ffupdater.app.impl

import android.os.Build
import de.marmaro.krt.ffupdater.R
import de.marmaro.krt.ffupdater.app.AvailableVersionResult
import de.marmaro.krt.ffupdater.app.BaseAppWithCachedUpdateCheck
import de.marmaro.krt.ffupdater.app.impl.fetch.github.GithubConsumer
import de.marmaro.krt.ffupdater.device.ABI

/**
 * https://github.com/mozilla-mobile/focus-android
 * https://api.github.com/repos/mozilla-mobile/focus-android/releases
 * https://firefox-ci-tc.services.mozilla.com/tasks/index/project.mobile.focus.release/latest
 * https://www.apkmirror.com/apk/mozilla/firefox-focus-private-browser/
 */
class FirefoxFocus : BaseAppWithCachedUpdateCheck() {
    override val packageName = "org.mozilla.focus"
    override val displayTitle = R.string.firefox_focus__title
    override val displayDescription = R.string.firefox_focus__description
    override val displayWarning: Int? = null
    override val displayDownloadSource = R.string.github
    override val displayIcon = R.mipmap.ic_logo_firefox_focus_klar
    override val minApiLevel = Build.VERSION_CODES.LOLLIPOP
    override val supportedAbis = listOf(ABI.ARM64_V8A, ABI.ARMEABI_V7A)

    @Suppress("SpellCheckingInspection")
    override val signatureHash = "6203a473be36d64ee37f87fa500edbc79eab930610ab9b9fa4ca7d5c1f1b4ffc"

    override suspend fun updateCheckWithoutCaching(): AvailableVersionResult {
        val fileName = getStringForCurrentAbi("Focus-arm.apk", "Focus-arm64.apk")
        val githubConsumer = GithubConsumer(
            repoOwner = "mozilla-mobile",
            repoName = "focus-android",
            resultsPerPage = 3,
            validReleaseTester = { release: GithubConsumer.Release ->
                !release.isPreRelease &&
                        !release.name.contains("beta") &&
                        release.assets.any { it.name == fileName }
            },
            correctAssetTester = { asset: GithubConsumer.Asset -> asset.name == fileName })
        val result = githubConsumer.updateCheck()

        val extractVersion = {
            val regexMatch = Regex("""^v((\d)+(\.\d+)*)""")
                .find(result.tagName)
            checkNotNull(regexMatch) {
                "Fail to extract the version with regex from string: \"${result.tagName}\""
            }
            val matchGroup = regexMatch.groups[1]
            checkNotNull(matchGroup) {
                "Fail to extract the version value from regex match: \"${regexMatch.value}\""
            }
            matchGroup.value
        }

        return AvailableVersionResult(
            downloadUrl = result.url,
            version = extractVersion(),
            publishDate = result.releaseDate,
            fileSizeBytes = result.fileSizeBytes,
            fileHash = null
        )
    }
}


code:
package de.marmaro.krt.ffupdater.app.impl

import android.os.Build
import de.marmaro.krt.ffupdater.R
import de.marmaro.krt.ffupdater.app.AvailableVersionResult
import de.marmaro.krt.ffupdater.app.BaseAppWithCachedUpdateCheck
import de.marmaro.krt.ffupdater.app.impl.fetch.github.GithubConsumer
import de.marmaro.krt.ffupdater.device.ABI

/**
 * https://github.com/mozilla-mobile/focus-android
 * https://api.github.com/repos/mozilla-mobile/focus-android/releases
 * https://firefox-ci-tc.services.mozilla.com/tasks/index/project.mobile.focus.release/latest
 * https://www.apkmirror.com/apk/mozilla/firefox-klar-the-privacy-browser-2/
 */
class FirefoxKlar : BaseAppWithCachedUpdateCheck() {
    override val packageName = "org.mozilla.klar"
    override val displayTitle = R.string.firefox_klar__title
    override val displayDescription = R.string.firefox_klar__description
    override val displayWarning: Int? = null
    override val displayDownloadSource = R.string.github
    override val displayIcon = R.mipmap.ic_logo_firefox_focus_klar
    override val minApiLevel = Build.VERSION_CODES.LOLLIPOP
    override val supportedAbis = listOf(ABI.ARM64_V8A, ABI.ARMEABI_V7A)

    @Suppress("SpellCheckingInspection")
    override val signatureHash = "6203a473be36d64ee37f87fa500edbc79eab930610ab9b9fa4ca7d5c1f1b4ffc"

    override suspend fun updateCheckWithoutCaching(): AvailableVersionResult {
        val fileName = getStringForCurrentAbi("Klar-arm.apk", "Klar-arm64.apk")
        val githubConsumer = GithubConsumer(
            repoOwner = "mozilla-mobile",
            repoName = "focus-android",
            resultsPerPage = 3,
            validReleaseTester = { release: GithubConsumer.Release ->
                !release.isPreRelease &&
                        !release.name.contains("beta") &&
                        release.assets.any { it.name == fileName }
            },
            correctAssetTester = { asset: GithubConsumer.Asset -> asset.name == fileName })
        val result = githubConsumer.updateCheck()

        val extractVersion = {
            val regexMatch = Regex("""^v((\d)+(\.\d+)*)""")
                .find(result.tagName)
            checkNotNull(regexMatch) {
                "Fail to extract the version with regex from string: \"${result.tagName}\""
            }
            val matchGroup = regexMatch.groups[1]
            checkNotNull(matchGroup) {
                "Fail to extract the version value from regex match: \"${regexMatch.value}\""
            }
            matchGroup.value
        }

        return AvailableVersionResult(
            downloadUrl = result.url,
            version = extractVersion(),
            publishDate = result.releaseDate,
            fileSizeBytes = result.fileSizeBytes,
            fileHash = null
        )
    }
}


code:
package de.marmaro.krt.ffupdater.app.impl

import android.content.Context
import android.content.pm.PackageManager
import android.os.Build
import androidx.preference.PreferenceManager
import de.marmaro.krt.ffupdater.R
import de.marmaro.krt.ffupdater.app.AvailableVersionResult
import de.marmaro.krt.ffupdater.app.BaseAppWithCachedUpdateCheck
import de.marmaro.krt.ffupdater.app.impl.fetch.mozillaci.MozillaCiJsonConsumer
import de.marmaro.krt.ffupdater.device.ABI
import de.marmaro.krt.ffupdater.device.DeviceEnvironment
import de.marmaro.krt.ffupdater.security.FileHashCalculator
import java.io.File
import java.time.format.DateTimeFormatter

/**
 * https://firefox-ci-tc.services.mozilla.com/tasks/index/mobile.v2.fenix.nightly.latest
 * https://www.apkmirror.com/apk/mozilla/firefox-fenix/
 */
class FirefoxNightly : BaseAppWithCachedUpdateCheck() {
    override val packageName = "org.mozilla.fenix"
    override val displayTitle = R.string.firefox_nightly__title
    override val displayDescription = R.string.firefox_nightly__description
    override val displayWarning = R.string.firefox_nightly__warning
    override val displayDownloadSource = R.string.mozilla_ci
    override val displayIcon = R.mipmap.ic_logo_firefox_nightly
    override val minApiLevel = Build.VERSION_CODES.LOLLIPOP
    override val supportedAbis = listOf(ABI.ARM64_V8A, ABI.ARMEABI_V7A, ABI.X86_64, ABI.X86)

    @Suppress("SpellCheckingInspection")
    override val signatureHash = "5004779088e7f988d5bc5cc5f8798febf4f8cd084a1b2a46efd4c8ee4aeaf211"

    override suspend fun updateCheckWithoutCaching(): AvailableVersionResult {
        val abiString = getStringForCurrentAbi(
            "armeabi-v7a", "arm64-v8a", "x86",
            "x86_64"
        )
        val result = MozillaCiJsonConsumer(
            task = "mobile.v2.fenix.nightly.latest.$abiString",
            apkArtifact = "public/build/$abiString/target.apk"
        ).updateCheck()
        val formatter = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm")
        val version = formatter.format(result.releaseDate)
        return AvailableVersionResult(
            downloadUrl = result.url,
            version = version,
            publishDate = result.releaseDate,
            fileSizeBytes = null,
            fileHash = result.fileHash
        )
    }

    override suspend fun isAvailableVersionEqualToArchive(
        context: Context,
        file: File,
        available: AvailableVersionResult,
    ): Boolean {
        val hash = FileHashCalculator.getSHA256ofFile(file)
        return hash == available.fileHash
    }

    override fun isAvailableVersionHigherThanInstalled(
        context: Context,
        available: AvailableVersionResult,
    ): Boolean {
        return try {
            val preferences = PreferenceManager.getDefaultSharedPreferences(context)
            val installedSha256Hash = preferences.getString(INSTALLED_SHA256_HASH, "unknown")
            val installedVersionCode = preferences.getLong(INSTALLED_VERSION_CODE, -1)
            val sameHex = available.fileHash?.hexValue == installedSha256Hash
            val sameVersionCode = getVersionCode(context) == installedVersionCode
            !(sameHex && sameVersionCode)
        } catch (e: PackageManager.NameNotFoundException) {
            false
        }
    }

    override fun appInstallationCallback(context: Context, available: AvailableVersionResult) {
        try {
            PreferenceManager.getDefaultSharedPreferences(context).edit()
                .putLong(INSTALLED_VERSION_CODE, getVersionCode(context))
                .putString(INSTALLED_SHA256_HASH, available.fileHash?.hexValue)
                .apply()
        } catch (e: PackageManager.NameNotFoundException) {
            throw Exception(
                "app should be installed because this method was called - but the app " +
                        "is not installed", e
            )
        }
    }

    /**
     * @throws PackageManager.NameNotFoundException
     */
    private fun getVersionCode(context: Context): Long {
        val packageInfo = context.packageManager.getPackageInfo(packageName, 0)
        if (DeviceEnvironment.supportsAndroid9()) {
            return packageInfo.longVersionCode
        }
        @Suppress("DEPRECATION")
        return packageInfo.versionCode.toLong()
    }

    companion object {
        const val INSTALLED_VERSION_CODE = "firefox_nightly_installed_version_code"
        const val INSTALLED_SHA256_HASH = "firefox_nightly_installed_sha256_hash"
    }
}


code:
package de.marmaro.krt.ffupdater.app.impl

import android.os.Build
import de.marmaro.krt.ffupdater.R
import de.marmaro.krt.ffupdater.app.AvailableVersionResult
import de.marmaro.krt.ffupdater.app.BaseAppWithCachedUpdateCheck
import de.marmaro.krt.ffupdater.app.impl.fetch.mozillaci.MozillaCiLogConsumer
import de.marmaro.krt.ffupdater.device.ABI

/**
 * https://firefox-ci-tc.services.mozilla.com/tasks/index/mobile.v2.fenix.release.latest
 * https://www.apkmirror.com/apk/mozilla/firefox/
 */
class FirefoxRelease : BaseAppWithCachedUpdateCheck() {
    override val packageName = "org.mozilla.firefox"
    override val displayTitle = R.string.firefox_release__title
    override val displayDescription = R.string.firefox_release__description
    override val displayWarning: Int? = null
    override val displayDownloadSource = R.string.mozilla_ci
    override val displayIcon = R.mipmap.ic_logo_firefox_release
    override val minApiLevel = Build.VERSION_CODES.LOLLIPOP
    override val supportedAbis = listOf(ABI.ARM64_V8A, ABI.ARMEABI_V7A, ABI.X86_64, ABI.X86)

    @Suppress("SpellCheckingInspection")
    override val signatureHash = "a78b62a5165b4494b2fead9e76a280d22d937fee6251aece599446b2ea319b04"

    override suspend fun updateCheckWithoutCaching(): AvailableVersionResult {
        val abiString = getStringForCurrentAbi(
            "armeabi-v7a", "arm64-v8a", "x86",
            "x86_64"
        )
        val mozillaCiConsumer = MozillaCiLogConsumer(
            task = "mobile.v2.fenix.release.latest.$abiString",
            apkArtifact = "public/build/$abiString/target.apk",
        )
        val result = mozillaCiConsumer.updateCheck()
        return AvailableVersionResult(
            downloadUrl = result.url,
            version = result.version,
            publishDate = result.releaseDate,
            fileSizeBytes = null,
            fileHash = null
        )
    }

    companion object
}


code:
package de.marmaro.krt.ffupdater.app.impl

import android.content.Context
import android.os.Build
import de.marmaro.krt.ffupdater.R
import de.marmaro.krt.ffupdater.app.AvailableVersionResult
import de.marmaro.krt.ffupdater.app.BaseAppWithCachedUpdateCheck
import de.marmaro.krt.ffupdater.app.impl.fetch.github.GithubConsumer
import de.marmaro.krt.ffupdater.app.impl.fetch.github.GithubConsumer.Asset
import de.marmaro.krt.ffupdater.app.impl.fetch.github.GithubConsumer.Release
import de.marmaro.krt.ffupdater.device.ABI

/**
 * https://github.com/fork-maintainers/iceraven-browser
 * https://api.github.com/repos/fork-maintainers/iceraven-browser/releases
 */
class Iceraven : BaseAppWithCachedUpdateCheck() {
    override val packageName = "io.github.forkmaintainers.iceraven"
    override val displayTitle = R.string.iceraven__title
    override val displayDescription = R.string.iceraven__description
    override val displayWarning: Int? = null
    override val displayDownloadSource = R.string.github
    override val displayIcon = R.mipmap.ic_logo_iceraven
    override val minApiLevel = Build.VERSION_CODES.LOLLIPOP
    override val supportedAbis = listOf(ABI.ARM64_V8A, ABI.ARMEABI_V7A, ABI.X86_64, ABI.X86)

    @Suppress("SpellCheckingInspection")
    override val signatureHash = "9c0d22379f487b70a4f9f8bec0173cf91a1644f08f93385b5b782ce37660ba81"

    override fun getInstalledVersion(context: Context): String? {
        val installedVersion = super.getInstalledVersion(context)
        return installedVersion?.replace("iceraven-", "")
    }

    override suspend fun updateCheckWithoutCaching(): AvailableVersionResult {
        val fileSuffix = getStringForCurrentAbi(
            "browser-armeabi-v7a-forkRelease.apk",
            "browser-arm64-v8a-forkRelease.apk",
            "browser-x86-forkRelease.apk",
            "browser-x86_64-forkRelease.apk"
        )
        val githubConsumer = GithubConsumer(
            repoOwner = "fork-maintainers",
            repoName = "iceraven-browser",
            resultsPerPage = 3,
            validReleaseTester = { release: Release ->
                !release.isPreRelease && release.assets.any { it.name.endsWith(".apk") }
            },
            correctAssetTester = { asset: Asset -> asset.name.endsWith(fileSuffix) })
        val result = githubConsumer.updateCheck()
        return AvailableVersionResult(
            downloadUrl = result.url,
            version = result.tagName.replace("iceraven-", ""),
            publishDate = result.releaseDate,
            fileSizeBytes = result.fileSizeBytes,
            fileHash = null
        )
    }
}


code:
package de.marmaro.krt.ffupdater.app.impl

import android.os.Build
import de.marmaro.krt.ffupdater.R
import de.marmaro.krt.ffupdater.app.AvailableVersionResult
import de.marmaro.krt.ffupdater.app.BaseAppWithCachedUpdateCheck
import de.marmaro.krt.ffupdater.app.impl.fetch.github.GithubConsumer
import de.marmaro.krt.ffupdater.app.impl.fetch.github.GithubConsumer.Asset
import de.marmaro.krt.ffupdater.app.impl.fetch.github.GithubConsumer.Release
import de.marmaro.krt.ffupdater.device.ABI

/**
 * https://api.github.com/repos/mozilla-lockwise/lockwise-android/releases
 * https://www.apkmirror.com/apk/mozilla/firefox-lockwise/
 */
class Lockwise : BaseAppWithCachedUpdateCheck() {
    override val packageName = "mozilla.lockbox"
    override val displayTitle = R.string.lockwise__title
    override val displayDescription = R.string.lockwise__description
    override val displayWarning: Int? = null
    override val displayDownloadSource = R.string.github
    override val displayIcon = R.mipmap.ic_logo_lockwise
    override val minApiLevel = Build.VERSION_CODES.N
    override val supportedAbis = listOf(
        ABI.ARM64_V8A, ABI.ARMEABI_V7A, ABI.ARMEABI, ABI.X86_64,
        ABI.X86, ABI.MIPS, ABI.MIPS64
    )

    @Suppress("SpellCheckingInspection")
    override val signatureHash = "64d26b507078deba2fee42d6bd0bfad41d39ffc4e791f281028e5e73d3c8d2f2"

    override suspend fun updateCheckWithoutCaching(): AvailableVersionResult {
        val githubConsumer = GithubConsumer(
            repoOwner = "mozilla-lockwise",
            repoName = "lockwise-android",
            resultsPerPage = 5,
            validReleaseTester = { release: Release ->
                !release.isPreRelease && release.assets.any { it.name.endsWith(".apk") }
            },
            correctAssetTester = { asset: Asset -> asset.name.endsWith(".apk") })
        val result = githubConsumer.updateCheck()

        val extractVersion = {
            // tag_name can be: "release-v4.0.3", "release-v4.0.0-RC-2"
            val regexMatch = Regex("""^release-v((\d)+(\.\d+)*)""")
                .find(result.tagName)
            checkNotNull(regexMatch) {
                "Fail to extract the version with regex from string: \"${result.tagName}\""
            }
            val matchGroup = regexMatch.groups[1]
            checkNotNull(matchGroup) {
                "Fail to extract the version value from regex match: \"${regexMatch.value}\""
            }
            matchGroup.value
        }

        return AvailableVersionResult(
            downloadUrl = result.url,
            version = extractVersion(),
            publishDate = result.releaseDate,
            fileSizeBytes = result.fileSizeBytes,
            fileHash = null
        )
    }
}


code:
package de.marmaro.krt.ffupdater.app.impl

import android.os.Build
import de.marmaro.krt.ffupdater.R
import de.marmaro.krt.ffupdater.app.AvailableVersionResult
import de.marmaro.krt.ffupdater.app.BaseAppWithCachedUpdateCheck
import de.marmaro.krt.ffupdater.app.impl.fetch.github.GithubConsumer
import de.marmaro.krt.ffupdater.device.ABI

/**
 * https://github.com/ungoogled-software/ungoogled-chromium-android/releases
 */

class UngoogledChromium : BaseAppWithCachedUpdateCheck() {
    override val packageName = "org.ungoogled.chromium.stable"
    override val displayTitle = R.string.ungoogled_chromium__title
    override val displayDescription = R.string.ungoogled_chromium__description
    override val displayWarning = R.string.ungoogled_chromium__warning
    override val displayDownloadSource = R.string.github
    override val displayIcon = R.mipmap.ic_logo_ungoogled_chromium
    override val minApiLevel = Build.VERSION_CODES.LOLLIPOP
    override val supportedAbis = listOf(ABI.ARMEABI_V7A, ABI.ARM64_V8A, ABI.X86)

    @Suppress("SpellCheckingInspection")
    override val signatureHash = "7e6ba7bbb939fa52d5569a8ea628056adf8c75292bf4dee6b353fafaf2c30e19"

    override suspend fun updateCheckWithoutCaching(): AvailableVersionResult {
        val fileName = getStringForCurrentAbi(
            "ChromeModernPublic_arm.apk",
            "ChromeModernPublic_arm64.apk",
            "ChromeModernPublic_x86.apk",
            null
        )
        val githubConsumer = GithubConsumer(
            repoOwner = "ungoogled-software",
            repoName = "ungoogled-chromium-android",
            resultsPerPage = 2,
            validReleaseTester = { release: GithubConsumer.Release ->
                !release.isPreRelease
                        && !release.name.contains("webview")
                        && release.assets.any { it.name == fileName }
            },
            correctAssetTester = { asset: GithubConsumer.Asset -> asset.name == fileName })
        val result = githubConsumer.updateCheckAllReleases()

        val extractVersion = {
            val regexMatch = Regex("""^([.0-9]+)-\d+$""")
                .find(result.tagName)
            checkNotNull(regexMatch) {
                "Fail to extract the version with regex from string '${result.tagName}'."
            }
            val matchGroup = regexMatch.groups[1]
            checkNotNull(matchGroup) {
                "Fail to extract the version value from regex match: '${regexMatch.value}'."
            }
            matchGroup.value
        }
        return AvailableVersionResult(
            downloadUrl = result.url,
            version = extractVersion(),
            publishDate = result.releaseDate,
            fileSizeBytes = result.fileSizeBytes,
            fileHash = null
        )
    }
}


code:
package de.marmaro.krt.ffupdater.app.impl

import android.os.Build
import de.marmaro.krt.ffupdater.R
import de.marmaro.krt.ffupdater.app.AvailableVersionResult
import de.marmaro.krt.ffupdater.app.BaseAppWithCachedUpdateCheck
import de.marmaro.krt.ffupdater.app.impl.fetch.ApiConsumer
import de.marmaro.krt.ffupdater.device.ABI

/**
 * https://vivaldi.com/de/download/
 * https://www.apkmirror.com/apk/vivaldi-technologies/vivaldi-browser-beta/
 */
class Vivaldi : BaseAppWithCachedUpdateCheck() {
    override val packageName = "com.vivaldi.browser"
    override val displayTitle = R.string.vivaldi__title
    override val displayDescription = R.string.vivaldi__description
    override val displayWarning = R.string.vivaldi__warning
    override val displayDownloadSource = R.string.vivaldi__source
    override val displayIcon = R.mipmap.ic_logo_vivaldi
    override val minApiLevel = Build.VERSION_CODES.LOLLIPOP
    override val supportedAbis = listOf(ABI.ARM64_V8A, ABI.ARMEABI_V7A, ABI.ARMEABI, ABI.X86_64)

    @Suppress("SpellCheckingInspection")
    override val signatureHash = "e8a78544655ba8c09817f732768f5689b1662ec4b2bc5a0bc0ec138d33ca3d1e"

    override suspend fun updateCheckWithoutCaching(): AvailableVersionResult {
        val regexPattern = getStringForCurrentAbi(
            """<a href="(https://downloads.vivaldi.com/stable/Vivaldi.([.0-9]{1,24})_armeabi-v7a.apk)"""",
            """<a href="(https://downloads.vivaldi.com/stable/Vivaldi.([.0-9]{1,24})_arm64-v8a.apk)"""",
            null,
            """<a href="(https://downloads.vivaldi.com/stable/Vivaldi.([.0-9]{1,24})_x86-64.apk)"""",
        )
        val content = ApiConsumer.consumeNetworkResource(DOWNLOAD_WEBSITE_URL, String::class)
        val regexMatch = Regex(regexPattern).find(content)
        checkNotNull(regexMatch) { "Can't find download link with regex pattern '$regexPattern'." }
        val downloadUrl = regexMatch.groups[1]
        checkNotNull(downloadUrl) { "Can't extract download url from regex match." }
        val availableVersion = regexMatch.groups[2]
        checkNotNull(availableVersion) { "Can't extract available version from regex match." }

        return AvailableVersionResult(
            downloadUrl = downloadUrl.value,
            version = availableVersion.value,
            publishDate = null,
            fileSizeBytes = null,
            fileHash = null
        )
    }

    companion object {
        const val DOWNLOAD_WEBSITE_URL = "https://vivaldi.com/download/"
    }
}


code:
package de.marmaro.krt.ffupdater.app.impl.exceptions

class GithubRateLimitExceededException(throwable: Throwable) :
    NetworkException("GitHub-API rate limit is exceeded.", throwable)


code:
package de.marmaro.krt.ffupdater.app.impl.exceptions

class InvalidApiResponseException(message: String) : Exception(message)


code:
package de.marmaro.krt.ffupdater.app.impl.exceptions

open class NetworkException(message: String, throwable: Throwable) : Exception(message, throwable)


code:
package de.marmaro.krt.ffupdater.app.impl.fetch

import android.net.TrafficStats
import androidx.annotation.MainThread
import androidx.annotation.WorkerThread
import com.google.gson.Gson
import de.marmaro.krt.ffupdater.app.impl.exceptions.GithubRateLimitExceededException
import de.marmaro.krt.ffupdater.app.impl.exceptions.NetworkException
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import java.io.BufferedReader
import java.io.FileNotFoundException
import java.io.IOException
import java.io.InputStreamReader
import java.net.URL
import java.util.zip.GZIPInputStream
import javax.net.ssl.HttpsURLConnection
import kotlin.reflect.KClass

/**
 * Consume a REST-API from the internet.
 */
object ApiConsumer {
    private const val GZIP = "gzip"
    private const val THREAD_ID = 10000
    private val gson = Gson()

    /**
     * Reads a network resource.
     * If clazz is String, then the network response is returned as a string.
     * If class is not a String, then the network response is parsed as JSON and returned as object.
     * @throws NetworkException if the network resource is not available after 5 retires.
     * @throws GithubRateLimitExceededException if the GitHub-API rate limit is exceeded.
     */
    @MainThread
    suspend fun <T : Any> consumeNetworkResource(url: String, clazz: KClass<T>): T {
        return withContext(Dispatchers.IO) {
            readNetworkResource(url, clazz)
        }
    }

    @WorkerThread
    private fun <T : Any> readNetworkResource(urlString: String, clazz: KClass<T>): T {
        TrafficStats.setThreadStatsTag(THREAD_ID)
        val reader = try {
            val url = URL(urlString)
            val connection = url.openConnection() as HttpsURLConnection
            connection.setRequestProperty("Accept-Encoding", GZIP)
            if (connection.contentEncoding == GZIP) {
                BufferedReader(InputStreamReader(GZIPInputStream(connection.inputStream)))
            } else {
                BufferedReader(InputStreamReader(connection.inputStream))
            }
        } catch (e: FileNotFoundException) {
            if (urlString.startsWith("https://api.github.com")) {
                throw GithubRateLimitExceededException(e)
            } else {
                throw NetworkException("Fail to consume ${clazz.qualifiedName} from '$urlString'.", e)
            }
        } catch (e: IOException) {
            throw NetworkException("Fail to consume ${clazz.qualifiedName} from '$urlString'.", e)
        }

        if (clazz == String::class) {
            @Suppress("UNCHECKED_CAST")
            return reader.use { it.readText() as T }
        }
        return reader.use { gson.fromJson(it, clazz.java) }
    }
}


code:
package de.marmaro.krt.ffupdater.app.impl.fetch.github

import androidx.annotation.MainThread
import com.google.gson.annotations.SerializedName
import de.marmaro.krt.ffupdater.app.impl.exceptions.InvalidApiResponseException
import de.marmaro.krt.ffupdater.app.impl.fetch.ApiConsumer
import java.time.ZonedDateTime
import java.time.format.DateTimeFormatter.ISO_ZONED_DATE_TIME
import java.util.*
import java.util.function.Predicate

class GithubConsumer(
    private val repoOwner: String,
    private val repoName: String,
    private val resultsPerPage: Int,
    private val validReleaseTester: Predicate<Release>,
    private val correctAssetTester: Predicate<Asset>,
) {
    init {
        check(resultsPerPage > 0)
    }

    @MainThread
    suspend fun updateCheck(): Result {
        val url = "https://api.github.com/repos/$repoOwner/$repoName/releases/latest"
        val release = ApiConsumer.consumeNetworkResource(url, Release::class)
        if (validReleaseTester.test(release)) {
            return convert(release)
        }
        return updateCheckAllReleases()
    }

    @MainThread
    suspend fun updateCheckAllReleases(): Result {
        val tries = 4
        for (page in 1..(tries + 1)) {
            val url = "https://api.github.com/repos/$repoOwner/$repoName/releases" +
                    "?per_page=$resultsPerPage&page=$page"
            val releases = ApiConsumer.consumeNetworkResource(url, Array<Release>::class)
            releases.forEach {
                if (validReleaseTester.test(it)) {
                    return convert(it)
                }
            }
        }
        throw InvalidApiResponseException("can't find release after $tries tries - abort")
    }

    /**
     * @throws InvalidApiResponseException
     */
    private fun convert(release: Release): Result {
        val asset = release.assets.firstOrNull { correctAssetTester.test(it) }
                ?: throw InvalidApiResponseException("${release.name} has no suitable asset")
        return Result(
                tagName = release.tagName,
                url = asset.downloadUrl,
                fileSizeBytes = asset.fileSizeBytes,
                releaseDate = ZonedDateTime.parse(release.publishedAt, ISO_ZONED_DATE_TIME))
    }

    data class Release(
            @SerializedName("tag_name")
            val tagName: String,
            @SerializedName("name")
            val name: String,
            @SerializedName("prerelease")
            val isPreRelease: Boolean,
            @SerializedName("assets")
            val assets: List<Asset>,
            @SerializedName("published_at")
            val publishedAt: String,
    )

    data class Asset(
            @SerializedName("name")
            val name: String,
            @SerializedName("browser_download_url")
            val downloadUrl: String,
            @SerializedName("size")
            val fileSizeBytes: Long,
    )

    data class Result(
            val tagName: String,
            val url: String,
            val fileSizeBytes: Long,
            val releaseDate: ZonedDateTime,
    )
}


code:
package de.marmaro.krt.ffupdater.app.impl.fetch.mozillaci

import androidx.annotation.MainThread
import de.marmaro.krt.ffupdater.app.impl.exceptions.NetworkException
import de.marmaro.krt.ffupdater.app.impl.fetch.ApiConsumer
import de.marmaro.krt.ffupdater.security.Sha256Hash
import java.time.ZonedDateTime
import java.time.format.DateTimeFormatter.ISO_ZONED_DATE_TIME

/**
 * Consume the "chain_of_trust.json".
 */
class MozillaCiJsonConsumer(task: String, private val apkArtifact: String) {
    private val baseUrl = "https://firefox-ci-tc.services.mozilla.com/api/index/v1/task/$task"
    private val jsonUrl = "$baseUrl/artifacts/public/chain-of-trust.json"

    /**
     * This method must not be called from the main thread or a android.os.NetworkOnMainThreadException
     * will be thrown
     * @throws NetworkException
     */
    @MainThread
    suspend fun updateCheck(): Result {
        val response = ApiConsumer.consumeNetworkResource(jsonUrl, ChainOfTrustJson::class)
        val artifact = response.artifacts[apkArtifact]
        checkNotNull(artifact) {
            "Missing artifact '$apkArtifact'. Only [${response.artifacts.keys.joinToString()}] " +
                    "are available."
        }
        val releaseDate = ZonedDateTime.parse(response.task.created, ISO_ZONED_DATE_TIME)
        return Result(
            fileHash = Sha256Hash(artifact.sha256),
            url = "$baseUrl/artifacts/$apkArtifact",
            releaseDate = releaseDate
        )
    }

    data class ChainOfTrustJson(
        val artifacts: Map<String, JsonValue>,
        val task: TaskValue
    )

    data class JsonValue(
        val sha256: String
    )

    data class TaskValue(
        val created: String
    )

    data class Result(
        val fileHash: Sha256Hash,
        val url: String,
        val releaseDate: ZonedDateTime,
    )
}


code:
package de.marmaro.krt.ffupdater.app.impl.fetch.mozillaci

import androidx.annotation.MainThread
import de.marmaro.krt.ffupdater.app.impl.exceptions.NetworkException
import de.marmaro.krt.ffupdater.app.impl.fetch.ApiConsumer
import java.time.ZonedDateTime
import java.time.format.DateTimeFormatter.ISO_ZONED_DATE_TIME

/**
 * Consume the "chain_of_trust.log".
 */
class MozillaCiLogConsumer(task: String, private val apkArtifact: String) {
    private val baseUrl = "https://firefox-ci-tc.services.mozilla.com/api/index/v1/task/$task"
    private val logUrl = "$baseUrl/artifacts/public/logs/chain_of_trust.log"

    /**
     * This method must not be called from the main thread or a android.os.NetworkOnMainThreadException
     * will be thrown
     * @throws NetworkException
     */
    @MainThread
    suspend fun updateCheck(): Result {
        val response = ApiConsumer.consumeNetworkResource(logUrl, String::class)
        val extractVersion = {
            val regexMatch = Regex("""'(version|tag_name)': 'v?(.+)'""")
                .find(response)
            checkNotNull(regexMatch) {
                "Fail to extract the version with regex from string: \"\"\"$response\"\"\""
            }
            val matchGroup = regexMatch.groups[2]
            checkNotNull(matchGroup) {
                "Fail to extract the version value from regex match: \"${regexMatch.value}\""
            }
            matchGroup.value
        }

        val extractDateString = {
            val regexMatch = Regex("""'(published_at|now)': '(.+)'""")
                .find(response)
            checkNotNull(regexMatch) {
                "Fail to extract the date with regex from string: \"\"\"$response\"\"\""
            }
            val matchGroup = regexMatch.groups[2]
            checkNotNull(matchGroup) {
                "Fail to extract the date value from regex match: \"${regexMatch.value}\""
            }
            matchGroup.value
        }

        return Result(
            version = extractVersion(),
            url = "$baseUrl/artifacts/$apkArtifact",
            releaseDate = ZonedDateTime.parse(extractDateString(), ISO_ZONED_DATE_TIME)
        )
    }

    data class Result(
        val version: String,
        val url: String,
        val releaseDate: ZonedDateTime,
    )
}