diff --git a/android/app/src/main/java/com/audiobookshelf/app/plugins/AbsDownloader.kt b/android/app/src/main/java/com/audiobookshelf/app/plugins/AbsDownloader.kt index 68cfdd67..57b9aa51 100644 --- a/android/app/src/main/java/com/audiobookshelf/app/plugins/AbsDownloader.kt +++ b/android/app/src/main/java/com/audiobookshelf/app/plugins/AbsDownloader.kt @@ -53,6 +53,18 @@ class AbsDownloader : Plugin() { super.handleOnDestroy() } + @PluginMethod + fun setDownloadNotificationStrings(call: PluginCall) { + DownloadServiceHost.setNotificationStrings( + mainActivity, + call.getString("preparing") ?: "Preparing downloads", + call.getString("downloadingFile") ?: "Downloading {0}", + call.getString("waitingForStorage") ?: "Waiting for available storage", + call.getString("downloads") ?: "Downloads", + call.getString("cancel") ?: "Cancel") + call.resolve() + } + /** Replays restored queue items when the frontend subscribes to download events. */ @PluginMethod(returnType = PluginMethod.RETURN_NONE) override fun addListener(call: PluginCall) { @@ -91,7 +103,7 @@ class AbsDownloader : Plugin() { if (localFolder == null && localFolderId.startsWith("internal-")) { Log.d(tag, "Creating new App Storage internal LocalFolder $localFolderId") - localFolder = LocalFolder(localFolderId, "Internal App Storage", "", "", "", "", "internal", libraryItem.mediaType) + localFolder = LocalFolder(localFolderId, "Internal App Storage", "", "", "", "internal", libraryItem.mediaType) DeviceManager.dbManager.saveLocalFolder(localFolder) } diff --git a/android/app/src/main/java/com/audiobookshelf/app/plugins/AbsFileSystem.kt b/android/app/src/main/java/com/audiobookshelf/app/plugins/AbsFileSystem.kt index 47d6304f..307c6c09 100644 --- a/android/app/src/main/java/com/audiobookshelf/app/plugins/AbsFileSystem.kt +++ b/android/app/src/main/java/com/audiobookshelf/app/plugins/AbsFileSystem.kt @@ -1,6 +1,7 @@ package com.audiobookshelf.app.plugins import android.app.AlertDialog +import android.content.Context import android.net.Uri import android.os.Build import android.util.Log @@ -68,6 +69,19 @@ class AbsFileSystem : Plugin() { } } + @PluginMethod + fun setFolderPickerStrings(call: PluginCall) { + mainActivity.getSharedPreferences(FOLDER_PICKER_PREFERENCES, Context.MODE_PRIVATE) + .edit() + .putString(KEY_WRITE_ACCESS_REQUIRED, call.getString("writeAccessRequired")) + .putString(KEY_ALLOW, call.getString("allow")) + .putString(KEY_CANCEL, call.getString("cancel")) + .putString(KEY_ACCESS_DENIED, call.getString("accessDenied")) + .putString(KEY_PERMISSION_DENIED, call.getString("permissionDenied")) + .apply() + call.resolve() + } + @PluginMethod fun selectFolder(call: PluginCall) { val mediaType = call.data.getString("mediaType", "book").toString() @@ -114,17 +128,14 @@ class AbsFileSystem : Plugin() { if (requestCode == REQUEST_CODE_SELECT_FOLDER) { val builder: AlertDialog.Builder = AlertDialog.Builder(mainActivity) - builder.setMessage( - "You have no write access to this storage, thus selecting this folder is useless." + - "\nWould you like to grant access to this folder?" - ) - builder.setNegativeButton("Dont Allow") { _, _ -> + builder.setMessage(folderPickerString(KEY_WRITE_ACCESS_REQUIRED, DEFAULT_WRITE_ACCESS_REQUIRED)) + builder.setNegativeButton(folderPickerString(KEY_CANCEL, DEFAULT_CANCEL)) { _, _ -> run { - jsobj.put("error", "User Canceled, Access Denied") + jsobj.put("error", folderPickerString(KEY_ACCESS_DENIED, DEFAULT_ACCESS_DENIED)) call.resolve(jsobj) } } - builder.setPositiveButton("Allow.") { _, _ -> + builder.setPositiveButton(folderPickerString(KEY_ALLOW, DEFAULT_ALLOW)) { _, _ -> mainActivity.storageHelper.requestStorageAccess( REQUEST_CODE_SDCARD_ACCESS, initialPath = FileFullPath(mainActivity, storageId, "") @@ -133,7 +144,7 @@ class AbsFileSystem : Plugin() { builder.show() } else { Log.d(TAG, "STORAGE ACCESS DENIED $requestCode") - jsobj.put("error", "Access Denied") + jsobj.put("error", folderPickerString(KEY_ACCESS_DENIED, DEFAULT_ACCESS_DENIED)) call.resolve(jsobj) } } @@ -141,7 +152,7 @@ class AbsFileSystem : Plugin() { override fun onStoragePermissionDenied(requestCode: Int) { Log.d(TAG, "STORAGE PERMISSION DENIED $requestCode") val jsobj = JSObject() - jsobj.put("error", "Permission Denied") + jsobj.put("error", folderPickerString(KEY_PERMISSION_DENIED, DEFAULT_PERMISSION_DENIED)) call.resolve(jsobj) } } @@ -295,4 +306,23 @@ class AbsFileSystem : Plugin() { call.resolve(JSObject("{\"success\":false}")) } } + + private fun folderPickerString(key: String, defaultValue: String): String = + mainActivity.getSharedPreferences(FOLDER_PICKER_PREFERENCES, Context.MODE_PRIVATE) + .getString(key, defaultValue) ?: defaultValue + + private companion object { + const val FOLDER_PICKER_PREFERENCES = "folder_picker" + const val KEY_WRITE_ACCESS_REQUIRED = "write_access_required" + const val KEY_ALLOW = "allow" + const val KEY_CANCEL = "cancel" + const val KEY_ACCESS_DENIED = "access_denied" + const val KEY_PERMISSION_DENIED = "permission_denied" + const val DEFAULT_WRITE_ACCESS_REQUIRED = + "You do not have write access to this folder. Would you like to grant access?" + const val DEFAULT_ALLOW = "Allow" + const val DEFAULT_CANCEL = "Cancel" + const val DEFAULT_ACCESS_DENIED = "Access denied" + const val DEFAULT_PERMISSION_DENIED = "Permission denied" + } } diff --git a/android/app/src/main/java/com/audiobookshelf/app/services/DownloadService.kt b/android/app/src/main/java/com/audiobookshelf/app/services/DownloadService.kt index 734041ce..453f6412 100644 --- a/android/app/src/main/java/com/audiobookshelf/app/services/DownloadService.kt +++ b/android/app/src/main/java/com/audiobookshelf/app/services/DownloadService.kt @@ -17,7 +17,7 @@ class DownloadService : Service() { override fun onCreate() { super.onCreate() createChannel() - startForeground(NOTIFICATION_ID, notification("Preparing downloads")) + startForeground(NOTIFICATION_ID, notification(DownloadServiceHost.notificationStrings(this).preparing)) DownloadServiceHost.attachService(this) } @@ -37,7 +37,10 @@ class DownloadService : Service() { override fun onBind(intent: Intent?): IBinder? = null fun onPartUpdate(part: DownloadItemPart) { - val text = if (part.waitingForSpace) "Waiting for available storage" else "Downloading ${part.filename}" + val strings = DownloadServiceHost.notificationStrings(this) + val text = + if (part.waitingForSpace) strings.waitingForStorage + else strings.downloadingFile.replace("{0}", part.filename) val progress = part.progress.coerceIn(0L, 100L).toInt() val notification = notification(text, progress, part.fileSize > 0L) (getSystemService(NOTIFICATION_SERVICE) as NotificationManager).notify(NOTIFICATION_ID, notification) @@ -55,18 +58,22 @@ class DownloadService : Service() { this, 1, Intent(this, DownloadService::class.java).setAction(ACTION_CANCEL), pendingIntentFlags()) return NotificationCompat.Builder(this, CHANNEL_ID) .setSmallIcon(R.drawable.icon) - .setContentTitle("Audiobookshelf downloads") + .setContentTitle(DownloadServiceHost.notificationStrings(this).downloads) .setContentText(text) .setOnlyAlertOnce(true) .setOngoing(true) .setProgress(100, progress, !determinate) - .addAction(0, "Cancel", cancelIntent) + .addAction(0, DownloadServiceHost.notificationStrings(this).cancel, cancelIntent) .build() } private fun createChannel() { val manager = getSystemService(NOTIFICATION_SERVICE) as NotificationManager - manager.createNotificationChannel(NotificationChannel(CHANNEL_ID, "Downloads", NotificationManager.IMPORTANCE_LOW)) + manager.createNotificationChannel( + NotificationChannel( + CHANNEL_ID, + DownloadServiceHost.notificationStrings(this).downloads, + NotificationManager.IMPORTANCE_LOW)) } private fun pendingIntentFlags(): Int = PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE diff --git a/android/app/src/main/java/com/audiobookshelf/app/services/DownloadServiceHost.kt b/android/app/src/main/java/com/audiobookshelf/app/services/DownloadServiceHost.kt index d37400fe..38f64a74 100644 --- a/android/app/src/main/java/com/audiobookshelf/app/services/DownloadServiceHost.kt +++ b/android/app/src/main/java/com/audiobookshelf/app/services/DownloadServiceHost.kt @@ -11,6 +11,14 @@ import java.util.Collections /** Shared process owner used by the foreground service and the Capacitor bridge. */ object DownloadServiceHost { + data class NotificationStrings( + val preparing: String, + val downloadingFile: String, + val waitingForStorage: String, + val downloads: String, + val cancel: String + ) + private var manager: DownloadItemManager? = null private var bridgeEmitter: DownloadItemManager.DownloadEventEmitter = NoopEmitter private var service: DownloadService? = null @@ -58,6 +66,36 @@ object DownloadServiceHost { @Synchronized fun cancelAll(context: Context) { ensure(context).cancelAll() } + fun setNotificationStrings( + context: Context, + preparing: String, + downloadingFile: String, + waitingForStorage: String, + downloads: String, + cancel: String + ) { + context.getSharedPreferences(NOTIFICATION_PREFERENCES, Context.MODE_PRIVATE) + .edit() + .putString(KEY_PREPARING, preparing) + .putString(KEY_DOWNLOADING_FILE, downloadingFile) + .putString(KEY_WAITING_FOR_STORAGE, waitingForStorage) + .putString(KEY_DOWNLOADS, downloads) + .putString(KEY_CANCEL, cancel) + .apply() + } + + fun notificationStrings(context: Context): NotificationStrings { + val preferences = context.getSharedPreferences(NOTIFICATION_PREFERENCES, Context.MODE_PRIVATE) + return NotificationStrings( + preferences.getString(KEY_PREPARING, DEFAULT_PREPARING) ?: DEFAULT_PREPARING, + preferences.getString(KEY_DOWNLOADING_FILE, DEFAULT_DOWNLOADING_FILE) + ?: DEFAULT_DOWNLOADING_FILE, + preferences.getString(KEY_WAITING_FOR_STORAGE, DEFAULT_WAITING_FOR_STORAGE) + ?: DEFAULT_WAITING_FOR_STORAGE, + preferences.getString(KEY_DOWNLOADS, DEFAULT_DOWNLOADS) ?: DEFAULT_DOWNLOADS, + preferences.getString(KEY_CANCEL, DEFAULT_CANCEL) ?: DEFAULT_CANCEL) + } + @Synchronized fun attachService(downloadService: DownloadService) { service = downloadService @@ -94,4 +132,16 @@ object DownloadServiceHost { override fun onDownloadItemComplete(jsobj: JSObject) = Unit override fun onQueueChanged(hasWork: Boolean) = Unit } + + private const val NOTIFICATION_PREFERENCES = "download_notifications" + private const val KEY_PREPARING = "preparing" + private const val KEY_DOWNLOADING_FILE = "downloading_file" + private const val KEY_WAITING_FOR_STORAGE = "waiting_for_storage" + private const val KEY_DOWNLOADS = "downloads" + private const val KEY_CANCEL = "cancel" + private const val DEFAULT_PREPARING = "Preparing downloads" + private const val DEFAULT_DOWNLOADING_FILE = "Downloading {0}" + private const val DEFAULT_WAITING_FOR_STORAGE = "Waiting for available storage" + private const val DEFAULT_DOWNLOADS = "Downloads" + private const val DEFAULT_CANCEL = "Cancel" } diff --git a/plugins/i18n.js b/plugins/i18n.js index 1dd6a238..b690f912 100644 --- a/plugins/i18n.js +++ b/plugins/i18n.js @@ -1,4 +1,6 @@ import Vue from 'vue' +import { Capacitor } from '@capacitor/core' +import { AbsDownloader, AbsFileSystem } from '@/plugins/capacitor' import enUsStrings from '../strings/en-us.json' const defaultCode = 'en-us' @@ -41,6 +43,24 @@ function supplant(str, subs) { }) } +function syncDownloadNotificationStrings() { + if (Capacitor.getPlatform() !== 'android') return + AbsDownloader.setDownloadNotificationStrings({ + preparing: Vue.prototype.$strings.MessagePreparingDownloads, + downloadingFile: Vue.prototype.$strings.MessageDownloadingFile, + waitingForStorage: Vue.prototype.$strings.MessageWaitingForAvailableStorage, + downloads: Vue.prototype.$strings.HeaderDownloads, + cancel: Vue.prototype.$strings.ButtonCancel + }).catch((error) => console.warn('Failed to update download notification strings', error)) + AbsFileSystem.setFolderPickerStrings({ + writeAccessRequired: Vue.prototype.$strings.MessageStorageWriteAccessRequired, + allow: Vue.prototype.$strings.ButtonAllow, + cancel: Vue.prototype.$strings.ButtonCancel, + accessDenied: Vue.prototype.$strings.MessageStorageAccessDenied, + permissionDenied: Vue.prototype.$strings.MessageStoragePermissionDenied + }).catch((error) => console.warn('Failed to update folder picker strings', error)) +} + Vue.prototype.$languageCodeOptions = Object.keys(languageCodeMap).map((code) => { return { text: languageCodeMap[code].label, @@ -108,6 +128,7 @@ async function loadi18n(code) { } Vue.prototype.$setDateFnsLocale(languageCodeMap[code].dateFnsLocale) + syncDownloadNotificationStrings() this.$eventBus.$emit('change-lang', code) return true @@ -145,5 +166,5 @@ async function initialize() { export default ({ app, store }, inject) => { $localStore = app.$localStore - initialize() + initialize().finally(syncDownloadNotificationStrings) } diff --git a/strings/en-us.json b/strings/en-us.json index 2f39d0dc..12ac5b8b 100644 --- a/strings/en-us.json +++ b/strings/en-us.json @@ -1,6 +1,7 @@ { "ButtonAdd": "Add", "ButtonAddNewServer": "Add New Server", + "ButtonAllow": "Allow", "ButtonAuthors": "Authors", "ButtonBack": "Back", "ButtonCancel": "Cancel", @@ -295,7 +296,7 @@ "MessageAudiobookshelfServerNotConnected": "Audiobookshelf server not connected", "MessageAudiobookshelfServerRequired": "Important! This app is designed to work with an Audiobookshelf server that you or someone you know is hosting. This app does not provide any content.", "MessageBookshelfEmpty": "Bookshelf empty", - "MessageConfirmAppExit":"Did you want to exit the app?", + "MessageConfirmAppExit": "Did you want to exit the app?", "MessageConfirmDeleteEpisodeDownloadQueue": "Are you sure you want to clear episode download queue?", "MessageConfirmDeleteLocalEpisode": "Remove local episode \"{0}\" from your device? The file on the server will be unaffected.", "MessageConfirmDeleteLocalFiles": "Remove local files of this item from your device? The files on the server and your progress will be unaffected.", @@ -305,13 +306,14 @@ "MessageConfirmDiscardProgress": "Are you sure you want to reset your progress?", "MessageConfirmDownloadUsingCellular": "You are about to download using cellular data. This may include carrier data charges. Do you wish to continue?", "MessageConfirmMarkAsFinished": "Are you sure you want to mark this item as finished?", - "MessageConfirmPlaybackTime":"Start playback for \"{0}\" at {1}?", + "MessageConfirmPlaybackTime": "Start playback for \"{0}\" at {1}?", "MessageConfirmRemoveBookmark": "Are you sure you want to remove bookmark?", "MessageConfirmStreamingUsingCellular": "You are about to stream using cellular data. This may include carrier data charges. Do you wish to continue?", "MessageDiscardProgress": "Discard Progress", "MessageDownloadCompleteProcessing": "Download complete. Processing...", "MessageDownloading": "Downloading...", "MessageDownloadingEpisode": "Downloading episode", + "MessageDownloadingFile": "Downloading {0}", "MessageEpisodesQueuedForDownload": "{0} Episode(s) queued for download", "MessageFailedToRefreshToken": "Failed to refresh token, re-login required", "MessageFeedURLWillBe": "Feed URL will be {0}", @@ -347,6 +349,7 @@ "MessageOldServerConnectionWarning": "Server connection config is using an old user ID. Please delete and re-add this server connection.", "MessageOldServerConnectionWarningHelp": "You originally set up the connection to this server prior to the database migration in 2.3.0, released June 2023. A future server update will remove the ability to sign in with this old connection. Please delete the existing server connection and connect again (using the same server address and credentials). If you have any downloaded media on this device, the media will need to be downloaded again to sync with the server.", "MessagePodcastSearchField": "Enter search term or RSS feed URL", + "MessagePreparingDownloads": "Preparing downloads", "MessageProgressSyncFailed": "The most recent attempt to report your listening progress to the server has failed. Progress sync requests will continue to be attempted every 15 seconds to 1 minute while media is playing.", "MessageReportBugsAndContribute": "Report bugs, request features, and contribute on", "MessageSeriesAlreadyDownloaded": "You have already downloaded all books in this series.", @@ -357,6 +360,10 @@ "MessageSocketConnectedOverUnmeteredCellular": "Socket connected over unmetered cellular", "MessageSocketConnectedOverUnmeteredWifi": "Socket connected over unmetered wifi", "MessageSocketNotConnected": "Socket not connected", + "MessageStorageAccessDenied": "Access denied", + "MessageStoragePermissionDenied": "Permission denied", + "MessageStorageWriteAccessRequired": "You do not have write access to this folder. Would you like to grant access?", + "MessageWaitingForAvailableStorage": "Waiting for available storage", "NoteRSSFeedPodcastAppsHttps": "Warning: Most podcast apps will require the RSS feed URL is using HTTPS", "NoteRSSFeedPodcastAppsPubDate": "Warning: 1 or more of your episodes do not have a Pub Date. Some podcast apps require this.", "ToastBookmarkCreateFailed": "Failed to create bookmark",