FEAT: added alram export and import functionality

This commit is contained in:
ronniedroid
2024-04-02 11:30:15 +03:00
parent ee717aec1e
commit b4503fc41d
10 changed files with 415 additions and 8 deletions

View File

@@ -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
}
)
}
}
}

View File

@@ -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)
}
}
}
}
}
}

View File

@@ -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<Alarm>,
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<Alarm>?): String {
if (alarms.isNullOrEmpty()) {
return "[]"
}
val jsonAlarms = mutableListOf<String>()
for (alarm in alarms) {
jsonAlarms.add(alarm.toJSON())
}
return "[${jsonAlarms.joinToString(",")}]"
}
}

View File

@@ -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
)
}
}

View File

@@ -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()
}

View File

@@ -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 = ":"

View File

@@ -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(

View File

@@ -0,0 +1,51 @@
<?xml version="1.0" encoding="utf-8"?>
<ScrollView xmlns:android="http://schemas.android.com/apk/res/android"
android:id="@+id/export_blocked_keywords_wrapper"
android:layout_width="match_parent"
android:layout_height="match_parent">
<LinearLayout
android:id="@+id/export_alarms_holder"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="vertical"
android:paddingLeft="@dimen/activity_margin"
android:paddingTop="@dimen/activity_margin"
android:paddingRight="@dimen/activity_margin">
<org.fossify.commons.views.MyTextView
android:id="@+id/export_alarms_folder_label"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginStart="@dimen/small_margin"
android:text="@string/folder"
android:textSize="@dimen/smaller_text_size" />
<org.fossify.commons.views.MyTextView
android:id="@+id/export_alarms_folder"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginBottom="@dimen/small_margin"
android:paddingStart="@dimen/small_margin"
android:paddingEnd="@dimen/small_margin"
android:paddingTop="@dimen/small_margin"
android:paddingBottom="@dimen/activity_margin" />
<org.fossify.commons.views.MyTextInputLayout
android:id="@+id/export_alarms_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:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginBottom="@dimen/activity_margin"
android:singleLine="true"
android:textCursorDrawable="@null"
android:textSize="@dimen/normal_text_size" />
</org.fossify.commons.views.MyTextInputLayout>
</LinearLayout>
</ScrollView>

View File

@@ -11,6 +11,14 @@
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,7 +32,8 @@
<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>