Skip to content

Commit b9ba45a

Browse files
authored
Merge branch 'ADFA-4399-floating-windows' into ADFA-4402-controller
2 parents de85d32 + fdcb1f7 commit b9ba45a

27 files changed

Lines changed: 731 additions & 39 deletions

File tree

‎app/src/main/java/com/itsaky/androidide/activities/editor/BaseEditorActivity.kt‎

Lines changed: 28 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -82,6 +82,7 @@ import com.google.android.material.tabs.TabLayout.Tab
8282
import com.itsaky.androidide.FeedbackButtonManager
8383
import com.itsaky.androidide.R
8484
import com.itsaky.androidide.R.string
85+
import com.itsaky.androidide.activities.MainActivity
8586
import com.itsaky.androidide.actions.build.DebugAction
8687
import com.itsaky.androidide.adapters.DiagnosticsAdapter
8788
import com.itsaky.androidide.adapters.SearchListAdapter
@@ -111,6 +112,7 @@ import com.itsaky.androidide.models.Range
111112
import com.itsaky.androidide.models.SearchResult
112113
import com.itsaky.androidide.plugins.manager.ui.PluginEditorTabManager
113114
import com.itsaky.androidide.preferences.internal.BuildPreferences
115+
import com.itsaky.androidide.preferences.internal.GeneralPreferences
114116
import com.itsaky.androidide.projects.IProjectManager
115117
import com.itsaky.androidide.projects.ProjectManagerImpl
116118
import com.itsaky.androidide.services.debug.DebuggerService
@@ -631,11 +633,35 @@ abstract class BaseEditorActivity :
631633
builder.show()
632634
}
633635

636+
/**
637+
* Restores the project path on recreation (saved state, launch intent, or last opened
638+
* project) and routes back to MainActivity if none is available, rather than crashing while
639+
* building the editor UI.
640+
*/
634641
override fun onCreate(savedInstanceState: Bundle?) {
635-
savedInstanceState?.getString(KEY_PROJECT_PATH)
636-
?.let(ProjectManagerImpl.getInstance()::projectPath::set)
642+
// The OS can recreate EditorActivity after process death without routing through
643+
// MainActivity, leaving the ProjectManagerImpl singleton's lateinit projectPath unset.
644+
// Restore it from the saved state, the launch intent, or the last opened project.
645+
val restoredProjectPath =
646+
savedInstanceState?.getString(KEY_PROJECT_PATH)?.takeIf { it.isNotBlank() }
647+
?: intent?.getStringExtra("PROJECT_PATH")?.takeIf { it.isNotBlank() }
648+
?: GeneralPreferences.lastOpenedProject
649+
.takeIf { it.isNotBlank() && it != GeneralPreferences.NO_OPENED_PROJECT }
650+
if (restoredProjectPath != null) {
651+
ProjectManagerImpl.getInstance().projectPath = restoredProjectPath
652+
}
637653
super.onCreate(savedInstanceState)
638654

655+
// If we still have no project path after every fallback, we cannot safely build the
656+
// editor UI (setupToolbar -> getProjectName dereferences the project path). Route the
657+
// user back to MainActivity instead of crashing.
658+
if (ProjectManagerImpl.getInstance().projectDirPath.isBlank()) {
659+
log.warn("No project path available in EditorActivity.onCreate(); returning to MainActivity")
660+
startActivity(Intent(this, MainActivity::class.java))
661+
finish()
662+
return
663+
}
664+
639665
editorViewModel.isBuildInProgress = false
640666
editorViewModel.isInitializing = false
641667

‎app/src/main/java/com/itsaky/androidide/activities/editor/EditorHandlerActivity.kt‎

Lines changed: 24 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -420,23 +420,35 @@ open class EditorHandlerActivity :
420420
syncPluginUiFontSize()
421421
}
422422

423+
/**
424+
* Restores the plugin tabs cached from the previous session, running the
425+
* SharedPreferences IO and Gson decode off the main thread to avoid a startup UI stall.
426+
*/
423427
private fun restoreOpenedPluginTabs() {
424-
try {
425-
val prefs = (application as BaseApplication).prefManager
426-
val json = prefs.getString(PREF_KEY_OPEN_PLUGIN_TABS, null) ?: return
428+
lifecycleScope.launch {
429+
try {
430+
val prefs = (application as BaseApplication).prefManager
431+
val json = withContext(Dispatchers.IO) {
432+
prefs.getString(PREF_KEY_OPEN_PLUGIN_TABS, null)
433+
} ?: return@launch
427434

428-
val tabIds = Gson().fromJson(json, Array<String>::class.java)?.toList() ?: return
429-
Log.d("EditorHandlerActivity", "Restoring plugin tabs: $tabIds")
435+
// Decoding the cached JSON off the main thread avoids a UI stall on startup.
436+
val tabIds = withContext(Dispatchers.Default) {
437+
Gson().fromJson(json, Array<String>::class.java)?.toList()
438+
} ?: return@launch
439+
Log.d("EditorHandlerActivity", "Restoring plugin tabs: $tabIds")
430440

431-
tabIds.forEach { tabId ->
432-
if (!pluginTabIndices.containsKey(tabId)) {
433-
selectPluginTabById(tabId)
441+
// Tab selection touches UI state, so keep it on the main thread.
442+
tabIds.forEach { tabId ->
443+
if (!pluginTabIndices.containsKey(tabId)) {
444+
selectPluginTabById(tabId)
445+
}
434446
}
435-
}
436447

437-
prefs.putString(PREF_KEY_OPEN_PLUGIN_TABS, null)
438-
} catch (e: Exception) {
439-
Log.e("EditorHandlerActivity", "Failed to restore plugin tabs", e)
448+
withContext(Dispatchers.IO) { prefs.putString(PREF_KEY_OPEN_PLUGIN_TABS, null) }
449+
} catch (e: Exception) {
450+
Log.e("EditorHandlerActivity", "Failed to restore plugin tabs", e)
451+
}
440452
}
441453
}
442454

‎app/src/main/java/com/itsaky/androidide/fragments/git/GitBottomSheetFragment.kt‎

Lines changed: 30 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -77,6 +77,7 @@ class GitBottomSheetFragment : Fragment(R.layout.fragment_git_bottom_sheet) {
7777
},
7878
onSelectionChanged = {
7979
validateCommitButton()
80+
updateCheckAllButton()
8081
},
8182
onResolveConflict = { change ->
8283
viewModel.resolveConflict(change.path)
@@ -118,6 +119,7 @@ class GitBottomSheetFragment : Fragment(R.layout.fragment_git_bottom_sheet) {
118119
emptyView.visibility = View.VISIBLE
119120
emptyView.text = getString(R.string.not_a_git_repo)
120121
recyclerView.visibility = View.GONE
122+
btnCheckAll.visibility = View.GONE
121123
commitSection.visibility = View.GONE
122124
authorWarning.visibility = View.GONE
123125
commitHistoryButton.visibility = View.GONE
@@ -128,24 +130,32 @@ class GitBottomSheetFragment : Fragment(R.layout.fragment_git_bottom_sheet) {
128130
emptyView.visibility = View.VISIBLE
129131
emptyView.text = getString(R.string.no_uncommitted_changes)
130132
recyclerView.visibility = View.GONE
133+
btnCheckAll.visibility = View.GONE
131134
commitSection.visibility = View.GONE
132135
authorWarning.visibility = View.GONE
133136
commitHistoryButton.visibility = View.VISIBLE
134137
btnAbortMerge.visibility = View.GONE
135138
}
136139

137140
else -> {
141+
// Only offer "Check All" when there is at least one
142+
// non-conflicted file; conflicted files can't be staged.
143+
val hasSelectable = allChanges.any { it.type != ChangeType.CONFLICTED }
138144
binding.apply {
139145
emptyView.visibility = View.GONE
140146
recyclerView.visibility = View.VISIBLE
147+
btnCheckAll.visibility =
148+
if (hasSelectable) View.VISIBLE else View.GONE
141149
commitSection.visibility = View.VISIBLE
142150
authorWarning.visibility =
143151
if (hasAuthorInfo()) View.GONE else View.VISIBLE
144152
commitHistoryButton.visibility = View.VISIBLE
145153
btnAbortMerge.visibility =
146154
if (status.isMerging) View.VISIBLE else View.GONE
147155
}
148-
fileChangeAdapter.submitList(allChanges)
156+
fileChangeAdapter.submitList(allChanges) {
157+
updateCheckAllButton()
158+
}
149159
}
150160
}
151161
}.collectLatest { }
@@ -186,6 +196,14 @@ class GitBottomSheetFragment : Fragment(R.layout.fragment_git_bottom_sheet) {
186196
binding.commitSummary.doAfterTextChanged { validateCommitButton() }
187197
binding.commitDescription.doAfterTextChanged { validateCommitButton() }
188198

199+
binding.btnCheckAll.setOnClickListener {
200+
if (fileChangeAdapter.areAllSelected()) {
201+
fileChangeAdapter.clearSelection()
202+
} else {
203+
fileChangeAdapter.selectAll()
204+
}
205+
}
206+
189207
binding.btnAbortMerge.apply {
190208
setOnClickListener {
191209
val dialog = MaterialAlertDialogBuilder(requireContext())
@@ -228,6 +246,7 @@ class GitBottomSheetFragment : Fragment(R.layout.fragment_git_bottom_sheet) {
228246
binding.commitSummary.text?.clear()
229247
binding.commitDescription.text?.clear()
230248
fileChangeAdapter.selectedFiles.clear()
249+
updateCheckAllButton()
231250
}
232251
}
233252
}
@@ -279,12 +298,22 @@ class GitBottomSheetFragment : Fragment(R.layout.fragment_git_bottom_sheet) {
279298
}
280299

281300
private fun validateCommitButton() {
301+
// May be invoked from async adapter callbacks; bail if the view is gone.
302+
val binding = _binding ?: return
282303
val hasSummary = !binding.commitSummary.text.isNullOrBlank()
283304
val hasSelection = fileChangeAdapter.selectedFiles.isNotEmpty()
284305
val hasAuthor = hasAuthorInfo()
285306
binding.commitButton.isEnabled = hasSummary && hasSelection && hasAuthor
286307
}
287308

309+
private fun updateCheckAllButton() {
310+
// May be invoked from the async submitList commit callback; bail if the view is gone.
311+
val binding = _binding ?: return
312+
binding.btnCheckAll.setText(
313+
if (fileChangeAdapter.areAllSelected()) R.string.uncheck_all else R.string.check_all
314+
)
315+
}
316+
288317
private fun setupPullUI() {
289318
viewLifecycleOwner.lifecycleScope.launch {
290319
viewModel.isGitRepository.collectLatest { isRepo ->

‎app/src/main/java/com/itsaky/androidide/fragments/git/adapter/GitFileChangeAdapter.kt‎

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,41 @@ class GitFileChangeAdapter(
2020
// Keep track of which files are selected to be committed
2121
val selectedFiles = mutableSetOf<String>()
2222

23+
// Conflicted files can't be staged, so they are excluded from "select all".
24+
private val selectablePaths: List<String>
25+
get() = currentList.filter { it.type != ChangeType.CONFLICTED }.map { it.path }
26+
27+
/** True when every selectable (non-conflicted) file is currently selected. */
28+
fun areAllSelected(): Boolean =
29+
selectablePaths.isNotEmpty() && selectedFiles.containsAll(selectablePaths)
30+
31+
/** Select every non-conflicted file. */
32+
fun selectAll() {
33+
selectedFiles.addAll(selectablePaths)
34+
notifyItemRangeChanged(0, itemCount)
35+
onSelectionChanged(selectedFiles.size)
36+
}
37+
38+
/** Clear the entire selection. */
39+
fun clearSelection() {
40+
selectedFiles.clear()
41+
notifyItemRangeChanged(0, itemCount)
42+
onSelectionChanged(selectedFiles.size)
43+
}
44+
45+
override fun onCurrentListChanged(
46+
previousList: List<FileChange>,
47+
currentList: List<FileChange>
48+
) {
49+
super.onCurrentListChanged(previousList, currentList)
50+
// Drop selections for files that are no longer in the change set so they
51+
// aren't committed and don't skew areAllSelected()/the commit button.
52+
val currentPaths = currentList.mapTo(HashSet()) { it.path }
53+
if (selectedFiles.retainAll(currentPaths)) {
54+
onSelectionChanged(selectedFiles.size)
55+
}
56+
}
57+
2358
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): ViewHolder {
2459
val binding = ItemGitFileChangeBinding.inflate(
2560
LayoutInflater.from(parent.context), parent, false

‎app/src/main/java/com/itsaky/androidide/fragments/onboarding/PermissionsFragment.kt‎

Lines changed: 10 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,7 @@ import androidx.core.net.toUri
3232
import androidx.fragment.app.viewModels
3333
import androidx.lifecycle.Lifecycle
3434
import androidx.lifecycle.repeatOnLifecycle
35+
import androidx.lifecycle.withResumed
3536
import androidx.recyclerview.widget.RecyclerView
3637
import com.github.appintro.SlidePolicy
3738
import com.github.appintro.SlideSelectionListener
@@ -45,6 +46,7 @@ import com.itsaky.androidide.databinding.LayoutOnboardingPermissionsBinding
4546
import com.itsaky.androidide.events.InstallationEvent
4647
import com.itsaky.androidide.preferences.internal.prefManager
4748
import com.itsaky.androidide.tasks.doAsyncWithProgress
49+
import com.itsaky.androidide.utils.OverlayPermissionGuide
4850
import com.itsaky.androidide.utils.PermissionsHelper
4951
import com.itsaky.androidide.utils.flashError
5052
import com.itsaky.androidide.utils.isTestMode
@@ -73,7 +75,6 @@ class PermissionsFragment :
7375
private var recyclerView: RecyclerView? = null
7476
private var finishButton: MaterialButton? = null
7577
private lateinit var pulseAnimation: Animation
76-
private var awaitingOverlayGrantResult = false
7778

7879
private val storagePermissionRequestLauncher =
7980
registerForActivityResult(
@@ -97,6 +98,8 @@ class PermissionsFragment :
9798
private val logger = LoggerFactory.getLogger(PermissionsFragment::class.java)
9899
private const val KEY_PRIVACY_DISCLOSURE_SHOWN = "privacy.disclosure.shown"
99100

101+
private var awaitingOverlayGrantResult = false
102+
100103
@JvmStatic
101104
fun newInstance(context: Context): PermissionsFragment =
102105
PermissionsFragment().apply {
@@ -231,12 +234,13 @@ class PermissionsFragment :
231234
}
232235
awaitingOverlayGrantResult = false
233236

234-
if (PermissionsHelper.canDrawOverlays(requireContext())) {
235-
return
237+
viewLifecycleScope.launch {
238+
viewLifecycleOwner.withResumed {
239+
if (!PermissionsHelper.canDrawOverlays(requireContext())) {
240+
OverlayPermissionGuide.showRestrictedSettingsDialog(requireContext())
241+
}
242+
}
236243
}
237-
238-
flashError(getString(R.string.permission_overlay_restricted_settings_hint))
239-
requestSettingsTogglePermission(Settings.ACTION_APPLICATION_DETAILS_SETTINGS)
240244
}
241245

242246
private fun startIdeSetup() {
Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,55 @@
1+
package com.itsaky.androidide.utils
2+
3+
import android.app.Activity
4+
import android.content.ActivityNotFoundException
5+
import android.content.Context
6+
import android.content.Intent
7+
import android.net.Uri
8+
import android.provider.Settings
9+
import android.text.SpannableStringBuilder
10+
import com.itsaky.androidide.resources.R
11+
import org.slf4j.LoggerFactory
12+
13+
object OverlayPermissionGuide {
14+
15+
private val log = LoggerFactory.getLogger(OverlayPermissionGuide::class.java)
16+
17+
fun showRestrictedSettingsDialog(context: Context) {
18+
val message = SpannableStringBuilder()
19+
message.append(context.getString(R.string.permission_overlay_restricted_dialog_message))
20+
message.append("\n\n")
21+
message.appendOrderedList(*context.resources.getStringArray(R.array.overlay_restricted_settings_steps))
22+
23+
DialogUtils
24+
.newMaterialDialogBuilder(context)
25+
.setTitle(R.string.permission_overlay_restricted_dialog_title)
26+
.setMessage(message)
27+
.setPositiveButton(R.string.permission_overlay_open_app_info) { dialog, _ ->
28+
dialog.dismiss()
29+
openAppInfo(context)
30+
}
31+
.setNegativeButton(android.R.string.cancel, null)
32+
.show()
33+
}
34+
35+
private fun openAppInfo(context: Context) {
36+
val intent = Intent(
37+
Settings.ACTION_APPLICATION_DETAILS_SETTINGS,
38+
Uri.fromParts("package", context.packageName, null),
39+
)
40+
if (context !is Activity) {
41+
intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
42+
}
43+
try {
44+
context.startActivity(intent)
45+
} catch (e: ActivityNotFoundException) {
46+
log.error("Failed to open App info settings", e)
47+
flashError(
48+
context.getString(
49+
R.string.err_no_activity_to_handle_action,
50+
Settings.ACTION_APPLICATION_DETAILS_SETTINGS,
51+
),
52+
)
53+
}
54+
}
55+
}

‎app/src/main/java/com/itsaky/androidide/viewmodel/EditorViewModel.kt‎

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -344,8 +344,16 @@ class EditorViewModel : ViewModel() {
344344
return file
345345
}
346346

347+
/**
348+
* Returns the open project's directory name, or an empty string when no project path is
349+
* available (the process-death recreation state where the project path is uninitialized).
350+
*/
347351
fun getProjectName(): String {
348352
val manager = ProjectManagerImpl.getInstance()
353+
val path = manager.projectDirPath
354+
if (path.isBlank()) {
355+
return ""
356+
}
349357
return manager.projectDir.name
350358
}
351359
}

0 commit comments

Comments
 (0)