diff --git a/app/src/main/java/helium314/keyboard/latin/common/FileUtils.java b/app/src/main/java/helium314/keyboard/latin/common/FileUtils.java index 71ba4213d..a69d76170 100644 --- a/app/src/main/java/helium314/keyboard/latin/common/FileUtils.java +++ b/app/src/main/java/helium314/keyboard/latin/common/FileUtils.java @@ -6,12 +6,18 @@ package helium314.keyboard.latin.common; +import android.content.Context; +import android.net.Uri; + import java.io.File; import java.io.FileOutputStream; import java.io.FilenameFilter; import java.io.IOException; import java.io.InputStream; import java.io.OutputStream; +import java.util.concurrent.CountDownLatch; + +import helium314.keyboard.latin.utils.ExecutorUtils; /** * A simple class to help with removing directories recursively. @@ -47,7 +53,32 @@ public class FileUtils { return hasDeletedAllFiles; } - public static void copyStreamToNewFile(InputStream in, File outfile) throws IOException { + /** + * copy data to file on different thread to avoid NetworkOnMainThreadException + * still effectively blocking, as we only use small files which are mostly stored locally + */ + public static void copyContentUriToNewFile(final Uri uri, final Context context, final File outfile) throws IOException { + final boolean[] allOk = new boolean[] { true }; + final CountDownLatch wait = new CountDownLatch(1); + ExecutorUtils.getBackgroundExecutor(ExecutorUtils.KEYBOARD).execute(() -> { + try { + copyStreamToNewFile(context.getContentResolver().openInputStream(uri), outfile); + } catch (IOException e) { + allOk[0] = false; + } finally { + wait.countDown(); + } + }); + try { + wait.await(); + } catch (InterruptedException e) { + allOk[0] = false; + } + if (!allOk[0]) + throw new IOException("could not copy from uri"); + } + + public static void copyStreamToNewFile(final InputStream in, final File outfile) throws IOException { File parentFile = outfile.getParentFile(); if (parentFile == null || (!parentFile.exists() && !parentFile.mkdirs())) { throw new IOException("could not create parent folder"); @@ -57,7 +88,7 @@ public class FileUtils { out.close(); } - public static void copyStreamToOtherStream(InputStream in, OutputStream out) throws IOException { + public static void copyStreamToOtherStream(final InputStream in, final OutputStream out) throws IOException { byte[] buf = new byte[1024]; int len; while ((len = in.read(buf)) > 0) { diff --git a/app/src/main/java/helium314/keyboard/latin/settings/AboutFragment.kt b/app/src/main/java/helium314/keyboard/latin/settings/AboutFragment.kt index e05d8e0fd..51f9ca982 100644 --- a/app/src/main/java/helium314/keyboard/latin/settings/AboutFragment.kt +++ b/app/src/main/java/helium314/keyboard/latin/settings/AboutFragment.kt @@ -17,6 +17,7 @@ import androidx.appcompat.app.AlertDialog import androidx.preference.Preference import helium314.keyboard.latin.BuildConfig import helium314.keyboard.latin.R +import helium314.keyboard.latin.utils.ExecutorUtils import helium314.keyboard.latin.utils.Log import helium314.keyboard.latin.utils.SpannableStringUtils @@ -46,8 +47,10 @@ class AboutFragment : SubScreenFragment() { private val logFilePicker = registerForActivityResult(ActivityResultContracts.StartActivityForResult()) { result -> if (result.resultCode != Activity.RESULT_OK) return@registerForActivityResult val uri = result.data?.data ?: return@registerForActivityResult - activity?.contentResolver?.openOutputStream(uri)?.use { os -> - os.bufferedWriter().use { it.write(Log.getLog().joinToString("\n")) } + ExecutorUtils.getBackgroundExecutor(ExecutorUtils.KEYBOARD).execute { + activity?.contentResolver?.openOutputStream(uri)?.use { os -> + os.bufferedWriter().use { it.write(Log.getLog().joinToString("\n")) } + } } } diff --git a/app/src/main/java/helium314/keyboard/latin/settings/AdvancedSettingsFragment.kt b/app/src/main/java/helium314/keyboard/latin/settings/AdvancedSettingsFragment.kt index a95f836cf..1512420ff 100644 --- a/app/src/main/java/helium314/keyboard/latin/settings/AdvancedSettingsFragment.kt +++ b/app/src/main/java/helium314/keyboard/latin/settings/AdvancedSettingsFragment.kt @@ -43,6 +43,7 @@ import helium314.keyboard.latin.settings.SeekBarDialogPreference.ValueProxy import helium314.keyboard.latin.utils.AdditionalSubtypeUtils import helium314.keyboard.latin.utils.CUSTOM_LAYOUT_PREFIX import helium314.keyboard.latin.utils.DeviceProtectedUtils +import helium314.keyboard.latin.utils.ExecutorUtils import helium314.keyboard.latin.utils.JniUtils import helium314.keyboard.latin.utils.editCustomLayout import helium314.keyboard.latin.utils.getStringResourceOrName @@ -54,6 +55,7 @@ import java.io.FileInputStream import java.io.FileOutputStream import java.io.IOException import java.io.OutputStream +import java.util.concurrent.CountDownLatch import java.util.zip.ZipEntry import java.util.zip.ZipInputStream import java.util.zip.ZipOutputStream @@ -207,12 +209,15 @@ class AdvancedSettingsFragment : SubScreenFragment() { private fun copyLibrary(uri: Uri) { val tmpfile = File(requireContext().filesDir.absolutePath + File.separator + "tmplib") try { - val inputStream = requireContext().contentResolver.openInputStream(uri) + val otherTemporaryFile = File(requireContext().filesDir.absolutePath + File.separator + "tmpfile") + FileUtils.copyContentUriToNewFile(uri, requireContext(), otherTemporaryFile) + val inputStream = FileInputStream(otherTemporaryFile) val outputStream = FileOutputStream(tmpfile) outputStream.use { tmpfile.setReadOnly() // as per recommendations in https://developer.android.com/about/versions/14/behavior-changes-14#safer-dynamic-code-loading FileUtils.copyStreamToOtherStream(inputStream, it) } + otherTemporaryFile.delete() val checksum = ChecksumCalculator.checksum(tmpfile.inputStream()) ?: "" if (checksum == JniUtils.expectedDefaultChecksum()) { @@ -269,7 +274,7 @@ class AdvancedSettingsFragment : SubScreenFragment() { private fun loadImage(uri: Uri, night: Boolean) { val imageFile = Settings.getCustomBackgroundFile(requireContext(), night) - FileUtils.copyStreamToNewFile(requireContext().contentResolver.openInputStream(uri), imageFile) + FileUtils.copyContentUriToNewFile(uri, requireContext(), imageFile) try { BitmapFactory.decodeFile(imageFile.absolutePath) } catch (_: Exception) { @@ -334,91 +339,110 @@ class AdvancedSettingsFragment : SubScreenFragment() { if (backupFilePatterns.any { path.matches(it) }) protectedFiles.add(file) } - try { - activity?.contentResolver?.openOutputStream(uri)?.use { os -> - // write files to zip - val zipStream = ZipOutputStream(os) - files.forEach { - val fileStream = FileInputStream(it).buffered() - zipStream.putNextEntry(ZipEntry(it.path.replace(filesPath, ""))) - fileStream.copyTo(zipStream, 1024) - fileStream.close() + var error: String? = "" + val wait = CountDownLatch(1) + ExecutorUtils.getBackgroundExecutor(ExecutorUtils.KEYBOARD).execute { + try { + activity?.contentResolver?.openOutputStream(uri)?.use { os -> + // write files to zip + val zipStream = ZipOutputStream(os) + files.forEach { + val fileStream = FileInputStream(it).buffered() + zipStream.putNextEntry(ZipEntry(it.path.replace(filesPath, ""))) + fileStream.copyTo(zipStream, 1024) + fileStream.close() + zipStream.closeEntry() + } + protectedFiles.forEach { + val fileStream = FileInputStream(it).buffered() + zipStream.putNextEntry(ZipEntry(it.path.replace(protectedFilesDir.path, "unprotected"))) + fileStream.copyTo(zipStream, 1024) + fileStream.close() + zipStream.closeEntry() + } + zipStream.putNextEntry(ZipEntry(PREFS_FILE_NAME)) + settingsToJsonStream(sharedPreferences.all, zipStream) zipStream.closeEntry() - } - protectedFiles.forEach { - val fileStream = FileInputStream(it).buffered() - zipStream.putNextEntry(ZipEntry(it.path.replace(protectedFilesDir.path, "unprotected"))) - fileStream.copyTo(zipStream, 1024) - fileStream.close() + zipStream.putNextEntry(ZipEntry(PROTECTED_PREFS_FILE_NAME)) + settingsToJsonStream(PreferenceManager.getDefaultSharedPreferences(requireContext()).all, zipStream) zipStream.closeEntry() + zipStream.close() } - zipStream.putNextEntry(ZipEntry(PREFS_FILE_NAME)) - settingsToJsonStream(sharedPreferences.all, zipStream) - zipStream.closeEntry() - zipStream.putNextEntry(ZipEntry(PROTECTED_PREFS_FILE_NAME)) - settingsToJsonStream(PreferenceManager.getDefaultSharedPreferences(requireContext()).all, zipStream) - zipStream.closeEntry() - zipStream.close() + } catch (t: Throwable) { + error = t.message + Log.w(TAG, "error during backup", t) + } finally { + wait.countDown() } - } catch (t: Throwable) { + } + wait.await() + if (!error.isNullOrBlank()) { // inform about every error - Log.w(TAG, "error during backup", t) - infoDialog(requireContext(), requireContext().getString(R.string.backup_error, t.message)) + infoDialog(requireContext(), requireContext().getString(R.string.backup_error, error)) } } private fun restore(uri: Uri) { - try { - activity?.contentResolver?.openInputStream(uri)?.use { inputStream -> - ZipInputStream(inputStream).use { zip -> - var entry: ZipEntry? = zip.nextEntry - val filesDir = requireContext().filesDir?.path ?: return - val deviceProtectedFilesDir = DeviceProtectedUtils.getFilesDir(requireContext()).path - Settings.getInstance().stopListener() - while (entry != null) { - if (entry.name.startsWith("unprotected${File.separator}")) { - val adjustedName = entry.name.substringAfter("unprotected${File.separator}") - if (backupFilePatterns.any { adjustedName.matches(it) }) { - val targetFileName = upgradeFileNames(adjustedName) - val file = File(deviceProtectedFilesDir, targetFileName) + var error: String? = "" + val wait = CountDownLatch(1) + ExecutorUtils.getBackgroundExecutor(ExecutorUtils.KEYBOARD).execute { + try { + activity?.contentResolver?.openInputStream(uri)?.use { inputStream -> + ZipInputStream(inputStream).use { zip -> + var entry: ZipEntry? = zip.nextEntry + val filesDir = requireContext().filesDir?.path ?: return@execute + val deviceProtectedFilesDir = DeviceProtectedUtils.getFilesDir(requireContext()).path + Settings.getInstance().stopListener() + while (entry != null) { + if (entry.name.startsWith("unprotected${File.separator}")) { + val adjustedName = entry.name.substringAfter("unprotected${File.separator}") + if (backupFilePatterns.any { adjustedName.matches(it) }) { + val targetFileName = upgradeFileNames(adjustedName) + val file = File(deviceProtectedFilesDir, targetFileName) + FileUtils.copyStreamToNewFile(zip, file) + } + } else if (backupFilePatterns.any { entry!!.name.matches(it) }) { + val targetFileName = upgradeFileNames(entry.name) + val file = File(filesDir, targetFileName) FileUtils.copyStreamToNewFile(zip, file) + } else if (entry.name == PREFS_FILE_NAME) { + val prefLines = String(zip.readBytes()).split("\n") + sharedPreferences.edit().clear().apply() + readJsonLinesToSettings(prefLines, sharedPreferences) + } else if (entry.name == PROTECTED_PREFS_FILE_NAME) { + val prefLines = String(zip.readBytes()).split("\n") + val protectedPrefs = PreferenceManager.getDefaultSharedPreferences(requireContext()) + protectedPrefs.edit().clear().apply() + readJsonLinesToSettings(prefLines, protectedPrefs) } - } else if (backupFilePatterns.any { entry!!.name.matches(it) }) { - val targetFileName = upgradeFileNames(entry.name) - val file = File(filesDir, targetFileName) - FileUtils.copyStreamToNewFile(zip, file) - } else if (entry.name == PREFS_FILE_NAME) { - val prefLines = String(zip.readBytes()).split("\n") - sharedPreferences.edit().clear().apply() - readJsonLinesToSettings(prefLines, sharedPreferences) - } else if (entry.name == PROTECTED_PREFS_FILE_NAME) { - val prefLines = String(zip.readBytes()).split("\n") - val protectedPrefs = PreferenceManager.getDefaultSharedPreferences(requireContext()) - protectedPrefs.edit().clear().apply() - readJsonLinesToSettings(prefLines, protectedPrefs) + zip.closeEntry() + entry = zip.nextEntry } - zip.closeEntry() - entry = zip.nextEntry } } + } catch (t: Throwable) { + error = t.message + Log.w(TAG, "error during restore", t) + } finally { + wait.countDown() } - } catch (t: Throwable) { - // inform about every error - Log.w(TAG, "error during restore", t) - infoDialog(requireContext(), requireContext().getString(R.string.restore_error, t.message)) - } finally { - checkVersionUpgrade(requireContext()) - Settings.getInstance().startListener() - val additionalSubtypes = Settings.readPrefAdditionalSubtypes(sharedPreferences, resources); - updateAdditionalSubtypes(AdditionalSubtypeUtils.createAdditionalSubtypesArray(additionalSubtypes)); - reloadEnabledSubtypes(requireContext()) - val newDictBroadcast = Intent(DictionaryPackConstants.NEW_DICTIONARY_INTENT_ACTION) - activity?.sendBroadcast(newDictBroadcast) - // reload current prefs screen - preferenceScreen.removeAll() - setupPreferences() - KeyboardSwitcher.getInstance().forceUpdateKeyboardTheme(requireContext()) } + wait.await() + if (!error.isNullOrBlank()) { + // inform about every error + infoDialog(requireContext(), requireContext().getString(R.string.restore_error, error)) + } + checkVersionUpgrade(requireContext()) + Settings.getInstance().startListener() + val additionalSubtypes = Settings.readPrefAdditionalSubtypes(sharedPreferences, resources); + updateAdditionalSubtypes(AdditionalSubtypeUtils.createAdditionalSubtypesArray(additionalSubtypes)); + reloadEnabledSubtypes(requireContext()) + val newDictBroadcast = Intent(DictionaryPackConstants.NEW_DICTIONARY_INTENT_ACTION) + activity?.sendBroadcast(newDictBroadcast) + // reload current prefs screen + preferenceScreen.removeAll() + setupPreferences() + KeyboardSwitcher.getInstance().forceUpdateKeyboardTheme(requireContext()) } // todo (later): remove this when new package name has been in use for long enough, this is only for migrating from old openboard name diff --git a/app/src/main/java/helium314/keyboard/latin/settings/SettingsFragment.java b/app/src/main/java/helium314/keyboard/latin/settings/SettingsFragment.java index 7e8c6b633..dc984b85a 100644 --- a/app/src/main/java/helium314/keyboard/latin/settings/SettingsFragment.java +++ b/app/src/main/java/helium314/keyboard/latin/settings/SettingsFragment.java @@ -128,7 +128,7 @@ public final class SettingsFragment extends PreferenceFragmentCompat { if (intent.getResultCode() != Activity.RESULT_OK || intent.getData() == null) return; final Uri uri = intent.getData().getData(); if (uri != null) - saveCrashReport(uri); + ExecutorUtils.getBackgroundExecutor(ExecutorUtils.KEYBOARD).execute(() -> saveCrashReport(uri)); }); private void saveCrashReport(final Uri uri) { diff --git a/app/src/main/java/helium314/keyboard/latin/utils/CustomLayoutUtils.kt b/app/src/main/java/helium314/keyboard/latin/utils/CustomLayoutUtils.kt index b3b367a97..cfa863104 100644 --- a/app/src/main/java/helium314/keyboard/latin/utils/CustomLayoutUtils.kt +++ b/app/src/main/java/helium314/keyboard/latin/utils/CustomLayoutUtils.kt @@ -18,6 +18,7 @@ import helium314.keyboard.keyboard.internal.keyboard_parser.POPUP_KEYS_NORMAL import helium314.keyboard.keyboard.internal.keyboard_parser.SimpleKeyboardParser import helium314.keyboard.keyboard.internal.keyboard_parser.addLocaleKeyTextsToParams import helium314.keyboard.latin.R +import helium314.keyboard.latin.common.FileUtils import helium314.keyboard.latin.settings.Settings import java.io.File import java.io.IOException @@ -28,8 +29,10 @@ fun loadCustomLayout(uri: Uri?, languageTag: String, context: Context, onAdded: return infoDialog(context, context.getString(R.string.layout_error, "layout file not found")) val layoutContent: String try { - val i = context.contentResolver.openInputStream(uri) - layoutContent = i?.use { it.reader().readText() } ?: throw IOException() + val tmpFile = File(context.filesDir.absolutePath + File.separator + "tmpfile") + FileUtils.copyContentUriToNewFile(uri, context, tmpFile) + layoutContent = tmpFile.readText() + tmpFile.delete() } catch (e: IOException) { return infoDialog(context, context.getString(R.string.layout_error, "cannot read layout file")) } diff --git a/app/src/main/java/helium314/keyboard/latin/utils/NewDictionaryAdder.kt b/app/src/main/java/helium314/keyboard/latin/utils/NewDictionaryAdder.kt index a2fa2370a..c7343c9fa 100644 --- a/app/src/main/java/helium314/keyboard/latin/utils/NewDictionaryAdder.kt +++ b/app/src/main/java/helium314/keyboard/latin/utils/NewDictionaryAdder.kt @@ -30,8 +30,7 @@ class NewDictionaryAdder(private val context: Context, private val onAdded: ((Bo cachedDictionaryFile.delete() try { - val i = context.contentResolver.openInputStream(uri) - FileUtils.copyStreamToNewFile(i, cachedDictionaryFile) + FileUtils.copyContentUriToNewFile(uri, context, cachedDictionaryFile) } catch (e: IOException) { return onDictionaryLoadingError(R.string.dictionary_load_error) }