moved to registerForActivityResult and added timers to exports

This commit is contained in:
ronniedroid
2024-04-18 13:05:54 +03:00
parent b4503fc41d
commit 2a381a3ee6
12 changed files with 271 additions and 184 deletions

View File

@@ -1,16 +1,13 @@
package org.fossify.clock.activities
import android.annotation.SuppressLint
import android.content.ActivityNotFoundException
import android.content.Intent
import android.content.pm.ShortcutInfo
import android.graphics.drawable.ColorDrawable
import android.graphics.drawable.Icon
import android.graphics.drawable.LayerDrawable
import android.net.Uri
import android.os.Bundle
import android.view.WindowManager
import android.widget.Toast
import me.grantland.widget.AutofitHelper
import org.fossify.clock.BuildConfig
import org.fossify.clock.R
@@ -22,10 +19,6 @@ import org.fossify.commons.databinding.BottomTablayoutItemBinding
import org.fossify.commons.extensions.*
import org.fossify.commons.helpers.*
import org.fossify.commons.models.FAQItem
import org.fossify.clock.dialogs.ExportAlarmsDialog
import org.fossify.commons.dialogs.FilePickerDialog
import java.io.FileOutputStream
import java.io.OutputStream
class MainActivity : SimpleActivity() {
private var storedTextColor = 0
@@ -33,11 +26,6 @@ class MainActivity : SimpleActivity() {
private var storedPrimaryColor = 0
private val binding: ActivityMainBinding by viewBinding(ActivityMainBinding::inflate)
private companion object {
private const val PICK_IMPORT_SOURCE_INTENT = 11
private const val PICK_EXPORT_FILE_INTENT = 21
}
override fun onCreate(savedInstanceState: Bundle?) {
isMaterialActivity = true
super.onCreate(savedInstanceState)
@@ -142,8 +130,6 @@ class MainActivity : SimpleActivity() {
R.id.more_apps_from_us -> launchMoreAppsFromUsIntent()
R.id.settings -> launchSettings()
R.id.about -> launchAbout()
R.id.export_alarms -> tryExportAlarms()
R.id.import_alarms -> tryImportAlarms()
else -> return@setOnMenuItemClickListener false
}
return@setOnMenuItemClickListener true
@@ -153,8 +139,6 @@ class MainActivity : SimpleActivity() {
private fun refreshMenuItems() {
binding.mainToolbar.menu.apply {
findItem(R.id.sort).isVisible = binding.viewPager.currentItem == TAB_ALARM
findItem(R.id.export_alarms).isVisible = binding.viewPager.currentItem == TAB_ALARM
findItem(R.id.import_alarms).isVisible = binding.viewPager.currentItem == TAB_ALARM
findItem(R.id.more_apps_from_us).isVisible = !resources.getBoolean(org.fossify.commons.R.bool.hide_google_relations)
}
}
@@ -188,13 +172,6 @@ class MainActivity : SimpleActivity() {
requestCode == PICK_AUDIO_FILE_INTENT_ID && resultCode == RESULT_OK && resultData != null -> {
storeNewAlarmSound(resultData)
}
requestCode == PICK_EXPORT_FILE_INTENT && resultCode == RESULT_OK && resultData != null && resultData.data != null -> {
val outputStream = contentResolver.openOutputStream(resultData.data!!)
exportAlarmsTo(outputStream)
}
requestCode == PICK_IMPORT_SOURCE_INTENT && resultCode == RESULT_OK && resultData != null && resultData.data != null -> {
tryImportAlarmsFromFile(resultData.data!!)
}
}
}
@@ -320,118 +297,4 @@ class MainActivity : SimpleActivity() {
startAboutActivity(R.string.app_name, licenses, BuildConfig.VERSION_NAME, faqItems, true)
}
private fun exportAlarmsTo(outputStream: OutputStream?) {
ensureBackgroundThread {
val alarms = dbHelper.getAlarms()
if (alarms.isEmpty()) {
toast(org.fossify.commons.R.string.no_entries_for_exporting)
} else {
AlarmsExporter.exportAlarms(alarms, outputStream) {
toast(
when (it) {
ExportResult.EXPORT_OK -> org.fossify.commons.R.string.exporting_successful
else -> org.fossify.commons.R.string.exporting_failed
}
)
}
}
}
}
private fun tryExportAlarms() {
if (isQPlus()) {
ExportAlarmsDialog(this, config.lastAlarmsExportPath, true) { file ->
Intent(Intent.ACTION_CREATE_DOCUMENT).apply {
type = "application/json"
putExtra(Intent.EXTRA_TITLE, file.name)
addCategory(Intent.CATEGORY_OPENABLE)
try {
startActivityForResult(this, PICK_EXPORT_FILE_INTENT)
} catch (e: ActivityNotFoundException) {
toast(org.fossify.commons.R.string.system_service_disabled, Toast.LENGTH_LONG)
} catch (e: Exception) {
showErrorToast(e)
}
}
}
} else {
handlePermission(PERMISSION_WRITE_STORAGE) { isAllowed ->
if (isAllowed) {
ExportAlarmsDialog(this, config.lastAlarmsExportPath, false) { file ->
getFileOutputStream(file.toFileDirItem(this), true) { out ->
exportAlarmsTo(out)
}
}
}
}
}
}
private fun tryImportAlarms() {
if (isQPlus()) {
Intent(Intent.ACTION_GET_CONTENT).apply {
addCategory(Intent.CATEGORY_OPENABLE)
type = "application/json"
try {
startActivityForResult(this, PICK_IMPORT_SOURCE_INTENT)
} catch (e: ActivityNotFoundException) {
toast(org.fossify.commons.R.string.system_service_disabled, Toast.LENGTH_LONG)
} catch (e: Exception) {
showErrorToast(e)
}
}
} else {
handlePermission(PERMISSION_READ_STORAGE) { isAllowed ->
if (isAllowed) {
pickFileToImportAlarms()
}
}
}
}
private fun pickFileToImportAlarms() {
FilePickerDialog(this) {
importAlarms(it)
}
}
private fun tryImportAlarmsFromFile(uri: Uri) {
when (uri.scheme) {
"file" -> importAlarms(uri.path!!)
"content" -> {
val tempFile = getTempFile("alarms", "alarms.json")
if (tempFile == null) {
toast(org.fossify.commons.R.string.unknown_error_occurred)
return
}
try {
val inputStream = contentResolver.openInputStream(uri)
val out = FileOutputStream(tempFile)
inputStream!!.copyTo(out)
importAlarms(tempFile.absolutePath)
} catch (e: Exception) {
showErrorToast(e)
}
}
else -> toast(org.fossify.commons.R.string.invalid_file_format)
}
}
private fun importAlarms(path: String) {
ensureBackgroundThread {
val result = AlarmsImporter(this, DBHelper.dbInstance!!).importAlarms(path)
toast(
when (result) {
AlarmsImporter.ImportResult.IMPORT_OK ->
org.fossify.commons.R.string.importing_successful
AlarmsImporter.ImportResult.IMPORT_FAIL -> org.fossify.commons.R.string.no_items_found
}
)
}
}
}

View File

@@ -1,16 +1,22 @@
package org.fossify.clock.activities
import android.content.ActivityNotFoundException
import android.content.Intent
import android.os.Bundle
import android.widget.Toast
import androidx.activity.result.contract.ActivityResultContracts
import org.fossify.clock.databinding.ActivitySettingsBinding
import org.fossify.clock.dialogs.ExportDataDialog
import org.fossify.clock.extensions.config
import org.fossify.clock.helpers.DEFAULT_MAX_ALARM_REMINDER_SECS
import org.fossify.clock.helpers.DEFAULT_MAX_TIMER_REMINDER_SECS
import org.fossify.clock.extensions.dbHelper
import org.fossify.clock.extensions.timerDb
import org.fossify.clock.extensions.timerHelper
import org.fossify.clock.helpers.*
import org.fossify.commons.R
import org.fossify.clock.R as CR
import org.fossify.commons.extensions.*
import org.fossify.commons.helpers.IS_CUSTOMIZING_COLORS
import org.fossify.commons.helpers.MINUTE_SECONDS
import org.fossify.commons.helpers.NavigationIcon
import org.fossify.commons.helpers.isTiramisuPlus
import org.fossify.commons.helpers.*
import java.io.OutputStream
import java.util.Locale
import kotlin.system.exitProcess
@@ -42,6 +48,7 @@ class SettingsActivity : SimpleActivity() {
setupTimerMaxReminder()
setupIncreaseVolumeGradually()
setupCustomizeWidgetColors()
setupExportData()
updateTextColors(binding.settingsHolder)
arrayOf(
@@ -170,4 +177,132 @@ class SettingsActivity : SimpleActivity() {
}
}
}
private fun setupExportData() {
binding.settingsExportDataHolder.setOnClickListener {
tryExportData()
}
}
private val exportActivityResultLauncher = registerForActivityResult(ActivityResultContracts.CreateDocument("application/json")) { uri ->
try {
val outputStream = uri?.let { contentResolver.openOutputStream(it) }
if (outputStream != null) {
exportDataTo(outputStream)
} else {
toast(CR.string.exporting_aborted_by_user)
}
} catch (e: Exception) {
showErrorToast(e)
}
}
private fun exportDataTo(outputStream: OutputStream?) {
ensureBackgroundThread {
val alarms = dbHelper.getAlarms()
val timers = timerDb.getTimers()
if (alarms.isEmpty()) {
toast(R.string.no_entries_for_exporting)
} else {
DataExporter.exportData(alarms, timers, outputStream) {
toast(
when (it) {
ExportResult.EXPORT_OK -> R.string.exporting_successful
else -> R.string.exporting_failed
}
)
}
}
}
}
private fun tryExportData() {
if (isQPlus()) {
ExportDataDialog(this, config.lastDataExportPath, true) { file ->
Intent(Intent.ACTION_CREATE_DOCUMENT).apply {
putExtra(Intent.EXTRA_TITLE, file.name)
addCategory(Intent.CATEGORY_OPENABLE)
try {
exportActivityResultLauncher.launch(file.name)
} catch (e: ActivityNotFoundException) {
toast(R.string.system_service_disabled, Toast.LENGTH_LONG)
} catch (e: Exception) {
showErrorToast(e)
}
}
}
} else {
handlePermission(PERMISSION_WRITE_STORAGE) { isAllowed ->
if (isAllowed) {
ExportDataDialog(this, config.lastDataExportPath, false) { file ->
getFileOutputStream(file.toFileDirItem(this), true) { out ->
exportDataTo(out)
}
}
}
}
}
}
// private fun tryImportData() {
// if (isQPlus()) {
// Intent(Intent.ACTION_GET_CONTENT).apply {
// addCategory(Intent.CATEGORY_OPENABLE)
// type = "application/json"
// }
// } else {
// handlePermission(PERMISSION_READ_STORAGE) { isAllowed ->
// if (isAllowed) {
// pickFileToImportData()
// }
// }
// }
// }
//
// private fun pickFileToImportData() {
// FilePickerDialog(this) {
// importData(it)
// }
// }
//
// private fun tryImportDataFromFile(uri: Uri) {
// when (uri.scheme) {
// "file" -> importData(uri.path!!)
// "content" -> {
// val tempFile = getTempFile("fossify_clock_data", "fossify_clock_data.json")
// if (tempFile == null) {
// toast(R.string.unknown_error_occurred)
// return
// }
//
// try {
// val inputStream = contentResolver.openInputStream(uri)
// val out = FileOutputStream(tempFile)
// inputStream!!.copyTo(out)
// importData(tempFile.absolutePath)
// } catch (e: Exception) {
// showErrorToast(e)
// }
// }
//
// else -> toast(R.string.invalid_file_format)
// }
// }
//
// private fun importData(path: String) {
// ensureBackgroundThread {
// val result = AlarmsImporter(this, DBHelper.dbInstance!!).importAlarms(path)
// toast(
// when (result) {
// AlarmsImporter.ImportResult.IMPORT_OK ->
// R.string.importing_successful
// AlarmsImporter.ImportResult.IMPORT_FAIL -> R.string.no_items_found
// }
// )
// }
// }
}

View File

@@ -6,12 +6,12 @@ import org.fossify.commons.dialogs.FilePickerDialog
import org.fossify.commons.extensions.*
import org.fossify.commons.helpers.ensureBackgroundThread
import org.fossify.clock.R
import org.fossify.clock.databinding.DialogExportAlarmsBinding
import org.fossify.clock.databinding.DialogExportDataBinding
import org.fossify.clock.extensions.config
import org.fossify.clock.helpers.ALARMS_EXPORT_EXTENSION
import org.fossify.clock.helpers.DATA_EXPORT_EXTENSION
import java.io.File
class ExportAlarmsDialog(
class ExportDataDialog(
val activity: BaseSimpleActivity,
val path: String,
val hidePath: Boolean,
@@ -21,17 +21,17 @@ class ExportAlarmsDialog(
private val config = activity.config
init {
val view = DialogExportAlarmsBinding.inflate(activity.layoutInflater, null, false).apply {
exportAlarmsFolder.text = activity.humanizePath(realPath)
exportAlarmsFilename.setText("${activity.getString(R.string.export_alarms)}_${activity.getCurrentFormattedDateTime()}")
val view = DialogExportDataBinding.inflate(activity.layoutInflater, null, false).apply {
exportDataFolder.text = activity.humanizePath(realPath)
exportDataFilename.setText("${activity.getString(R.string.settings_export_data)}_${activity.getCurrentFormattedDateTime()}")
if (hidePath) {
exportAlarmsFolderLabel.beGone()
exportAlarmsFolder.beGone()
exportDataFolderLabel.beGone()
exportDataFolder.beGone()
} else {
exportAlarmsFolder.setOnClickListener {
exportDataFolder.setOnClickListener {
FilePickerDialog(activity, realPath, false, showFAB = true) {
exportAlarmsFolder.text = activity.humanizePath(it)
exportDataFolder.text = activity.humanizePath(it)
realPath = it
}
}
@@ -42,21 +42,21 @@ class ExportAlarmsDialog(
.setPositiveButton(org.fossify.commons.R.string.ok, null)
.setNegativeButton(org.fossify.commons.R.string.cancel, null)
.apply {
activity.setupDialogStuff(view.root, this, R.string.export_alarms) { alertDialog ->
alertDialog.showKeyboard(view.exportAlarmsFilename)
activity.setupDialogStuff(view.root, this, R.string.settings_export_data) { alertDialog ->
alertDialog.showKeyboard(view.exportDataFilename)
alertDialog.getButton(AlertDialog.BUTTON_POSITIVE).setOnClickListener {
val filename = view.exportAlarmsFilename.value
val filename = view.exportDataFilename.value
when {
filename.isEmpty() -> activity.toast(org.fossify.commons.R.string.empty_name)
filename.isAValidFilename() -> {
val file = File(realPath, "$filename$ALARMS_EXPORT_EXTENSION")
val file = File(realPath, "$filename$DATA_EXPORT_EXTENSION")
if (!hidePath && file.exists()) {
activity.toast(org.fossify.commons.R.string.name_taken)
return@setOnClickListener
}
ensureBackgroundThread {
config.lastAlarmsExportPath = file.absolutePath.getParentPath()
config.lastDataExportPath = file.absolutePath.getParentPath()
callback(file)
alertDialog.dismiss()
}

View File

@@ -105,7 +105,7 @@ class Config(context: Context) : BaseConfig(context) {
get() = prefs.getBoolean(WAS_INITIAL_WIDGET_SET_UP, false)
set(wasInitialWidgetSetUp) = prefs.edit().putBoolean(WAS_INITIAL_WIDGET_SET_UP, wasInitialWidgetSetUp).apply()
var lastAlarmsExportPath: String
get() = prefs.getString(LAST_ALARMS_EXPORT_PATH, "")!!
set(lastBlockedNumbersExportPath) = prefs.edit().putString(LAST_ALARMS_EXPORT_PATH, lastBlockedNumbersExportPath).apply()
var lastDataExportPath: String
get() = prefs.getString(LAST_DATA_EXPORT_PATH, "")!!
set(lastDataExportPath) = prefs.edit().putString(LAST_DATA_EXPORT_PATH, lastDataExportPath).apply()
}

View File

@@ -26,8 +26,8 @@ const val INCREASE_VOLUME_GRADUALLY = "increase_volume_gradually"
const val ALARMS_SORT_BY = "alarms_sort_by"
const val STOPWATCH_LAPS_SORT_BY = "stopwatch_laps_sort_by"
const val WAS_INITIAL_WIDGET_SET_UP = "was_initial_widget_set_up"
const val ALARMS_EXPORT_EXTENSION = ".json"
const val LAST_ALARMS_EXPORT_PATH = "last_alarms_export_path"
const val DATA_EXPORT_EXTENSION = ".json"
const val LAST_DATA_EXPORT_PATH = "last_alarms_export_path"
const val TABS_COUNT = 4
const val EDITED_TIME_ZONE_SEPARATOR = ":"

View File

@@ -1,12 +1,15 @@
package org.fossify.clock.helpers
import org.fossify.clock.models.Alarm
import org.fossify.clock.models.Timer
import org.fossify.commons.helpers.ExportResult
import java.io.OutputStream
object AlarmsExporter {
fun exportAlarms(
object DataExporter {
fun exportData(
alarms: ArrayList<Alarm>,
timers: List<Timer>,
outputStream: OutputStream?,
callback: (result: ExportResult) -> Unit,
) {
@@ -16,10 +19,13 @@ object AlarmsExporter {
}
val alarmsToExport = alarmsToJSON(alarms)
val timersToExport = timersToJSON(timers)
val dataToExport = "{\"alarms\": $alarmsToExport, \"timers\": $timersToExport"
try {
outputStream.bufferedWriter().use { out ->
out.write(alarmsToExport)
out.write(dataToExport)
}
callback.invoke(ExportResult.EXPORT_OK)
} catch (e: Exception) {
@@ -27,6 +33,7 @@ object AlarmsExporter {
}
}
// Replace with a generic later
private fun alarmsToJSON(alarms: List<Alarm>?): String {
if (alarms.isNullOrEmpty()) {
return "[]"
@@ -40,4 +47,16 @@ object AlarmsExporter {
return "[${jsonAlarms.joinToString(",")}]"
}
private fun timersToJSON(timers: List<Timer>?): String {
if (timers.isNullOrEmpty()) {
return "[]"
}
val jsonTimers = mutableListOf<String>()
for (timer in timers) {
jsonTimers.add(timer.toJSON())
}
return "[${jsonTimers.joinToString(",")}]"
}
}

View File

@@ -8,7 +8,7 @@ import org.json.JSONObject
import java.io.File
class AlarmsImporter(
class DataImporter(
private val activity: Activity,
private val dbHelper: DBHelper,
) {
@@ -16,7 +16,7 @@ class AlarmsImporter(
IMPORT_FAIL, IMPORT_OK
}
fun importAlarms(path: String): ImportResult {
fun importData(path: String): ImportResult {
return try {
val inputStream = File(path).inputStream()
val jsonString = inputStream.bufferedReader().use { it.readText().trimEnd() }

View File

@@ -3,6 +3,7 @@ package org.fossify.clock.models
import androidx.annotation.Keep
import androidx.room.Entity
import androidx.room.PrimaryKey
import org.json.JSONObject
@Entity(tableName = "timers")
@Keep
@@ -17,7 +18,22 @@ data class Timer(
var createdAt: Long,
var channelId: String? = null,
var oneShot: Boolean = false,
)
) {
@Keep
fun toJSON(): String {
val jsonObject = JSONObject()
jsonObject.put("id", id)
jsonObject.put("state", state)
jsonObject.put("vibrate", vibrate)
jsonObject.put("soundUri", soundUri)
jsonObject.put("soundTitle", soundTitle)
jsonObject.put("label", label)
jsonObject.put("createdAt", createdAt)
jsonObject.put("channelId", channelId)
jsonObject.put("oneShot", oneShot)
return jsonObject.toString()
}
}
@Keep
data class ObfuscatedTimer(

View File

@@ -283,6 +283,63 @@
tools:text="1 minute" />
</RelativeLayout>
<include
android:id="@+id/settings_timer_tab_divider"
layout="@layout/divider"/>
<TextView
android:id="@+id/settings_export_and_import"
style="@style/SettingsSectionLabelStyle"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:text="@string/settings_export_and_import" />
<RelativeLayout
android:id="@+id/settings_export_data_holder"
style="@style/SettingsHolderTextViewStyle"
android:layout_width="match_parent"
android:layout_height="wrap_content">
<org.fossify.commons.views.MyTextView
android:id="@+id/settings_export_data_label"
style="@style/SettingsTextLabelStyle"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="@string/settings_export_data" />
<org.fossify.commons.views.MyTextView
android:id="@+id/settings_export_data"
style="@style/SettingsTextValueStyle"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_below="@+id/settings_export_data_label"
tools:text="@+string/settings_export_data_subtitle" />
</RelativeLayout>
<RelativeLayout
android:id="@+id/settings_import_data_holder"
style="@style/SettingsHolderTextViewStyle"
android:layout_width="match_parent"
android:layout_height="wrap_content">
<org.fossify.commons.views.MyTextView
android:id="@+id/settings_import_data_label"
style="@style/SettingsTextLabelStyle"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="@string/settings_import_data" />
<org.fossify.commons.views.MyTextView
android:id="@+id/settings_import_data"
style="@style/SettingsTextValueStyle"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_below="@+id/settings_import_data_label"
tools:text="@+string/settings_import_data_subtitle" />
</RelativeLayout>
</LinearLayout>
</androidx.core.widget.NestedScrollView>
</androidx.coordinatorlayout.widget.CoordinatorLayout>

View File

@@ -5,7 +5,7 @@
android:layout_height="match_parent">
<LinearLayout
android:id="@+id/export_alarms_holder"
android:id="@+id/export_data_holder"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="vertical"
@@ -14,7 +14,7 @@
android:paddingRight="@dimen/activity_margin">
<org.fossify.commons.views.MyTextView
android:id="@+id/export_alarms_folder_label"
android:id="@+id/export_data_folder_label"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginStart="@dimen/small_margin"
@@ -22,7 +22,7 @@
android:textSize="@dimen/smaller_text_size" />
<org.fossify.commons.views.MyTextView
android:id="@+id/export_alarms_folder"
android:id="@+id/export_data_folder"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginBottom="@dimen/small_margin"
@@ -32,13 +32,13 @@
android:paddingBottom="@dimen/activity_margin" />
<org.fossify.commons.views.MyTextInputLayout
android:id="@+id/export_alarms_hint"
android:id="@+id/export_data_hint"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:hint="@string/filename_without_json">
<com.google.android.material.textfield.TextInputEditText
android:id="@+id/export_alarms_filename"
android:id="@+id/export_data_filename"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginBottom="@dimen/activity_margin"

View File

@@ -11,14 +11,6 @@
android:icon="@drawable/ic_settings_cog_vector"
android:title="@string/settings"
app:showAsAction="ifRoom" />
<item
android:id="@+id/export_alarms"
android:title="@string/export_alarms"
app:showAsAction="never" />
<item
android:id="@+id/import_alarms"
android:title="@string/import_alarms"
app:showAsAction="never" />
<item
android:id="@+id/about"
android:icon="@drawable/ic_info_vector"

View File

@@ -32,8 +32,7 @@
<string name="add_timer">Add timer</string>
<string name="upcoming_alarm">Upcoming alarm</string>
<string name="early_alarm_dismissal">Early alarm dismissal</string>
<string name="import_alarms">Import alarms</string>
<string name="export_alarms">Export alarms</string>
<!-- Timer -->
<string name="timers_notification_msg">Timers are running</string>
<string name="timer_single_notification_label_msg">Timer for %s is running</string>
@@ -51,6 +50,12 @@
<string name="timer_tab">Timer tab</string>
<string name="show_seconds">Show seconds</string>
<string name="increase_volume_gradually">Increase volume gradually</string>
<string name="settings_export_and_import">Export and import</string>
<string name="settings_import_data">Import data</string>
<string name="settings_import_data_subtitle">Import app data (Alarms and Timers)</string>
<string name="settings_export_data">Export data</string>
<string name="settings_export_data_subtitle">Export app data (Alarms and Timers)</string>
<string name="exporting_aborted_by_user">Exporting aborted by user</string>
<!-- FAQ -->
<string name="faq_1_title">How can I change lap sorting at the stopwatch tab?</string>