From 6e7ebeabef00c66b93b3c742789dfddbfd95d605 Mon Sep 17 00:00:00 2001 From: Nite Date: Fri, 2 Oct 2020 18:47:21 +0200 Subject: [PATCH] Implemented file logging settings Implemented log rotation, log deletion Minor fixes --- .../ultrasonic/fragment/SettingsFragment.java | 55 +++++++ .../org/moire/ultrasonic/util/Constants.java | 1 + .../java/org/moire/ultrasonic/util/Util.java | 7 + .../kotlin/org/moire/ultrasonic/app/UApp.kt | 7 +- .../moire/ultrasonic/di/MusicServiceModule.kt | 2 +- .../moire/ultrasonic/log/FileLoggerTree.kt | 149 ++++++++++++++++-- .../moire/ultrasonic/log/TimberKoinLogger.kt | 2 +- .../ultrasonic/log/TimberOkHttpLogger.kt | 2 +- ultrasonic/src/main/res/values-de/strings.xml | 7 + ultrasonic/src/main/res/values-es/strings.xml | 7 + ultrasonic/src/main/res/values-fr/strings.xml | 7 + ultrasonic/src/main/res/values-hu/strings.xml | 7 + ultrasonic/src/main/res/values-nl/strings.xml | 7 + ultrasonic/src/main/res/values-pl/strings.xml | 7 + .../src/main/res/values-pt-rBR/strings.xml | 7 + ultrasonic/src/main/res/values-pt/strings.xml | 7 + ultrasonic/src/main/res/values/strings.xml | 7 + ultrasonic/src/main/res/xml/settings.xml | 9 +- 18 files changed, 278 insertions(+), 19 deletions(-) diff --git a/ultrasonic/src/main/java/org/moire/ultrasonic/fragment/SettingsFragment.java b/ultrasonic/src/main/java/org/moire/ultrasonic/fragment/SettingsFragment.java index 06292b84..7b1fe14a 100644 --- a/ultrasonic/src/main/java/org/moire/ultrasonic/fragment/SettingsFragment.java +++ b/ultrasonic/src/main/java/org/moire/ultrasonic/fragment/SettingsFragment.java @@ -22,6 +22,7 @@ import org.moire.ultrasonic.featureflags.Feature; import org.moire.ultrasonic.featureflags.FeatureStorage; import org.moire.ultrasonic.filepicker.FilePickerDialog; import org.moire.ultrasonic.filepicker.OnFileSelectedListener; +import org.moire.ultrasonic.log.FileLoggerTree; import org.moire.ultrasonic.provider.SearchSuggestionProvider; import org.moire.ultrasonic.service.Consumer; import org.moire.ultrasonic.service.MediaPlayerController; @@ -70,6 +71,7 @@ public class SettingsFragment extends PreferenceFragment private PreferenceCategory serversCategory; private Preference resumeOnBluetoothDevice; private Preference pauseOnBluetoothDevice; + private CheckBoxPreference debugLogToFile; private SharedPreferences settings; @@ -118,6 +120,7 @@ public class SettingsFragment extends PreferenceFragment serversCategory = (PreferenceCategory) findPreference(Constants.PREFERENCES_KEY_SERVERS_KEY); resumeOnBluetoothDevice = findPreference(Constants.PREFERENCES_KEY_RESUME_ON_BLUETOOTH_DEVICE); pauseOnBluetoothDevice = findPreference(Constants.PREFERENCES_KEY_PAUSE_ON_BLUETOOTH_DEVICE); + debugLogToFile = (CheckBoxPreference) findPreference(Constants.PREFERENCES_KEY_DEBUG_LOG_TO_FILE); sharingDefaultGreeting.setText(Util.getShareGreeting(getActivity())); setupClearSearchPreference(); @@ -171,6 +174,8 @@ public class SettingsFragment extends PreferenceFragment setBluetoothPreferences(sharedPreferences.getBoolean(key, true)); } else if (Constants.PREFERENCES_KEY_IMAGE_LOADER_CONCURRENCY.equals(key)) { setImageLoaderConcurrency(Integer.parseInt(sharedPreferences.getString(key, "5"))); + } else if (Constants.PREFERENCES_KEY_DEBUG_LOG_TO_FILE.equals(key)) { + setDebugLogToFile(sharedPreferences.getBoolean(key, false)); } } @@ -415,6 +420,13 @@ public class SettingsFragment extends PreferenceFragment sendBluetoothAlbumArt.setChecked(false); sendBluetoothAlbumArt.setEnabled(false); } + + if (debugLogToFile.isChecked()) { + debugLogToFile.setSummary(getString(R.string.settings_debug_log_path, + FileUtil.getUltrasonicDirectory(getActivity()), FileLoggerTree.FILENAME)); + } else { + debugLogToFile.setSummary(""); + } } private static void setImageLoaderConcurrency(int concurrency) { @@ -482,4 +494,47 @@ public class SettingsFragment extends PreferenceFragment // Clear download queue. mediaPlayerControllerLazy.getValue().clear(); } + + private void setDebugLogToFile(boolean writeLog) { + if (writeLog) { + FileLoggerTree.Companion.plantToTimberForest(getActivity().getApplicationContext()); + Timber.i("Enabled debug logging to file"); + } else { + FileLoggerTree.Companion.uprootFromTimberForest(); + Timber.i("Disabled debug logging to file"); + + int fileNum = FileLoggerTree.Companion.getLogFileNumber(getActivity()); + long fileSize = FileLoggerTree.Companion.getLogFileSizes(getActivity()); + String message = getString(R.string.settings_debug_log_summary, + String.valueOf(fileNum), + String.valueOf(Math.ceil(fileSize / 1000000d)), + FileUtil.getUltrasonicDirectory(getActivity())); + + new AlertDialog.Builder(getActivity()) + .setMessage(message) + .setIcon(android.R.drawable.ic_dialog_info) + .setNegativeButton(R.string.settings_debug_log_keep, new DialogInterface.OnClickListener() { + @Override + public void onClick(DialogInterface dialogInterface, int i) { + dialogInterface.cancel(); + } + }) + .setPositiveButton(R.string.settings_debug_log_delete, new DialogInterface.OnClickListener() { + @Override + public void onClick(DialogInterface dialogInterface, int i) { + FileLoggerTree.Companion.deleteLogFiles(getActivity()); + Timber.i("Deleted debug log files"); + dialogInterface.dismiss(); + new AlertDialog.Builder(getActivity()).setMessage(R.string.settings_debug_log_deleted) + .setPositiveButton(R.string.common_ok, new DialogInterface.OnClickListener() { + @Override + public void onClick(DialogInterface dialogInterface, int i) { + dialogInterface.dismiss(); + } + }).create().show(); + } + }) + .create().show(); + } + } } diff --git a/ultrasonic/src/main/java/org/moire/ultrasonic/util/Constants.java b/ultrasonic/src/main/java/org/moire/ultrasonic/util/Constants.java index 07355b94..5de36cb3 100644 --- a/ultrasonic/src/main/java/org/moire/ultrasonic/util/Constants.java +++ b/ultrasonic/src/main/java/org/moire/ultrasonic/util/Constants.java @@ -134,6 +134,7 @@ public final class Constants public static final String PREFERENCES_KEY_FIRST_RUN_EXECUTED = "firstRunExecuted"; public static final String PREFERENCES_KEY_RESUME_ON_BLUETOOTH_DEVICE = "resumeOnBluetoothDevice"; public static final String PREFERENCES_KEY_PAUSE_ON_BLUETOOTH_DEVICE = "pauseOnBluetoothDevice"; + public static final String PREFERENCES_KEY_DEBUG_LOG_TO_FILE = "debugLogToFile"; public static final int PREFERENCE_VALUE_ALL = 0; public static final int PREFERENCE_VALUE_A2DP = 1; diff --git a/ultrasonic/src/main/java/org/moire/ultrasonic/util/Util.java b/ultrasonic/src/main/java/org/moire/ultrasonic/util/Util.java index f90b8574..543774a9 100644 --- a/ultrasonic/src/main/java/org/moire/ultrasonic/util/Util.java +++ b/ultrasonic/src/main/java/org/moire/ultrasonic/util/Util.java @@ -1457,4 +1457,11 @@ public class Util SharedPreferences preferences = getPreferences(context); return preferences.getInt(Constants.PREFERENCES_KEY_PAUSE_ON_BLUETOOTH_DEVICE, Constants.PREFERENCE_VALUE_A2DP); } + + public static boolean getDebugLogToFile(Context context) + { + SharedPreferences preferences = getPreferences(context); + return preferences.getBoolean(Constants.PREFERENCES_KEY_DEBUG_LOG_TO_FILE, false); + } + } diff --git a/ultrasonic/src/main/kotlin/org/moire/ultrasonic/app/UApp.kt b/ultrasonic/src/main/kotlin/org/moire/ultrasonic/app/UApp.kt index 210b209e..1df73657 100644 --- a/ultrasonic/src/main/kotlin/org/moire/ultrasonic/app/UApp.kt +++ b/ultrasonic/src/main/kotlin/org/moire/ultrasonic/app/UApp.kt @@ -2,7 +2,6 @@ package org.moire.ultrasonic.app import androidx.multidex.MultiDexApplication import org.koin.android.ext.koin.androidContext -import org.koin.android.ext.koin.androidLogger import org.koin.core.context.startKoin import org.koin.core.logger.Level import org.moire.ultrasonic.BuildConfig @@ -13,8 +12,8 @@ import org.moire.ultrasonic.di.featureFlagsModule import org.moire.ultrasonic.di.mediaPlayerModule import org.moire.ultrasonic.di.musicServiceModule import org.moire.ultrasonic.log.FileLoggerTree -import org.moire.ultrasonic.log.TimberKoinLogger import org.moire.ultrasonic.log.timberLogger +import org.moire.ultrasonic.util.Util import timber.log.Timber import timber.log.Timber.DebugTree @@ -24,7 +23,9 @@ class UApp : MultiDexApplication() { if (BuildConfig.DEBUG) { Timber.plant(DebugTree()) - Timber.plant(FileLoggerTree(this)) + } + if (Util.getDebugLogToFile(this)) { + FileLoggerTree.plantToTimberForest(this) } startKoin { diff --git a/ultrasonic/src/main/kotlin/org/moire/ultrasonic/di/MusicServiceModule.kt b/ultrasonic/src/main/kotlin/org/moire/ultrasonic/di/MusicServiceModule.kt index bca16832..72b67e43 100644 --- a/ultrasonic/src/main/kotlin/org/moire/ultrasonic/di/MusicServiceModule.kt +++ b/ultrasonic/src/main/kotlin/org/moire/ultrasonic/di/MusicServiceModule.kt @@ -1,8 +1,8 @@ @file:JvmName("MusicServiceModule") package org.moire.ultrasonic.di -import okhttp3.logging.HttpLoggingInterceptor import kotlin.math.abs +import okhttp3.logging.HttpLoggingInterceptor import org.koin.android.ext.koin.androidContext import org.koin.core.qualifier.named import org.koin.dsl.module diff --git a/ultrasonic/src/main/kotlin/org/moire/ultrasonic/log/FileLoggerTree.kt b/ultrasonic/src/main/kotlin/org/moire/ultrasonic/log/FileLoggerTree.kt index 3ef42191..ed0a16ab 100644 --- a/ultrasonic/src/main/kotlin/org/moire/ultrasonic/log/FileLoggerTree.kt +++ b/ultrasonic/src/main/kotlin/org/moire/ultrasonic/log/FileLoggerTree.kt @@ -1,37 +1,106 @@ package org.moire.ultrasonic.log import android.content.Context +import java.io.File +import java.io.FileWriter +import java.text.SimpleDateFormat +import java.util.Date +import java.util.Locale import org.moire.ultrasonic.util.FileUtil import org.moire.ultrasonic.util.Util import timber.log.Timber -import java.io.File -import java.io.FileWriter /** * A Timber Tree which can be used to log to a file * Subclass of the DebugTree so it inherits the Tag handling */ class FileLoggerTree(val context: Context) : Timber.DebugTree() { - private val filename = "ultrasonic.log" + private val dateFormat = SimpleDateFormat("HH:mm:ss.SSS", Locale.getDefault()) + /** + * Writes a log entry to file + */ override fun log(priority: Int, tag: String?, message: String, t: Throwable?) { - var file: File? = null var writer: FileWriter? = null + callNum++ try { - file = File(FileUtil.getUltrasonicDirectory(context), filename) + getNextLogFile() writer = FileWriter(file, true) - val exceptionString = t?.toString() ?: ""; - writer.write("${logLevelToString(priority)} $tag $message $exceptionString\n") - writer.flush() + val exceptionString = t?.toString() ?: "" + val time: String = dateFormat.format(Date()) + synchronized(file!!) { + writer.write( + "$time: ${logLevelToString(priority)} $tag $message $exceptionString\n" + ) + writer.flush() + } } catch (x: Throwable) { - super.e(x, "Failed to write log to %s", file) + // Using base class DebugTree here, we don't want to try to log this into file + super.log(6, TAG, String.format("Failed to write log to %s", file), x) } finally { if (writer != null) Util.close(writer) } } - private fun logLevelToString(logLevel: Int) : String { - return when(logLevel) { + /** + * Sets the file to log into + * This function also rotates the log files periodically, when they reach the predefined size + */ + private fun getNextLogFile() { + if (file == null) { + synchronized(this) { + if (file != null) return + getNumberedFile(false) + // Using base class DebugTree here, we don't want to try to log this into file + super.log(4, TAG,String.format("Logging into file %s", file?.name), null) + return + } + } + if (callNum % 100 == 0) { + // Gain some performance by only executing this rarely + if (file!!.length() > MAX_LOGFILE_LENGTH) { + synchronized(this) { + if (file!!.length() <= MAX_LOGFILE_LENGTH) return + getNumberedFile(true) + // Using base class DebugTree here, we don't want to try to log this into file + super.log( + 4, + TAG, + String.format("Log file rotated, logging into file %s", file?.name), + null + ) + } + } + } + } + + /** + * Checks the number of log files + * @param next: if false, sets the current log file with the greatest number + * if true, sets a new file for logging with the next number + */ + private fun getNumberedFile(next: Boolean) { + var fileNum = 1 + val fileList = getLogFileList(context) + + if (!fileList.isNullOrEmpty()) { + fileList.sortByDescending { t -> t.name } + val lastFile = fileList[0] + val number = fileNumberRegex.find(lastFile.name)?.groups?.get(1)?.value + if (number != null) { + fileNum = number.toInt() + } + } + + if (next) fileNum++ + file = File( + FileUtil.getUltrasonicDirectory(context), + FILENAME.replace("*", fileNum.toString()) + ) + } + + private fun logLevelToString(logLevel: Int): String { + return when (logLevel) { 2 -> "V" 3 -> "D" 4 -> "I" @@ -41,4 +110,60 @@ class FileLoggerTree(val context: Context) : Timber.DebugTree() { else -> "U" } } -} \ No newline at end of file + + companion object { + val TAG = FileLoggerTree::class.simpleName + @Volatile private var file: File? = null + const val FILENAME = "ultrasonic.*.log" + private val fileNameRegex = Regex( + FILENAME.replace(".", "\\.").replace("*", "\\d*") + ) + private val fileNumberRegex = Regex( + FILENAME.replace(".", "\\.").replace("*", "(\\d*)") + ) + const val MAX_LOGFILE_LENGTH = 10000000 + var callNum = 0 + + fun plantToTimberForest(context: Context) { + if (!Timber.forest().any { t -> t is FileLoggerTree }) { + Timber.plant(FileLoggerTree(context)) + } + } + + fun uprootFromTimberForest() { + val fileLoggerTree = Timber.forest().singleOrNull { t -> t is FileLoggerTree } + ?: return + Timber.uproot(fileLoggerTree) + file = null + } + + fun getLogFileNumber(context: Context): Int { + val fileList = getLogFileList(context) + if (!fileList.isNullOrEmpty()) return fileList.size + return 0 + } + + fun getLogFileSizes(context: Context): Long { + var sizeSum: Long = 0 + val fileList = getLogFileList(context) + if (fileList.isNullOrEmpty()) return sizeSum + for (file in fileList) { + sizeSum += file.length() + } + return sizeSum + } + + fun deleteLogFiles(context: Context) { + val fileList = getLogFileList(context) + if (fileList.isNullOrEmpty()) return + for (file in fileList) { + file.delete() + } + } + + private fun getLogFileList(context: Context): Array { + val directory = FileUtil.getUltrasonicDirectory(context) + return directory.listFiles { t -> t.name.matches(fileNameRegex) } + } + } +} diff --git a/ultrasonic/src/main/kotlin/org/moire/ultrasonic/log/TimberKoinLogger.kt b/ultrasonic/src/main/kotlin/org/moire/ultrasonic/log/TimberKoinLogger.kt index 060e6bfb..ccb3fbb2 100644 --- a/ultrasonic/src/main/kotlin/org/moire/ultrasonic/log/TimberKoinLogger.kt +++ b/ultrasonic/src/main/kotlin/org/moire/ultrasonic/log/TimberKoinLogger.kt @@ -19,7 +19,7 @@ fun KoinApplication.timberLogger( /** * Timber Logger implementation for Koin */ -class TimberKoinLogger (level: Level = Level.INFO) : Logger(level) { +class TimberKoinLogger(level: Level = Level.INFO) : Logger(level) { override fun log(level: Level, msg: MESSAGE) { if (this.level <= level) { diff --git a/ultrasonic/src/main/kotlin/org/moire/ultrasonic/log/TimberOkHttpLogger.kt b/ultrasonic/src/main/kotlin/org/moire/ultrasonic/log/TimberOkHttpLogger.kt index e2073a73..874138d2 100644 --- a/ultrasonic/src/main/kotlin/org/moire/ultrasonic/log/TimberOkHttpLogger.kt +++ b/ultrasonic/src/main/kotlin/org/moire/ultrasonic/log/TimberOkHttpLogger.kt @@ -10,4 +10,4 @@ class TimberOkHttpLogger : HttpLoggingInterceptor.Logger { override fun log(message: String) { Timber.d(message) } -} \ No newline at end of file +} diff --git a/ultrasonic/src/main/res/values-de/strings.xml b/ultrasonic/src/main/res/values-de/strings.xml index a0575548..43d4a56b 100644 --- a/ultrasonic/src/main/res/values-de/strings.xml +++ b/ultrasonic/src/main/res/values-de/strings.xml @@ -401,6 +401,13 @@ All Bluetooth devices Only audio (A2DP) devices Disabled + Debug options + Write debug log to file + The log files are available at %1$s/%2$s + There are %1$s log files taking up ~%2$s MB space in the %3$s directory. Do you want to keep these? + Keep files + Delete files + Deleted log files. Ultrasonic can\'t access the music file cache. Cache location was reset to the default path. Warning diff --git a/ultrasonic/src/main/res/values-es/strings.xml b/ultrasonic/src/main/res/values-es/strings.xml index fa244ae8..c44cc688 100644 --- a/ultrasonic/src/main/res/values-es/strings.xml +++ b/ultrasonic/src/main/res/values-es/strings.xml @@ -402,6 +402,13 @@ Todos los dispositivos Bluetooth Solo dispositivos de audio (A2DP) Deshabilitado + Debug options + Write debug log to file + The log files are available at %1$s/%2$s + There are %1$s log files taking up ~%2$s MB space in the %3$s directory. Do you want to keep these? + Keep files + Delete files + Deleted log files. Ultrasonic no puede acceder a la caché de los ficheros de música. La ubicación de la caché se restableció a la ruta predeterminada. Atención diff --git a/ultrasonic/src/main/res/values-fr/strings.xml b/ultrasonic/src/main/res/values-fr/strings.xml index ffb4d850..18113715 100644 --- a/ultrasonic/src/main/res/values-fr/strings.xml +++ b/ultrasonic/src/main/res/values-fr/strings.xml @@ -402,6 +402,13 @@ All Bluetooth devices Only audio (A2DP) devices Disabled + Debug options + Write debug log to file + The log files are available at %1$s/%2$s + There are %1$s log files taking up ~%2$s MB space in the %3$s directory. Do you want to keep these? + Keep files + Delete files + Deleted log files. Ultrasonic can\'t access the music file cache. Cache location was reset to the default path. Warning diff --git a/ultrasonic/src/main/res/values-hu/strings.xml b/ultrasonic/src/main/res/values-hu/strings.xml index 8e15af77..16b00426 100644 --- a/ultrasonic/src/main/res/values-hu/strings.xml +++ b/ultrasonic/src/main/res/values-hu/strings.xml @@ -402,6 +402,13 @@ Minden Bluetooth eszköz Csak audio (A2DP) eszközök Kikapcsolva + Hibakeresési lehetőségek + Hibakeresési napló írása fájlba + A naplófájlok elérhetőek a következő helyen: %1$s/%2$s + %1$s napló fájl ~%2$s MB méretben található a %3$s könyvtárban. Meg szeretnéd atrtani ezeket? + Fájlok megtartása + Fájlok törlése + Naplófájlok törölve. Az Ultrasonic nem éri el a zenei fájl gyorsítótárat. A gyorsítótár helye visszaállítva az alapbeállításra. Figyelem diff --git a/ultrasonic/src/main/res/values-nl/strings.xml b/ultrasonic/src/main/res/values-nl/strings.xml index dfa04395..4cc13417 100644 --- a/ultrasonic/src/main/res/values-nl/strings.xml +++ b/ultrasonic/src/main/res/values-nl/strings.xml @@ -402,6 +402,13 @@ All Bluetooth devices Only audio (A2DP) devices Disabled + Debug options + Write debug log to file + The log files are available at %1$s/%2$s + There are %1$s log files taking up ~%2$s MB space in the %3$s directory. Do you want to keep these? + Keep files + Delete files + Deleted log files. Ultrasonic can\'t access the music file cache. Cache location was reset to the default path. Warning diff --git a/ultrasonic/src/main/res/values-pl/strings.xml b/ultrasonic/src/main/res/values-pl/strings.xml index b2896d75..741b44fd 100644 --- a/ultrasonic/src/main/res/values-pl/strings.xml +++ b/ultrasonic/src/main/res/values-pl/strings.xml @@ -402,6 +402,13 @@ ponieważ api Subsonic nie wspiera nowego sposobu autoryzacji dla użytkowników All Bluetooth devices Only audio (A2DP) devices Disabled + Debug options + Write debug log to file + The log files are available at %1$s/%2$s + There are %1$s log files taking up ~%2$s MB space in the %3$s directory. Do you want to keep these? + Keep files + Delete files + Deleted log files. Ultrasonic can\'t access the music file cache. Cache location was reset to the default path. Warning diff --git a/ultrasonic/src/main/res/values-pt-rBR/strings.xml b/ultrasonic/src/main/res/values-pt-rBR/strings.xml index 8fb726ab..3e75b94e 100644 --- a/ultrasonic/src/main/res/values-pt-rBR/strings.xml +++ b/ultrasonic/src/main/res/values-pt-rBR/strings.xml @@ -402,6 +402,13 @@ All Bluetooth devices Only audio (A2DP) devices Disabled + Debug options + Write debug log to file + The log files are available at %1$s/%2$s + There are %1$s log files taking up ~%2$s MB space in the %3$s directory. Do you want to keep these? + Keep files + Delete files + Deleted log files. Ultrasonic can\'t access the music file cache. Cache location was reset to the default path. Warning diff --git a/ultrasonic/src/main/res/values-pt/strings.xml b/ultrasonic/src/main/res/values-pt/strings.xml index 0ce98f5d..7de65351 100644 --- a/ultrasonic/src/main/res/values-pt/strings.xml +++ b/ultrasonic/src/main/res/values-pt/strings.xml @@ -402,6 +402,13 @@ All Bluetooth devices Only audio (A2DP) devices Disabled + Debug options + Write debug log to file + The log files are available at %1$s/%2$s + There are %1$s log files taking up ~%2$s MB space in the %3$s directory. Do you want to keep these? + Keep files + Delete files + Deleted log files. Ultrasonic can\'t access the music file cache. Cache location was reset to the default path. Warning diff --git a/ultrasonic/src/main/res/values/strings.xml b/ultrasonic/src/main/res/values/strings.xml index e3c5ca39..bf5b2cc1 100644 --- a/ultrasonic/src/main/res/values/strings.xml +++ b/ultrasonic/src/main/res/values/strings.xml @@ -405,6 +405,13 @@ All Bluetooth devices Only audio (A2DP) devices Disabled + Debug options + Write debug log to file + The log files are available at %1$s/%2$s + There are %1$s log files taking up ~%2$s MB space in the %3$s directory. Do you want to keep these? + Keep files + Delete files + Deleted log files. Ultrasonic can\'t access the music file cache. Cache location was reset to the default path. Warning diff --git a/ultrasonic/src/main/res/xml/settings.xml b/ultrasonic/src/main/res/xml/settings.xml index 51d61dfb..25a15b86 100644 --- a/ultrasonic/src/main/res/xml/settings.xml +++ b/ultrasonic/src/main/res/xml/settings.xml @@ -305,7 +305,14 @@ a:title="@string/feature_flags_five_star_rating_title" a:summary="@string/feature_flags_five_star_rating_description" /> - + + + \ No newline at end of file