diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index a427092..a5385a1 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -191,6 +191,11 @@ android:resource="@xml/shared_file_paths" /> + + diff --git a/app/src/main/java/com/fox2code/mmm/MainApplication.java b/app/src/main/java/com/fox2code/mmm/MainApplication.java index 887b39e..95a8fc4 100644 --- a/app/src/main/java/com/fox2code/mmm/MainApplication.java +++ b/app/src/main/java/com/fox2code/mmm/MainApplication.java @@ -52,6 +52,7 @@ import java.security.SecureRandom; import java.security.UnrecoverableKeyException; import java.security.cert.CertificateException; import java.text.SimpleDateFormat; +import java.util.ArrayList; import java.util.Arrays; import java.util.Date; import java.util.HashMap; @@ -89,6 +90,7 @@ public class MainApplication extends FoxApplication implements androidx.work.Con @SuppressLint("RestrictedApi") // Use FoxProcess wrapper helper. private static final boolean wrapped = !FoxProcessExt.isRootLoader(); + private static boolean SHOWCASE_MODE_TRUE = false; public static boolean isOfficial = false; private static long secret; private static Locale timeFormatLocale = Resources.getSystem().getConfiguration().getLocales().get(0); @@ -98,6 +100,7 @@ public class MainApplication extends FoxApplication implements androidx.work.Con private static MainApplication INSTANCE; private static boolean firstBoot; private static HashMap mSharedPrefs; + private static final ArrayList callers = new ArrayList<>(); static { Shell.setDefaultBuilder(shellBuilder = Shell.Builder.create().setFlags(Shell.FLAG_REDIRECT_STDERR).setTimeout(10).setInitializers(InstallerInitializer.class)); @@ -140,6 +143,24 @@ public class MainApplication extends FoxApplication implements androidx.work.Con Timber.d("Creating shared prefs map"); mSharedPrefs = new HashMap<>(); } + /* + this part is only here because with added encryption, parts of code that were previously calling this over and over again or on each invocation of a method are causing performance issues. + */ + if (BuildConfig.DEBUG) { + // get file, function, and line number + StackTraceElement[] stackTrace = Thread.currentThread().getStackTrace(); + // get the caller of this method + StackTraceElement caller = stackTrace[3]; + Timber.d("Shared prefs file: %s, caller: %s:%d", name, caller.getMethodName(), caller.getLineNumber()); + // add the caller to an array. if the last 3 callers are the same, then we are in a loop, log at error level + callers.add(name + ":" + caller.getLineNumber() + ":" + caller.getMethodName()); + // get the last 3 callers + List last3 = callers.subList(Math.max(callers.size() - 3, 0), callers.size()); + // if the last 3 callers are the same, then we are in a loop, log at error level + if (last3.size() == 3 && last3.get(0).equals(last3.get(1)) && last3.get(1).equals(last3.get(2))) { + Timber.e("Shared prefs loop detected. File: %s, caller: %s:%d", name, caller.getMethodName(), caller.getLineNumber()); + } + } if (mSharedPrefs.containsKey(name)) { Timber.d("Returning cached shared prefs"); return (SharedPreferences) mSharedPrefs.get(name); @@ -167,7 +188,10 @@ public class MainApplication extends FoxApplication implements androidx.work.Con } public static boolean isShowcaseMode() { - return getPreferences("mmm").getBoolean("pref_showcase_mode", false); + if (SHOWCASE_MODE_TRUE) return true; + boolean showcaseMode = getPreferences("mmm").getBoolean("pref_showcase_mode", false); + SHOWCASE_MODE_TRUE = showcaseMode; + return showcaseMode; } public static boolean shouldPreventReboot() { diff --git a/app/src/main/java/com/fox2code/mmm/androidacy/AndroidacyRepoData.java b/app/src/main/java/com/fox2code/mmm/androidacy/AndroidacyRepoData.java index 4b74117..f89dc71 100644 --- a/app/src/main/java/com/fox2code/mmm/androidacy/AndroidacyRepoData.java +++ b/app/src/main/java/com/fox2code/mmm/androidacy/AndroidacyRepoData.java @@ -43,6 +43,7 @@ import timber.log.Timber; @SuppressWarnings("KotlinInternalInJava") public final class AndroidacyRepoData extends RepoData { + public static String ANDROIDACY_DEVICE_ID = null; public static String token = MainApplication.getPreferences("androidacy").getString("pref_androidacy_api_token", null); static { @@ -86,10 +87,15 @@ public final class AndroidacyRepoData extends RepoData { // Generates a unique device ID. This is used to identify the device in the API for rate // limiting and fraud detection. public static String generateDeviceId() { + // first, check if ANDROIDACY_DEVICE_ID is already set + if (ANDROIDACY_DEVICE_ID != null) { + return ANDROIDACY_DEVICE_ID; + } // Try to get the device ID from the shared preferences SharedPreferences sharedPreferences = MainApplication.getPreferences("androidacy"); String deviceIdPref = sharedPreferences.getString("device_id", null); if (deviceIdPref != null) { + ANDROIDACY_DEVICE_ID = deviceIdPref; return deviceIdPref; } else { // Really not that scary - just hashes some device info. We can't even get the info @@ -119,6 +125,7 @@ public final class AndroidacyRepoData extends RepoData { digest = MessageDigest.getInstance("SHA-256"); } catch (NoSuchAlgorithmException ignored) { // This should never happen so we can just return the original device ID + ANDROIDACY_DEVICE_ID = deviceId; return deviceId; } byte[] hash = digest.digest(deviceId.getBytes()); @@ -135,6 +142,8 @@ public final class AndroidacyRepoData extends RepoData { SharedPreferences.Editor editor = sharedPreferences.edit(); editor.putString("device_id", hexString.toString()); editor.apply(); + // Set ANDROIDACY_DEVICE_ID + ANDROIDACY_DEVICE_ID = hexString.toString(); // Return it return hexString.toString(); } @@ -151,8 +160,7 @@ public final class AndroidacyRepoData extends RepoData { // set role and permissions on userInfo property userInfo = new String[][]{{"role", memberLevel}, {"permissions", String.valueOf(memberPermissions)}}; return true; - } catch ( - HttpException e) { + } catch (HttpException e) { if (e.getErrorCode() == 401) { Timber.w("Invalid token, resetting..."); // Remove saved preference @@ -162,8 +170,7 @@ public final class AndroidacyRepoData extends RepoData { return false; } throw e; - } catch ( - JSONException e) { + } catch (JSONException e) { // response is not JSON Timber.w("Invalid token, resetting..."); Timber.w(e); @@ -185,8 +192,7 @@ public final class AndroidacyRepoData extends RepoData { editor.apply(); return false; } - if (Http.needCaptchaAndroidacy()) - return false; + if (Http.needCaptchaAndroidacy()) return false; // Implementation details discussed on telegram // First, ping the server to check if it's alive try { @@ -208,15 +214,13 @@ public final class AndroidacyRepoData extends RepoData { } return false; } - } catch ( - Exception e) { + } catch (Exception e) { Timber.e(e, "Failed to ping server"); return false; } String deviceId = generateDeviceId(); long time = System.currentTimeMillis(); - if (this.androidacyBlockade > time) - return true; // fake it till you make it. Basically, + if (this.androidacyBlockade > time) return true; // fake it till you make it. Basically, // don't fail just because we're rate limited. API and web rate limits are different. this.androidacyBlockade = time + 30_000L; try { @@ -234,8 +238,7 @@ public final class AndroidacyRepoData extends RepoData { } else { Timber.i("Using validated cached token"); } - } catch ( - IOException e) { + } catch (IOException e) { if (HttpException.shouldTimeout(e)) { Timber.e(e, "We are being rate limited!"); this.androidacyBlockade = time + 3_600_000L; @@ -256,8 +259,7 @@ public final class AndroidacyRepoData extends RepoData { //noinspection SuspiciousRegexArgument Timber.d("Token: %s", token.substring(0, token.length() - 4).replaceAll(".", "*") + token.substring(token.length() - 4)); memberLevel = jsonObject.getString("role"); - } catch ( - JSONException e) { + } catch (JSONException e) { Timber.e(e, "Failed to parse token"); // Show a toast Looper mainLooper = Looper.getMainLooper(); @@ -280,8 +282,7 @@ public final class AndroidacyRepoData extends RepoData { editor.apply(); Timber.i("Token saved to shared preference"); } - } catch ( - Exception e) { + } catch (Exception e) { if (HttpException.shouldTimeout(e)) { Timber.e(e, "We are being rate limited!"); this.androidacyBlockade = time + 3_600_000L; @@ -384,8 +385,7 @@ public final class AndroidacyRepoData extends RepoData { moduleInfo.minMagisk = // Allow 24.1 to mean 24100 (Integer.parseInt(minMagisk.substring(0, c)) * 1000) + (Integer.parseInt(minMagisk.substring(c + 1)) * 100); } - } catch ( - Exception e) { + } catch (Exception e) { moduleInfo.minMagisk = 0; } moduleInfo.needRamdisk = jsonObject.optBoolean("needRamdisk", false); @@ -437,8 +437,7 @@ public final class AndroidacyRepoData extends RepoData { private String injectToken(String url) { // Do not inject token for non Androidacy urls - if (!AndroidacyUtil.isAndroidacyLink(url)) - return url; + if (!AndroidacyUtil.isAndroidacyLink(url)) return url; if (this.testMode) { if (url.startsWith("https://production-api.androidacy.com/")) { Timber.e("Got non test mode url: %s", AndroidacyUtil.hideToken(url)); diff --git a/app/src/main/java/com/fox2code/mmm/background/BackgroundUpdateChecker.java b/app/src/main/java/com/fox2code/mmm/background/BackgroundUpdateChecker.java index f2d2db8..86d9585 100644 --- a/app/src/main/java/com/fox2code/mmm/background/BackgroundUpdateChecker.java +++ b/app/src/main/java/com/fox2code/mmm/background/BackgroundUpdateChecker.java @@ -51,10 +51,10 @@ public class BackgroundUpdateChecker extends Worker { if (!MainApplication.getPreferences("mmm").getBoolean("pref_background_update_check", false)) { return; } - if (MainApplication.getINSTANCE().isInForeground()) { + //if (MainApplication.getINSTANCE().isInForeground()) { // don't check if app is in foreground, this is a background check - return; - } + // return; + //} // next, check if user requires wifi if (MainApplication.getPreferences("mmm").getBoolean("pref_background_update_check_wifi", true)) { // check if wifi is connected @@ -68,7 +68,7 @@ public class BackgroundUpdateChecker extends Worker { // start foreground service Intent intent = new Intent(context, BackgroundUpdateCheckerService.class); intent.setAction(BackgroundUpdateCheckerService.ACTION_START_FOREGROUND_SERVICE); - ContextCompat.startForegroundService(context, intent); + context.startService(intent); } public static void postNotification(Context context, HashMap updateable, int updateCount, boolean test) { diff --git a/app/src/main/java/com/fox2code/mmm/background/BackgroundUpdateCheckerService.java b/app/src/main/java/com/fox2code/mmm/background/BackgroundUpdateCheckerService.java index 4852c92..baf4f02 100644 --- a/app/src/main/java/com/fox2code/mmm/background/BackgroundUpdateCheckerService.java +++ b/app/src/main/java/com/fox2code/mmm/background/BackgroundUpdateCheckerService.java @@ -6,16 +6,17 @@ import static com.fox2code.mmm.background.BackgroundUpdateChecker.postNotificati import android.Manifest; import android.annotation.SuppressLint; import android.app.PendingIntent; +import android.app.Service; import android.content.Context; import android.content.Intent; import android.content.pm.PackageManager; +import android.os.IBinder; import androidx.core.app.NotificationChannelCompat; import androidx.core.app.NotificationCompat; import androidx.core.app.NotificationManagerCompat; import androidx.core.content.ContextCompat; -import com.fox2code.foxcompat.app.internal.FoxIntentActivity; import com.fox2code.mmm.AppUpdateManager; import com.fox2code.mmm.MainApplication; import com.fox2code.mmm.R; @@ -31,7 +32,7 @@ import java.util.HashMap; import timber.log.Timber; @SuppressLint("RestrictedApi") -public class BackgroundUpdateCheckerService extends FoxIntentActivity { +public class BackgroundUpdateCheckerService extends Service { public static final String NOTIFICATION_CHANNEL_ID = "background_update"; public static final String NOTIFICATION_CHANNEL_ID_APP = "background_update_app"; public static final String ACTION_START_FOREGROUND_SERVICE = "ACTION_START_FOREGROUND_SERVICE"; @@ -68,83 +69,81 @@ public class BackgroundUpdateCheckerService extends FoxIntentActivity { } } - public void onCreate() { + @Override + public IBinder onBind(Intent intent) { Context context = MainApplication.getINSTANCE().getApplicationContext(); - // check if action is ACTION_START_FOREGROUND_SERVICE, bail out if not - if (!ACTION_START_FOREGROUND_SERVICE.equals(getIntent().getAction())) { - return; - } - Timber.d("Starting background update checker service"); - // acquire lock - synchronized (lock) { - // post checking notification if notofiications are enabled - if (ContextCompat.checkSelfPermission(MainApplication.getINSTANCE(), Manifest.permission.POST_NOTIFICATIONS) == PackageManager.PERMISSION_GRANTED) { - NotificationManagerCompat notificationManager = NotificationManagerCompat.from(context); - notificationManager.createNotificationChannel(new NotificationChannelCompat.Builder(NOTIFICATION_CHANNEL_ID_ONGOING, NotificationManagerCompat.IMPORTANCE_MIN).setName(context.getString(R.string.notification_channel_category_background_update)).setDescription(context.getString(R.string.notification_channel_category_background_update_description)).setGroup(NOTFIICATION_GROUP).build()); - NotificationCompat.Builder builder = new NotificationCompat.Builder(context, NOTIFICATION_CHANNEL_ID); - builder.setSmallIcon(R.drawable.ic_baseline_update_24); - builder.setPriority(NotificationCompat.PRIORITY_MIN); - builder.setCategory(NotificationCompat.CATEGORY_SERVICE); - builder.setShowWhen(false); - builder.setOnlyAlertOnce(true); - builder.setOngoing(true); - builder.setAutoCancel(false); - builder.setGroup("update"); - builder.setContentTitle(context.getString(R.string.notification_channel_background_update)); - builder.setContentText(context.getString(R.string.notification_channel_background_update_description)); - notificationManager.notify(NOTIFICATION_ID_ONGOING, builder.build()); - } else { - Timber.d("Not posting notification because of missing permission"); - } - Thread.currentThread().setPriority(2); - ModuleManager.getINSTANCE().scanAsync(); - RepoManager.getINSTANCE().update(null); - ModuleManager.getINSTANCE().runAfterScan(() -> { - int moduleUpdateCount = 0; - HashMap repoModules = RepoManager.getINSTANCE().getModules(); - // hasmap of updateable modules names - HashMap updateableModules = new HashMap<>(); - for (LocalModuleInfo localModuleInfo : ModuleManager.getINSTANCE().getModules().values()) { - if ("twrp-keep".equals(localModuleInfo.id)) continue; - // exclude all modules with id's stored in the pref pref_background_update_check_excludes - try { - if (MainApplication.getPreferences("mmm").getStringSet("pref_background_update_check_excludes", null).contains(localModuleInfo.id)) - continue; - } catch (Exception ignored) { + // check if ACTION_START_FOREGROUND_SERVICE was called + if (intent.getAction() != null && intent.getAction().equals(ACTION_START_FOREGROUND_SERVICE)) { + synchronized (lock) { + // post checking notification if notifications are enabled + if (ContextCompat.checkSelfPermission(MainApplication.getINSTANCE(), Manifest.permission.POST_NOTIFICATIONS) == PackageManager.PERMISSION_GRANTED) { + NotificationManagerCompat notificationManager = NotificationManagerCompat.from(context); + notificationManager.createNotificationChannel(new NotificationChannelCompat.Builder(NOTIFICATION_CHANNEL_ID_ONGOING, NotificationManagerCompat.IMPORTANCE_MIN).setName(context.getString(R.string.notification_channel_category_background_update)).setDescription(context.getString(R.string.notification_channel_category_background_update_description)).setGroup(NOTFIICATION_GROUP).build()); + NotificationCompat.Builder builder = new NotificationCompat.Builder(context, NOTIFICATION_CHANNEL_ID); + builder.setSmallIcon(R.drawable.ic_baseline_update_24); + builder.setPriority(NotificationCompat.PRIORITY_MIN); + builder.setCategory(NotificationCompat.CATEGORY_SERVICE); + builder.setShowWhen(false); + builder.setOnlyAlertOnce(true); + builder.setOngoing(true); + builder.setAutoCancel(false); + builder.setGroup("update_bg"); + builder.setContentTitle(context.getString(R.string.notification_channel_background_update)); + builder.setContentText(context.getString(R.string.notification_channel_background_update_description)); + notificationManager.notify(NOTIFICATION_ID_ONGOING, builder.build()); + } else { + Timber.d("Not posting notification because of missing permission"); + } + ModuleManager.getINSTANCE().scanAsync(); + RepoManager.getINSTANCE().update(null); + ModuleManager.getINSTANCE().runAfterScan(() -> { + int moduleUpdateCount = 0; + HashMap repoModules = RepoManager.getINSTANCE().getModules(); + // hashmap of updateable modules names + HashMap updateableModules = new HashMap<>(); + for (LocalModuleInfo localModuleInfo : ModuleManager.getINSTANCE().getModules().values()) { + if ("twrp-keep".equals(localModuleInfo.id)) continue; + // exclude all modules with id's stored in the pref pref_background_update_check_excludes + try { + if (MainApplication.getPreferences("mmm").getStringSet("pref_background_update_check_excludes", null).contains(localModuleInfo.id)) + continue; + } catch (Exception ignored) { + } + RepoModule repoModule = repoModules.get(localModuleInfo.id); + localModuleInfo.checkModuleUpdate(); + if (localModuleInfo.updateVersionCode > localModuleInfo.versionCode && !PropUtils.isNullString(localModuleInfo.updateVersion)) { + moduleUpdateCount++; + updateableModules.put(localModuleInfo.name, localModuleInfo.version); + } else if (repoModule != null && repoModule.moduleInfo.versionCode > localModuleInfo.versionCode && !PropUtils.isNullString(repoModule.moduleInfo.version)) { + moduleUpdateCount++; + updateableModules.put(localModuleInfo.name, localModuleInfo.version); + } } - RepoModule repoModule = repoModules.get(localModuleInfo.id); - localModuleInfo.checkModuleUpdate(); - if (localModuleInfo.updateVersionCode > localModuleInfo.versionCode && !PropUtils.isNullString(localModuleInfo.updateVersion)) { - moduleUpdateCount++; - updateableModules.put(localModuleInfo.name, localModuleInfo.version); - } else if (repoModule != null && repoModule.moduleInfo.versionCode > localModuleInfo.versionCode && !PropUtils.isNullString(repoModule.moduleInfo.version)) { - moduleUpdateCount++; - updateableModules.put(localModuleInfo.name, localModuleInfo.version); + if (moduleUpdateCount != 0) { + Timber.d("Found %d updates", moduleUpdateCount); + postNotification(context, updateableModules, moduleUpdateCount, false); } - } - if (moduleUpdateCount != 0) { - Timber.d("Found %d updates", moduleUpdateCount); - postNotification(context, updateableModules, moduleUpdateCount, false); - } - }); - // check for app updates - if (MainApplication.getPreferences("mmm").getBoolean("pref_background_update_check_app", false)) { - try { - boolean shouldUpdate = AppUpdateManager.getAppUpdateManager().checkUpdate(true); - if (shouldUpdate) { - Timber.d("Found app update"); - postNotificationForAppUpdate(context); + }); + // check for app updates + if (MainApplication.getPreferences("mmm").getBoolean("pref_background_update_check_app", false)) { + try { + boolean shouldUpdate = AppUpdateManager.getAppUpdateManager().checkUpdate(true); + if (shouldUpdate) { + Timber.d("Found app update"); + postNotificationForAppUpdate(context); + } + } catch (Exception e) { + e.printStackTrace(); } - } catch (Exception e) { - e.printStackTrace(); } - } - // remove checking notification - if (ContextCompat.checkSelfPermission(MainApplication.getINSTANCE(), Manifest.permission.POST_NOTIFICATIONS) == PackageManager.PERMISSION_GRANTED) { - Timber.d("Removing notification"); - NotificationManagerCompat notificationManager = NotificationManagerCompat.from(context); - notificationManager.cancel(NOTIFICATION_ID_ONGOING); + // remove checking notification + if (ContextCompat.checkSelfPermission(MainApplication.getINSTANCE(), Manifest.permission.POST_NOTIFICATIONS) == PackageManager.PERMISSION_GRANTED) { + Timber.d("Removing notification"); + NotificationManagerCompat notificationManager = NotificationManagerCompat.from(context); + notificationManager.cancel(NOTIFICATION_ID_ONGOING); + } } } + return null; } } diff --git a/app/src/main/java/com/fox2code/mmm/settings/SettingsActivity.java b/app/src/main/java/com/fox2code/mmm/settings/SettingsActivity.java index 0c1c169..7c15b09 100644 --- a/app/src/main/java/com/fox2code/mmm/settings/SettingsActivity.java +++ b/app/src/main/java/com/fox2code/mmm/settings/SettingsActivity.java @@ -361,6 +361,42 @@ public class SettingsActivity extends FoxActivity implements LanguageActivity { return true; }); + // handle restart required for showcase mode + findPreference("pref_showcase_mode").setOnPreferenceChangeListener((p, v) -> { + if (v.equals(true)) { + new MaterialAlertDialogBuilder(requireContext()).setTitle(R.string.restart).setMessage(R.string.showcase_mode_dialogue_message).setPositiveButton(R.string.ok, (dialog, which) -> { + // Toggle showcase mode on + ((TwoStatePreference) findPreference("pref_showcase_mode")).setChecked(true); + editor.putBoolean("pref_showcase_mode", true).apply(); + // restart app + Intent mStartActivity = new Intent(requireContext(), MainActivity.class); + mStartActivity.setFlags(Intent.FLAG_ACTIVITY_CLEAR_TOP | Intent.FLAG_ACTIVITY_NEW_TASK); + int mPendingIntentId = 123456; + PendingIntent mPendingIntent; + mPendingIntent = PendingIntent.getActivity(requireContext(), mPendingIntentId, mStartActivity, PendingIntent.FLAG_CANCEL_CURRENT | PendingIntent.FLAG_IMMUTABLE); + AlarmManager mgr = (AlarmManager) requireContext().getSystemService(Context.ALARM_SERVICE); + mgr.set(AlarmManager.RTC, System.currentTimeMillis() + 100, mPendingIntent); + Timber.d("Restarting app to save showcase mode preference: %s", v); + System.exit(0); // Exit app process + }).setNegativeButton(R.string.cancel, (dialog, which) -> { + // Revert to showcase mode on + ((TwoStatePreference) findPreference("pref_showcase_mode")).setChecked(false); + editor.putBoolean("pref_showcase_mode", false).apply(); + // restart app + Intent mStartActivity = new Intent(requireContext(), MainActivity.class); + mStartActivity.setFlags(Intent.FLAG_ACTIVITY_CLEAR_TOP | Intent.FLAG_ACTIVITY_NEW_TASK); + int mPendingIntentId = 123456; + PendingIntent mPendingIntent; + mPendingIntent = PendingIntent.getActivity(requireContext(), mPendingIntentId, mStartActivity, PendingIntent.FLAG_CANCEL_CURRENT | PendingIntent.FLAG_IMMUTABLE); + AlarmManager mgr = (AlarmManager) requireContext().getSystemService(Context.ALARM_SERVICE); + mgr.set(AlarmManager.RTC, System.currentTimeMillis() + 100, mPendingIntent); + Timber.d("Restarting app to save showcase mode preference: %s", v); + System.exit(0); // Exit app process + }).show(); + } + return true; + }); + Preference languageSelector = findPreference("pref_language_selector"); languageSelector.setOnPreferenceClickListener(preference -> { LanguageSwitcher ls = new LanguageSwitcher(getActivity()); diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 0680c31..e933fb7 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -399,4 +399,5 @@ Language %s has not been translated. Help translate it? Creates a blur effect behind some dialogs and elements. Note that blur may not perform well on some devices and may not work for everyone. An error occurred reading shared preferences. Please reset the app. + An app restart is required to enable showcase mode. diff --git a/app/src/main/res/xml/root_preferences.xml b/app/src/main/res/xml/root_preferences.xml index 7d03400..17e48cb 100644 --- a/app/src/main/res/xml/root_preferences.xml +++ b/app/src/main/res/xml/root_preferences.xml @@ -156,7 +156,9 @@ app:singleLineTitle="false" app:summary="@string/dns_over_https_desc" app:title="@string/dns_over_https_pref" /> - + + +