diff --git a/app/src/main/java/com/itsaky/androidide/activities/MainActivity.kt b/app/src/main/java/com/itsaky/androidide/activities/MainActivity.kt index de2731000f..7f51981128 100755 --- a/app/src/main/java/com/itsaky/androidide/activities/MainActivity.kt +++ b/app/src/main/java/com/itsaky/androidide/activities/MainActivity.kt @@ -20,11 +20,9 @@ package com.itsaky.androidide.activities import android.content.Intent import android.content.res.Configuration import android.os.Bundle -import android.util.Log import android.view.KeyEvent import android.view.View import androidx.activity.OnBackPressedCallback -import org.koin.androidx.viewmodel.ext.android.viewModel import androidx.core.graphics.Insets import androidx.core.view.WindowInsetsCompat import androidx.core.view.isVisible @@ -34,35 +32,39 @@ import androidx.transition.doOnEnd import com.google.android.material.transition.MaterialSharedAxis import com.itsaky.androidide.FeedbackButtonManager import com.itsaky.androidide.R -import com.itsaky.androidide.activities.editor.EditorActivityKt import com.itsaky.androidide.actions.ActionData +import com.itsaky.androidide.activities.editor.EditorActivityKt import com.itsaky.androidide.analytics.IAnalyticsManager import com.itsaky.androidide.app.EdgeToEdgeIDEActivity import com.itsaky.androidide.databinding.ActivityMainBinding +import com.itsaky.androidide.fragments.MainFragment +import com.itsaky.androidide.fragments.RecentProjectsFragment import com.itsaky.androidide.idetooltips.TooltipManager import com.itsaky.androidide.idetooltips.TooltipTag.PROJECT_RECENT_TOP import com.itsaky.androidide.idetooltips.TooltipTag.SETUP_OVERVIEW +import com.itsaky.androidide.localWebServer.ServerConfig +import com.itsaky.androidide.localWebServer.WebServer import com.itsaky.androidide.preferences.internal.GeneralPreferences import com.itsaky.androidide.projects.ProjectManagerImpl import com.itsaky.androidide.resources.R.string +import com.itsaky.androidide.roomData.recentproject.RecentProject +import com.itsaky.androidide.shortcuts.IdeShortcutActions +import com.itsaky.androidide.shortcuts.ShortcutContext +import com.itsaky.androidide.shortcuts.ShortcutExecutionContext +import com.itsaky.androidide.shortcuts.ShortcutManager import com.itsaky.androidide.templates.ITemplateProvider import com.itsaky.androidide.utils.DialogUtils import com.itsaky.androidide.utils.Environment import com.itsaky.androidide.utils.FeatureFlags +import com.itsaky.androidide.utils.MainScreenActions import com.itsaky.androidide.utils.UrlManager +import com.itsaky.androidide.utils.applyBottomWindowInsetsPadding import com.itsaky.androidide.utils.findValidProjects import com.itsaky.androidide.utils.flashInfo -import com.itsaky.androidide.utils.applyBottomWindowInsetsPadding -import com.itsaky.androidide.utils.MainScreenActions -import com.itsaky.androidide.fragments.MainFragment -import com.itsaky.androidide.fragments.RecentProjectsFragment -import com.itsaky.androidide.roomData.recentproject.RecentProject -import com.itsaky.androidide.shortcuts.IdeShortcutActions -import com.itsaky.androidide.shortcuts.ShortcutContext -import com.itsaky.androidide.shortcuts.ShortcutExecutionContext -import com.itsaky.androidide.shortcuts.ShortcutManager import com.itsaky.androidide.utils.getCreatedTime import com.itsaky.androidide.utils.getLastModifiedTime +import com.itsaky.androidide.utils.hasVisibleDialog +import com.itsaky.androidide.utils.readProjectLanguage import com.itsaky.androidide.viewmodel.MainViewModel import com.itsaky.androidide.viewmodel.MainViewModel.Companion.SCREEN_CLONE_REPO import com.itsaky.androidide.viewmodel.MainViewModel.Companion.SCREEN_DELETE_PROJECTS @@ -74,12 +76,10 @@ import com.itsaky.androidide.viewmodel.MainViewModel.Companion.TOOLTIPS_WEB_VIEW import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch import kotlinx.coroutines.withContext -import com.itsaky.androidide.localWebServer.ServerConfig -import com.itsaky.androidide.localWebServer.WebServer import org.koin.android.ext.android.inject +import org.koin.androidx.viewmodel.ext.android.viewModel import org.slf4j.LoggerFactory import java.io.File -import com.itsaky.androidide.utils.hasVisibleDialog class MainActivity : EdgeToEdgeIDEActivity() { private val log = LoggerFactory.getLogger(MainActivity::class.java) @@ -119,7 +119,7 @@ class MainActivity : EdgeToEdgeIDEActivity() { private val binding: ActivityMainBinding get() = checkNotNull(_binding) - override fun onCreate(savedInstanceState: Bundle?) { + override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) MainScreenActions.register(this) @@ -127,7 +127,9 @@ class MainActivity : EdgeToEdgeIDEActivity() { // Start WebServer after installation is complete startWebServer() - if (savedInstanceState == null) { openLastProject() } + if (savedInstanceState == null) { + openLastProject() + } if (FeatureFlags.isExperimentsEnabled) { binding.codeOnTheGoLabel.title = getString(R.string.app_name) + "." @@ -172,21 +174,21 @@ class MainActivity : EdgeToEdgeIDEActivity() { } } - override fun dispatchKeyEvent(event: KeyEvent): Boolean { - return shortcutManager.dispatch( + override fun dispatchKeyEvent(event: KeyEvent): Boolean = + shortcutManager.dispatch( event = event, context = ShortcutContext.MAIN, focusView = currentFocus, hasModal = supportFragmentManager.hasVisibleDialog(), executionContext = mainShortcutExecutionContext, ) || super.dispatchKeyEvent(event) - } private val mainShortcutExecutionContext by lazy { ShortcutExecutionContext( - ideShortcutActions = IdeShortcutActions { - ActionData.create(this) - }, + ideShortcutActions = + IdeShortcutActions { + ActionData.create(this) + }, ) } @@ -245,17 +247,23 @@ class MainActivity : EdgeToEdgeIDEActivity() { */ private fun recreateVisibleFragmentView() { when (viewModel.currentScreen.value) { - SCREEN_MAIN -> - supportFragmentManager.beginTransaction() + SCREEN_MAIN -> { + supportFragmentManager + .beginTransaction() .setReorderingAllowed(true) .replace(R.id.main, MainFragment()) .commitNow() - SCREEN_SAVED_PROJECTS -> - supportFragmentManager.beginTransaction() + } + + SCREEN_SAVED_PROJECTS -> { + supportFragmentManager + .beginTransaction() .setReorderingAllowed(true) .replace(R.id.saved_projects_view, RecentProjectsFragment()) .commitNow() - else -> { } + } + + else -> {} } } @@ -318,7 +326,7 @@ class MainActivity : EdgeToEdgeIDEActivity() { TOOLTIPS_WEB_VIEW -> binding.tooltipWebView SCREEN_SAVED_PROJECTS -> binding.savedProjectsView SCREEN_DELETE_PROJECTS -> binding.deleteProjectsView - SCREEN_CLONE_REPO -> binding.cloneRepositoryView + SCREEN_CLONE_REPO -> binding.cloneRepositoryView else -> throw IllegalArgumentException("Invalid screen id: '$screen'") } @@ -329,7 +337,7 @@ class MainActivity : EdgeToEdgeIDEActivity() { binding.tooltipWebView, binding.savedProjectsView, binding.deleteProjectsView, - binding.cloneRepositoryView, + binding.cloneRepositoryView, )) { fragment.isVisible = fragment == currentFragment } @@ -365,20 +373,25 @@ class MainActivity : EdgeToEdgeIDEActivity() { val validProjects = findValidProjects(Environment.PROJECTS_DIR) val lastOpenedPath = GeneralPreferences.lastOpenedProject - val projectToOpen = validProjects.find { it.absolutePath == lastOpenedPath } - ?: validProjects.maxByOrNull { it.lastModified() } + val projectToOpen = + validProjects.find { it.absolutePath == lastOpenedPath } + ?: validProjects.maxByOrNull { it.lastModified() } withContext(Dispatchers.Main) { when { - projectToOpen != null -> handleOpenProject(projectToOpen) + projectToOpen != null -> { + handleOpenProject(projectToOpen) + } - lastOpenedPath.isNotBlank() && lastOpenedPath != GeneralPreferences.NO_OPENED_PROJECT -> { - if (!File(lastOpenedPath).exists()) { - flashInfo(string.msg_opened_project_does_not_exist) - } - } + lastOpenedPath.isNotBlank() && lastOpenedPath != GeneralPreferences.NO_OPENED_PROJECT -> { + if (!File(lastOpenedPath).exists()) { + flashInfo(string.msg_opened_project_does_not_exist) + } + } - else -> Unit + else -> { + Unit + } } } } @@ -402,23 +415,29 @@ class MainActivity : EdgeToEdgeIDEActivity() { builder.show() } - internal fun openProject(root: File, project: RecentProject? = null, hasTemplateIssues: Boolean = false) { + internal fun openProject( + root: File, + project: RecentProject? = null, + hasTemplateIssues: Boolean = false, + ) { ProjectManagerImpl.getInstance().projectPath = root.absolutePath - GeneralPreferences.lastOpenedProject = root.absolutePath - - lifecycleScope.launch(Dispatchers.IO) { - val location = root.absolutePath - val recentProject = project ?: RecentProject( - name = root.name, - location = location, - createdAt = getCreatedTime(location).toString(), - lastModified = getLastModifiedTime(location).toString() - ) - viewModel.saveProjectToRecents(recentProject) - } + GeneralPreferences.lastOpenedProject = root.absolutePath + + lifecycleScope.launch(Dispatchers.IO) { + val location = root.absolutePath + val recentProject = + project ?: RecentProject( + name = root.name, + location = location, + createdAt = getCreatedTime(location).toString(), + lastModified = getLastModifiedTime(location).toString(), + language = readProjectLanguage(root), + ) + viewModel.saveProjectToRecents(recentProject) + } // Track project open in Firebase Analytics - analyticsManager.trackProjectOpened(root.absolutePath) + analyticsManager.trackProjectOpened(root.absolutePath) if (isFinishing) { return @@ -427,21 +446,27 @@ class MainActivity : EdgeToEdgeIDEActivity() { val intent = Intent(this, EditorActivityKt::class.java).apply { putExtra("PROJECT_PATH", root.absolutePath) - if (hasTemplateIssues) { - putExtra("HAS_TEMPLATE_ISSUES", true) - } + if (hasTemplateIssues) { + putExtra("HAS_TEMPLATE_ISSUES", true) + } addFlags(Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_SINGLE_TOP) } startActivity(intent) } - private fun startWebServer() { + private fun startWebServer() { lifecycleScope.launch(Dispatchers.IO) { try { val dbFile = Environment.DOC_DB log.info("Starting WebServer - using database file from: {}", dbFile.absolutePath) - val server = WebServer(ServerConfig(databasePath = dbFile.absolutePath, fileDirPath = applicationContext.filesDir.absolutePath)) + val server = + WebServer( + ServerConfig( + databasePath = dbFile.absolutePath, + fileDirPath = applicationContext.filesDir.absolutePath, + ), + ) webServer = server server.start() } catch (e: Exception) { diff --git a/app/src/main/java/com/itsaky/androidide/roomData/recentproject/RecentProjectDao.kt b/app/src/main/java/com/itsaky/androidide/roomData/recentproject/RecentProjectDao.kt index e93e01b7e1..dcd6be9ace 100644 --- a/app/src/main/java/com/itsaky/androidide/roomData/recentproject/RecentProjectDao.kt +++ b/app/src/main/java/com/itsaky/androidide/roomData/recentproject/RecentProjectDao.kt @@ -7,35 +7,46 @@ import androidx.room.Query @Dao interface RecentProjectDao { + @Insert(onConflict = OnConflictStrategy.IGNORE) + suspend fun insert(project: RecentProject) - @Insert(onConflict = OnConflictStrategy.IGNORE) - suspend fun insert(project: RecentProject) + @Query("DELETE FROM recent_project_table WHERE name = :name") + suspend fun deleteByName(name: String) - @Query("DELETE FROM recent_project_table WHERE name = :name") - suspend fun deleteByName(name: String) + @Query("SELECT * FROM recent_project_table order by last_modified DESC, create_at DESC") + suspend fun dumpAll(): List? - @Query("SELECT * FROM recent_project_table order by last_modified DESC, create_at DESC") - suspend fun dumpAll(): List? + @Query("SELECT * FROM recent_project_table WHERE name = :name LIMIT 1") + suspend fun getProjectByName(name: String): RecentProject? - @Query("SELECT * FROM recent_project_table WHERE name = :name LIMIT 1") - suspend fun getProjectByName(name: String): RecentProject? + @Query("SELECT * FROM recent_project_table WHERE name IN (:names)") + suspend fun getProjectsByNames(names: List): List - @Query("SELECT * FROM recent_project_table WHERE name IN (:names)") - suspend fun getProjectsByNames(names: List): List + @Query("DELETE FROM recent_project_table") + suspend fun deleteAll() - @Query("DELETE FROM recent_project_table") - suspend fun deleteAll() + @Query("DELETE FROM recent_project_table WHERE name IN (:names)") + suspend fun deleteByNames(names: List) - @Query("DELETE FROM recent_project_table WHERE name IN (:names)") - suspend fun deleteByNames(names: List) + @Query("UPDATE recent_project_table SET name = :newName, location = :newLocation WHERE name = :oldName") + suspend fun updateNameAndLocation( + oldName: String, + newName: String, + newLocation: String, + ) - @Query("UPDATE recent_project_table SET name = :newName, location = :newLocation WHERE name = :oldName") - suspend fun updateNameAndLocation(oldName: String, newName: String, newLocation: String) + @Query("UPDATE recent_project_table SET last_modified = :lastModified WHERE name = :projectName") + suspend fun updateLastModified( + projectName: String, + lastModified: String, + ) - @Query("UPDATE recent_project_table SET last_modified = :lastModified WHERE name = :projectName") - suspend fun updateLastModified(projectName: String, lastModified: String) - - @Query("SELECT COUNT(*) FROM recent_project_table") - suspend fun getCount(): Int + @Query("UPDATE recent_project_table SET language = :language WHERE location = :location") + suspend fun updateLanguage( + location: String, + language: String, + ) + @Query("SELECT COUNT(*) FROM recent_project_table") + suspend fun getCount(): Int } diff --git a/app/src/main/java/com/itsaky/androidide/ui/ProjectInfoBottomSheet.kt b/app/src/main/java/com/itsaky/androidide/ui/ProjectInfoBottomSheet.kt index 2040193810..614a70267d 100644 --- a/app/src/main/java/com/itsaky/androidide/ui/ProjectInfoBottomSheet.kt +++ b/app/src/main/java/com/itsaky/androidide/ui/ProjectInfoBottomSheet.kt @@ -7,166 +7,184 @@ import android.view.ViewGroup import android.widget.Toast import com.google.android.material.bottomsheet.BottomSheetDialogFragment import com.itsaky.androidide.databinding.LayoutProjectInfoSheetBinding +import com.itsaky.androidide.models.ProjectFile import com.itsaky.androidide.resources.R import com.itsaky.androidide.roomData.recentproject.RecentProject +import com.itsaky.androidide.templates.Language import com.itsaky.androidide.utils.ProjectDetails +import com.itsaky.androidide.utils.capitalizeString import com.itsaky.androidide.utils.formatDate import com.itsaky.androidide.utils.loadProjectDetails import com.itsaky.androidide.utils.viewLifecycleScope import com.termux.shared.interact.ShareUtils.copyTextToClipboard import kotlinx.coroutines.launch -import com.itsaky.androidide.models.ProjectFile class ProjectInfoBottomSheet : BottomSheetDialogFragment() { - companion object { - fun newInstance(project: ProjectFile, recent: RecentProject?): ProjectInfoBottomSheet { - val args = Bundle() - args.putString("name", project.name) - args.putString("path", project.path) - args.putString("created", project.createdAt) - args.putString("modified", project.lastModified) - - args.putString("template", recent?.templateName) - args.putString("lang", recent?.language) - - val fragment = ProjectInfoBottomSheet() - fragment.arguments = args - return fragment - } - } - - private var _binding: LayoutProjectInfoSheetBinding? = null - private val binding get() = _binding!! - - private val pName by lazy { arguments?.getString("name") ?: "" } - private val pPath by lazy { arguments?.getString("path") ?: "" } - private val pCreated by lazy { arguments?.getString("created") } - private val pModified by lazy { arguments?.getString("modified") } - - private val pTemplate by lazy { arguments?.getString("template") } - private val pLang by lazy { arguments?.getString("lang") } - - override fun onCreateView( - inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle? - ): View { - _binding = LayoutProjectInfoSheetBinding.inflate(inflater, container, false) - return binding.root - } - - override fun onViewCreated(view: View, savedInstanceState: Bundle?) { - super.onViewCreated(view, savedInstanceState) - - bindGeneral() - - setLoadingState(true) - - viewLifecycleScope.launch { - val details = loadProjectDetails(pPath, requireContext()) - - if (isAdded && _binding != null) { - bindStructure(details) - bindBuildSetup(details) - setLoadingState(false) - } - } - - binding.btnClose.setOnClickListener { dismiss() } - } - - // ----------------------------- - // GENERAL - // ----------------------------- - private fun bindGeneral() { - val unknown = getString(R.string.unknown) - - binding.infoName.setLabelAndValue( - getString(R.string.project_info_name), - pName - ) - - binding.infoLocation.setLabelAndValue( - getString(R.string.project_info_path), - pPath - ) - binding.infoLocation.setOnClickListener { copyToClipboard(pPath) } - - binding.infoTemplate.setLabelAndValue( - getString(R.string.project_info_template), - pTemplate ?: unknown - ) - - binding.infoCreatedAt.setLabelAndValue( - getString(R.string.date_created_label), - formatDate(pCreated ?: unknown) - ) - - binding.infoModifiedAt.setLabelAndValue( - getString(R.string.date_modified_label), - formatDate(pModified ?: unknown) - ) - } - - // ----------------------------- - // STRUCTURE - // ----------------------------- - private fun bindStructure(details: ProjectDetails) { - binding.infoSize.setLabelAndValue( - getString(R.string.project_info_size), details.sizeFormatted - ) - binding.infoFilesCount.setLabelAndValue( - getString(R.string.project_info_files_count), details.numberOfFiles.toString() - ) - } - - // ----------------------------- - // BUILD SETUP - // ----------------------------- - private fun bindBuildSetup(details: ProjectDetails) { - val unknown = getString(R.string.unknown) - - binding.infoLanguage.setLabelAndValue( - getString(R.string.wizard_language), - pLang ?: unknown - ) - binding.infoGradleVersion.setLabelAndValue( - getString(R.string.project_info_gradle_v), - details.gradleVersion - ) - binding.infoKotlinVersion.setLabelAndValue( - getString(R.string.project_info_kotlin_v), - details.kotlinVersion - ) - binding.infoJavaVersion.setLabelAndValue( - getString(R.string.project_info_java_v), - details.javaVersion - ) - } - - private fun setLoadingState(isLoading: Boolean) { - if (isLoading) { - binding.progressHeavyData.visibility = View.VISIBLE - binding.containerHeavyData.visibility = View.GONE - } else { - binding.progressHeavyData.visibility = View.GONE - - binding.containerHeavyData.apply { - alpha = 0f - visibility = View.VISIBLE - animate() - .alpha(1f) - .setDuration(300) - .start() - } - } - } - - private fun copyToClipboard(value: String) { - copyTextToClipboard(context, value) - Toast.makeText(requireContext(), getString(R.string.copied), Toast.LENGTH_SHORT).show() - } - - override fun onDestroyView() { - super.onDestroyView() - _binding = null - } -} \ No newline at end of file + companion object { + fun newInstance( + project: ProjectFile, + recent: RecentProject?, + ): ProjectInfoBottomSheet { + val args = Bundle() + args.putString("name", project.name) + args.putString("path", project.path) + args.putString("created", project.createdAt) + args.putString("modified", project.lastModified) + + args.putString("template", recent?.templateName) + args.putString("lang", recent?.language) + + val fragment = ProjectInfoBottomSheet() + fragment.arguments = args + return fragment + } + } + + private var _binding: LayoutProjectInfoSheetBinding? = null + val binding get() = _binding!! + + private val pName by lazy { arguments?.getString("name") ?: "" } + private val pPath by lazy { arguments?.getString("path") ?: "" } + private val pCreated by lazy { arguments?.getString("created") } + private val pModified by lazy { arguments?.getString("modified") } + + private val pTemplate by lazy { arguments?.getString("template") } + private val pLang by lazy { arguments?.getString("lang") } + + override fun onCreateView( + inflater: LayoutInflater, + container: ViewGroup?, + savedInstanceState: Bundle?, + ): View { + _binding = LayoutProjectInfoSheetBinding.inflate(inflater, container, false) + return binding.root + } + + override fun onViewCreated( + view: View, + savedInstanceState: Bundle?, + ) { + super.onViewCreated(view, savedInstanceState) + + bindGeneral() + + setLoadingState(true) + + viewLifecycleScope.launch { + val details = loadProjectDetails(pPath, requireContext()) + + if (isAdded && _binding != null) { + bindStructure(details) + bindBuildSetup(details) + setLoadingState(false) + } + } + + binding.btnClose.setOnClickListener { dismiss() } + } + + // ----------------------------- + // GENERAL + // ----------------------------- + private fun bindGeneral() { + val unknown = getString(R.string.unknown) + + binding.infoName.setLabelAndValue( + getString(R.string.project_info_name), + pName, + ) + + binding.infoLocation.setLabelAndValue( + getString(R.string.project_info_path), + pPath, + ) + binding.infoLocation.setOnClickListener { copyToClipboard(pPath) } + + binding.infoTemplate.setLabelAndValue( + getString(R.string.project_info_template), + pTemplate ?: unknown, + ) + + binding.infoCreatedAt.setLabelAndValue( + getString(R.string.date_created_label), + formatDate(pCreated ?: unknown), + ) + + binding.infoModifiedAt.setLabelAndValue( + getString(R.string.date_modified_label), + formatDate(pModified ?: unknown), + ) + } + + // ----------------------------- + // STRUCTURE + // ----------------------------- + private fun bindStructure(details: ProjectDetails) { + binding.infoSize.setLabelAndValue( + getString(R.string.project_info_size), + details.sizeFormatted, + ) + binding.infoFilesCount.setLabelAndValue( + getString(R.string.project_info_files_count), + details.numberOfFiles.toString(), + ) + } + + // ----------------------------- + // BUILD SETUP + // ----------------------------- + private fun bindBuildSetup(details: ProjectDetails) { + val unknown = Language.Unknown.lang + + val languageToDisplay = + pLang?.takeIf { it.isNotBlank() && !it.equals(unknown, ignoreCase = true) } + ?: details.language.takeIf { + it.isNotBlank() && !it.equals(unknown, ignoreCase = true) + } ?: unknown + + binding.infoLanguage.setLabelAndValue( + getString(R.string.wizard_language), + languageToDisplay.capitalizeString(), + ) + binding.infoGradleVersion.setLabelAndValue( + getString(R.string.project_info_gradle_v), + details.gradleVersion, + ) + binding.infoKotlinVersion.setLabelAndValue( + getString(R.string.project_info_kotlin_v), + details.kotlinVersion, + ) + binding.infoJavaVersion.setLabelAndValue( + getString(R.string.project_info_java_v), + details.javaVersion, + ) + } + + private fun setLoadingState(isLoading: Boolean) { + if (isLoading) { + binding.progressHeavyData.visibility = View.VISIBLE + binding.containerHeavyData.visibility = View.GONE + } else { + binding.progressHeavyData.visibility = View.GONE + + binding.containerHeavyData.apply { + alpha = 0f + visibility = View.VISIBLE + animate() + .alpha(1f) + .setDuration(300) + .start() + } + } + } + + private fun copyToClipboard(value: String) { + copyTextToClipboard(context, value) + Toast.makeText(requireContext(), getString(R.string.copied), Toast.LENGTH_SHORT).show() + } + + override fun onDestroyView() { + super.onDestroyView() + _binding = null + } +} diff --git a/app/src/main/java/com/itsaky/androidide/viewmodel/MainViewModel.kt b/app/src/main/java/com/itsaky/androidide/viewmodel/MainViewModel.kt index 46f42ba1ab..4d59706af4 100644 --- a/app/src/main/java/com/itsaky/androidide/viewmodel/MainViewModel.kt +++ b/app/src/main/java/com/itsaky/androidide/viewmodel/MainViewModel.kt @@ -17,6 +17,7 @@ package com.itsaky.androidide.viewmodel +import android.database.SQLException import androidx.lifecycle.LifecycleOwner import androidx.lifecycle.LiveData import androidx.lifecycle.MutableLiveData @@ -25,7 +26,9 @@ import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import com.itsaky.androidide.roomData.recentproject.RecentProject import com.itsaky.androidide.roomData.recentproject.RecentProjectDao +import com.itsaky.androidide.templates.Language import com.itsaky.androidide.templates.Template +import kotlinx.coroutines.CancellationException import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.channels.Channel import kotlinx.coroutines.flow.receiveAsFlow @@ -40,83 +43,95 @@ import java.util.concurrent.atomic.AtomicInteger * @author Akash Yadav */ class MainViewModel( - private val recentProjectDao: RecentProjectDao + private val recentProjectDao: RecentProjectDao, ) : ViewModel() { - - companion object { - - // The values assigned to these variables reflect the order in which the screens are presented - // to the user. A screen with a lower value is displayed before a screen with a higher value. - // For example, SCREEN_MAIN is the first screen visible to the user, followed by SCREEN_TEMPLATE_LIST, - // and then SCREEN_TEMPLATE_DETAILS. - // - // These values are used as unique identifiers for the screens as well as for determining whether - // the screen change transition should be forward or backward. - const val SCREEN_MAIN = 0 - const val SCREEN_TEMPLATE_LIST = 1 - const val SCREEN_TEMPLATE_DETAILS = 2 - const val TOOLTIPS_WEB_VIEW = 3 - const val SCREEN_SAVED_PROJECTS = 4 - const val SCREEN_DELETE_PROJECTS = 5 - const val SCREEN_CLONE_REPO = 6 - - val logger : Logger = LoggerFactory.getLogger(MainViewModel::class.java) - } - - private val _currentScreen = MutableLiveData(-1) - private val _previousScreen = AtomicInteger(-1) - private val _isTransitionInProgress = MutableLiveData(false) - - private val cloneRepositoryEventChannel = Channel(Channel.BUFFERED) - - internal val template = MutableLiveData>(null) - internal val creatingProject = MutableLiveData(false) - - val currentScreen: LiveData = _currentScreen - - val cloneRepositoryEvent = cloneRepositoryEventChannel.receiveAsFlow() - - val previousScreen: Int - get() = _previousScreen.get() - - var isTransitionInProgress: Boolean - get() = _isTransitionInProgress.value ?: false - set(value) { - _isTransitionInProgress.value = value - } - - fun setScreen(screen: Int) { - _previousScreen.set(_currentScreen.value ?: SCREEN_MAIN) - _currentScreen.value = screen - } - - fun requestCloneRepository(url: String) { - viewModelScope.launch { - cloneRepositoryEventChannel.send(url) - } - setScreen(SCREEN_CLONE_REPO) - } - - fun postTransition(owner: LifecycleOwner, action: Runnable) { - if (isTransitionInProgress) { - _isTransitionInProgress.observe(owner, object : Observer { - override fun onChanged(t: Boolean) { - _isTransitionInProgress.removeObserver(this) - action.run() - } - }) - } else { - action.run() - } - } - - fun saveProjectToRecents(project: RecentProject) { - viewModelScope.launch(Dispatchers.IO) { - try { - recentProjectDao.insert(project) - } catch (e: Exception) { - logger.warn("Failed to save project to recents", e) - } - } - } + companion object { + // The values assigned to these variables reflect the order in which the screens are presented + // to the user. A screen with a lower value is displayed before a screen with a higher value. + // For example, SCREEN_MAIN is the first screen visible to the user, followed by SCREEN_TEMPLATE_LIST, + // and then SCREEN_TEMPLATE_DETAILS. + // + // These values are used as unique identifiers for the screens as well as for determining whether + // the screen change transition should be forward or backward. + const val SCREEN_MAIN = 0 + const val SCREEN_TEMPLATE_LIST = 1 + const val SCREEN_TEMPLATE_DETAILS = 2 + const val TOOLTIPS_WEB_VIEW = 3 + const val SCREEN_SAVED_PROJECTS = 4 + const val SCREEN_DELETE_PROJECTS = 5 + const val SCREEN_CLONE_REPO = 6 + + val logger: Logger = LoggerFactory.getLogger(MainViewModel::class.java) + } + + private val _currentScreen = MutableLiveData(-1) + private val _previousScreen = AtomicInteger(-1) + private val _isTransitionInProgress = MutableLiveData(false) + + private val cloneRepositoryEventChannel = Channel(Channel.BUFFERED) + + internal val template = MutableLiveData>(null) + internal val creatingProject = MutableLiveData(false) + + val currentScreen: LiveData = _currentScreen + + val cloneRepositoryEvent = cloneRepositoryEventChannel.receiveAsFlow() + + val previousScreen: Int + get() = _previousScreen.get() + + var isTransitionInProgress: Boolean + get() = _isTransitionInProgress.value ?: false + set(value) { + _isTransitionInProgress.value = value + } + + fun setScreen(screen: Int) { + _previousScreen.set(_currentScreen.value ?: SCREEN_MAIN) + _currentScreen.value = screen + } + + fun requestCloneRepository(url: String) { + viewModelScope.launch { + cloneRepositoryEventChannel.send(url) + } + setScreen(SCREEN_CLONE_REPO) + } + + fun postTransition( + owner: LifecycleOwner, + action: Runnable, + ) { + if (isTransitionInProgress) { + _isTransitionInProgress.observe( + owner, + object : Observer { + override fun onChanged(t: Boolean) { + _isTransitionInProgress.removeObserver(this) + action.run() + } + }, + ) + } else { + action.run() + } + } + + fun saveProjectToRecents(project: RecentProject) { + viewModelScope.launch(Dispatchers.IO) { + try { + // Insert is IGNOREd for projects already in recents, so refresh the + // detected language separately - but never clobber a stored value + // with a failed detection. + recentProjectDao.insert(project) + if (!project.language.equals(Language.Unknown.lang, ignoreCase = true)) { + recentProjectDao.updateLanguage(project.location, project.language) + } + } catch (e: CancellationException) { + throw e + } catch (e: SQLException) { + logger.warn("Failed to save project to recents", e) + } + } + } } diff --git a/app/src/main/java/com/itsaky/androidide/viewmodel/RecentProjectsViewModel.kt b/app/src/main/java/com/itsaky/androidide/viewmodel/RecentProjectsViewModel.kt index 4c545f01ca..3fadd60479 100644 --- a/app/src/main/java/com/itsaky/androidide/viewmodel/RecentProjectsViewModel.kt +++ b/app/src/main/java/com/itsaky/androidide/viewmodel/RecentProjectsViewModel.kt @@ -7,14 +7,16 @@ import androidx.lifecycle.LiveData import androidx.lifecycle.MutableLiveData import androidx.lifecycle.application import androidx.lifecycle.viewModelScope -import com.itsaky.androidide.resources.R import com.itsaky.androidide.adapters.RecentProjectsAdapter +import com.itsaky.androidide.models.ProjectFile +import com.itsaky.androidide.resources.R import com.itsaky.androidide.roomData.recentproject.RecentProject import com.itsaky.androidide.roomData.recentproject.RecentProjectDao import com.itsaky.androidide.roomData.recentproject.RecentProjectRoomDatabase +import com.itsaky.androidide.templates.Language import com.itsaky.androidide.utils.getCreatedTime import com.itsaky.androidide.utils.getLastModifiedTime -import com.itsaky.androidide.models.ProjectFile +import com.itsaky.androidide.utils.readProjectLanguage import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Job import kotlinx.coroutines.flow.MutableSharedFlow @@ -29,290 +31,292 @@ import java.io.File import java.io.IOException enum class SortCriteria { - NAME, - DATE_CREATED, - DATE_MODIFIED + NAME, + DATE_CREATED, + DATE_MODIFIED, } data class FilterState( - val query: String = "", - val sort: SortCriteria? = null, - val ascending: Boolean = true + val query: String = "", + val sort: SortCriteria? = null, + val ascending: Boolean = true, ) { - val hasAny: Boolean get() = sort != null || query.isNotEmpty() + val hasAny: Boolean get() = sort != null || query.isNotEmpty() } -class RecentProjectsViewModel(application: Application) : AndroidViewModel(application) { - - companion object { - private val logger = LoggerFactory.getLogger(RecentProjectsViewModel::class.java) - } - - private val _projects = MutableLiveData>() - private var allProjects: List = emptyList() - val projects: LiveData> = _projects - private val _filterEvents = MutableSharedFlow() - val filterEvents = _filterEvents - var didBootstrap = false - private var currentQuery: String = "" - private var currentSort: SortCriteria? = null - private var isAscending: Boolean = true - - private val _filterState = MutableStateFlow(FilterState()) - val filterState: StateFlow = _filterState.asStateFlow() - - val currentSortCriteria: SortCriteria? get() = currentSort - val currentSortAscending: Boolean get() = isAscending - val hasActiveFilters: Boolean - get() = _filterState.value.hasAny - - private val _deletionStatus = MutableSharedFlow(replay = 1) - val deletionStatus = _deletionStatus.asSharedFlow() - - private val _renameStatus = MutableSharedFlow() - val renameStatus = _renameStatus.asSharedFlow() - - // Get the database and DAO instance - private val recentProjectDatabase: RecentProjectRoomDatabase = - RecentProjectRoomDatabase.getDatabase(application, viewModelScope) - private val recentProjectDao: RecentProjectDao = recentProjectDatabase.recentProjectDao() - - fun loadProjects(): Job { - return viewModelScope.launch(Dispatchers.IO) { - val projectsFromDb = recentProjectDao.dumpAll() ?: emptyList() - allProjects = projectsFromDb.map { ProjectFile(it.location, it.createdAt, it.lastModified) } - applyFilters() - } - } - - fun notifyFiltersSaved() { - viewModelScope.launch { - _filterEvents.emit(Unit) - } - } - - private suspend fun applyFilters() { - _filterState.value = FilterState(currentQuery, currentSort, isAscending) - withContext(Dispatchers.Default) { - var result = allProjects - - if (currentQuery.isNotEmpty()) { - result = result.filter { it.name.contains(currentQuery, ignoreCase = true) } - } - - val criteria = currentSort - if (criteria != null) { - result = when (criteria) { - SortCriteria.NAME -> result.sortedBy { it.name.lowercase() } - SortCriteria.DATE_CREATED -> result.sortedBy { it.createdAt } - SortCriteria.DATE_MODIFIED -> result.sortedBy { it.lastModified } - } - if (!isAscending) { - result = result.reversed() - } - } - _projects.postValue(result) - } - } - - suspend fun onSearchQuery(query: String) { - currentQuery = query.trim() - applyFilters() - } - - suspend fun onSortSelected(criteria: SortCriteria?) { - currentSort = criteria - applyFilters() - } - - suspend fun onSortDirectionChanged(ascending: Boolean) { - isAscending = ascending - applyFilters() - } - - suspend fun clearFilters() { - currentSort = null - isAscending = true - currentQuery = "" - applyFilters() - } - - suspend fun clearSort() { - currentSort = null - isAscending = true - applyFilters() - } - - suspend fun getProjectByName(name: String): RecentProject? { - return withContext(Dispatchers.IO) { - recentProjectDao.getProjectByName(name) - } - } - - fun projectNameExists(name: String): Boolean = - allProjects.any { it.name == name } - - fun insertProjectFromFolder(name: String, location: String) = - viewModelScope.launch(Dispatchers.IO) { - // Check if the project already exists - val existingProject = getProjectByName(name) - if (existingProject == null) { - val createdAt = getCreatedTime(location) - val modifiedAt = getLastModifiedTime(location) - val unknown = application.getString(R.string.unknown) - recentProjectDao.insert( - RecentProject( - location = location, - name = name, - createdAt = createdAt.toString(), - lastModified = modifiedAt.toString(), - templateName = unknown, - language = unknown - ) - ) - } - } +class RecentProjectsViewModel( + application: Application, +) : AndroidViewModel(application) { + companion object { + private val logger = LoggerFactory.getLogger(RecentProjectsViewModel::class.java) + } + + private val _projects = MutableLiveData>() + private var allProjects: List = emptyList() + val projects: LiveData> = _projects + private val _filterEvents = MutableSharedFlow() + val filterEvents = _filterEvents + var didBootstrap = false + private var currentQuery: String = "" + private var currentSort: SortCriteria? = null + private var isAscending: Boolean = true + + private val _filterState = MutableStateFlow(FilterState()) + val filterState: StateFlow = _filterState.asStateFlow() + + val currentSortCriteria: SortCriteria? get() = currentSort + val currentSortAscending: Boolean get() = isAscending + val hasActiveFilters: Boolean + get() = _filterState.value.hasAny + + private val _deletionStatus = MutableSharedFlow(replay = 1) + val deletionStatus = _deletionStatus.asSharedFlow() + + private val _renameStatus = MutableSharedFlow() + val renameStatus = _renameStatus.asSharedFlow() + + // Get the database and DAO instance + private val recentProjectDatabase: RecentProjectRoomDatabase = + RecentProjectRoomDatabase.getDatabase(application, viewModelScope) + private val recentProjectDao: RecentProjectDao = recentProjectDatabase.recentProjectDao() + + fun loadProjects(): Job = + viewModelScope.launch(Dispatchers.IO) { + val projectsFromDb = recentProjectDao.dumpAll() ?: emptyList() + allProjects = projectsFromDb.map { ProjectFile(it.location, it.createdAt, it.lastModified) } + applyFilters() + } + + fun notifyFiltersSaved() { + viewModelScope.launch { + _filterEvents.emit(Unit) + } + } + + private suspend fun applyFilters() { + _filterState.value = FilterState(currentQuery, currentSort, isAscending) + withContext(Dispatchers.Default) { + var result = allProjects + + if (currentQuery.isNotEmpty()) { + result = result.filter { it.name.contains(currentQuery, ignoreCase = true) } + } + val criteria = currentSort + if (criteria != null) { + result = + when (criteria) { + SortCriteria.NAME -> result.sortedBy { it.name.lowercase() } + SortCriteria.DATE_CREATED -> result.sortedBy { it.createdAt } + SortCriteria.DATE_MODIFIED -> result.sortedBy { it.lastModified } + } + if (!isAscending) { + result = result.reversed() + } + } + _projects.postValue(result) + } + } + + suspend fun onSearchQuery(query: String) { + currentQuery = query.trim() + applyFilters() + } + + suspend fun onSortSelected(criteria: SortCriteria?) { + currentSort = criteria + applyFilters() + } + + suspend fun onSortDirectionChanged(ascending: Boolean) { + isAscending = ascending + applyFilters() + } + + suspend fun clearFilters() { + currentSort = null + isAscending = true + currentQuery = "" + applyFilters() + } + + suspend fun clearSort() { + currentSort = null + isAscending = true + applyFilters() + } + + suspend fun getProjectByName(name: String): RecentProject? = + withContext(Dispatchers.IO) { + recentProjectDao.getProjectByName(name) + } + + fun projectNameExists(name: String): Boolean = allProjects.any { it.name == name } + + fun insertProjectFromFolder( + name: String, + location: String, + ) = viewModelScope.launch(Dispatchers.IO) { + // Check if the project already exists + val existingProject = getProjectByName(name) + if (existingProject == null) { + val createdAt = getCreatedTime(location) + val modifiedAt = getLastModifiedTime(location) + val unknown = Language.Unknown.lang + val detectedLanguage = readProjectLanguage(File(location)) + val languageToStore = if (detectedLanguage != unknown) detectedLanguage else unknown + recentProjectDao.insert( + RecentProject( + location = location, + name = name, + createdAt = createdAt.toString(), + lastModified = modifiedAt.toString(), + templateName = unknown, + language = languageToStore, + ), + ) + } + } fun deleteProject(project: ProjectFile) = deleteProject(project.name) - fun deleteProject(name: String) = viewModelScope.launch { - try { - val success = withContext(Dispatchers.IO) { - // Delete files from storage first - val projectToDelete = recentProjectDao.getProjectByName(name) - ?: return@withContext false - val isDeleted = File(projectToDelete.location).deleteRecursively() - - // Delete from DB if storage deletion was successful - if (isDeleted) { - recentProjectDao.deleteByName(name) - } - isDeleted - } - - if (success) { - // Update LiveData - val currentList = _projects.value ?: emptyList() - allProjects = allProjects.filter { it.name != name } - _projects.value = currentList.filter { it.name != name } - _deletionStatus.emit(true) - } else { - // Emit failure if files couldn't be deleted - _deletionStatus.emit(false) - } - } catch (e: IOException) { - logger.error("An I/O error occurred during project deletion", e) - _deletionStatus.emit(false) - } catch (e: SQLException) { - logger.error("A database error occurred during project deletion", e) - _deletionStatus.emit(false) - } catch (e: SecurityException) { - logger.error("Security error during project deletion", e) - _deletionStatus.emit(false) - } - vacuumDatabase() - } + fun deleteProject(name: String) = + viewModelScope.launch { + try { + val success = + withContext(Dispatchers.IO) { + // Delete files from storage first + val projectToDelete = + recentProjectDao.getProjectByName(name) + ?: return@withContext false + val isDeleted = File(projectToDelete.location).deleteRecursively() + + // Delete from DB if storage deletion was successful + if (isDeleted) { + recentProjectDao.deleteByName(name) + } + isDeleted + } + + if (success) { + // Update LiveData + val currentList = _projects.value ?: emptyList() + allProjects = allProjects.filter { it.name != name } + _projects.value = currentList.filter { it.name != name } + _deletionStatus.emit(true) + } else { + // Emit failure if files couldn't be deleted + _deletionStatus.emit(false) + } + } catch (e: IOException) { + logger.error("An I/O error occurred during project deletion", e) + _deletionStatus.emit(false) + } catch (e: SQLException) { + logger.error("A database error occurred during project deletion", e) + _deletionStatus.emit(false) + } catch (e: SecurityException) { + logger.error("Security error during project deletion", e) + _deletionStatus.emit(false) + } + vacuumDatabase() + } fun updateProject(renamedFile: RecentProjectsAdapter.RenamedFile) = updateProject( renamedFile.oldName, renamedFile.newName, renamedFile.oldPath, - renamedFile.newPath + renamedFile.newPath, ) - fun updateProject( - oldName: String, - newName: String, - oldLocation: String, - newLocation: String - ) = - viewModelScope.launch(Dispatchers.IO) { - try { - val modifiedAt = System.currentTimeMillis().toString() - recentProjectDao.updateNameAndLocation( - oldName = oldName, - newName = newName, - newLocation = newLocation - ) - recentProjectDao.updateLastModified( - projectName = newName, - lastModified = modifiedAt - ) - loadProjects() - _renameStatus.emit(true) - } catch (e: SQLException) { - logger.error("Failed to update project after rename ($oldName -> $newName)", e) - val rolledBack = File(newLocation).renameTo(File(oldLocation)) - if (rolledBack) { - logger.info("Rolled back filesystem rename: $newLocation -> $oldLocation") - } else { - logger.error("Rollback failed; filesystem and DB are out of sync (disk=$newLocation, db=$oldLocation)") - } - _renameStatus.emit(false) - } - } + fun updateProject( + oldName: String, + newName: String, + oldLocation: String, + newLocation: String, + ) = viewModelScope.launch(Dispatchers.IO) { + try { + val modifiedAt = System.currentTimeMillis().toString() + recentProjectDao.updateNameAndLocation( + oldName = oldName, + newName = newName, + newLocation = newLocation, + ) + recentProjectDao.updateLastModified( + projectName = newName, + lastModified = modifiedAt, + ) + loadProjects() + _renameStatus.emit(true) + } catch (e: SQLException) { + logger.error("Failed to update project after rename ($oldName -> $newName)", e) + val rolledBack = File(newLocation).renameTo(File(oldLocation)) + if (rolledBack) { + logger.info("Rolled back filesystem rename: $newLocation -> $oldLocation") + } else { + logger.error("Rollback failed; filesystem and DB are out of sync (disk=$newLocation, db=$oldLocation)") + } + _renameStatus.emit(false) + } + } fun updateProjectModifiedDate(name: String) = viewModelScope.launch(Dispatchers.IO) { val modifiedAt = System.currentTimeMillis() recentProjectDao.updateLastModified( - projectName = name, - lastModified = modifiedAt.toString() + projectName = name, + lastModified = modifiedAt.toString(), ) loadProjects() } - fun deleteSelectedProjects(selectedNames: List) = - viewModelScope.launch { - if (selectedNames.isEmpty()) { - return@launch - } - - var allDeletionsSucceeded = true - - try { - withContext(Dispatchers.IO) { - // Find the full project details for the selected project names - val projectsToDelete = recentProjectDao.getProjectsByNames(selectedNames) - val successfullyDeletedNames = mutableListOf() - - for (project in projectsToDelete) { - // Delete from storage - val isDeletedFromStorage = File(project.location).deleteRecursively() - - if (isDeletedFromStorage) { - successfullyDeletedNames.add(project.name) - } else { - logger.warn("Failed to delete project files from storage: ${project.location}") - allDeletionsSucceeded = false - } - } - - if (successfullyDeletedNames.isNotEmpty()) { - // Delete from database - recentProjectDao.deleteByNames(successfullyDeletedNames) - } - } - - vacuumDatabase() - loadProjects() - - _deletionStatus.emit(allDeletionsSucceeded) - - } catch (e: Exception) { - logger.error("An exception occurred during project deletion", e) - _deletionStatus.emit(false) - } - } - - - private suspend fun vacuumDatabase() { - withContext(Dispatchers.IO) { - runCatching { - recentProjectDatabase.vacuum() + fun deleteSelectedProjects(selectedNames: List) = + viewModelScope.launch { + if (selectedNames.isEmpty()) { + return@launch + } + + var allDeletionsSucceeded = true + + try { + withContext(Dispatchers.IO) { + // Find the full project details for the selected project names + val projectsToDelete = recentProjectDao.getProjectsByNames(selectedNames) + val successfullyDeletedNames = mutableListOf() + + for (project in projectsToDelete) { + // Delete from storage + val isDeletedFromStorage = File(project.location).deleteRecursively() + + if (isDeletedFromStorage) { + successfullyDeletedNames.add(project.name) + } else { + logger.warn("Failed to delete project files from storage: ${project.location}") + allDeletionsSucceeded = false + } + } + + if (successfullyDeletedNames.isNotEmpty()) { + // Delete from database + recentProjectDao.deleteByNames(successfullyDeletedNames) + } + } + + vacuumDatabase() + loadProjects() + + _deletionStatus.emit(allDeletionsSucceeded) + } catch (e: Exception) { + logger.error("An exception occurred during project deletion", e) + _deletionStatus.emit(false) } - } - } + } + + private suspend fun vacuumDatabase() { + withContext(Dispatchers.IO) { + runCatching { + recentProjectDatabase.vacuum() + } + } + } } diff --git a/common/src/main/java/com/itsaky/androidide/templates/Language.kt b/common/src/main/java/com/itsaky/androidide/templates/Language.kt new file mode 100644 index 0000000000..81718c8791 --- /dev/null +++ b/common/src/main/java/com/itsaky/androidide/templates/Language.kt @@ -0,0 +1,30 @@ +/* + * This file is part of AndroidIDE. + * + * AndroidIDE is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * AndroidIDE is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with AndroidIDE. If not, see . + */ + +package com.itsaky.androidide.templates + +/** + * Language for source files. + */ +enum class Language( + val lang: String, + val ext: String, +) { + Java("Java", "java"), + Kotlin("Kotlin", "kt"), + Unknown("Unknown", ""), +} diff --git a/common/src/main/java/com/itsaky/androidide/utils/GetProjectBuildVersions.kt b/common/src/main/java/com/itsaky/androidide/utils/GetProjectBuildVersions.kt index 653972dccf..898a9f4c6d 100644 --- a/common/src/main/java/com/itsaky/androidide/utils/GetProjectBuildVersions.kt +++ b/common/src/main/java/com/itsaky/androidide/utils/GetProjectBuildVersions.kt @@ -1,82 +1,143 @@ package com.itsaky.androidide.utils +import com.itsaky.androidide.templates.Language import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.withContext import java.io.File -suspend fun readGradleVersion(root: File): String = withContext(Dispatchers.IO) { - val gradleWrapper = File(root, "gradle/wrapper/gradle-wrapper.properties") - if (!gradleWrapper.exists()) return@withContext "Unknown" +suspend fun readGradleVersion(root: File): String = + withContext(Dispatchers.IO) { + val gradleWrapper = File(root, "gradle/wrapper/gradle-wrapper.properties") + if (!gradleWrapper.exists()) return@withContext "Unknown" - val text = gradleWrapper.readText() - val match = Regex("distributionUrl=.*gradle-(.*)-").find(text) - return@withContext match?.groupValues?.get(1) ?: "Unknown" -} - -suspend fun readKotlinVersion(root: File): String = withContext(Dispatchers.IO) { - val kotlinVarRegex = - Regex("""kotlin_version\s*=\s*"([^"]+)"""") - - val kotlinPluginRegex = - Regex("""id\(["']org.jetbrains.kotlin[^"']+["']\)\s*version\s*"([^"]+)"""") - - val kotlinForceRegex = - Regex("""force\(["']org.jetbrains.kotlin:kotlin-stdlib:([^"']+)["']\)""") - - val tomlDirectRegex = - Regex("""kotlin\s*=\s*"([^"]+)"""") - - val tomlRefRegex = - Regex("""version\.ref\s*=\s*"([^"]+)"""") - - val gradleFiles = sequenceOf( - File(root, "app/build.gradle"), - File(root, "app/build.gradle.kts"), - File(root, "build.gradle"), - File(root, "build.gradle.kts"), - ).filter { it.exists() } - - for (file in gradleFiles) { - val text = file.readText() - - kotlinVarRegex.find(text)?.groupValues?.get(1)?.let { return@withContext it } - kotlinPluginRegex.find(text)?.groupValues?.get(1)?.let { return@withContext it } - kotlinForceRegex.find(text)?.groupValues?.get(1)?.let { return@withContext it } + val text = gradleWrapper.readText() + val match = Regex("distributionUrl=.*gradle-(.*)-").find(text) + return@withContext match?.groupValues?.get(1) ?: "Unknown" } - val libsToml = File(root, "gradle/libs.versions.toml") - if (!libsToml.exists()) return@withContext "Unknown" - - val toml = libsToml.readText() - - tomlDirectRegex.find(toml)?.groupValues?.get(1)?.let { return@withContext it } +suspend fun readKotlinVersion(root: File): String = + withContext(Dispatchers.IO) { + val kotlinVarRegex = + Regex("""kotlin_version\s*=\s*"([^"]+)"""") + + val kotlinPluginRegex = + Regex("""id\(["']org.jetbrains.kotlin[^"']+["']\)\s*version\s*"([^"]+)"""") + + val kotlinForceRegex = + Regex("""force\(["']org.jetbrains.kotlin:kotlin-stdlib:([^"']+)["']\)""") + + // ([^":]+) excludes shorthand coordinates like "org.jetbrains.kotlin:kotlin-stdlib:1.9.24" + val tomlDirectKotlinRegex = + Regex("""(?i)\b(?:kotlin|kotlinVersion|kotlin-version|org-jetbrains-kotlin[a-zA-Z0-9_-]*)\s*=\s*"([^":]+)"""") + + // Matches the quoted id/module/group of a kotlin plugin or library entry, then the + // version.ref that follows it on the same line. + val tomlKotlinRefRegex = + Regex("""(?i)"org\.jetbrains\.kotlin[^"]*".*?version\.ref\s*=\s*"([^"]+)"""") + + val gradleFiles = + sequenceOf( + File(root, "app/build.gradle"), + File(root, "app/build.gradle.kts"), + File(root, "build.gradle"), + File(root, "build.gradle.kts"), + ).filter { it.exists() } + + for (file in gradleFiles) { + val text = file.readText() + + kotlinVarRegex + .find(text) + ?.groupValues + ?.get(1) + ?.let { return@withContext it } + kotlinPluginRegex + .find(text) + ?.groupValues + ?.get(1) + ?.let { return@withContext it } + kotlinForceRegex + .find(text) + ?.groupValues + ?.get(1) + ?.let { return@withContext it } + } + + val libsToml = File(root, "gradle/libs.versions.toml") + if (!libsToml.exists()) return@withContext "Unknown" + + val toml = libsToml.readText() + + tomlDirectKotlinRegex + .find(toml) + ?.groupValues + ?.get(1) + ?.let { return@withContext it } + + val refName = + tomlKotlinRefRegex.find(toml)?.groupValues?.get(1) + ?: return@withContext "Unknown" + + Regex("""(?m)^${Regex.escape(refName)}\s*=\s*"([^"]+)"""") + .find(toml) + ?.groupValues + ?.get(1) + ?: "Unknown" + } - val refName = tomlRefRegex.find(toml)?.groupValues?.get(1) - ?: return@withContext "Unknown" +suspend fun readJavaVersion(root: File): String = + withContext(Dispatchers.IO) { + val buildGradle = File(root, "build.gradle") + val buildGradleKts = File(root, "build.gradle.kts") - Regex("""$refName\s*=\s*"([^"]+)"""") - .find(toml) - ?.groupValues - ?.get(1) - ?: "Unknown" -} + val file = + when { + buildGradle.exists() -> buildGradle + buildGradleKts.exists() -> buildGradleKts + else -> return@withContext "Unknown" + } + val text = file.readText() -suspend fun readJavaVersion(root: File): String = withContext(Dispatchers.IO) { - val buildGradle = File(root, "build.gradle") - val buildGradleKts = File(root, "build.gradle.kts") + // Regex: sourceCompatibility = JavaVersion.VERSION_17 | JavaVersion.VERSION_1_8 + val regex = Regex("""sourceCompatibility\s*=\s*JavaVersion\.VERSION_([0-9_]+)""") + val match = regex.find(text) - val file = when { - buildGradle.exists() -> buildGradle - buildGradleKts.exists() -> buildGradleKts - else -> return@withContext "Unknown" + return@withContext match?.groupValues?.get(1) ?: "Unknown" } - val text = file.readText() - - // Regex: sourceCompatibility = JavaVersion.VERSION_17 | JavaVersion.VERSION_1_8 - val regex = Regex("""sourceCompatibility\s*=\s*JavaVersion\.VERSION_([0-9_]+)""") - val match = regex.find(text) - - return@withContext match?.groupValues?.get(1) ?: "Unknown" -} \ No newline at end of file +/** + * Detects the primary programming language of an Android project by scanning its source directory. + * + * Kotlin source files, including Kotlin script files (`.kts`), take precedence over Java source + * files. If no recognized source files are found, or if the expected source directory does not + * exist, `"Unknown"` is returned. + * + * The scan is performed on [Dispatchers.IO] to avoid blocking the calling coroutine. + * + * @param root the root directory of the project. + * @return [Language.Kotlin] if Kotlin source is found, [Language.Java] if only Java + * source is found, or [Language.Unknown] if no recognized source is found. + */ +suspend fun readProjectLanguage(root: File): String = + withContext(Dispatchers.IO) { + val srcDir = + listOf( + File(root, "app/src/main"), + File(root, "src/main"), + ).firstOrNull(File::exists) ?: return@withContext Language.Unknown.lang + + var hasJava = false + + srcDir + .walkTopDown() + .filter(File::isFile) + .forEach { file -> + when (file.extension.lowercase()) { + Language.Kotlin.ext, "kts" -> return@withContext Language.Kotlin.lang + Language.Java.ext -> hasJava = true + } + } + + if (hasJava) Language.Java.lang else Language.Unknown.lang + } diff --git a/common/src/main/java/com/itsaky/androidide/utils/GetProjectDetails.kt b/common/src/main/java/com/itsaky/androidide/utils/GetProjectDetails.kt index 24ff10dc89..e372473f06 100644 --- a/common/src/main/java/com/itsaky/androidide/utils/GetProjectDetails.kt +++ b/common/src/main/java/com/itsaky/androidide/utils/GetProjectDetails.kt @@ -7,37 +7,39 @@ import kotlinx.coroutines.withContext import java.io.File data class ProjectDetails( - val sizeFormatted: String, - val numberOfFiles: Int, - val gradleVersion: String, - val kotlinVersion: String, - val javaVersion: String + val sizeFormatted: String, + val numberOfFiles: Int, + val gradleVersion: String, + val kotlinVersion: String, + val javaVersion: String, + val language: String ) suspend fun loadProjectDetails(projectPath: String, context: Context): ProjectDetails = - withContext(Dispatchers.IO) { - val root = File(projectPath) - val appDir = root.toPath().resolve("app").toFile() - var sizeBytes = 0L - var fileCount = 0 + withContext(Dispatchers.IO) { + val root = File(projectPath) + val appDir = root.toPath().resolve("app").toFile() + var sizeBytes = 0L + var fileCount = 0 - val ignoredDirs = arrayOf("build", ".gradle", ".git", ".idea") + val ignoredDirs = arrayOf("build", ".gradle", ".git", ".idea") - root.walkTopDown() - .onEnter { !ignoredDirs.contains(it.name) } - .forEach { file -> - if (file.isFile) { - fileCount++ - sizeBytes += file.length() - } - } - val sizeFormatted = formatFileSize(context, sizeBytes) + root.walkTopDown() + .onEnter { !ignoredDirs.contains(it.name) } + .forEach { file -> + if (file.isFile) { + fileCount++ + sizeBytes += file.length() + } + } + val sizeFormatted = formatFileSize(context, sizeBytes) - ProjectDetails( - sizeFormatted = sizeFormatted, - numberOfFiles = fileCount, - gradleVersion = readGradleVersion(root), - kotlinVersion = readKotlinVersion(root), - javaVersion = readJavaVersion(appDir) - ) - } \ No newline at end of file + ProjectDetails( + sizeFormatted = sizeFormatted, + numberOfFiles = fileCount, + gradleVersion = readGradleVersion(root), + kotlinVersion = readKotlinVersion(root), + javaVersion = readJavaVersion(appDir), + language = readProjectLanguage(root) + ) + } diff --git a/common/src/test/java/com/itsaky/androidide/utils/GetProjectBuildVersionsTest.kt b/common/src/test/java/com/itsaky/androidide/utils/GetProjectBuildVersionsTest.kt new file mode 100644 index 0000000000..af170c3342 --- /dev/null +++ b/common/src/test/java/com/itsaky/androidide/utils/GetProjectBuildVersionsTest.kt @@ -0,0 +1,176 @@ +package com.itsaky.androidide.utils + +import com.google.common.truth.Truth.assertThat +import kotlinx.coroutines.runBlocking +import org.junit.Rule +import org.junit.Test +import org.junit.rules.TemporaryFolder +import java.io.File + +class GetProjectBuildVersionsTest { + @get:Rule + val tempFolder = TemporaryFolder() + + @Test + fun `readProjectLanguage identifies Java project with java files`() = + runBlocking { + val root = tempFolder.newFolder("JavaProject") + val srcDir = File(root, "app/src/main/java/com/example") + srcDir.mkdirs() + File( + srcDir, + "MainActivity.java", + ).writeText("package com.example; public class MainActivity {}") + + val gradleToml = File(root, "gradle") + gradleToml.mkdirs() + File(gradleToml, "libs.versions.toml").writeText( + """ + [versions] + agp = "8.2.0" + appcompat = "1.6.1" + [libraries] + androidx-appcompat = { group = "androidx.appcompat", name = "appcompat", version.ref = "appcompat" } + """.trimIndent(), + ) + + val language = readProjectLanguage(root) + assertThat(language).isEqualTo("Java") + } + + @Test + fun `readProjectLanguage identifies Kotlin project with kt files`() = + runBlocking { + val root = tempFolder.newFolder("KotlinProject") + val srcDir = File(root, "app/src/main/java/com/example") + srcDir.mkdirs() + File(srcDir, "MainActivity.kt").writeText("package com.example\nclass MainActivity") + + val language = readProjectLanguage(root) + assertThat(language).isEqualTo("Kotlin") + } + + @Test + fun `readProjectLanguage identifies Kotlin project with kts files`() = + runBlocking { + val root = tempFolder.newFolder("KotlinScriptProject") + val srcDir = File(root, "app/src/main") + srcDir.mkdirs() + File(srcDir, "build.gradle.kts").writeText( + """ + plugins { + id("com.android.application") + } + """.trimIndent(), + ) + + val language = readProjectLanguage(root) + + assertThat(language).isEqualTo("Kotlin") + } + + @Test + fun `readProjectLanguage returns Unknown when source tree has no supported files`() = + runBlocking { + val root = tempFolder.newFolder("UnsupportedProject") + val srcDir = File(root, "app/src/main/java/com/example") + srcDir.mkdirs() + File(srcDir, "MainActivity.xml").writeText( + """ + + """.trimIndent(), + ) + + val language = readProjectLanguage(root) + + assertThat(language).isEqualTo("Unknown") + } + + @Test + fun `readProjectLanguage returns Unknown for empty source tree`() = + runBlocking { + val root = tempFolder.newFolder("EmptyProject") + File(root, "app/src/main").mkdirs() + + val language = readProjectLanguage(root) + + assertThat(language).isEqualTo("Unknown") + } + + @Test + fun `readKotlinVersion returns Unknown when libs toml has no kotlin version`() = + runBlocking { + val root = tempFolder.newFolder("TomlProject") + val gradleToml = File(root, "gradle") + gradleToml.mkdirs() + File(gradleToml, "libs.versions.toml").writeText( + """ + [versions] + agp = "8.2.0" + appcompat = "1.6.1" + [libraries] + androidx-appcompat = { group = "androidx.appcompat", name = "appcompat", version.ref = "appcompat" } + """.trimIndent(), + ) + + val kotlinVer = readKotlinVersion(root) + assertThat(kotlinVer).isEqualTo("Unknown") + } + + @Test + fun `readKotlinVersion parses kotlin version correctly from libs toml`() = + runBlocking { + val root = tempFolder.newFolder("KotlinTomlProject") + val gradleToml = File(root, "gradle") + gradleToml.mkdirs() + File(gradleToml, "libs.versions.toml").writeText( + """ + [versions] + agp = "8.2.0" + kotlin = "1.9.20" + """.trimIndent(), + ) + + val kotlinVer = readKotlinVersion(root) + assertThat(kotlinVer).isEqualTo("1.9.20") + } + + @Test + fun `readKotlinVersion resolves kotlin version through version ref`() = + runBlocking { + val root = tempFolder.newFolder("KotlinRefProject") + val gradleToml = File(root, "gradle") + gradleToml.mkdirs() + File(gradleToml, "libs.versions.toml").writeText( + """ + [versions] + agp = "8.2.0" + kgp = "1.9.20" + [plugins] + kotlin-android = { id = "org.jetbrains.kotlin.android", version.ref = "kgp" } + """.trimIndent(), + ) + + val kotlinVer = readKotlinVersion(root) + assertThat(kotlinVer).isEqualTo("1.9.20") + } + + @Test + fun `readKotlinVersion ignores shorthand kotlin library coordinates`() = + runBlocking { + val root = tempFolder.newFolder("KotlinShorthandProject") + val gradleToml = File(root, "gradle") + gradleToml.mkdirs() + File(gradleToml, "libs.versions.toml").writeText( + """ + [libraries] + org-jetbrains-kotlin-stdlib = "org.jetbrains.kotlin:kotlin-stdlib:1.9.24" + [versions] + kotlin = "2.0.0" + """.trimIndent(), + ) + + val kotlinVer = readKotlinVersion(root) + assertThat(kotlinVer).isEqualTo("2.0.0") + } +} diff --git a/templates-api/src/main/java/com/itsaky/androidide/templates/template.kt b/templates-api/src/main/java/com/itsaky/androidide/templates/template.kt index 4a631e51f3..5e1c4a1084 100644 --- a/templates-api/src/main/java/com/itsaky/androidide/templates/template.kt +++ b/templates-api/src/main/java/com/itsaky/androidide/templates/template.kt @@ -59,8 +59,8 @@ val data: D * Result of recipe execution for a [ProjectTemplate]. */ interface ProjectTemplateRecipeResult : TemplateRecipeResultWithData { - val hasErrorsWarnings: Boolean - get() = false + val hasErrorsWarnings: Boolean + get() = false } /** @@ -120,14 +120,6 @@ fun buildGradleFile(): File { } } -/** - * Language for source files. - */ -enum class Language(val lang: String, val ext: String) { - -Java("Java", "java"), Kotlin("Kotlin", "kt"); -} - /** * The type of module. * @@ -241,8 +233,8 @@ fun srcFolder(srcSet: SrcSet): File { * @property thumb The thumbnail for the template. */ open class Template(@StringRes open val templateName: Int, - @DrawableRes open val thumb: Int, open val tooltipTag: String?, open val widgets: List>, - open val recipe: TemplateRecipe, open val templateNameStr: String = "", open val thumbData: ByteArray? = null +@DrawableRes open val thumb: Int, open val tooltipTag: String?, open val widgets: List>, +open val recipe: TemplateRecipe, open val templateNameStr: String = "", open val thumbData: ByteArray? = null ) { /** @@ -348,7 +340,7 @@ fun build(): Template { requireNotNull(templateName) { "Template must have a name id" } requireNotNull(thumb) { "Template must have a thumbnail" } requireNotNull(recipe) { "Template must have a recipe" } - requireNotNull(templateNameStr) {"Template must have a name"} +requireNotNull(templateNameStr) {"Template must have a name"} this.widgets = this.widgets ?: emptyList()