From b4503fc41d46ea18062ce5a7dabbf9cee46cc4e7 Mon Sep 17 00:00:00 2001 From: ronniedroid Date: Tue, 2 Apr 2024 11:30:15 +0300 Subject: [PATCH] FEAT: added alram export and import functionality --- .../fossify/clock/activities/MainActivity.kt | 148 +++++++++++++++++- .../clock/dialogs/ExportAlarmsDialog.kt | 72 +++++++++ .../fossify/clock/helpers/AlarmsExporter.kt | 43 +++++ .../fossify/clock/helpers/AlarmsImporter.kt | 74 +++++++++ .../org/fossify/clock/helpers/Config.kt | 4 + .../org/fossify/clock/helpers/Constants.kt | 2 + .../kotlin/org/fossify/clock/models/Alarm.kt | 18 ++- .../main/res/layout/dialog_export_alarms.xml | 51 ++++++ app/src/main/res/menu/menu.xml | 8 + app/src/main/res/values/strings.xml | 3 +- 10 files changed, 415 insertions(+), 8 deletions(-) create mode 100644 app/src/main/kotlin/org/fossify/clock/dialogs/ExportAlarmsDialog.kt create mode 100644 app/src/main/kotlin/org/fossify/clock/helpers/AlarmsExporter.kt create mode 100644 app/src/main/kotlin/org/fossify/clock/helpers/AlarmsImporter.kt create mode 100644 app/src/main/res/layout/dialog_export_alarms.xml diff --git a/app/src/main/kotlin/org/fossify/clock/activities/MainActivity.kt b/app/src/main/kotlin/org/fossify/clock/activities/MainActivity.kt index 6f33aa8d..48bf8bb2 100644 --- a/app/src/main/kotlin/org/fossify/clock/activities/MainActivity.kt +++ b/app/src/main/kotlin/org/fossify/clock/activities/MainActivity.kt @@ -1,27 +1,31 @@ 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 import org.fossify.clock.adapters.ViewPagerAdapter import org.fossify.clock.databinding.ActivityMainBinding -import org.fossify.clock.extensions.config -import org.fossify.clock.extensions.getEnabledAlarms -import org.fossify.clock.extensions.rescheduleEnabledAlarms -import org.fossify.clock.extensions.updateWidgets +import org.fossify.clock.extensions.* import org.fossify.clock.helpers.* 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 @@ -29,6 +33,11 @@ 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) @@ -133,6 +142,8 @@ 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 @@ -142,6 +153,8 @@ 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) } } @@ -171,8 +184,17 @@ class MainActivity : SimpleActivity() { override fun onActivityResult(requestCode: Int, resultCode: Int, resultData: Intent?) { super.onActivityResult(requestCode, resultCode, resultData) - if (requestCode == PICK_AUDIO_FILE_INTENT_ID && resultCode == RESULT_OK && resultData != null) { - storeNewAlarmSound(resultData) + when { + 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!!) + } } } @@ -298,4 +320,118 @@ 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 + } + ) + } + } } diff --git a/app/src/main/kotlin/org/fossify/clock/dialogs/ExportAlarmsDialog.kt b/app/src/main/kotlin/org/fossify/clock/dialogs/ExportAlarmsDialog.kt new file mode 100644 index 00000000..de48d2e4 --- /dev/null +++ b/app/src/main/kotlin/org/fossify/clock/dialogs/ExportAlarmsDialog.kt @@ -0,0 +1,72 @@ +package org.fossify.clock.dialogs + +import androidx.appcompat.app.AlertDialog +import org.fossify.commons.activities.BaseSimpleActivity +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.extensions.config +import org.fossify.clock.helpers.ALARMS_EXPORT_EXTENSION +import java.io.File + +class ExportAlarmsDialog( + val activity: BaseSimpleActivity, + val path: String, + val hidePath: Boolean, + callback: (file: File) -> Unit, +) { + private var realPath = path.ifEmpty { activity.internalStoragePath } + 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()}") + + if (hidePath) { + exportAlarmsFolderLabel.beGone() + exportAlarmsFolder.beGone() + } else { + exportAlarmsFolder.setOnClickListener { + FilePickerDialog(activity, realPath, false, showFAB = true) { + exportAlarmsFolder.text = activity.humanizePath(it) + realPath = it + } + } + } + } + + activity.getAlertDialogBuilder() + .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) + alertDialog.getButton(AlertDialog.BUTTON_POSITIVE).setOnClickListener { + val filename = view.exportAlarmsFilename.value + when { + filename.isEmpty() -> activity.toast(org.fossify.commons.R.string.empty_name) + filename.isAValidFilename() -> { + val file = File(realPath, "$filename$ALARMS_EXPORT_EXTENSION") + if (!hidePath && file.exists()) { + activity.toast(org.fossify.commons.R.string.name_taken) + return@setOnClickListener + } + + ensureBackgroundThread { + config.lastAlarmsExportPath = file.absolutePath.getParentPath() + callback(file) + alertDialog.dismiss() + } + } + + else -> activity.toast(org.fossify.commons.R.string.invalid_name) + } + } + } + } + } +} + diff --git a/app/src/main/kotlin/org/fossify/clock/helpers/AlarmsExporter.kt b/app/src/main/kotlin/org/fossify/clock/helpers/AlarmsExporter.kt new file mode 100644 index 00000000..28e4896e --- /dev/null +++ b/app/src/main/kotlin/org/fossify/clock/helpers/AlarmsExporter.kt @@ -0,0 +1,43 @@ +package org.fossify.clock.helpers + +import org.fossify.clock.models.Alarm +import org.fossify.commons.helpers.ExportResult +import java.io.OutputStream + +object AlarmsExporter { + fun exportAlarms( + alarms: ArrayList, + outputStream: OutputStream?, + callback: (result: ExportResult) -> Unit, + ) { + if (outputStream == null) { + callback.invoke(ExportResult.EXPORT_FAIL) + return + } + + val alarmsToExport = alarmsToJSON(alarms) + + try { + outputStream.bufferedWriter().use { out -> + out.write(alarmsToExport) + } + callback.invoke(ExportResult.EXPORT_OK) + } catch (e: Exception) { + callback.invoke(ExportResult.EXPORT_FAIL) + } + } + + private fun alarmsToJSON(alarms: List?): String { + if (alarms.isNullOrEmpty()) { + return "[]" + } + + val jsonAlarms = mutableListOf() + for (alarm in alarms) { + jsonAlarms.add(alarm.toJSON()) + } + + return "[${jsonAlarms.joinToString(",")}]" + } + +} diff --git a/app/src/main/kotlin/org/fossify/clock/helpers/AlarmsImporter.kt b/app/src/main/kotlin/org/fossify/clock/helpers/AlarmsImporter.kt new file mode 100644 index 00000000..7c97f8fd --- /dev/null +++ b/app/src/main/kotlin/org/fossify/clock/helpers/AlarmsImporter.kt @@ -0,0 +1,74 @@ +package org.fossify.clock.helpers + +import android.app.Activity +import org.fossify.clock.models.Alarm +import org.fossify.commons.extensions.showErrorToast +import org.json.JSONArray +import org.json.JSONObject + +import java.io.File + +class AlarmsImporter( + private val activity: Activity, + private val dbHelper: DBHelper, +) { + enum class ImportResult { + IMPORT_FAIL, IMPORT_OK + } + + fun importAlarms(path: String): ImportResult { + return try { + val inputStream = File(path).inputStream() + val jsonString = inputStream.bufferedReader().use { it.readText().trimEnd() } + val jsonArray = JSONArray(jsonString) + + val insertedCount = insertAlarmsFromJSON(jsonArray) + if (insertedCount > 0) { + ImportResult.IMPORT_OK + } else { + ImportResult.IMPORT_FAIL + } + } catch (e: Exception) { + activity.showErrorToast(e) + ImportResult.IMPORT_FAIL + } + } + + private fun insertAlarmsFromJSON(jsonArray: JSONArray): Int { + var insertedCount = 0 + for (i in 0 until jsonArray.length()) { + val jsonObject = jsonArray.getJSONObject(i) + val alarm = parseAlarmFromJSON(jsonObject) + val insertedId = dbHelper.insertAlarm(alarm) + if (insertedId != -1) { + insertedCount++ + } + } + return insertedCount + } + + private fun parseAlarmFromJSON(jsonObject: JSONObject): Alarm { + val id = jsonObject.getInt("id") + val timeInMinutes = jsonObject.getInt("timeInMinutes") + val days = jsonObject.getInt("days") + val isEnabled = jsonObject.getBoolean("isEnabled") + val vibrate = jsonObject.getBoolean("vibrate") + val soundTitle = jsonObject.getString("soundTitle") + val soundUri = jsonObject.getString("soundUri") + val label = jsonObject.getString("label") + val oneShot = jsonObject.optBoolean("oneShot", false) + + return Alarm( + id, + timeInMinutes, + days, + isEnabled, + vibrate, + soundTitle, + soundUri, + label, + oneShot + ) + } +} + diff --git a/app/src/main/kotlin/org/fossify/clock/helpers/Config.kt b/app/src/main/kotlin/org/fossify/clock/helpers/Config.kt index 2dbf8cdc..94d7a75c 100644 --- a/app/src/main/kotlin/org/fossify/clock/helpers/Config.kt +++ b/app/src/main/kotlin/org/fossify/clock/helpers/Config.kt @@ -104,4 +104,8 @@ class Config(context: Context) : BaseConfig(context) { var wasInitialWidgetSetUp: Boolean 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() } diff --git a/app/src/main/kotlin/org/fossify/clock/helpers/Constants.kt b/app/src/main/kotlin/org/fossify/clock/helpers/Constants.kt index 99fd99c4..214926cc 100644 --- a/app/src/main/kotlin/org/fossify/clock/helpers/Constants.kt +++ b/app/src/main/kotlin/org/fossify/clock/helpers/Constants.kt @@ -26,6 +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 TABS_COUNT = 4 const val EDITED_TIME_ZONE_SEPARATOR = ":" diff --git a/app/src/main/kotlin/org/fossify/clock/models/Alarm.kt b/app/src/main/kotlin/org/fossify/clock/models/Alarm.kt index fc939900..17b9d523 100644 --- a/app/src/main/kotlin/org/fossify/clock/models/Alarm.kt +++ b/app/src/main/kotlin/org/fossify/clock/models/Alarm.kt @@ -1,6 +1,7 @@ package org.fossify.clock.models import androidx.annotation.Keep +import org.json.JSONObject @Keep data class Alarm( @@ -13,7 +14,22 @@ data class Alarm( var soundUri: String, var label: String, var oneShot: Boolean = false, -) +) { + @Keep + fun toJSON(): String { + val jsonObject = JSONObject() + jsonObject.put("id", id) + jsonObject.put("timeInMinutes", timeInMinutes) + jsonObject.put("days", days) + jsonObject.put("isEnabled", isEnabled) + jsonObject.put("vibrate", vibrate) + jsonObject.put("soundTitle", soundTitle) + jsonObject.put("soundUri", soundUri) + jsonObject.put("label", label) + jsonObject.put("oneShot", oneShot) + return jsonObject.toString() + } +} @Keep data class ObfuscatedAlarm( diff --git a/app/src/main/res/layout/dialog_export_alarms.xml b/app/src/main/res/layout/dialog_export_alarms.xml new file mode 100644 index 00000000..dac0d742 --- /dev/null +++ b/app/src/main/res/layout/dialog_export_alarms.xml @@ -0,0 +1,51 @@ + + + + + + + + + + + + + + + + diff --git a/app/src/main/res/menu/menu.xml b/app/src/main/res/menu/menu.xml index a3db8c50..be670e05 100644 --- a/app/src/main/res/menu/menu.xml +++ b/app/src/main/res/menu/menu.xml @@ -11,6 +11,14 @@ android:icon="@drawable/ic_settings_cog_vector" android:title="@string/settings" app:showAsAction="ifRoom" /> + + Add timer Upcoming alarm Early alarm dismissal - + Import alarms + Export alarms Timers are running Timer for %s is running