diff --git a/TRANSLATIONS.md b/TRANSLATIONS.md index d3aeef55..a85c3de2 100644 --- a/TRANSLATIONS.md +++ b/TRANSLATIONS.md @@ -19,34 +19,34 @@ See [Android Translations Converter](https://github.com/Crustack/android-transla | Language | Coverage | |----------|----------| -| 🇺🇸 English | 100% (316/316) | -| 🇪🇸 Catalan | 20% (65/316) | -| 🇨🇿 Czech | 99% (313/316) | -| 🇩🇰 Danish | 21% (69/316) | -| 🇩🇪 German | 99% (313/316) | -| 🇬🇷 Greek | 22% (72/316) | -| 🇪🇸 Spanish | 99% (313/316) | -| 🇫🇷 French | 95% (301/316) | -| 🇭🇺 Hungarian | 20% (65/316) | -| 🇮🇩 Indonesian | 23% (75/316) | -| 🇮🇹 Italian | 92% (291/316) | -| 🇯🇵 Japanese | 23% (73/316) | -| 🇲🇲 Burmese | 28% (90/316) | -| 🇳🇴 Norwegian Bokmål | 33% (106/316) | -| 🇳🇱 Dutch | 67% (212/316) | -| 🇳🇴 Norwegian Nynorsk | 33% (106/316) | -| 🇵🇱 Polish | 94% (300/316) | -| 🇧🇷 Portuguese (Brazil) | 98% (312/316) | -| 🇵🇹 Portuguese (Portugal) | 22% (71/316) | -| 🇷🇴 Romanian | 95% (301/316) | -| 🇷🇺 Russian | 96% (305/316) | -| 🇸🇰 Slovak | 20% (65/316) | -| 🇸🇮 Slovenian | 34% (109/316) | -| 🇸🇪 Swedish | 19% (63/316) | -| 🇵🇭 Tagalog | 20% (65/316) | -| 🇹🇷 Turkish | 23% (73/316) | -| 🇺🇦 Ukrainian | 99% (314/316) | -| 🇻🇳 Vietnamese | 33% (107/316) | -| 🇨🇳 Chinese (Simplified) | 98% (312/316) | -| 🇹🇼 Chinese (Traditional) | 93% (294/316) | +| 🇺🇸 English | 100% (320/320) | +| 🇪🇸 Catalan | 20% (65/320) | +| 🇨🇿 Czech | 97% (313/320) | +| 🇩🇰 Danish | 21% (69/320) | +| 🇩🇪 German | 97% (313/320) | +| 🇬🇷 Greek | 22% (72/320) | +| 🇪🇸 Spanish | 97% (313/320) | +| 🇫🇷 French | 94% (301/320) | +| 🇭🇺 Hungarian | 20% (65/320) | +| 🇮🇩 Indonesian | 23% (75/320) | +| 🇮🇹 Italian | 90% (291/320) | +| 🇯🇵 Japanese | 22% (73/320) | +| 🇲🇲 Burmese | 28% (90/320) | +| 🇳🇴 Norwegian Bokmål | 33% (106/320) | +| 🇳🇱 Dutch | 66% (212/320) | +| 🇳🇴 Norwegian Nynorsk | 33% (106/320) | +| 🇵🇱 Polish | 93% (300/320) | +| 🇧🇷 Portuguese (Brazil) | 97% (312/320) | +| 🇵🇹 Portuguese (Portugal) | 22% (71/320) | +| 🇷🇴 Romanian | 94% (301/320) | +| 🇷🇺 Russian | 95% (305/320) | +| 🇸🇰 Slovak | 20% (65/320) | +| 🇸🇮 Slovenian | 34% (109/320) | +| 🇸🇪 Swedish | 19% (63/320) | +| 🇵🇭 Tagalog | 20% (65/320) | +| 🇹🇷 Turkish | 22% (73/320) | +| 🇺🇦 Ukrainian | 98% (314/320) | +| 🇻🇳 Vietnamese | 33% (107/320) | +| 🇨🇳 Chinese (Simplified) | 97% (312/320) | +| 🇹🇼 Chinese (Traditional) | 91% (294/320) | \ No newline at end of file diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 8e3721d9..bbf32d67 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -191,6 +191,14 @@ android:exported="false" android:foregroundServiceType="mediaPlayback" /> + + + + + + diff --git a/app/src/main/java/com/philkes/notallyx/NotallyXApplication.kt b/app/src/main/java/com/philkes/notallyx/NotallyXApplication.kt index 95a5f331..fe7703e6 100644 --- a/app/src/main/java/com/philkes/notallyx/NotallyXApplication.kt +++ b/app/src/main/java/com/philkes/notallyx/NotallyXApplication.kt @@ -30,6 +30,7 @@ import com.philkes.notallyx.utils.backup.isEqualTo import com.philkes.notallyx.utils.backup.modifiedNoteBackupExists import com.philkes.notallyx.utils.backup.scheduleAutoBackup import com.philkes.notallyx.utils.backup.updateAutoBackup +import com.philkes.notallyx.utils.checkForMigrations import com.philkes.notallyx.utils.observeOnce import com.philkes.notallyx.utils.security.UnlockReceiver import java.util.concurrent.TimeUnit @@ -52,6 +53,7 @@ class NotallyXApplication : Application(), Application.ActivityLifecycleCallback registerActivityLifecycleCallbacks(this) if (isTestRunner()) return preferences = NotallyXPreferences.getInstance(this) + checkForMigrations() if (preferences.useDynamicColors.value) { if (DynamicColors.isDynamicColorAvailable()) { DynamicColors.applyToActivitiesIfAvailable(this) diff --git a/app/src/main/java/com/philkes/notallyx/presentation/activity/note/PickNoteActivity.kt b/app/src/main/java/com/philkes/notallyx/presentation/activity/note/PickNoteActivity.kt index 49b754d1..2c1d99fc 100644 --- a/app/src/main/java/com/philkes/notallyx/presentation/activity/note/PickNoteActivity.kt +++ b/app/src/main/java/com/philkes/notallyx/presentation/activity/note/PickNoteActivity.kt @@ -22,7 +22,7 @@ import com.philkes.notallyx.presentation.view.misc.ItemListener import com.philkes.notallyx.presentation.viewmodel.BaseNoteModel import com.philkes.notallyx.presentation.viewmodel.preference.NotallyXPreferences import com.philkes.notallyx.presentation.viewmodel.preference.NotesView -import com.philkes.notallyx.utils.getExternalImagesDirectory +import com.philkes.notallyx.utils.getCurrentImagesDirectory import java.util.Collections import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch @@ -59,7 +59,7 @@ open class PickNoteActivity : LockedActivity(), ItemLis labelTagsHiddenInOverview.value, imagesHiddenInOverview.value, ), - application.getExternalImagesDirectory(), + application.getCurrentImagesDirectory(), this@PickNoteActivity, ) } diff --git a/app/src/main/java/com/philkes/notallyx/presentation/activity/note/PlayAudioActivity.kt b/app/src/main/java/com/philkes/notallyx/presentation/activity/note/PlayAudioActivity.kt index b72f94ee..bde515ef 100644 --- a/app/src/main/java/com/philkes/notallyx/presentation/activity/note/PlayAudioActivity.kt +++ b/app/src/main/java/com/philkes/notallyx/presentation/activity/note/PlayAudioActivity.kt @@ -23,10 +23,9 @@ import com.philkes.notallyx.presentation.activity.LockedActivity import com.philkes.notallyx.presentation.add import com.philkes.notallyx.presentation.dp import com.philkes.notallyx.presentation.setCancelButton -import com.philkes.notallyx.presentation.view.note.audio.AudioControlView import com.philkes.notallyx.utils.audio.AudioPlayService import com.philkes.notallyx.utils.audio.LocalBinder -import com.philkes.notallyx.utils.getExternalAudioDirectory +import com.philkes.notallyx.utils.getCurrentAudioDirectory import com.philkes.notallyx.utils.getUriForFile import com.philkes.notallyx.utils.wrapWithChooser import java.io.File @@ -180,7 +179,7 @@ class PlayAudioActivity : LockedActivity() { } private fun share() { - val audioRoot = application.getExternalAudioDirectory() + val audioRoot = application.getCurrentAudioDirectory() val file = if (audioRoot != null) File(audioRoot, audio.name) else null if (file != null && file.exists()) { val uri = getUriForFile(file) @@ -209,7 +208,7 @@ class PlayAudioActivity : LockedActivity() { } private fun saveToDevice() { - val audioRoot = application.getExternalAudioDirectory() + val audioRoot = application.getCurrentAudioDirectory() val file = if (audioRoot != null) File(audioRoot, audio.name) else null if (file != null && file.exists()) { val intent = @@ -231,7 +230,7 @@ class PlayAudioActivity : LockedActivity() { private fun writeAudioToUri(uri: Uri) { lifecycleScope.launch { withContext(Dispatchers.IO) { - val audioRoot = application.getExternalAudioDirectory() + val audioRoot = application.getCurrentAudioDirectory() val file = if (audioRoot != null) File(audioRoot, audio.name) else null if (file != null && file.exists()) { val output = contentResolver.openOutputStream(uri) as FileOutputStream diff --git a/app/src/main/java/com/philkes/notallyx/presentation/activity/note/ViewImageActivity.kt b/app/src/main/java/com/philkes/notallyx/presentation/activity/note/ViewImageActivity.kt index 489be120..4cf6e430 100644 --- a/app/src/main/java/com/philkes/notallyx/presentation/activity/note/ViewImageActivity.kt +++ b/app/src/main/java/com/philkes/notallyx/presentation/activity/note/ViewImageActivity.kt @@ -27,10 +27,11 @@ import com.philkes.notallyx.presentation.activity.note.EditActivity.Companion.EX import com.philkes.notallyx.presentation.add import com.philkes.notallyx.presentation.setCancelButton import com.philkes.notallyx.presentation.view.note.image.ImageAdapter -import com.philkes.notallyx.utils.getExternalImagesDirectory +import com.philkes.notallyx.utils.SUBFOLDER_IMAGES +import com.philkes.notallyx.utils.getCurrentImagesDirectory import com.philkes.notallyx.utils.getUriForFile +import com.philkes.notallyx.utils.resolveAttachmentFile import com.philkes.notallyx.utils.wrapWithChooser -import java.io.File import java.io.FileInputStream import java.io.FileOutputStream import kotlinx.coroutines.Dispatchers @@ -91,7 +92,7 @@ class ViewImageActivity : LockedActivity() { val images = ArrayList(original.size) original.filterNotTo(images) { image -> deletedImages.contains(image) } - val mediaRoot = application.getExternalImagesDirectory() + val mediaRoot = application.getCurrentImagesDirectory() val adapter = ImageAdapter(mediaRoot, images) binding.MainListView.adapter = adapter setupToolbar(binding, adapter) @@ -192,8 +193,7 @@ class ViewImageActivity : LockedActivity() { } private fun share(image: FileAttachment) { - val mediaRoot = application.getExternalImagesDirectory() - val file = if (mediaRoot != null) File(mediaRoot, image.localName) else null + val file = application.resolveAttachmentFile(SUBFOLDER_IMAGES, image.localName) if (file != null && file.exists()) { val uri = getUriForFile(file) val intent = @@ -214,8 +214,7 @@ class ViewImageActivity : LockedActivity() { } private fun saveToDevice(image: FileAttachment) { - val mediaRoot = application.getExternalImagesDirectory() - val file = if (mediaRoot != null) File(mediaRoot, image.localName) else null + val file = application.resolveAttachmentFile(SUBFOLDER_IMAGES, image.localName) if (file != null && file.exists()) { val intent = Intent(Intent.ACTION_CREATE_DOCUMENT) @@ -233,14 +232,8 @@ class ViewImageActivity : LockedActivity() { private fun writeImageToUri(uri: Uri) { lifecycleScope.launch { withContext(Dispatchers.IO) { - val mediaRoot = application.getExternalImagesDirectory() - val file = - if (mediaRoot != null) - File( - mediaRoot, - requireNotNull(currentImage, { "currentImage is null" }).localName, - ) - else null + val ci = requireNotNull(currentImage) { "currentImage is null" } + val file = application.resolveAttachmentFile(SUBFOLDER_IMAGES, ci.localName) if (file != null && file.exists()) { val output = contentResolver.openOutputStream(uri) as FileOutputStream output.channel.truncate(0) diff --git a/app/src/main/java/com/philkes/notallyx/presentation/view/main/BaseNoteVH.kt b/app/src/main/java/com/philkes/notallyx/presentation/view/main/BaseNoteVH.kt index df90dec0..333b8e70 100644 --- a/app/src/main/java/com/philkes/notallyx/presentation/view/main/BaseNoteVH.kt +++ b/app/src/main/java/com/philkes/notallyx/presentation/view/main/BaseNoteVH.kt @@ -290,42 +290,45 @@ class BaseNoteVH( private fun setImages(images: List, mediaRoot: File?) { binding.apply { if (images.isNotEmpty() && !preferences.hideImages) { - ImageView.visibility = VISIBLE Message.visibility = GONE - val image = images[0] val file = if (mediaRoot != null) File(mediaRoot, image.localName) else null + if (file?.exists() == true) { + ImageView.visibility = VISIBLE + Glide.with(ImageView) + .load(file) + .centerCrop() + .transition(DrawableTransitionOptions.withCrossFade()) + .diskCacheStrategy(DiskCacheStrategy.NONE) + .listener( + object : RequestListener { + + override fun onLoadFailed( + e: GlideException?, + model: Any?, + target: Target?, + isFirstResource: Boolean, + ): Boolean { + Message.visibility = VISIBLE + return false + } - Glide.with(ImageView) - .load(file) - .centerCrop() - .transition(DrawableTransitionOptions.withCrossFade()) - .diskCacheStrategy(DiskCacheStrategy.NONE) - .listener( - object : RequestListener { - - override fun onLoadFailed( - e: GlideException?, - model: Any?, - target: Target?, - isFirstResource: Boolean, - ): Boolean { - Message.visibility = VISIBLE - return false - } - - override fun onResourceReady( - resource: Drawable?, - model: Any?, - target: Target?, - dataSource: DataSource?, - isFirstResource: Boolean, - ): Boolean { - return false + override fun onResourceReady( + resource: Drawable?, + model: Any?, + target: Target?, + dataSource: DataSource?, + isFirstResource: Boolean, + ): Boolean { + return false + } } - } - ) - .into(ImageView) + ) + .into(ImageView) + } else { + ImageView.visibility = GONE + Message.visibility = VISIBLE + } if (images.size > 1) { ImageViewMore.apply { text = images.size.toString() @@ -335,7 +338,7 @@ class BaseNoteVH( ImageViewMore.visibility = GONE } } else { - ImageView.visibility = GONE + ImageLayout.visibility = GONE Message.visibility = GONE ImageViewMore.visibility = GONE Glide.with(ImageView).clear(ImageView) diff --git a/app/src/main/java/com/philkes/notallyx/presentation/view/note/image/ImageVH.kt b/app/src/main/java/com/philkes/notallyx/presentation/view/note/image/ImageVH.kt index ebe893d3..21ecb2a3 100644 --- a/app/src/main/java/com/philkes/notallyx/presentation/view/note/image/ImageVH.kt +++ b/app/src/main/java/com/philkes/notallyx/presentation/view/note/image/ImageVH.kt @@ -30,7 +30,7 @@ class ImageVH(private val binding: RecyclerImageBinding) : RecyclerView.ViewHold fun bind(file: File?) { binding.SSIV.recycle() - if (file != null) { + if (file?.exists() == true) { binding.Message.visibility = View.GONE val source = ImageSource.uri(Uri.fromFile(file)) binding.SSIV.setImage(source) diff --git a/app/src/main/java/com/philkes/notallyx/presentation/view/note/preview/PreviewImageVH.kt b/app/src/main/java/com/philkes/notallyx/presentation/view/note/preview/PreviewImageVH.kt index bae99442..fc9ad21c 100644 --- a/app/src/main/java/com/philkes/notallyx/presentation/view/note/preview/PreviewImageVH.kt +++ b/app/src/main/java/com/philkes/notallyx/presentation/view/note/preview/PreviewImageVH.kt @@ -23,6 +23,10 @@ class PreviewImageVH( } fun bind(file: File?) { + if (file?.exists() == false) { + binding.Message.visibility = View.VISIBLE + return + } binding.Message.visibility = View.GONE Glide.with(binding.ImageView) diff --git a/app/src/main/java/com/philkes/notallyx/presentation/viewmodel/BaseNoteModel.kt b/app/src/main/java/com/philkes/notallyx/presentation/viewmodel/BaseNoteModel.kt index 6b24fe80..66bbaede 100644 --- a/app/src/main/java/com/philkes/notallyx/presentation/viewmodel/BaseNoteModel.kt +++ b/app/src/main/java/com/philkes/notallyx/presentation/viewmodel/BaseNoteModel.kt @@ -77,9 +77,10 @@ import com.philkes.notallyx.utils.cancelNoteReminders import com.philkes.notallyx.utils.copyToLarge import com.philkes.notallyx.utils.deleteAttachments import com.philkes.notallyx.utils.getBackupDir -import com.philkes.notallyx.utils.getExternalImagesDirectory +import com.philkes.notallyx.utils.getCurrentImagesDirectory import com.philkes.notallyx.utils.getExternalMediaDirectory import com.philkes.notallyx.utils.log +import com.philkes.notallyx.utils.migrateAllAttachments import com.philkes.notallyx.utils.scheduleNoteReminders import com.philkes.notallyx.utils.security.DecryptionException import com.philkes.notallyx.utils.security.EncryptionException @@ -136,7 +137,8 @@ class BaseNoteModel(private val app: Application) : AndroidViewModel(app) { val preferences = NotallyXPreferences.getInstance(app) - val imageRoot = app.getExternalImagesDirectory() + val imageRoot + get() = app.getCurrentImagesDirectory() val importProgress = MutableLiveData() val progress = MutableLiveData() @@ -275,6 +277,7 @@ class BaseNoteModel(private val app: Application) : AndroidViewModel(app) { "Moving '${internalDatabaseFiles.map { it.name }}' to public '$targetDirectory' folder failed" ) } + app.migrateAllAttachments(toPrivate = false) } savePreference(preferences.dataInPublicFolder, true) callback?.invoke() @@ -297,6 +300,7 @@ class BaseNoteModel(private val app: Application) : AndroidViewModel(app) { "Moving public '${externalDatabaseFiles.map { it.name }}' to internal '$targetDirectory' folder failed" ) } + app.migrateAllAttachments(toPrivate = true) } savePreference(preferences.dataInPublicFolder, false) callback?.invoke() diff --git a/app/src/main/java/com/philkes/notallyx/presentation/viewmodel/NotallyModel.kt b/app/src/main/java/com/philkes/notallyx/presentation/viewmodel/NotallyModel.kt index 264049fb..37ecc3d7 100644 --- a/app/src/main/java/com/philkes/notallyx/presentation/viewmodel/NotallyModel.kt +++ b/app/src/main/java/com/philkes/notallyx/presentation/viewmodel/NotallyModel.kt @@ -50,9 +50,9 @@ import com.philkes.notallyx.utils.backup.importFile import com.philkes.notallyx.utils.cancelNoteReminders import com.philkes.notallyx.utils.cancelReminder import com.philkes.notallyx.utils.deleteAttachments -import com.philkes.notallyx.utils.getExternalAudioDirectory -import com.philkes.notallyx.utils.getExternalFilesDirectory -import com.philkes.notallyx.utils.getExternalImagesDirectory +import com.philkes.notallyx.utils.getCurrentAudioDirectory +import com.philkes.notallyx.utils.getCurrentFilesDirectory +import com.philkes.notallyx.utils.getCurrentImagesDirectory import com.philkes.notallyx.utils.getTempAudioFile import com.philkes.notallyx.utils.scheduleReminder import java.io.File @@ -99,9 +99,9 @@ class NotallyModel(private val app: Application) : AndroidViewModel(app) { val addingFiles = MutableLiveData() val eventBus = MutableLiveData>>() - var imageRoot = app.getExternalImagesDirectory() - var audioRoot = app.getExternalAudioDirectory() - var filesRoot = app.getExternalFilesDirectory() + var imageRoot = app.getCurrentImagesDirectory() + var audioRoot = app.getCurrentAudioDirectory() + var filesRoot = app.getCurrentFilesDirectory() var originalNote: BaseNote? = null @@ -134,7 +134,7 @@ class NotallyModel(private val app: Application) : AndroidViewModel(app) { Regenerate because the directory may have been deleted between the time of activity creation and image addition */ - imageRoot = app.getExternalImagesDirectory() + imageRoot = app.getCurrentImagesDirectory() requireNotNull(imageRoot) { "imageRoot is null" } addFiles(uris, imageRoot!!, FileType.IMAGE) } @@ -144,7 +144,7 @@ class NotallyModel(private val app: Application) : AndroidViewModel(app) { Regenerate because the directory may have been deleted between the time of activity creation and image addition */ - filesRoot = app.getExternalFilesDirectory() + filesRoot = app.getCurrentFilesDirectory() requireNotNull(filesRoot) { "filesRoot is null" } addFiles(uris, filesRoot!!, FileType.ANY) } diff --git a/app/src/main/java/com/philkes/notallyx/presentation/viewmodel/preference/NotallyXPreferences.kt b/app/src/main/java/com/philkes/notallyx/presentation/viewmodel/preference/NotallyXPreferences.kt index 9aa95229..76c71ca3 100644 --- a/app/src/main/java/com/philkes/notallyx/presentation/viewmodel/preference/NotallyXPreferences.kt +++ b/app/src/main/java/com/philkes/notallyx/presentation/viewmodel/preference/NotallyXPreferences.kt @@ -155,6 +155,17 @@ class NotallyXPreferences private constructor(private val context: Context) { val dataInPublicFolder = BooleanPreference("dataOnExternalStorage", preferences, false, R.string.data_in_public) + /** + * Tracks app-internal data schema/migration steps. 0 = initial state, no migrations run yet See + * [DataSchemaMigrations.kt] + */ + val dataSchemaId = IntPreference("dataSchemaId", preferences, 0, 0, Integer.MAX_VALUE) + + fun setDataSchemaId(value: Int) { + preferences.edit(true) { putInt(dataSchemaId.key, value) } + dataSchemaId.refresh() + } + fun getWidgetData(id: Int) = preferences.getLong("widget:$id", 0) fun getWidgetNoteType(id: Int) = diff --git a/app/src/main/java/com/philkes/notallyx/utils/AndroidExtensions.kt b/app/src/main/java/com/philkes/notallyx/utils/AndroidExtensions.kt index 716feee5..cdd3abf6 100644 --- a/app/src/main/java/com/philkes/notallyx/utils/AndroidExtensions.kt +++ b/app/src/main/java/com/philkes/notallyx/utils/AndroidExtensions.kt @@ -393,7 +393,7 @@ fun ContextWrapper.shareNote(note: BaseNote) { } val filesUris = note.images - .map { File(getExternalImagesDirectory(), it.localName) } + .map { File(getCurrentImagesDirectory(), it.localName) } .map { getUriForFile(it) } shareNote(note.title, body, filesUris) } diff --git a/app/src/main/java/com/philkes/notallyx/utils/DataSchemaMigrations.kt b/app/src/main/java/com/philkes/notallyx/utils/DataSchemaMigrations.kt new file mode 100644 index 00000000..494b4802 --- /dev/null +++ b/app/src/main/java/com/philkes/notallyx/utils/DataSchemaMigrations.kt @@ -0,0 +1,35 @@ +package com.philkes.notallyx.utils + +import android.app.Application +import com.philkes.notallyx.presentation.viewmodel.preference.NotallyXPreferences +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.MainScope +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext + +private const val TAG = "DataSchemaMigrations" + +fun Application.checkForMigrations() { + val preferences = NotallyXPreferences.getInstance(this) + val dataSchemaId = preferences.dataSchemaId.value + var newDataSchemaId = dataSchemaId + + MainScope().launch { + withContext(Dispatchers.IO) { + if (dataSchemaId < 1) { + moveAttachments(preferences) + newDataSchemaId = 1 + } + preferences.setDataSchemaId(newDataSchemaId) + } + } +} + +private fun Application.moveAttachments(preferences: NotallyXPreferences) { + val toPrivate = !preferences.dataInPublicFolder.value + log( + TAG, + "Running migration 1: Moving attachments to ${if(toPrivate) "private" else "public"} folder", + ) + migrateAllAttachments(toPrivate) +} diff --git a/app/src/main/java/com/philkes/notallyx/utils/IOExtensions.kt b/app/src/main/java/com/philkes/notallyx/utils/IOExtensions.kt index cc247aff..d28164f8 100644 --- a/app/src/main/java/com/philkes/notallyx/utils/IOExtensions.kt +++ b/app/src/main/java/com/philkes/notallyx/utils/IOExtensions.kt @@ -16,6 +16,7 @@ import com.philkes.notallyx.data.model.Audio import com.philkes.notallyx.data.model.FileAttachment import com.philkes.notallyx.data.model.isImage import com.philkes.notallyx.presentation.view.misc.Progress +import com.philkes.notallyx.presentation.viewmodel.preference.NotallyXPreferences import com.philkes.notallyx.presentation.viewmodel.progress.DeleteAttachmentProgress import com.philkes.notallyx.presentation.widget.WidgetProvider import java.io.File @@ -34,14 +35,168 @@ const val SUBFOLDER_IMAGES = "Images" const val SUBFOLDER_FILES = "Files" const val SUBFOLDER_AUDIOS = "Audios" -fun ContextWrapper.getExternalImagesDirectory() = getExternalMediaDirectory(SUBFOLDER_IMAGES) +private fun ContextWrapper.getExternalImagesDirectory() = + getExternalMediaDirectory(SUBFOLDER_IMAGES) -fun ContextWrapper.getExternalAudioDirectory() = getExternalMediaDirectory(SUBFOLDER_AUDIOS) +private fun ContextWrapper.getExternalAudioDirectory() = getExternalMediaDirectory(SUBFOLDER_AUDIOS) -fun ContextWrapper.getExternalFilesDirectory() = getExternalMediaDirectory(SUBFOLDER_FILES) +private fun ContextWrapper.getExternalFilesDirectory() = getExternalMediaDirectory(SUBFOLDER_FILES) fun ContextWrapper.getExternalMediaDirectory() = getExternalMediaDirectory("") +// Private (internal) storage roots for attachments when biometric lock is enabled and +// dataInPublicFolder is disabled. +fun ContextWrapper.getPrivateAttachmentsRoot(): File { + val root = File(filesDir, "attachments") + if (!root.exists()) root.mkdir() + return root +} + +fun ContextWrapper.getPrivateImagesDirectory(): File { + val dir = File(getPrivateAttachmentsRoot(), SUBFOLDER_IMAGES) + if (!dir.exists()) dir.mkdir() + return dir +} + +fun ContextWrapper.getPrivateFilesDirectory(): File { + val dir = File(getPrivateAttachmentsRoot(), SUBFOLDER_FILES) + if (!dir.exists()) dir.mkdir() + return dir +} + +fun ContextWrapper.getPrivateAudioDirectory(): File { + val dir = File(getPrivateAttachmentsRoot(), SUBFOLDER_AUDIOS) + if (!dir.exists()) dir.mkdir() + return dir +} + +private fun ContextWrapper.isDataInPublicEnabled(): Boolean { + return NotallyXPreferences.getInstance(this).dataInPublicFolder.value +} + +fun ContextWrapper.getCurrentImagesDirectory(): File { + return if (isDataInPublicEnabled()) getExternalImagesDirectory() + else getPrivateImagesDirectory() +} + +fun ContextWrapper.getCurrentFilesDirectory(): File { + return if (isDataInPublicEnabled()) getExternalFilesDirectory() else getPrivateFilesDirectory() +} + +fun ContextWrapper.getCurrentAudioDirectory(): File { + return if (isDataInPublicEnabled()) getExternalAudioDirectory() else getPrivateAudioDirectory() +} + +fun ContextWrapper.getCurrentMediaRoot(): File { + return if (isDataInPublicEnabled()) getExternalMediaDirectory() else getPrivateAttachmentsRoot() +} + +fun ContextWrapper.getAlternateImagesDirectory(): File { + return if (isDataInPublicEnabled()) getExternalImagesDirectory() + else getPrivateImagesDirectory() +} + +fun ContextWrapper.getAlternateFilesDirectory(): File { + return if (isDataInPublicEnabled()) getExternalFilesDirectory() else getPrivateFilesDirectory() +} + +fun ContextWrapper.getAlternateAudioDirectory(): File { + return if (isDataInPublicEnabled()) getExternalAudioDirectory() else getPrivateAudioDirectory() +} + +/** + * Resolve an attachment file location by checking the current-mode directory first, then fallback. + */ +fun ContextWrapper.resolveAttachmentFile(subfolder: String, localName: String): File? { + val current = + when (subfolder) { + SUBFOLDER_IMAGES -> getCurrentImagesDirectory() + SUBFOLDER_FILES -> getCurrentFilesDirectory() + SUBFOLDER_AUDIOS -> getCurrentAudioDirectory() + else -> null + } + val alt = + when (subfolder) { + SUBFOLDER_IMAGES -> getAlternateImagesDirectory() + SUBFOLDER_FILES -> getAlternateFilesDirectory() + SUBFOLDER_AUDIOS -> getAlternateAudioDirectory() + else -> null + } + val inCurrent = current?.let { File(it, localName) } + if (inCurrent != null && inCurrent.exists()) return inCurrent + val inAlt = alt?.let { File(it, localName) } + if (inAlt != null && inAlt.exists()) return inAlt + return inCurrent ?: inAlt +} + +/** + * Move all attachment files between public and private storage to match current mode. If + * [toPrivate] is true, move from external app media to private dirs; else the opposite. + */ +fun ContextWrapper.migrateAllAttachments(toPrivate: Boolean): Pair { + var moved = 0 + var failed = 0 + val sources = listOf(SUBFOLDER_IMAGES, SUBFOLDER_FILES, SUBFOLDER_AUDIOS) + sources.forEach { sub -> + val (srcRoot, dstRoot) = + if (toPrivate) { + val src = + when (sub) { + SUBFOLDER_IMAGES -> getExternalImagesDirectory() + SUBFOLDER_FILES -> getExternalFilesDirectory() + SUBFOLDER_AUDIOS -> getExternalAudioDirectory() + else -> null + } + val dst = + when (sub) { + SUBFOLDER_IMAGES -> getPrivateImagesDirectory() + SUBFOLDER_FILES -> getPrivateFilesDirectory() + SUBFOLDER_AUDIOS -> getPrivateAudioDirectory() + else -> null + } + Pair(src, dst) + } else { + val src = + when (sub) { + SUBFOLDER_IMAGES -> getPrivateImagesDirectory() + SUBFOLDER_FILES -> getPrivateFilesDirectory() + SUBFOLDER_AUDIOS -> getPrivateAudioDirectory() + else -> null + } + val dst = + when (sub) { + SUBFOLDER_IMAGES -> getExternalImagesDirectory() + SUBFOLDER_FILES -> getExternalFilesDirectory() + SUBFOLDER_AUDIOS -> getExternalAudioDirectory() + else -> null + } + Pair(src, dst) + } + if (srcRoot == null || dstRoot == null) return@forEach + srcRoot.listFiles()?.forEach { file -> + try { + val target = File(dstRoot, file.name) + file.copyTo(target, overwrite = true) + if (file.delete()) { + moved++ + } else { + // try overwrite move on legacy devices + // if (file.renameTo(target)) moved++ else failed++ + failed++ + } + } catch (t: Throwable) { + Log.e( + TAG, + "Failed to move '${file.absolutePath}' to ${if(toPrivate) "private" else "public"} folder '${dstRoot.absolutePath}'", + t, + ) + failed++ + } + } + } + return Pair(moved, failed) +} + fun Context.getTempAudioFile(): File { return File(externalCacheDir, "Temp.m4a") } @@ -177,24 +332,23 @@ fun ContextWrapper.getLogFile(): File { return File(getLogsDir(), "$APP_LOG_FILE_NAME.txt") } -private fun ContextWrapper.getExternalMediaDirectory(name: String): File? { - return externalMediaDirs.firstOrNull()?.let { getDirectory(it, name) } +private fun ContextWrapper.getExternalMediaDirectory(name: String): File { + return getDirectory( + requireNotNull(externalMediaDirs.firstOrNull()) { + "External media directory does not exist" + }, + name, + ) } -private fun getDirectory(dir: File, name: String): File? { - var file: File? = null - try { - file = File(dir, name) - if (file.exists()) { - if (!file.isDirectory) { - file.delete() - file.createDirectory() - } - } else file.createDirectory() - } catch (exception: Exception) { - exception.printStackTrace() - } - +private fun getDirectory(dir: File, name: String): File { + val file = File(dir, name) + if (file.exists()) { + if (!file.isDirectory) { + file.delete() + file.createDirectory() + } + } else file.createDirectory() return file } diff --git a/app/src/main/java/com/philkes/notallyx/utils/audio/AudioPlayService.kt b/app/src/main/java/com/philkes/notallyx/utils/audio/AudioPlayService.kt index e98a4dff..84946b57 100644 --- a/app/src/main/java/com/philkes/notallyx/utils/audio/AudioPlayService.kt +++ b/app/src/main/java/com/philkes/notallyx/utils/audio/AudioPlayService.kt @@ -5,7 +5,7 @@ import android.content.Intent import android.media.MediaPlayer import android.os.IBinder import com.philkes.notallyx.data.model.Audio -import com.philkes.notallyx.utils.getExternalAudioDirectory +import com.philkes.notallyx.utils.getCurrentAudioDirectory import com.philkes.notallyx.utils.log import java.io.File @@ -46,7 +46,7 @@ class AudioPlayService : Service() { fun initialise(audio: Audio) { if (state == IDLE) { - val audioRoot = application.getExternalAudioDirectory() + val audioRoot = application.getCurrentAudioDirectory() if (audioRoot != null) { try { val file = File(audioRoot, audio.name) diff --git a/app/src/main/java/com/philkes/notallyx/utils/backup/CleanupMissingAttachmentsReceiver.kt b/app/src/main/java/com/philkes/notallyx/utils/backup/CleanupMissingAttachmentsReceiver.kt new file mode 100644 index 00000000..367a76ca --- /dev/null +++ b/app/src/main/java/com/philkes/notallyx/utils/backup/CleanupMissingAttachmentsReceiver.kt @@ -0,0 +1,21 @@ +package com.philkes.notallyx.utils.backup + +import android.content.BroadcastReceiver +import android.content.Context +import android.content.Intent +import androidx.work.OneTimeWorkRequestBuilder +import androidx.work.WorkManager + +class CleanupMissingAttachmentsReceiver : BroadcastReceiver() { + override fun onReceive(context: Context, intent: Intent) { + if (intent.action == ACTION_CLEANUP_MISSING_ATTACHMENTS) { + val request = OneTimeWorkRequestBuilder().build() + WorkManager.getInstance(context).enqueue(request) + } + } + + companion object { + const val ACTION_CLEANUP_MISSING_ATTACHMENTS = + "com.philkes.notallyx.action.CLEANUP_MISSING_ATTACHMENTS" + } +} diff --git a/app/src/main/java/com/philkes/notallyx/utils/backup/CleanupMissingAttachmentsWorker.kt b/app/src/main/java/com/philkes/notallyx/utils/backup/CleanupMissingAttachmentsWorker.kt new file mode 100644 index 00000000..6e0e5e9b --- /dev/null +++ b/app/src/main/java/com/philkes/notallyx/utils/backup/CleanupMissingAttachmentsWorker.kt @@ -0,0 +1,109 @@ +package com.philkes.notallyx.utils.backup + +import android.app.NotificationManager +import android.content.Context +import android.content.ContextWrapper +import android.os.Build +import androidx.core.app.NotificationCompat +import androidx.core.content.getSystemService +import androidx.work.CoroutineWorker +import androidx.work.WorkerParameters +import com.philkes.notallyx.R +import com.philkes.notallyx.data.NotallyDatabase +import com.philkes.notallyx.data.model.Audio +import com.philkes.notallyx.data.model.FileAttachment +import com.philkes.notallyx.utils.SUBFOLDER_AUDIOS +import com.philkes.notallyx.utils.SUBFOLDER_FILES +import com.philkes.notallyx.utils.SUBFOLDER_IMAGES +import com.philkes.notallyx.utils.createChannelIfNotExists +import com.philkes.notallyx.utils.log +import com.philkes.notallyx.utils.resolveAttachmentFile + +/** Scans all notes and removes references to attachments whose underlying files are missing. */ +class CleanupMissingAttachmentsWorker(appContext: Context, params: WorkerParameters) : + CoroutineWorker(appContext, params) { + + override suspend fun doWork(): Result { + val ctx = ContextWrapper(applicationContext) + val database = NotallyDatabase.getDatabase(ctx, observePreferences = false).value + val dao = database.getBaseNoteDao() + + var removedImages = 0 + var removedFiles = 0 + var removedAudios = 0 + var affectedNotes = 0 + + val notes = dao.getAll() + ctx.log(TAG, "Scanning ${notes.size} notes to check for missing attachments...") + notes.forEach { note -> + val originalImages = ArrayList(note.images) + val originalFiles = ArrayList(note.files) + val originalAudios = ArrayList(note.audios) + + val filteredImages = + originalImages.filter { fa: FileAttachment -> + val file = ctx.resolveAttachmentFile(SUBFOLDER_IMAGES, fa.localName) + file != null && file.exists() + } + val filteredFiles = + originalFiles.filter { fa: FileAttachment -> + val file = ctx.resolveAttachmentFile(SUBFOLDER_FILES, fa.localName) + file != null && file.exists() + } + val filteredAudios = + originalAudios.filter { au: Audio -> + val file = ctx.resolveAttachmentFile(SUBFOLDER_AUDIOS, au.name) + file != null && file.exists() + } + + val imgRemoved = originalImages.size - filteredImages.size + val fileRemoved = originalFiles.size - filteredFiles.size + val audRemoved = originalAudios.size - filteredAudios.size + + if (imgRemoved + fileRemoved + audRemoved > 0) { + affectedNotes++ + if (imgRemoved > 0) { + removedImages += imgRemoved + dao.updateImages(note.id, filteredImages) + } + if (fileRemoved > 0) { + removedFiles += fileRemoved + dao.updateFiles(note.id, filteredFiles) + } + if (audRemoved > 0) { + removedAudios += audRemoved + dao.updateAudios(note.id, filteredAudios) + } + } + } + ctx.log(TAG, "Cleaned up missing attachments from $affectedNotes notes") + + postCompletionNotification(removedImages + removedFiles + removedAudios, affectedNotes) + return Result.success() + } + + private fun postCompletionNotification(totalRemoved: Int, affectedNotes: Int) { + val ctx = ContextWrapper(applicationContext) + val manager = ctx.getSystemService() ?: return + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + manager.createChannelIfNotExists(WORKER_NOTIFICATION_CHANNEL_ID) + } + val notification = + NotificationCompat.Builder(ctx, WORKER_NOTIFICATION_CHANNEL_ID) + .setSmallIcon(R.drawable.delete) + .setContentTitle(ctx.getString(R.string.cleanup_finished_title)) + .setContentText( + ctx.getString(R.string.cleanup_finished_summary, totalRemoved, affectedNotes) + ) + .setPriority(NotificationCompat.PRIORITY_DEFAULT) + .build() + manager.notify(WORKER_NOTIFICATION_ID, notification) + } + + companion object { + private const val TAG = "CleanupMissingAttachmentsWorker" + // Reuse the same channel name as backups for simplicity + private const val WORKER_NOTIFICATION_CHANNEL_ID = "AutoBackups" + private const val WORKER_NOTIFICATION_ID = 123416 + } +} diff --git a/app/src/main/java/com/philkes/notallyx/utils/backup/ExportExtensions.kt b/app/src/main/java/com/philkes/notallyx/utils/backup/ExportExtensions.kt index 77d17058..d9d8d392 100644 --- a/app/src/main/java/com/philkes/notallyx/utils/backup/ExportExtensions.kt +++ b/app/src/main/java/com/philkes/notallyx/utils/backup/ExportExtensions.kt @@ -55,15 +55,17 @@ import com.philkes.notallyx.utils.copyToLarge import com.philkes.notallyx.utils.createChannelIfNotExists import com.philkes.notallyx.utils.createFileSafe import com.philkes.notallyx.utils.createReportBugIntent +import com.philkes.notallyx.utils.getCurrentAudioDirectory +import com.philkes.notallyx.utils.getCurrentFilesDirectory +import com.philkes.notallyx.utils.getCurrentImagesDirectory +import com.philkes.notallyx.utils.getCurrentMediaRoot import com.philkes.notallyx.utils.getExportedPath -import com.philkes.notallyx.utils.getExternalAudioDirectory -import com.philkes.notallyx.utils.getExternalFilesDirectory -import com.philkes.notallyx.utils.getExternalImagesDirectory import com.philkes.notallyx.utils.getLogFileUri import com.philkes.notallyx.utils.listZipFiles import com.philkes.notallyx.utils.log import com.philkes.notallyx.utils.md5Hash import com.philkes.notallyx.utils.recreateDir +import com.philkes.notallyx.utils.resolveAttachmentFile import com.philkes.notallyx.utils.security.decryptDatabase import com.philkes.notallyx.utils.security.getInitializedCipherForDecryption import com.philkes.notallyx.utils.verify @@ -212,19 +214,19 @@ suspend fun ContextWrapper.autoBackupOnSave( images.map { BackupFile( SUBFOLDER_IMAGES, - File(getExternalImagesDirectory(), it.localName), + File(getCurrentImagesDirectory(), it.localName), ) } + files.map { BackupFile( SUBFOLDER_FILES, - File(getExternalFilesDirectory(), it.localName), + File(getCurrentFilesDirectory(), it.localName), ) } + audios.map { BackupFile( SUBFOLDER_AUDIOS, - File(getExternalAudioDirectory(), it.name), + File(getCurrentAudioDirectory(), it.name), ) } + BackupFile(null, databaseFile) @@ -338,10 +340,6 @@ fun ContextWrapper.exportAsZip( val (databaseOriginal, databaseCopy) = copyDatabase() zipFile.addFile(databaseCopy, zipParameters.copy(DATABASE_NAME)) - val imageRoot = getExternalImagesDirectory() - val fileRoot = getExternalFilesDirectory() - val audioRoot = getExternalAudioDirectory() - val totalNotes = databaseOriginal.getBaseNoteDao().count() val images = databaseOriginal.getBaseNoteDao().getAllImages().toFileAttachments() val files = databaseOriginal.getBaseNoteDao().getAllFiles().toFileAttachments() @@ -350,32 +348,43 @@ fun ContextWrapper.exportAsZip( backupProgress?.postValue(BackupProgress(0, totalAttachments)) val counter = AtomicInteger(0) + val missingAttachments = ArrayList() images.export( zipFile, zipParameters, - imageRoot, SUBFOLDER_IMAGES, this, backupProgress, totalAttachments, counter, + missingAttachments, ) files.export( zipFile, zipParameters, - fileRoot, SUBFOLDER_FILES, this, backupProgress, totalAttachments, counter, + missingAttachments, ) audios .asSequence() .flatMap { string -> Converters.jsonToAudios(string) } .forEach { audio -> try { - backupFile(zipFile, zipParameters, audioRoot, SUBFOLDER_AUDIOS, audio.name) + if ( + !backupAttachmentFile( + this, + zipFile, + zipParameters, + SUBFOLDER_AUDIOS, + audio.name, + ) + ) { + missingAttachments.add("Audio: ${audio.name}") + } } catch (exception: Exception) { log(TAG, throwable = exception) } finally { @@ -429,6 +438,10 @@ fun ContextWrapper.exportAsZip( zipFile.file.delete() databaseCopy.delete() backupProgress?.postValue(BackupProgress(inProgress = false)) + // Post skipped attachments notification if any were missing + if (missingAttachments.isNotEmpty()) { + postSkippedAttachmentsNotification(missingAttachments) + } return totalNotes } finally { tempFile.delete() @@ -444,10 +457,13 @@ fun Context.exportToZip( try { val zipInputStream = contentResolver.openInputStream(zipUri) ?: return false extractZipToDirectory(zipInputStream, tempDir, password) - files.forEach { file -> - val targetFile = File(tempDir, "${file.first?.let { "$it/" } ?: ""}${file.second.name}") - file.second.copyToLarge(targetFile, overwrite = true) - } + files + .filter { it.second.exists() } + .forEach { file -> + val targetFile = + File(tempDir, "${file.first?.let { "$it/" } ?: ""}${file.second.name}") + file.second.copyToLarge(targetFile, overwrite = true) + } val zipOutputStream = contentResolver.openOutputStream(zipUri, "w") ?: return false val tempZipFile = createTempFile("tempZip", ".zip") try { @@ -528,16 +544,25 @@ private fun List.toFileAttachments(): Sequence { private fun Sequence.export( zipFile: ZipFile, zipParameters: ZipParameters, - fileRoot: File?, subfolder: String, context: ContextWrapper, backupProgress: MutableLiveData?, total: Int, counter: AtomicInteger, + missingDisplayNames: MutableList, ) { forEach { file -> try { - backupFile(zipFile, zipParameters, fileRoot, subfolder, file.localName) + if (!backupAttachmentFile(context, zipFile, zipParameters, subfolder, file.localName)) { + val typePrefix = + when (subfolder) { + SUBFOLDER_IMAGES -> "Image" + SUBFOLDER_FILES -> "File" + else -> subfolder + } + val display = file.originalName.ifBlank { file.localName } + missingDisplayNames.add("$typePrefix: $display") + } } catch (exception: Exception) { context.log(TAG, throwable = exception) } finally { @@ -584,16 +609,74 @@ fun WorkManager.scheduleAutoBackup(context: ContextWrapper, periodInDays: Long) } } -private fun backupFile( +// Try to backup attachment by resolving it according to current/private/public directories. +// Returns true if the file was found and added to the zip, false if missing. +private fun backupAttachmentFile( + context: ContextWrapper, zipFile: ZipFile, zipParameters: ZipParameters, - root: File?, folder: String, name: String, -) { - val file = if (root != null) File(root, name) else null - if (file != null && file.exists()) { +): Boolean { + val file = context.resolveAttachmentFile(folder, name) + return if (file != null && file.exists()) { zipFile.addFile(file, zipParameters.copy("$folder/$name")) + true + } else { + false + } +} + +private fun ContextWrapper.postSkippedAttachmentsNotification(missingAttachments: List) { + getSystemService()?.let { manager -> + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + manager.createChannelIfNotExists(NOTIFICATION_CHANNEL_ID) + } + val maxToShow = 10 + val lines = missingAttachments.take(maxToShow) + val more = missingAttachments.size - lines.size + val bigText = buildString { + append(getString(R.string.auto_backup_skipped_files, missingAttachments.size)) + append(" (${getCurrentMediaRoot()})") + append('\n') + lines.forEachIndexed { idx, name -> + if (idx > 0) append('\n') + append("• ") + append(name) + } + if (more > 0) { + append('\n') + append("+") + append(more) + append(" …") + } + } + + val cleanupIntent = + Intent(this, CleanupMissingAttachmentsReceiver::class.java).apply { + action = CleanupMissingAttachmentsReceiver.ACTION_CLEANUP_MISSING_ATTACHMENTS + } + val cleanupPendingIntent = + PendingIntent.getBroadcast( + this, + 0, + cleanupIntent, + PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE, + ) + + val notification = + NotificationCompat.Builder(this, NOTIFICATION_CHANNEL_ID) + .setSmallIcon(R.drawable.export) + .setContentTitle(getString(R.string.backup)) + .setContentText( + getString(R.string.auto_backup_skipped_files, missingAttachments.size) + + " (${getCurrentMediaRoot()})" + ) + .setStyle(NotificationCompat.BigTextStyle().bigText(bigText)) + .setPriority(NotificationCompat.PRIORITY_DEFAULT) + .addAction(R.drawable.delete, getString(R.string.clean_up), cleanupPendingIntent) + .build() + manager.notify(NOTIFICATION_ID + 1, notification) } } @@ -651,7 +734,7 @@ fun exportPdfFile( val html = note.toHtml( NotallyXPreferences.getInstance(app).showDateCreated(), - app.getExternalImagesDirectory(), + app.getCurrentImagesDirectory(), ) app.printPdf( tempFile, @@ -737,7 +820,7 @@ fun exportPlainTextFile( ExportMimeType.HTML -> note.toHtml( NotallyXPreferences.getInstance(app).showDateCreated(), - app.getExternalImagesDirectory(), + app.getCurrentImagesDirectory(), ) ExportMimeType.MD -> note.toMarkdown() diff --git a/app/src/main/java/com/philkes/notallyx/utils/backup/ImportExtensions.kt b/app/src/main/java/com/philkes/notallyx/utils/backup/ImportExtensions.kt index 91a0d5e4..36c95a16 100644 --- a/app/src/main/java/com/philkes/notallyx/utils/backup/ImportExtensions.kt +++ b/app/src/main/java/com/philkes/notallyx/utils/backup/ImportExtensions.kt @@ -37,9 +37,9 @@ import com.philkes.notallyx.utils.cancelNoteReminders import com.philkes.notallyx.utils.clearDirectory import com.philkes.notallyx.utils.copyToFile import com.philkes.notallyx.utils.determineMimeTypeAndExtension -import com.philkes.notallyx.utils.getExternalAudioDirectory -import com.philkes.notallyx.utils.getExternalFilesDirectory -import com.philkes.notallyx.utils.getExternalImagesDirectory +import com.philkes.notallyx.utils.getCurrentAudioDirectory +import com.philkes.notallyx.utils.getCurrentFilesDirectory +import com.philkes.notallyx.utils.getCurrentImagesDirectory import com.philkes.notallyx.utils.getFileName import com.philkes.notallyx.utils.log import com.philkes.notallyx.utils.mimeTypeToFileExtension @@ -145,9 +145,9 @@ suspend fun ContextWrapper.importZip( ) val current = AtomicInteger(1) - val imageRoot = getExternalImagesDirectory() - val fileRoot = getExternalFilesDirectory() - val audioRoot = getExternalAudioDirectory() + val imageRoot = getCurrentImagesDirectory() + val fileRoot = getCurrentFilesDirectory() + val audioRoot = getCurrentAudioDirectory() baseNotes.forEach { baseNote -> importFiles( baseNote.images, @@ -478,7 +478,7 @@ suspend fun ContextWrapper.importFile( uri: Uri, proposedMimeType: String? = null, ): Pair { - val filesRoot = getExternalFilesDirectory() + val filesRoot = getCurrentFilesDirectory() requireNotNull(filesRoot) { "filesRoot is null" } return importFile(uri, filesRoot, FileType.ANY, proposedMimeType = proposedMimeType) } @@ -487,7 +487,7 @@ suspend fun ContextWrapper.importImage( uri: Uri, proposedMimeType: String? = null, ): Pair { - val imagesRoot = getExternalImagesDirectory() + val imagesRoot = getCurrentImagesDirectory() requireNotNull(imagesRoot) { "imagesRoot is null" } return importFile(uri, imagesRoot, FileType.IMAGE, proposedMimeType = proposedMimeType) } @@ -498,7 +498,7 @@ suspend fun ContextWrapper.importAudio(original: File, deleteOriginalFile: Boole Regenerate because the directory may have been deleted between the time of activity creation and audio recording */ - val audioRoot = getExternalAudioDirectory() + val audioRoot = getCurrentAudioDirectory() requireNotNull(audioRoot) { "audioRoot is null" } /* diff --git a/app/src/main/res/layout/recycler_base_note.xml b/app/src/main/res/layout/recycler_base_note.xml index 6a42593a..508488c7 100644 --- a/app/src/main/res/layout/recycler_base_note.xml +++ b/app/src/main/res/layout/recycler_base_note.xml @@ -53,12 +53,14 @@ Denně Tmavé Uložit data do veřejné složky - Pokud tuto možnost povolíte, interní databáze aplikace bude přesunuta do veřejné složky aplikace (Android/media/com.philkes.notallyx). V kombinaci s aplikacemi pro synchronizaci souborů to může být použito k synchronizaci dat NotallyX mezi více zařízeními. + Pokud tuto možnost povolíte, interní databáze aplikace a připojené soubory budou přesunuty do veřejné složky aplikace (Android/media/com.philkes.notallyx). V kombinaci s aplikacemi pro synchronizaci souborů to může být použito k synchronizaci dat NotallyX mezi více zařízeními. Datum Formát data Použít také v poznámkách diff --git a/app/src/main/res/values-de/strings.xml b/app/src/main/res/values-de/strings.xml index 1004b93c..952ed513 100644 --- a/app/src/main/res/values-de/strings.xml +++ b/app/src/main/res/values-de/strings.xml @@ -94,7 +94,7 @@ Täglich Dunkel Daten in öffentlichen Ordner auslagern - Wird dies aktiviert, wird die app-interne Datenbank in einen öffentlichen Ordner ausgelagert (Android/media/com.philkes.notallyx). In Kombination mit Datei-Synchronisations Apps kann dies genutzt werden um NotallyX Daten zwischen mehrere Geräte zu synchronisieren. + Wird dies aktiviert, werden die app-interne Datenbank und angehängte Dateien in einen öffentlichen Ordner ausgelagert (Android/media/com.philkes.notallyx). In Kombination mit Datei-Synchronisations Apps kann dies genutzt werden um NotallyX Daten zwischen mehrere Geräte zu synchronisieren. Datum Datumsformat Auch in Notiz-Ansicht anwenden diff --git a/app/src/main/res/values-es/strings.xml b/app/src/main/res/values-es/strings.xml index ee05ad73..e698cd67 100644 --- a/app/src/main/res/values-es/strings.xml +++ b/app/src/main/res/values-es/strings.xml @@ -94,7 +94,7 @@ Diariamente Oscuro Guardar datos en carpeta pública - Al habilitar esta opción, la base de datos interna de la aplicación se moverá al directorio público de la aplicación (Android/media/com.philkes.notallyx).\nEn combinación con aplicaciones de sincronización de archivos, esta opción se puede utilizar para sincronizar datos de NotallyX entre varios dispositivos. + Al habilitar esta opción, la base de datos interna de la aplicación y los archivos adjuntos se moverán al directorio público de la aplicación (Android/media/com.philkes.notallyx). En combinación con aplicaciones de sincronización de archivos, esta opción se puede utilizar para sincronizar datos de NotallyX entre varios dispositivos. Fecha Formato de fecha Aplicar también en la vista de nota diff --git a/app/src/main/res/values-fr/strings.xml b/app/src/main/res/values-fr/strings.xml index 68bc6d7d..2e76ff63 100644 --- a/app/src/main/res/values-fr/strings.xml +++ b/app/src/main/res/values-fr/strings.xml @@ -87,7 +87,7 @@ Tous les jours Sombre Stocker les données sur le dossier public - En activant cette option, la base de données interne de l\'application sera déplacée dans le dossier public de l\'application (Android/media/com.philkes.notallyx). En combinaison avec des applications de synchronisation de fichiers, cela peut être utilisé pour synchroniser les données de NotallyX entre plusieurs appareils. + En activant cette option, la base de données interne de l\'application et les fichiers joints seront déplacés dans le dossier public de l\'application (Android/media/com.philkes.notallyx). En combinaison avec des applications de synchronisation de fichiers, cela peut être utilisé pour synchroniser les données de NotallyX entre plusieurs appareils. Date Format de la date Appliquer aussi dans les notes diff --git a/app/src/main/res/values-it/strings.xml b/app/src/main/res/values-it/strings.xml index 2e3eb02b..92129390 100644 --- a/app/src/main/res/values-it/strings.xml +++ b/app/src/main/res/values-it/strings.xml @@ -82,7 +82,7 @@ Giornaliera Scuro Salvare i dati nella cartella pubblica - Attivandolo, il database interno dell’app verrà spostato nella cartella pubblica dell’app (Android/media/com.philkes.notallyx).\nAbbinandolo con un’app di sincronizzazione file potrai così sincronizzare i dati di NotallyX tra dispositivi diversi. + Attivandolo, il database interno dell’app e i file allegati verranno spostati nella cartella pubblica dell’app (Android/media/com.philkes.notallyx). Abbinandolo con un’app di sincronizzazione file potrai così sincronizzare i dati di NotallyX tra dispositivi diversi. Data Formato data Applica anche nella vista nota diff --git a/app/src/main/res/values-pl/strings.xml b/app/src/main/res/values-pl/strings.xml index 23f365d5..9fe3145c 100644 --- a/app/src/main/res/values-pl/strings.xml +++ b/app/src/main/res/values-pl/strings.xml @@ -93,7 +93,7 @@ Dziennie Ciemny Przechowuj dane w folderze publicznym - Po włączeniu tej opcji wewnętrzna baza danych aplikacji zostanie przeniesiona do folderu publicznego aplikacji (Android/media/com.philkes.notallyx). W połączeniu z aplikacjami do synchronizacji plików można to wykorzystać do synchronizacji danych NotallyX między wieloma urządzeniami. + Po włączeniu tej opcji wewnętrzna baza danych aplikacji i załączone pliki zostaną przeniesione do folderu publicznego aplikacji (Android/media/com.philkes.notallyx). W połączeniu z aplikacjami do synchronizacji plików można to wykorzystać do synchronizacji danych NotallyX między wieloma urządzeniami. Data Format daty Zastosuj także w widoku notatek diff --git a/app/src/main/res/values-pt-rBR/strings.xml b/app/src/main/res/values-pt-rBR/strings.xml index 27a9d2ea..ba6d6c23 100644 --- a/app/src/main/res/values-pt-rBR/strings.xml +++ b/app/src/main/res/values-pt-rBR/strings.xml @@ -95,7 +95,7 @@ Diariamente Escuro Armazenar dados em pasta pública - Ao ativar esta opção, o banco de dados interno do aplicativo será movido para a pasta pública do aplicativo (Android/media/com.philkes.notallyx).\nEm combinação com aplicativos de sincronização de arquivos, isso pode ser usado para sincronizar os dados do NotallyX entre vários dispositivos. + Ao ativar esta opção, o banco de dados interno do aplicativo e os arquivos anexados serão movidos para a pasta pública do aplicativo (Android/media/com.philkes.notallyx). Em combinação com aplicativos de sincronização de arquivos, isso pode ser usado para sincronizar os dados do NotallyX entre vários dispositivos. Data Formato de data Também aplicar na visualização da nota diff --git a/app/src/main/res/values-ro/strings.xml b/app/src/main/res/values-ro/strings.xml index 5e1d5169..0247e0f4 100644 --- a/app/src/main/res/values-ro/strings.xml +++ b/app/src/main/res/values-ro/strings.xml @@ -87,7 +87,7 @@ Zilnic Întunecată Stocarea datelor într-un dosar public - Prin activarea acestei opțiuni, baza de date internă a aplicației va fi mutată în dosarul public al aplicației (Android/media/com.philkes.notallyx).\n\nÎn combinație cu aplicațiile de sincronizare a fișierelor, aceasta poate fi utilizată pentru a sincroniza datele NotallyX între mai multe dispozitive. + Prin activarea acestei opțiuni, baza de date internă a aplicației și fișierele atașate vor fi mutate în dosarul public al aplicației (Android/media/com.philkes.notallyx). În combinație cu aplicațiile de sincronizare a fișierelor, aceasta poate fi utilizată pentru a sincroniza datele NotallyX între mai multe dispozitive. Data Formatul datei Se aplică și în vizualizarea notei diff --git a/app/src/main/res/values-ru/strings.xml b/app/src/main/res/values-ru/strings.xml index a884e8d2..0bd5c873 100644 --- a/app/src/main/res/values-ru/strings.xml +++ b/app/src/main/res/values-ru/strings.xml @@ -92,7 +92,7 @@ Дни Тёмная Сохранять данные в общедоступной папке - При включении этой функции внутренняя база данных приложения будет перенесена в его общедоступную папку (Android/media/com.philkes.notallyx).\nЭто позволит синхронизировать данные NotallyX между устройствами с помощью приложений для синхронизации файлов. + При включении этой функции внутренняя база данных приложения и прикрепленные файлы будут перенесены в его общедоступную папку (Android/media/com.philkes.notallyx). Это позволит синхронизировать данные NotallyX между устройствами с помощью приложений для синхронизации файлов. Дата Формат даты Применять и в просмотре заметки diff --git a/app/src/main/res/values-uk/strings.xml b/app/src/main/res/values-uk/strings.xml index 0d5c7c2f..058b1bba 100644 --- a/app/src/main/res/values-uk/strings.xml +++ b/app/src/main/res/values-uk/strings.xml @@ -95,7 +95,7 @@ Щодня Темна Зберігання даних у публічній папці - Увімкнувши цю опцію, внутрішню базу даних застосунку буде переміщено до публічної папки застосунку (Android/media/com.philkes.notallyx).\nУ поєднанні з застосунками для синхронізації файлів це можна використовувати для синхронізації даних NotallyX між кількома пристроями. + Увімкнувши цю опцію, внутрішню базу даних застосунку та прикріплені файли буде переміщено до публічної папки застосунку (Android/media/com.philkes.notallyx). У поєднанні з застосунками для синхронізації файлів це можна використовувати для синхронізації даних NotallyX між кількома пристроями. Дата Формат дати Застосовувати також у режимі перегляду нотатки diff --git a/app/src/main/res/values-zh-rCN/strings.xml b/app/src/main/res/values-zh-rCN/strings.xml index 8efb8fb3..77767602 100644 --- a/app/src/main/res/values-zh-rCN/strings.xml +++ b/app/src/main/res/values-zh-rCN/strings.xml @@ -94,7 +94,7 @@ 每天 在公共文件夹中存储数据 - 启用此项,应用程序的内部数据库将被移动到应用程序的外部存储(Android / Media / Com.Philkes.notallyX)中。与文件同步应用组合,这可用于同步多个设备之间的数据。 + 启用此项,应用程序的内部数据库和附件将被移动到应用程序的外部存储(Android/media/com.philkes.notallyx)中。与文件同步应用组合,这可用于同步多个设备之间的数据。 日期 日期格式 同样应用于笔记视图 diff --git a/app/src/main/res/values-zh-rTW/strings.xml b/app/src/main/res/values-zh-rTW/strings.xml index e22a60bc..438190d3 100644 --- a/app/src/main/res/values-zh-rTW/strings.xml +++ b/app/src/main/res/values-zh-rTW/strings.xml @@ -87,7 +87,7 @@ 每天 深色 將資料儲存在公共資料夾中 - 啟用此功能,應用程式的內部資料庫將被移至應用程式的外部儲存(Android / Media / Com.Philkes.notallyX)中。與檔案同步應用程式組合,這可用於同步多個裝置之間的資料。 + 啟用此功能,應用程式的內部資料庫和附件將被移至應用程式的公開資料夾(Android/media/com.philkes.notallyx)中。與檔案同步應用程式組合,這可用於同步多個裝置之間的資料。 日期 日期格式 也應用於筆記檢視 diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 1226ebe8..aeef6025 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -27,6 +27,7 @@ Last Backup Backup on exit Note automatically By enabling this, a backup (\"NotallyX_AutoBackup.zip\") is automatically created in the configured \"Backups Folder\" whenever a note is exited.\nBe aware this might affect performance + Skipped %1$d missing attached files/images Backups Folder Folder in which all auto backups will be stored in.\nIf you want to use Cloud-Storage/WebDAV for your backups, please read the docs (click \"%1$s\") You need to re-choose your Backups Folder so that NotallyX has permission to write to it.\nYou can also press cancel to skip importing Backups Folder value @@ -74,6 +75,9 @@ Choose another folder Choose folder Choose which app to import from + Clean Up + Removed %1$d missing attachments from %2$d notes + Cleanup Finished Clear Clear Data All Notes, Images, Files, Audios will be permanently deleted @@ -97,7 +101,7 @@ Daily Dark Store data in public folder - By enabling this, the app’s internal database will be moved into the app’s public folder (Android/media/com.philkes.notallyx).\nIn combination with file synchronization apps this can be used to synchronize NotallyX data between multiple devices. + By enabling this, the app’s internal database and attached files will be moved into the app’s public folder (Android/media/com.philkes.notallyx). In combination with file synchronization apps this can be used to synchronize NotallyX data between multiple devices. Date Date format Also apply in Note View diff --git a/app/translations.xlsx b/app/translations.xlsx index 7c9cadca..de0b4721 100644 Binary files a/app/translations.xlsx and b/app/translations.xlsx differ