Skip to content
This repository has been archived by the owner on Jul 26, 2022. It is now read-only.

Commit

Permalink
basic backup feature
Browse files Browse the repository at this point in the history
  • Loading branch information
y20k committed Apr 28, 2022
1 parent 7089579 commit 7a6c1d0
Show file tree
Hide file tree
Showing 13 changed files with 203 additions and 48 deletions.
2 changes: 1 addition & 1 deletion app/build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -67,7 +67,7 @@ dependencies {
implementation "org.jetbrains.kotlinx:kotlinx-coroutines-core:$coroutinesVersion"
implementation "org.jetbrains.kotlinx:kotlinx-coroutines-android:$coroutinesVersion"

implementation 'com.google.android.material:material:1.6.0-alpha03'
implementation 'com.google.android.material:material:1.6.0-rc01'

implementation "androidx.activity:activity-ktx:1.4.0"
implementation "androidx.appcompat:appcompat:1.4.1"
Expand Down
2 changes: 2 additions & 0 deletions app/src/main/java/org/y20k/transistor/Keys.kt
Original file line number Diff line number Diff line change
Expand Up @@ -147,6 +147,7 @@ object Keys {
const val MIME_TYPE_M3U = "audio/x-mpegurl"
const val MIME_TYPE_PLS = "audio/x-scpls"
const val MIME_TYPE_XML = "text/xml"
const val MIME_TYPE_ZIP = "application/zip"
const val MIME_TYPE_OCTET_STREAM = "application/octet-stream"
const val MIME_TYPE_UNSUPPORTED = "unsupported"
val MIME_TYPES_M3U = arrayOf("application/mpegurl", "application/x-mpegurl", "audio/mpegurl", "audio/x-mpegurl")
Expand All @@ -168,6 +169,7 @@ object Keys {
// file names and extensions
const val COLLECTION_FILE: String = "collection.json"
const val COLLECTION_M3U_FILE: String = "collection.m3u"
const val COLLECTION_BACKUP_FILE: String = "collection-backup.zip"
const val STATION_IMAGE_FILE: String = "station-image.jpg"
const val STATION_SMALL_IMAGE_FILE: String = "station-image-small.jpg"
const val DEBUG_LOG_FILE: String = "log-can-be-deleted.txt"
Expand Down
86 changes: 71 additions & 15 deletions app/src/main/java/org/y20k/transistor/SettingsFragment.kt
Original file line number Diff line number Diff line change
Expand Up @@ -109,6 +109,30 @@ class SettingsFragment: PreferenceFragmentCompat(), YesNoDialog.YesNoDialogListe
// }


// set up "M3U Export" preference
val preferenceM3uExport: Preference = Preference(activity as Context)
preferenceM3uExport.title = getString(R.string.pref_m3u_export_title)
preferenceM3uExport.setIcon(R.drawable.ic_playlist_24dp)
preferenceM3uExport.summary = getString(R.string.pref_m3u_export_summary)
preferenceM3uExport.setOnPreferenceClickListener {
openSaveM3uDialog()
return@setOnPreferenceClickListener true
}


// set up "Backup Stations" preference
val preferenceBackupStations: Preference = Preference(activity as Context)
preferenceBackupStations.title = "Backup Stations" // todo convert to res
// preferenceBackupStations.title = getString(R.string.pref_m3u_export_title)
preferenceBackupStations.setIcon(R.drawable.ic_save_24dp)
preferenceBackupStations.summary = "Save entire collection of radio stations including images to device storage." // todo convert to res
// preferenceBackupStations.summary = getString(R.string.pref_m3u_export_summary)
preferenceBackupStations.setOnPreferenceClickListener {
openBackupStationsDialog()
return@setOnPreferenceClickListener true
}


// set up "Edit Stream Address" preference
val preferenceEnableEditingStreamUri: SwitchPreferenceCompat = SwitchPreferenceCompat(activity as Context)
preferenceEnableEditingStreamUri.title = getString(R.string.pref_edit_station_stream_title)
Expand Down Expand Up @@ -156,17 +180,6 @@ class SettingsFragment: PreferenceFragmentCompat(), YesNoDialog.YesNoDialogListe
}


// set up "M3U Export" preference
val preferenceM3uExport: Preference = Preference(activity as Context)
preferenceM3uExport.title = getString(R.string.pref_m3u_export_title)
preferenceM3uExport.setIcon(R.drawable.ic_save_24dp)
preferenceM3uExport.summary = getString(R.string.pref_m3u_export_summary)
preferenceM3uExport.setOnPreferenceClickListener {
openSaveM3uDialog()
return@setOnPreferenceClickListener true
}


// set up "Report Issue" preference
val preferenceReportIssue: Preference = Preference(context)
preferenceReportIssue.title = getString(R.string.pref_report_issue_title)
Expand Down Expand Up @@ -212,9 +225,11 @@ class SettingsFragment: PreferenceFragmentCompat(), YesNoDialog.YesNoDialogListe
screen.addPreference(preferenceUpdateStationImages)
// screen.addPreference(preferenceUpdateCollection)
screen.addPreference(preferenceM3uExport)
screen.addPreference(preferenceBackupStations)
screen.addPreference(preferenceCategoryAdvanced)
screen.addPreference(preferenceEnableEditingGeneral)
screen.addPreference(preferenceEnableEditingStreamUri)

screen.addPreference(preferenceCategoryAbout)
screen.addPreference(preferenceAppVersion)
screen.addPreference(preferenceReportIssue)
Expand Down Expand Up @@ -251,11 +266,17 @@ class SettingsFragment: PreferenceFragmentCompat(), YesNoDialog.YesNoDialogListe

}

/* Register the ActivityResultLauncher */
private val requestSaveM3uLauncher =
registerForActivityResult(StartActivityForResult(), this::requestSaveM3uResult)

/* Pass the activity result */
/* Register the ActivityResultLauncher for the save m3u dialog */
private val requestSaveM3uLauncher = registerForActivityResult(StartActivityForResult(), this::requestSaveM3uResult)


/* Register the ActivityResultLauncher for the save m3u dialog */
private val requestBackupStationsLauncher = registerForActivityResult(StartActivityForResult(), this::requestBackupStationsResult)



/* Pass the activity result for the save m3u dialog */
private fun requestSaveM3uResult(result: ActivityResult) {
// save M3U file to result file location
if (result.resultCode == Activity.RESULT_OK && result.data != null) {
Expand All @@ -274,6 +295,22 @@ class SettingsFragment: PreferenceFragmentCompat(), YesNoDialog.YesNoDialogListe
}


/* Pass the activity result for the backup stations dialog */
private fun requestBackupStationsResult(result: ActivityResult) {
// save station backup file to result file location
if (result.resultCode == Activity.RESULT_OK && result.data != null) {
val targetUri: Uri? = result.data?.data
if (targetUri != null) {
BackupHelper.backup(activity as Context, targetUri)
Toast.makeText(activity as Context, "Backing up to $targetUri", Toast.LENGTH_LONG).show() // todo extract to res
LogHelper.e(TAG, "Backing up to $targetUri")
} else {
LogHelper.w(TAG, "Station backup failed.")
}
}
}


/* Updates collection */
private fun updateCollection() {
if (NetworkHelper.isConnectedToNetwork(activity as Context)) {
Expand Down Expand Up @@ -319,4 +356,23 @@ class SettingsFragment: PreferenceFragmentCompat(), YesNoDialog.YesNoDialogListe
}




/* Opens up a file picker to select the backup location */
private fun openBackupStationsDialog() {
val intent = Intent(Intent.ACTION_CREATE_DOCUMENT).apply {
addCategory(Intent.CATEGORY_OPENABLE)
type = Keys.MIME_TYPE_ZIP
putExtra(Intent.EXTRA_TITLE, Keys.COLLECTION_BACKUP_FILE)
}
// file gets saved in the ActivityResult
try {
requestBackupStationsLauncher.launch(intent)
} catch (exception: Exception) {
LogHelper.e(TAG, "Unable to save M3U.\n$exception")
Toast.makeText(activity as Context, R.string.toastmessage_install_file_helper, Toast.LENGTH_LONG).show()
}
}


}
107 changes: 107 additions & 0 deletions app/src/main/java/org/y20k/transistor/helpers/BackupHelper.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,107 @@
/*
* BackupHelper.kt
* Implements the BackupHelper object
* A BackupHelper provides helper methods for backing up and restoring the radio station collection
*
* This file is part of
* TRANSISTOR - Radio App for Android
*
* Copyright (c) 2015-22 - Y20K.org
* Licensed under the MIT-License
* http://opensource.org/licenses/MIT
*/


package org.y20k.transistor.helpers

import android.content.ContentResolver
import android.content.Context
import android.net.Uri
import java.io.*
import java.util.zip.ZipEntry
import java.util.zip.ZipOutputStream

object BackupHelper {

/* Define log tag */
private val TAG: String = LogHelper.makeLogTag(BackupHelper::class.java)


/* xyz */
fun backup(context: Context, destinationUri: Uri) {
val sourceFolder: File? = context.getExternalFilesDir("")
if (sourceFolder != null && sourceFolder.isDirectory) {
val resolver: ContentResolver = context.contentResolver
val outputStream: OutputStream? = resolver.openOutputStream(destinationUri)
ZipOutputStream(BufferedOutputStream(outputStream)).use { zipOutputStream ->
zipOutputStream.use {
zipFolder(it, sourceFolder, "")
}
}
} else {
LogHelper.e(TAG, "Unable to access External Storage.")
}
}


/* Compresses folder into ZIP file - Credit: https://stackoverflow.com/a/52216574 */
private fun zipFolder(zipOutputStream: ZipOutputStream, source: File, parentDirPath: String) {
// source.listFiles() will return null, if source is not a directory
if (source.isDirectory) {
val data = ByteArray(2048)
// get all File objects in folder
for (file in source.listFiles()!!) {
val path = parentDirPath + File.separator + file.name
when (file.isDirectory) {
// CASE: Folder
true -> {
// val entry = ZipEntry(path + File.separator) // add separator to make entry a folder
// entry.time = file.lastModified()
// entry.size = file.length()
// zipOutputStream.putNextEntry(entry)
// call zipFolder recursively to add files within this folder
zipFolder(zipOutputStream, file, path)
}
// CASE: File
false -> {
FileInputStream(file).use { fileInputStream ->
BufferedInputStream(fileInputStream).use { bufferedInputStream ->
val entry = ZipEntry(path)
entry.time = file.lastModified()
entry.size = file.length()
zipOutputStream.putNextEntry(entry)
while (true) {
val readBytes = bufferedInputStream.read(data)
if (readBytes == -1) {
break
}
zipOutputStream.write(data, 0, readBytes)
}
}
}
}
}
}

}
}


/* Normalize file path - protects against zip slip attack */
@Throws(IOException::class)
private fun getFile(destinationFolder: File, zipEntry: ZipEntry): File {
val destinationFile = File(destinationFolder, zipEntry.name)
val destinationFolderPath = destinationFolder.canonicalPath
val destinationFilePath = destinationFile.canonicalPath
// make sure that zipEntry path is in the destination folder
if (!destinationFilePath.startsWith(destinationFolderPath + File.separator)) {
throw IOException("ZIP entry is not within of the destination folder: " + zipEntry.name)
}
return destinationFile
}


}



9 changes: 9 additions & 0 deletions app/src/main/res/drawable/ic_playlist_24dp.xml
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24">
<path
android:fillColor="@color/icon_default"
android:pathData="M15,6H3v2h12V6zM15,10H3v2h12V10zM3,16h8v-2H3V16zM17,6v8.18C16.69,14.07 16.35,14 16,14c-1.66,0 -3,1.34 -3,3s1.34,3 3,3s3,-1.34 3,-3V8h3V6H17z"/>
</vector>
9 changes: 9 additions & 0 deletions app/src/main/res/drawable/ic_restore_24dp.xml
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24">
<path
android:fillColor="@color/icon_default"
android:pathData="M14,12c0,-1.1 -0.9,-2 -2,-2s-2,0.9 -2,2 0.9,2 2,2 2,-0.9 2,-2zM12,3c-4.97,0 -9,4.03 -9,9L0,12l4,4 4,-4L5,12c0,-3.87 3.13,-7 7,-7s7,3.13 7,7 -3.13,7 -7,7c-1.51,0 -2.91,-0.49 -4.06,-1.3l-1.42,1.44C8.04,20.3 9.94,21 12,21c4.97,0 9,-4.03 9,-9s-4.03,-9 -9,-9z"/>
</vector>
7 changes: 0 additions & 7 deletions app/src/main/res/drawable/shape_cover_small.xml

This file was deleted.

21 changes: 0 additions & 21 deletions app/src/main/res/drawable/shape_player_background.xml

This file was deleted.

1 change: 0 additions & 1 deletion app/src/main/res/layout/bottom_sheet_playback_controls.xml
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,6 @@
android:id="@+id/station_icon"
android:layout_width="80dp"
android:layout_height="80dp"
android:background="@drawable/shape_cover_small"
android:contentDescription="@string/descr_player_station_image"
app:shapeAppearanceOverlay="@style/RoundedCornerLeftTop"
app:layout_constraintStart_toStartOf="parent"
Expand Down
2 changes: 1 addition & 1 deletion app/src/main/res/layout/card_add_new_station.xml
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@
android:layout_marginEnd="8dp"
android:layout_marginBottom="24dp"
android:text="@string/station_list_button_add_new_station"
app:icon="@drawable/ic_add_24"
app:icon="@drawable/ic_add_24dp"
app:iconGravity="textStart"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toStartOf="@+id/card_settings"
Expand Down
3 changes: 2 additions & 1 deletion app/src/main/res/layout/card_station.xml
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
<?xml version="1.0" encoding="utf-8"?>
<androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android"
<androidx.constraintlayout.widget.ConstraintLayout
xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools"
android:id="@+id/station_card"
Expand Down
2 changes: 1 addition & 1 deletion build.gradle
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
// Top-level build file where you can add configuration options common to all sub-projects/modules.

buildscript {
ext.kotlinVersion = '1.6.20'
ext.kotlinVersion = '1.6.21'

repositories {
google()
Expand Down

0 comments on commit 7a6c1d0

Please sign in to comment.