diff --git a/app/src/main/java/com/itsaky/androidide/activities/editor/EditorHandlerActivity.kt b/app/src/main/java/com/itsaky/androidide/activities/editor/EditorHandlerActivity.kt index f92cc51179..36d66fb6db 100644 --- a/app/src/main/java/com/itsaky/androidide/activities/editor/EditorHandlerActivity.kt +++ b/app/src/main/java/com/itsaky/androidide/activities/editor/EditorHandlerActivity.kt @@ -73,6 +73,7 @@ import com.itsaky.androidide.utils.UniqueNameBuilder import com.itsaky.androidide.utils.flashSuccess import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch +import kotlinx.coroutines.runBlocking import kotlinx.coroutines.withContext import org.adfa.constants.CONTENT_KEY import org.greenrobot.eventbus.Subscribe @@ -196,14 +197,17 @@ open class EditorHandlerActivity : override fun onPause() { super.onPause() // Record timestamps for all currently open files before saving the cache - editorViewModel.getOpenedFiles().forEach { file -> - // Note: Using the file's absolutePath as the key - fileTimestamps[file.absolutePath] = file.lastModified() + val openFiles = editorViewModel.getOpenedFiles() + lifecycleScope.launch(Dispatchers.IO) { + openFiles.forEach { file -> + // Note: Using the file's absolutePath as the key + fileTimestamps[file.absolutePath] = file.lastModified() + } } ActionContextProvider.clearActivity() if (!isOpenedFilesSaved.get()) { saveOpenedFiles() - saveAllAsync(notify = false) + saveAllAsync(notify = false) } } @@ -221,19 +225,24 @@ open class EditorHandlerActivity : val openFiles = editorViewModel.getOpenedFiles() if (openFiles.isEmpty() || fileTimestamps.isEmpty()) return - // Check each open file - openFiles.forEach { file -> - val lastKnownTimestamp = fileTimestamps[file.absolutePath] ?: return@forEach - val currentTimestamp = file.lastModified() - val editorView = getEditorForFile(file) - - // If the file on disk is newer AND the editor for it exists AND has no unsaved changes... - if (currentTimestamp > lastKnownTimestamp && editorView != null && !editorView.isModified) { - val newContent = file.readText() - editorView.editor?.post { - editorView.editor?.setText(newContent) - editorView.markAsSaved() - updateTabs() + lifecycleScope.launch(Dispatchers.IO) { + // Check each open file + openFiles.forEach { file -> + val lastKnownTimestamp = fileTimestamps[file.absolutePath] ?: return@forEach + val currentTimestamp = file.lastModified() + + // If the file on disk is newer. + if (currentTimestamp > lastKnownTimestamp) { + val newContent = runCatching { file.readText() }.getOrNull() ?: return@forEach + withContext(Dispatchers.Main) { + // If the editor for the new file exists AND has no unsaved changes... + val editorView = getEditorForFile(file) ?: return@withContext + if (editorView.isModified) return@withContext + + editorView.editor?.setText(newContent) + editorView.markAsSaved() + updateTabs() + } } } } @@ -270,35 +279,44 @@ open class EditorHandlerActivity : override fun onStart() { super.onStart() - try { - val prefs = (application as BaseApplication).prefManager - val jsonCache = prefs.getString(PREF_KEY_OPEN_FILES_CACHE, null) - if (jsonCache != null) { - val cache = Gson().fromJson(jsonCache, OpenedFilesCache::class.java) + lifecycleScope.launch { + try { + val prefs = (application as BaseApplication).prefManager + val jsonCache = withContext(Dispatchers.IO) { + prefs.getString(PREF_KEY_OPEN_FILES_CACHE, null) + } ?: return@launch + + val cache = withContext(Dispatchers.Default) { + Gson().fromJson(jsonCache, OpenedFilesCache::class.java) + } onReadOpenedFilesCache(cache) // Clear the preference so it's only loaded once on startup - prefs.putString(PREF_KEY_OPEN_FILES_CACHE, null) + withContext(Dispatchers.IO) { prefs.putString(PREF_KEY_OPEN_FILES_CACHE, null) } + } catch (err: Throwable) { + log.error("Failed to reopen recently opened files", err) } - } catch (err: Throwable) { - log.error("Failed to reopen recently opened files", err) } } private fun onReadOpenedFilesCache(cache: OpenedFilesCache?) { cache ?: return - val existingFiles = cache.allFiles.filter { File(it.filePath).exists() } - val selectedFileExists = File(cache.selectedFile).exists() + lifecycleScope.launch(Dispatchers.IO) { + val existingFiles = cache.allFiles.filter { File(it.filePath).exists() } + val selectedFileExists = File(cache.selectedFile).exists() - if (existingFiles.isEmpty()) return + if (existingFiles.isEmpty()) return@launch - existingFiles.forEach { file -> - openFile(File(file.filePath), file.selection) - } + withContext(Dispatchers.Main) { + existingFiles.forEach { file -> + openFile(File(file.filePath), file.selection) + } - if (selectedFileExists) { - openFile(File(cache.selectedFile)) + if (selectedFileExists) { + openFile(File(cache.selectedFile)) + } + } } } @@ -386,7 +404,11 @@ open class EditorHandlerActivity : selection: Range?, ): CodeEditorView? { val range = selection ?: Range.NONE - if (ImageUtils.isImage(file)) { + val isImage = runBlocking { + withContext(Dispatchers.IO) { ImageUtils.isImage(file) } + } + + if (isImage) { openImage(this, file) return null } @@ -515,11 +537,11 @@ open class EditorHandlerActivity : progressConsumer: ((Int, Int) -> Unit)?, runAfter: (() -> Unit)?, ) { - lifecycleScope.launch { - saveAll(notify, requestSync, processResources, progressConsumer) - runAfter?.invoke() - } - } + lifecycleScope.launch { + saveAll(notify, requestSync, processResources, progressConsumer) + runAfter?.invoke() + } + } override suspend fun saveAll( notify: Boolean, diff --git a/layouteditor/src/main/java/org/appdevforall/codeonthego/layouteditor/activities/EditorActivity.kt b/layouteditor/src/main/java/org/appdevforall/codeonthego/layouteditor/activities/EditorActivity.kt index 7af9087946..6ca9c0dd8d 100644 --- a/layouteditor/src/main/java/org/appdevforall/codeonthego/layouteditor/activities/EditorActivity.kt +++ b/layouteditor/src/main/java/org/appdevforall/codeonthego/layouteditor/activities/EditorActivity.kt @@ -31,7 +31,9 @@ import com.itsaky.androidide.idetooltips.TooltipCategory import com.itsaky.androidide.idetooltips.TooltipManager import com.itsaky.androidide.utils.getCreatedTime import com.itsaky.androidide.utils.getLastModifiedTime +import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext import org.adfa.constants.CONTENT_KEY import org.adfa.constants.CONTENT_TITLE_KEY import org.appdevforall.codeonthego.layouteditor.BaseActivity @@ -100,8 +102,10 @@ class EditorActivity : BaseActivity() { } else -> { - saveXml() - finishAfterTransition() + lifecycleScope.launch { + saveXml() + finishAfterTransition() + } } } } @@ -189,37 +193,64 @@ class EditorActivity : BaseActivity() { } private fun androidToDesignConversion(uri: Uri?) { - val path = uri?.path - if (path != null && path.endsWith(".xml")) { - val xml = FileUtil.readFromUri(uri, this@EditorActivity) - val xmlConverted = ConvertImportedXml(xml).getXmlConverted(this@EditorActivity) + if (uri == null) { + Toast.makeText( + this@EditorActivity, + getString(string.error_invalid_xml_file), + Toast.LENGTH_SHORT + ).show() + return + } + + lifecycleScope.launch { + try { + val fileName = FileUtil.getLastSegmentFromPath(uri.path ?: "") + + if (!fileName.endsWith(".xml", ignoreCase = true)) { + Toast.makeText( + this@EditorActivity, + getString(string.error_invalid_xml_file), + Toast.LENGTH_SHORT + ).show() + return@launch + } + + val xml = withContext(Dispatchers.IO) { + FileUtil.readFromUri(uri, this@EditorActivity) + } ?: run { + make(binding.root, getString(string.error_failed_to_import)) + .setSlideAnimation() + .showAsError() + return@launch + } - if (xmlConverted != null) { - val fileName = FileUtil.getLastSegmentFromPath(path) + val xmlConverted = withContext(Dispatchers.Default) { + ConvertImportedXml(xml).getXmlConverted(this@EditorActivity) + } + + if (xmlConverted == null) { + make(binding.root, getString(string.error_failed_to_import)) + .setSlideAnimation() + .showAsError() + return@launch + } val productionPath = project.layoutPath + fileName val designPath = project.layoutDesignPath + fileName - - FileUtil.writeFile(productionPath, xml) - FileUtil.writeFile(designPath, xmlConverted) + withContext(Dispatchers.IO) { + FileUtil.writeFile(productionPath, xml) + FileUtil.writeFile(designPath, xmlConverted) + } openLayout(LayoutFile(productionPath, designPath)) - make(binding.root, getString(string.success_imported)) .setFadeAnimation() .showAsSuccess() - } else { + } catch (t: Throwable) { make(binding.root, getString(string.error_failed_to_import)) .setSlideAnimation() .showAsError() } - } else { - Toast - .makeText( - this@EditorActivity, - getString(string.error_invalid_xml_file), - Toast.LENGTH_SHORT, - ).show() } } @@ -426,11 +457,13 @@ class EditorActivity : BaseActivity() { if (result.isEmpty()) { showNothingDialog() } else { - saveXml() - startActivity( - Intent(this, PreviewLayoutActivity::class.java) - .putExtra(Constants.EXTRA_KEY_LAYOUT, project.currentLayout), - ) + lifecycleScope.launch { + saveXml() + startActivity( + Intent(this@EditorActivity, PreviewLayoutActivity::class.java) + .putExtra(Constants.EXTRA_KEY_LAYOUT, project.currentLayout), + ) + } } return true } @@ -522,8 +555,10 @@ class EditorActivity : BaseActivity() { if (binding.editorLayout.isLayoutModified()) { showSaveChangesDialog() } else { - saveXml() - finishAfterTransition() + lifecycleScope.launch { + saveXml() + finishAfterTransition() + } } return true } @@ -563,8 +598,10 @@ class EditorActivity : BaseActivity() { if (result.isEmpty()) { showNothingDialog() } else { - saveXml() - finish() + lifecycleScope.launch { + saveXml() + finish() + } } } @@ -686,23 +723,53 @@ class EditorActivity : BaseActivity() { .show() } - private fun saveXml() { - val currentLayoutFile = project.currentLayout as? LayoutFile ?: return + private fun currentLayoutFileOrNull(): LayoutFile? = + project.currentLayout as? LayoutFile - if (binding.editorLayout.isEmpty()) { - currentLayoutFile.saveLayout("") - currentLayoutFile.saveDesignFile("") - binding.editorLayout.markAsSaved() - ToastUtils.showShort(getString(string.layout_saved)) - return + private fun restoreOriginalXmlIfNeeded() { + val xmlToRestore = originalDesignXml ?: originalProductionXml + if (!xmlToRestore.isNullOrBlank()) { + binding.editorLayout.loadLayoutFromParser(xmlToRestore) } + } + + /** + * Writes the current editor state to disk. + * - Generates XML on the current thread (UI-safe) + * - Performs file I/O on Dispatchers.IO + * - No UI side-effects (no toast / no markAsSaved) to keep it reusable + */ + private suspend fun persistEditorLayout(layoutFile: LayoutFile): Boolean { + return runCatching { + if (binding.editorLayout.isEmpty()) { + withContext(Dispatchers.IO) { + layoutFile.saveLayout("") + layoutFile.saveDesignFile("") + } + return@runCatching + } - val productionXml = XmlLayoutGenerator().generate(binding.editorLayout, true) - currentLayoutFile.saveLayout(productionXml) + val generator = XmlLayoutGenerator() + val productionXml = generator.generate(binding.editorLayout, true) + val designXml = generator.generate(binding.editorLayout, false) - // Generate and save the DESIGN-TIME XML for the editor's internal use - val designXml = XmlLayoutGenerator().generate(binding.editorLayout, false) - currentLayoutFile.saveDesignFile(designXml) + withContext(Dispatchers.IO) { + layoutFile.saveLayout(productionXml) + layoutFile.saveDesignFile(designXml) + } + }.isSuccess + } + + private suspend fun saveXml() { + val layoutFile = currentLayoutFileOrNull() ?: return + + val success = persistEditorLayout(layoutFile) + if (!success) { + withContext(Dispatchers.Main) { + ToastUtils.showShort(getString(string.failed_to_save_layout)) + } + return + } binding.editorLayout.markAsSaved() ToastUtils.showShort(getString(string.layout_saved)) @@ -713,25 +780,20 @@ class EditorActivity : BaseActivity() { .setTitle(R.string.save_changes) .setMessage(R.string.msg_save_changes_to_layout) .setPositiveButton(R.string.save_changes_and_exit) { _, _ -> - saveXml() - finishAfterTransition() + lifecycleScope.launch { + saveXml() + finishAfterTransition() + } }.setNegativeButton(R.string.discard_changes_and_exit) { _, _ -> - val layoutFile = project.currentLayout as? LayoutFile ?: run { - finishAfterTransition() - return@setNegativeButton - } - - val xmlToRestore = originalDesignXml ?: originalProductionXml - if (!xmlToRestore.isNullOrBlank()) { - binding.editorLayout.loadLayoutFromParser(xmlToRestore) + lifecycleScope.launch { + val layoutFile = currentLayoutFileOrNull() ?: run { + finishAfterTransition() + return@launch + } + restoreOriginalXmlIfNeeded() + persistEditorLayout(layoutFile) + finishAfterTransition() } - - val prettyXml = XmlLayoutGenerator().generate(binding.editorLayout, true) - layoutFile.saveLayout(prettyXml) - - val designXml = XmlLayoutGenerator().generate(binding.editorLayout, false) - layoutFile.saveDesignFile(designXml) - finishAfterTransition() }.setNeutralButton(R.string.cancel_and_stay_in_editor) { dialog, _ -> dialog.dismiss() }.setCancelable(false) diff --git a/layouteditor/src/main/res/values/strings.xml b/layouteditor/src/main/res/values/strings.xml index a664a55b93..a4d1488e9c 100644 --- a/layouteditor/src/main/res/values/strings.xml +++ b/layouteditor/src/main/res/values/strings.xml @@ -7,6 +7,7 @@ Project saved. New project Layout Saved + Failed to save layout New Layout Layout empty! Add a view before saving… Add new