From 812f8d0ebeaa6d1333f5f32f117f4d3e889c632a Mon Sep 17 00:00:00 2001 From: OpenSauce Date: Fri, 10 Jul 2026 20:31:05 +0100 Subject: [PATCH] android: Implement update checker functionality mirroring the Qt frontend (#2265) * update_checker.cpp: Tiny correctness adjustment * android: Initial UpdateChecker object implementation No actual frontend functionality yet * android: Very initial implementation of frontend update checker notifications * android: Fix UpdateChecker returnning incorrectly formatted tag when using stable channel * android: Add downgrade mitigation to update checker, mirroring Qt * Display new version number in Android update check notification * android: Fix update checker notification not having a cancel button * android: Add user-configurable options to control update checker behaviour * android: Auto-disable update checker for debug builds * Don't show update checker settings when using Google Play build Not like they would do anything anyway, but they shouldn't be visible * android: Improve hardiness of tag decoding in update checker --- CMakeModules/GenerateSettingKeys.cmake | 4 +- src/android/app/build.gradle.kts | 2 + .../features/settings/SettingKeys.kt | 2 + .../features/settings/model/BooleanSetting.kt | 3 +- .../features/settings/model/IntSetting.kt | 3 +- .../features/settings/model/Settings.kt | 3 +- .../settings/ui/SettingsFragmentPresenter.kt | 36 +++++- .../features/updatechecker/UpdateChecker.kt | 114 ++++++++++++++++++ .../citra_emu/fragments/GamesFragment.kt | 42 +++++++ .../UpdateAvailableNotificationFragment.kt | 66 ++++++++++ .../citra_emu/viewmodel/HomeViewModel.kt | 2 + src/android/app/src/main/jni/default_ini.h | 8 ++ .../app/src/main/res/values/arrays.xml | 9 ++ .../app/src/main/res/values/strings.xml | 12 ++ src/citra_qt/update_checker.cpp | 4 +- 15 files changed, 298 insertions(+), 12 deletions(-) create mode 100644 src/android/app/src/main/java/org/citra/citra_emu/features/updatechecker/UpdateChecker.kt create mode 100644 src/android/app/src/main/java/org/citra/citra_emu/fragments/UpdateAvailableNotificationFragment.kt diff --git a/CMakeModules/GenerateSettingKeys.cmake b/CMakeModules/GenerateSettingKeys.cmake index 541e03cff..8cb3a07dd 100644 --- a/CMakeModules/GenerateSettingKeys.cmake +++ b/CMakeModules/GenerateSettingKeys.cmake @@ -149,6 +149,8 @@ foreach(KEY IN ITEMS "web_api_url" "citra_username" "citra_token" + "check_for_update_on_start" + "update_check_channel" ) set(SETTING_KEY_LIST "${SETTING_KEY_LIST}\n\"${KEY}\",") set(SETTING_KEY_DEFINITIONS "${SETTING_KEY_DEFINITIONS}\nDEFINE_KEY(${KEY})") @@ -234,8 +236,6 @@ if (ENABLE_QT) "pauseWhenInBackground" "muteWhenInBackground" "hideInactiveMouse" - "check_for_update_on_start" - "update_check_channel" "inserted_cartridge" "enable_discord_presence" "iconSize" diff --git a/src/android/app/build.gradle.kts b/src/android/app/build.gradle.kts index c386932b3..21cd4e371 100644 --- a/src/android/app/build.gradle.kts +++ b/src/android/app/build.gradle.kts @@ -87,6 +87,8 @@ android { } } + buildConfigField("String", "GIT_VERSION", "\"${getGitVersion()}\"") + // ^ Has no suffix, unlike VERSION_NAME buildConfigField("String", "GIT_HASH", "\"${getGitHash()}\"") buildConfigField("String", "BRANCH", "\"${getBranch()}\"") } diff --git a/src/android/app/src/main/java/org/citra/citra_emu/features/settings/SettingKeys.kt b/src/android/app/src/main/java/org/citra/citra_emu/features/settings/SettingKeys.kt index 955ece959..e25455973 100644 --- a/src/android/app/src/main/java/org/citra/citra_emu/features/settings/SettingKeys.kt +++ b/src/android/app/src/main/java/org/citra/citra_emu/features/settings/SettingKeys.kt @@ -127,6 +127,8 @@ object SettingKeys { external fun udp_input_port(): String external fun udp_pad_index(): String external fun record_frame_times(): String + external fun check_for_update_on_start(): String + external fun update_check_channel(): String // Android external fun expand_to_cutout_area(): String diff --git a/src/android/app/src/main/java/org/citra/citra_emu/features/settings/model/BooleanSetting.kt b/src/android/app/src/main/java/org/citra/citra_emu/features/settings/model/BooleanSetting.kt index 3a2ffd764..0f5c6047a 100644 --- a/src/android/app/src/main/java/org/citra/citra_emu/features/settings/model/BooleanSetting.kt +++ b/src/android/app/src/main/java/org/citra/citra_emu/features/settings/model/BooleanSetting.kt @@ -129,7 +129,8 @@ enum class BooleanSetting( SettingKeys.simulate_3ds_gpu_timings(), Settings.SECTION_RENDERER, false - ); + ), + CHECK_FOR_UPDATES(SettingKeys.check_for_update_on_start(), Settings.SECTION_MISC, true); override var boolean: Boolean = defaultValue diff --git a/src/android/app/src/main/java/org/citra/citra_emu/features/settings/model/IntSetting.kt b/src/android/app/src/main/java/org/citra/citra_emu/features/settings/model/IntSetting.kt index a5ec7ea5a..eca9a28ac 100644 --- a/src/android/app/src/main/java/org/citra/citra_emu/features/settings/model/IntSetting.kt +++ b/src/android/app/src/main/java/org/citra/citra_emu/features/settings/model/IntSetting.kt @@ -64,7 +64,8 @@ enum class IntSetting( 0 ), RENDER_3D_WHICH_DISPLAY(SettingKeys.render_3d_which_display(), Settings.SECTION_RENDERER, 0), - ASPECT_RATIO(SettingKeys.aspect_ratio(), Settings.SECTION_LAYOUT, 0); + ASPECT_RATIO(SettingKeys.aspect_ratio(), Settings.SECTION_LAYOUT, 0), + UPDATE_CHECK_CHANNEL(SettingKeys.update_check_channel(), Settings.SECTION_MISC, 0); override var int: Int = defaultValue diff --git a/src/android/app/src/main/java/org/citra/citra_emu/features/settings/model/Settings.kt b/src/android/app/src/main/java/org/citra/citra_emu/features/settings/model/Settings.kt index ccd4e8744..2f5a928c5 100644 --- a/src/android/app/src/main/java/org/citra/citra_emu/features/settings/model/Settings.kt +++ b/src/android/app/src/main/java/org/citra/citra_emu/features/settings/model/Settings.kt @@ -247,7 +247,8 @@ class Settings { SECTION_STORAGE, SECTION_UTILITY, SECTION_AUDIO, - SECTION_DEBUG + SECTION_DEBUG, + SECTION_MISC ) } } diff --git a/src/android/app/src/main/java/org/citra/citra_emu/features/settings/ui/SettingsFragmentPresenter.kt b/src/android/app/src/main/java/org/citra/citra_emu/features/settings/ui/SettingsFragmentPresenter.kt index 6472f9c5c..b0e2578cb 100644 --- a/src/android/app/src/main/java/org/citra/citra_emu/features/settings/ui/SettingsFragmentPresenter.kt +++ b/src/android/app/src/main/java/org/citra/citra_emu/features/settings/ui/SettingsFragmentPresenter.kt @@ -14,6 +14,7 @@ import android.os.Build import android.text.TextUtils import androidx.preference.PreferenceManager import com.google.android.material.dialog.MaterialAlertDialogBuilder +import org.citra.citra_emu.BuildConfig import org.citra.citra_emu.CitraApplication import org.citra.citra_emu.R import org.citra.citra_emu.display.ScreenLayout @@ -46,6 +47,7 @@ import org.citra.citra_emu.features.settings.model.view.SwitchSetting import org.citra.citra_emu.features.settings.utils.SettingsFile import org.citra.citra_emu.fragments.ResetSettingsDialogFragment import org.citra.citra_emu.utils.BirthdayMonth +import org.citra.citra_emu.utils.BuildUtil import org.citra.citra_emu.utils.GraphicsUtil import org.citra.citra_emu.utils.Log import org.citra.citra_emu.utils.SystemSaveGame @@ -268,13 +270,37 @@ class SettingsFragmentPresenter(private val fragmentView: SettingsFragmentView) ) add( SwitchSetting( - BooleanSetting.ANDROID_HIDE_IMAGES, - R.string.android_hide_images, - R.string.android_hide_images_description, - BooleanSetting.ANDROID_HIDE_IMAGES.key, - BooleanSetting.ANDROID_HIDE_IMAGES.defaultValue + BooleanSetting.CHECK_FOR_UPDATES, + R.string.check_for_updates, + R.string.check_for_updates_description, + BooleanSetting.CHECK_FOR_UPDATES.key, + BooleanSetting.CHECK_FOR_UPDATES.defaultValue, + isEnabled = !BuildConfig.DEBUG ) ) + if (!BuildUtil.isGooglePlayBuild) { + add( + SingleChoiceSetting( + IntSetting.UPDATE_CHECK_CHANNEL, + R.string.update_check_channel, + R.string.update_check_channel_description, + R.array.updateCheckChannels, + R.array.updateCheckChannelsValues, + IntSetting.UPDATE_CHECK_CHANNEL.key, + IntSetting.UPDATE_CHECK_CHANNEL.defaultValue, + isEnabled = (!BuildConfig.DEBUG && BooleanSetting.CHECK_FOR_UPDATES.boolean) + ) + ) + add( + SwitchSetting( + BooleanSetting.ANDROID_HIDE_IMAGES, + R.string.android_hide_images, + R.string.android_hide_images_description, + BooleanSetting.ANDROID_HIDE_IMAGES.key, + BooleanSetting.ANDROID_HIDE_IMAGES.defaultValue + ) + ) + } } } diff --git a/src/android/app/src/main/java/org/citra/citra_emu/features/updatechecker/UpdateChecker.kt b/src/android/app/src/main/java/org/citra/citra_emu/features/updatechecker/UpdateChecker.kt new file mode 100644 index 000000000..6271bfd96 --- /dev/null +++ b/src/android/app/src/main/java/org/citra/citra_emu/features/updatechecker/UpdateChecker.kt @@ -0,0 +1,114 @@ +// Copyright Citra Emulator Project / Azahar Emulator Project +// Licensed under GPLv2 or any later version +// Refer to the license.txt file included. + +package org.citra.citra_emu.features.updatechecker + +import java.net.HttpURLConnection +import java.net.URL +import kotlin.time.Duration.Companion.seconds +import kotlinx.serialization.json.Json +import kotlinx.serialization.json.JsonArray +import kotlinx.serialization.json.JsonElement +import kotlinx.serialization.json.jsonObject +import org.citra.citra_emu.utils.Log + +object UpdateChecker { + + private fun getResponse(urlString: String): String? { + var connection: HttpURLConnection? = null + try { + val url = URL(urlString) + connection = url.openConnection() as HttpURLConnection + connection.requestMethod = "GET" + connection.connectTimeout = 15.seconds.inWholeMilliseconds.toInt() + connection.readTimeout = 15.seconds.inWholeMilliseconds.toInt() + + if (connection.responseCode == HttpURLConnection.HTTP_OK) { + return connection.inputStream.bufferedReader().use { it.readText() } + } else { + Log.error( + "[UpdateChecker] Failed to get HTTP response with HTTP response code ${connection.responseCode}" + ) + return null + } + } catch (e: Exception) { + Log.error( + "[UpdateChecker] Failed to get HTTP response with Kotlin exception:\n" + + "Type: $e\n" + + "Message: ${e.message}" + ) + return null + } finally { + connection?.disconnect() + } + } + + private fun String.stripQuotes(): String? { + if (this.first() != '"' || this.last() != '"') { + return null + } + return this.drop(1).dropLast(1) + } + + fun getLatestRelease(includePrereleases: Boolean): String? { + val updateCheckUrl = "https://api.github.com" + var updateCheckPath = "/repos/azahar-emu/azahar" + if (includePrereleases) { // This can return either a prerelease or a stable release, + // whichever is more recent. + val updateCheckTagsPath = "$updateCheckPath/tags" + val updateCheckReleasesPath = "$updateCheckPath/releases" + + val tagsResponse = getResponse(updateCheckUrl + updateCheckTagsPath) + val releasesResponse = getResponse(updateCheckUrl + updateCheckReleasesPath) + + if (tagsResponse.isNullOrEmpty() || releasesResponse.isNullOrEmpty()) { + return null + } + + var latestTag: String? + try { + latestTag = Json.decodeFromString( + tagsResponse + )[0].jsonObject["name"].toString().stripQuotes() + } catch (e: Exception) { + Log.error("[UpdateChecker] JSON decode failed: $e") + return null + } + if (latestTag.isNullOrEmpty()) { + Log.error("[UpdateChecker] Failed to strip quotes from tag or tag was blank") + return null + } + val latestTagHasRelease = releasesResponse.contains("\"$latestTag\"") + // If there is a newer tag, but that tag has no associated release, don't prompt the + // user to update. + if (!latestTagHasRelease) { + return null + } + + return latestTag + } else { // Stable releases only + updateCheckPath += "/releases/latest" + val response = getResponse(updateCheckUrl + updateCheckPath) + + if (response.isNullOrEmpty()) { + return null + } + + var latestTag: String? + try { + latestTag = Json.decodeFromString( + response + ).jsonObject["tag_name"].toString().stripQuotes() + } catch (e: Exception) { + Log.error("[UpdateChecker] JSON decode failed: $e") + return null + } + if (latestTag.isNullOrEmpty()) { + Log.error("[UpdateChecker] Failed to strip quotes from tag or tag was blank") + return null + } + return latestTag + } + } +} diff --git a/src/android/app/src/main/java/org/citra/citra_emu/fragments/GamesFragment.kt b/src/android/app/src/main/java/org/citra/citra_emu/fragments/GamesFragment.kt index df748a9ea..98c70f879 100644 --- a/src/android/app/src/main/java/org/citra/citra_emu/fragments/GamesFragment.kt +++ b/src/android/app/src/main/java/org/citra/citra_emu/fragments/GamesFragment.kt @@ -29,12 +29,16 @@ import com.google.android.material.transition.MaterialFadeThrough import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.flow.collectLatest import kotlinx.coroutines.launch +import org.citra.citra_emu.BuildConfig import org.citra.citra_emu.CitraApplication import org.citra.citra_emu.NativeLibrary import org.citra.citra_emu.R import org.citra.citra_emu.adapters.GameAdapter import org.citra.citra_emu.databinding.FragmentGamesBinding +import org.citra.citra_emu.features.settings.model.BooleanSetting +import org.citra.citra_emu.features.settings.model.IntSetting import org.citra.citra_emu.features.settings.model.Settings +import org.citra.citra_emu.features.updatechecker.UpdateChecker import org.citra.citra_emu.model.Game import org.citra.citra_emu.utils.BuildUtil import org.citra.citra_emu.viewmodel.CompressProgressDialogViewModel @@ -233,6 +237,44 @@ class GamesFragment : Fragment() { setInsets() } + private fun getMajorVersion(version: String): Int? = version.split('.')[0].toIntOrNull() + + override fun onResume() { + super.onResume() + + // Perform update check + if (!BuildConfig.DEBUG && + !BuildUtil.isGooglePlayBuild && + BooleanSetting.CHECK_FOR_UPDATES.boolean && + !homeViewModel.updatePromptShown + ) { + Thread({ + val checkForPrereleaseUpdates = (IntSetting.UPDATE_CHECK_CHANNEL.int == 1) + val latestReleaseTag = UpdateChecker.getLatestRelease(checkForPrereleaseUpdates) + if (!latestReleaseTag.isNullOrEmpty() && + latestReleaseTag != BuildConfig.GIT_VERSION + ) { + val latestMajorVersion = getMajorVersion(latestReleaseTag) + val currentMajorVersion = getMajorVersion(BuildConfig.GIT_VERSION) + if (latestMajorVersion != null && + currentMajorVersion != null && + currentMajorVersion <= latestMajorVersion + ) { + UpdateAvailableNotificationFragment.newInstance( + latestReleaseTag, + checkForPrereleaseUpdates + ) + .show( + requireActivity().supportFragmentManager, + UpdateAvailableNotificationFragment.TAG + ) + } + } + }).start() + homeViewModel.updatePromptShown = true + } + } + override fun onDestroyView() { super.onDestroyView() _binding = null diff --git a/src/android/app/src/main/java/org/citra/citra_emu/fragments/UpdateAvailableNotificationFragment.kt b/src/android/app/src/main/java/org/citra/citra_emu/fragments/UpdateAvailableNotificationFragment.kt new file mode 100644 index 000000000..c33602133 --- /dev/null +++ b/src/android/app/src/main/java/org/citra/citra_emu/fragments/UpdateAvailableNotificationFragment.kt @@ -0,0 +1,66 @@ +// Copyright Citra Emulator Project / Azahar Emulator Project +// Licensed under GPLv2 or any later version +// Refer to the license.txt file included. + +package org.citra.citra_emu.fragments + +import android.app.Dialog +import android.content.DialogInterface +import android.content.Intent +import android.net.Uri +import android.os.Bundle +import androidx.fragment.app.DialogFragment +import com.google.android.material.dialog.MaterialAlertDialogBuilder +import org.citra.citra_emu.R +import org.citra.citra_emu.ui.main.MainActivity +import org.citra.citra_emu.utils.BuildUtil + +class UpdateAvailableNotificationFragment( + newVersionOverride: String, + checkForPrereleaseUpdatesOverride: Boolean +) : DialogFragment() { + private lateinit var mainActivity: MainActivity + + private val newVersion = newVersionOverride + private val checkForPrereleaseUpdates = checkForPrereleaseUpdatesOverride + + override fun onCreateDialog(savedInstanceState: Bundle?): Dialog { + BuildUtil.assertNotGooglePlay() + mainActivity = requireActivity() as MainActivity + + isCancelable = false + + val updateNotificationDescription = + getString(R.string.update_available_description, newVersion) + + return MaterialAlertDialogBuilder(requireContext()) + .setTitle(R.string.update_available) + .setMessage(updateNotificationDescription) + .setPositiveButton(android.R.string.ok) { _: DialogInterface, _: Int -> + val updateLink: String = if (checkForPrereleaseUpdates) { + getString(R.string.prerelease_channel_update_link) + } else { + getString(R.string.prerelease_channel_update_link) + } + val intent = Intent( + Intent.ACTION_VIEW, + Uri.parse(updateLink) + ) + startActivity(intent) + } + .setNegativeButton(android.R.string.cancel, null) + .show() + } + + companion object { + const val TAG = "UpdateAvailableNotificationFragment" + + fun newInstance( + newVersion: String, + checkForPrereleaseUpdates: Boolean + ): UpdateAvailableNotificationFragment { + BuildUtil.assertNotGooglePlay() + return UpdateAvailableNotificationFragment(newVersion, checkForPrereleaseUpdates) + } + } +} diff --git a/src/android/app/src/main/java/org/citra/citra_emu/viewmodel/HomeViewModel.kt b/src/android/app/src/main/java/org/citra/citra_emu/viewmodel/HomeViewModel.kt index 0261a27a6..7cec7bd88 100644 --- a/src/android/app/src/main/java/org/citra/citra_emu/viewmodel/HomeViewModel.kt +++ b/src/android/app/src/main/java/org/citra/citra_emu/viewmodel/HomeViewModel.kt @@ -82,6 +82,8 @@ class HomeViewModel : ViewModel() { _selectedGamesDirectory.value = value } + var updatePromptShown = false + fun setNavigationVisibility(visible: Boolean, animated: Boolean) { if (_navigationVisible.value.first == visible) { return diff --git a/src/android/app/src/main/jni/default_ini.h b/src/android/app/src/main/jni/default_ini.h index c38475b5b..db9426049 100644 --- a/src/android/app/src/main/jni/default_ini.h +++ b/src/android/app/src/main/jni/default_ini.h @@ -543,6 +543,14 @@ static const char* android_config_default_file_content = (BOOST_HANA_STRING(R"( # 0 (default): No, 1: Yes )") DECLARE_KEY(android_hide_images) BOOST_HANA_STRING(R"( +# Whether or not an in-app notification should be displayed when an update is available for Azahar +# 0: No, 1 (default): Yes +)") DECLARE_KEY(check_for_update_on_start) BOOST_HANA_STRING(R"( + +# Which update channel should be used by the update checker +# 0 (default): Stable, 1: Prerelease +)") DECLARE_KEY(update_check_channel) BOOST_HANA_STRING(R"( + [Debugging] # Record frame time data, can be found in the log directory. Boolean value )") DECLARE_KEY(record_frame_times) BOOST_HANA_STRING(R"( diff --git a/src/android/app/src/main/res/values/arrays.xml b/src/android/app/src/main/res/values/arrays.xml index ba7c35dd9..d09cfb69f 100644 --- a/src/android/app/src/main/res/values/arrays.xml +++ b/src/android/app/src/main/res/values/arrays.xml @@ -682,4 +682,13 @@ 9 + + @string/update_check_channel_stable + @string/update_check_channel_prerelease + + + 0 + 1 + + diff --git a/src/android/app/src/main/res/values/strings.xml b/src/android/app/src/main/res/values/strings.xml index 8d9b1c84a..52ffc412b 100644 --- a/src/android/app/src/main/res/values/strings.xml +++ b/src/android/app/src/main/res/values/strings.xml @@ -995,4 +995,16 @@ Decompression failed. Already installed applications cannot be compressed or decompressed. + + https://azahar-emu.org/pages/download/ + https://github.com/azahar-emu/azahar/releases + Update Available + A newer version of Azahar is available (version %1$s).\nWould you like to download it? + Check For Updates + Display an in-app notification when an update is available for Azahar. + Update Channel + Which update channel to use when checking for updates. + Stable + Prerelease + diff --git a/src/citra_qt/update_checker.cpp b/src/citra_qt/update_checker.cpp index 3686775e4..e78894f1f 100644 --- a/src/citra_qt/update_checker.cpp +++ b/src/citra_qt/update_checker.cpp @@ -10,7 +10,7 @@ #include "common/logging/log.h" #include "update_checker.h" -std::optional GetResponse(std::string url, std::string path) { +static std::optional GetResponse(std::string url, std::string path) { constexpr std::size_t timeout_seconds = 15; std::unique_ptr client = std::make_unique(url); @@ -77,7 +77,7 @@ std::optional UpdateChecker::GetLatestRelease(bool include_prerelea return {}; return latest_tag; - } else { // This is a stable release, only check for other stable releases. + } else { // Stable releases only update_check_path += "/releases/latest"; const auto response = GetResponse(update_check_url, update_check_path);