From 014aa5801c44eaeb34d3da6c2c69fc07d88317b4 Mon Sep 17 00:00:00 2001 From: Akash Yadav Date: Wed, 2 Sep 2026 15:59:00 +0000 Subject: [PATCH 1/5] ADFA-5199: Reindent TemplateDetailsFragment to tabs No behaviour change. The Spotless ratchet is file-level rather than line-level, so the one-line fix in the next commit puts this whole 4-space-indented file under the formatter. Kept separate so that fix stays reviewable; read this one with `git show -w`. --- .../fragments/TemplateDetailsFragment.kt | 331 +++++++++--------- 1 file changed, 169 insertions(+), 162 deletions(-) diff --git a/app/src/main/java/com/itsaky/androidide/fragments/TemplateDetailsFragment.kt b/app/src/main/java/com/itsaky/androidide/fragments/TemplateDetailsFragment.kt index b0923ed4ad..4143734dae 100644 --- a/app/src/main/java/com/itsaky/androidide/fragments/TemplateDetailsFragment.kt +++ b/app/src/main/java/com/itsaky/androidide/fragments/TemplateDetailsFragment.kt @@ -17,10 +17,13 @@ package com.itsaky.androidide.fragments +import android.animation.ObjectAnimator import android.os.Bundle import android.view.View +import android.view.animation.LinearInterpolator +import androidx.core.view.ViewCompat +import androidx.core.view.isVisible import androidx.lifecycle.lifecycleScope -import org.koin.androidx.viewmodel.ext.android.activityViewModel import androidx.recyclerview.widget.LinearLayoutManager import androidx.transition.TransitionManager import com.itsaky.androidide.R @@ -35,18 +38,15 @@ import com.itsaky.androidide.idetooltips.TooltipTag.SETUP_PREVIOUS import com.itsaky.androidide.templates.ParameterWidget import com.itsaky.androidide.templates.Template import com.itsaky.androidide.utils.ProjectCreationManager -import com.itsaky.androidide.utils.ui.TemplateScrollGateKeeper import com.itsaky.androidide.utils.flashError import com.itsaky.androidide.utils.flashSuccess +import com.itsaky.androidide.utils.ui.TemplateScrollGateKeeper import com.itsaky.androidide.viewmodel.MainViewModel import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Job import kotlinx.coroutines.launch import kotlinx.coroutines.withContext -import android.animation.ObjectAnimator -import android.view.animation.LinearInterpolator -import androidx.core.view.ViewCompat -import androidx.core.view.isVisible +import org.koin.androidx.viewmodel.ext.android.activityViewModel /** * A fragment which shows a wizard-like interface for creating templates. @@ -54,160 +54,167 @@ import androidx.core.view.isVisible * @author Akash Yadav */ class TemplateDetailsFragment : - FragmentWithBinding( - R.layout.fragment_template_details, FragmentTemplateDetailsBinding::bind - ) { - - private val viewModel by activityViewModel() - private var widgetsBindJob: Job? = null - - private var scrollGateKeeper: TemplateScrollGateKeeper? = null - private val projectCreationManager by lazy { ProjectCreationManager(requireContext()) } - private var blinkAnimator: ObjectAnimator? = null - - override fun onViewCreated(view: View, savedInstanceState: Bundle?) { - super.onViewCreated(view, savedInstanceState) - - setupRecyclerView() - setupTooltips() - setupObservers() - setupClickListeners() - startBlinkingIndicator() - } - - override fun onDestroyView() { - super.onDestroyView() - - blinkAnimator?.cancel() - blinkAnimator = null - - scrollGateKeeper?.detach() - scrollGateKeeper = null - } - - private fun setupRecyclerView() { - binding.widgets.layoutManager = LinearLayoutManager(requireContext()) - - scrollGateKeeper = TemplateScrollGateKeeper(binding.widgets) { - updateFinishEnabledState() - } - scrollGateKeeper?.attach() - } - - private fun setupObservers() { - viewModel.template.observe(viewLifecycleOwner) { - binding.widgets.adapter = null - scrollGateKeeper?.reset() - updateFinishEnabledState() - viewModel.postTransition(viewLifecycleOwner) { bindWithTemplate(it) } - } - - viewModel.creatingProject.observe(viewLifecycleOwner) { isCreating -> - TransitionManager.beginDelayedTransition(binding.root) - updateFinishEnabledState() - binding.previous.isEnabled = !isCreating - } - } - - private fun setupClickListeners() { - binding.previous.setOnClickListener { - viewModel.setScreen(MainViewModel.SCREEN_TEMPLATE_LIST) - } - - binding.finish.setOnClickListener { - handleProjectCreation() - } - } - - private fun setupTooltips() { - binding.previous.setOnLongClickListener { - TooltipManager.showIdeCategoryTooltip(requireContext(), it, SETUP_PREVIOUS) - true - } - - binding.finish.setOnLongClickListener { - TooltipManager.showIdeCategoryTooltip(requireContext(), it, SETUP_CREATE_PROJECT) - true - } - - binding.title.setOnLongClickListener { - TooltipManager.showIdeCategoryTooltip(requireContext(), binding.root, SETUP_OVERVIEW) - true - } - } - - private fun handleProjectCreation() { - val template = viewModel.template.value ?: run { - viewModel.setScreen(MainViewModel.SCREEN_MAIN) - return - } - - projectCreationManager.execute( - template = template, - onStart = { viewModel.creatingProject.value = true }, - onSuccess = { result, project -> - viewModel.creatingProject.value = false - viewModel.setScreen(MainViewModel.SCREEN_MAIN) - flashSuccess(string.project_created_successfully) - - viewModel.postTransition(viewLifecycleOwner) { - // open the project - (requireActivity() as MainActivity).openProject( - result.data.projectDir, - project = project, - hasTemplateIssues = result.hasErrorsWarnings - ) - } - }, - onError = { errorMsg -> - viewModel.creatingProject.value = false - flashError(errorMsg) - } - ) - } - - private fun bindWithTemplate(template: Template<*>?) { - template ?: return - - binding.title.text = template.templateNameStr - - // Some parameters do disk work in their beforeCreateView hook (e.g. computing a - // non-colliding default project name). Run those hooks on Dispatchers.IO before - // attaching the adapter so that onBindViewHolder skips them (they are one-shot). - widgetsBindJob?.cancel() - widgetsBindJob = viewLifecycleOwner.lifecycleScope.launch { - withContext(Dispatchers.IO) { - template.widgets.forEach { widget -> - if (widget is ParameterWidget<*>) { - widget.parameter.beforeCreateView() - } - } - } - _binding ?: return@launch - binding.widgets.adapter = TemplateWidgetsListAdapter(template.widgets) - binding.widgets.post { - scrollGateKeeper?.checkIfReachedEnd() - } - } - } - - private fun updateFinishEnabledState() { - val isCreating = viewModel.creatingProject.value ?: false - val hasScrolledToBottom = scrollGateKeeper?.hasReachedEnd ?: false - val canFinish = !isCreating && hasScrolledToBottom - val stateDesc = if (canFinish) null else getString(string.msg_scroll_to_create_project) - - binding.finish.isEnabled = !isCreating && hasScrolledToBottom - binding.scrollIndicator.isVisible = !hasScrolledToBottom - ViewCompat.setStateDescription(binding.finish, stateDesc) - } - - private fun startBlinkingIndicator() { - blinkAnimator = ObjectAnimator.ofFloat(binding.scrollIndicator, View.ALPHA, 1f, 0.2f, 1f).apply { - duration = 1200 - interpolator = LinearInterpolator() - repeatCount = ObjectAnimator.INFINITE - start() - } - } + FragmentWithBinding( + R.layout.fragment_template_details, + FragmentTemplateDetailsBinding::bind, + ) { + private val viewModel by activityViewModel() + private var widgetsBindJob: Job? = null + + private var scrollGateKeeper: TemplateScrollGateKeeper? = null + private val projectCreationManager by lazy { ProjectCreationManager(requireContext()) } + private var blinkAnimator: ObjectAnimator? = null + + override fun onViewCreated( + view: View, + savedInstanceState: Bundle?, + ) { + super.onViewCreated(view, savedInstanceState) + + setupRecyclerView() + setupTooltips() + setupObservers() + setupClickListeners() + startBlinkingIndicator() + } + + override fun onDestroyView() { + super.onDestroyView() + + blinkAnimator?.cancel() + blinkAnimator = null + + scrollGateKeeper?.detach() + scrollGateKeeper = null + } + + private fun setupRecyclerView() { + binding.widgets.layoutManager = LinearLayoutManager(requireContext()) + + scrollGateKeeper = + TemplateScrollGateKeeper(binding.widgets) { + updateFinishEnabledState() + } + scrollGateKeeper?.attach() + } + + private fun setupObservers() { + viewModel.template.observe(viewLifecycleOwner) { + binding.widgets.adapter = null + scrollGateKeeper?.reset() + updateFinishEnabledState() + viewModel.postTransition(viewLifecycleOwner) { bindWithTemplate(it) } + } + + viewModel.creatingProject.observe(viewLifecycleOwner) { isCreating -> + TransitionManager.beginDelayedTransition(binding.root) + updateFinishEnabledState() + binding.previous.isEnabled = !isCreating + } + } + + private fun setupClickListeners() { + binding.previous.setOnClickListener { + viewModel.setScreen(MainViewModel.SCREEN_TEMPLATE_LIST) + } + + binding.finish.setOnClickListener { + handleProjectCreation() + } + } + + private fun setupTooltips() { + binding.previous.setOnLongClickListener { + TooltipManager.showIdeCategoryTooltip(requireContext(), it, SETUP_PREVIOUS) + true + } + + binding.finish.setOnLongClickListener { + TooltipManager.showIdeCategoryTooltip(requireContext(), it, SETUP_CREATE_PROJECT) + true + } + + binding.title.setOnLongClickListener { + TooltipManager.showIdeCategoryTooltip(requireContext(), binding.root, SETUP_OVERVIEW) + true + } + } + + private fun handleProjectCreation() { + val template = + viewModel.template.value ?: run { + viewModel.setScreen(MainViewModel.SCREEN_MAIN) + return + } + + projectCreationManager.execute( + template = template, + onStart = { viewModel.creatingProject.value = true }, + onSuccess = { result, project -> + viewModel.creatingProject.value = false + viewModel.setScreen(MainViewModel.SCREEN_MAIN) + flashSuccess(string.project_created_successfully) + + viewModel.postTransition(viewLifecycleOwner) { + // open the project + (requireActivity() as MainActivity).openProject( + result.data.projectDir, + project = project, + hasTemplateIssues = result.hasErrorsWarnings, + ) + } + }, + onError = { errorMsg -> + viewModel.creatingProject.value = false + flashError(errorMsg) + }, + ) + } + + private fun bindWithTemplate(template: Template<*>?) { + template ?: return + + binding.title.text = template.templateNameStr + + // Some parameters do disk work in their beforeCreateView hook (e.g. computing a + // non-colliding default project name). Run those hooks on Dispatchers.IO before + // attaching the adapter so that onBindViewHolder skips them (they are one-shot). + widgetsBindJob?.cancel() + widgetsBindJob = + viewLifecycleOwner.lifecycleScope.launch { + withContext(Dispatchers.IO) { + template.widgets.forEach { widget -> + if (widget is ParameterWidget<*>) { + widget.parameter.beforeCreateView() + } + } + } + _binding ?: return@launch + binding.widgets.adapter = TemplateWidgetsListAdapter(template.widgets) + binding.widgets.post { + scrollGateKeeper?.checkIfReachedEnd() + } + } + } + + private fun updateFinishEnabledState() { + val isCreating = viewModel.creatingProject.value ?: false + val hasScrolledToBottom = scrollGateKeeper?.hasReachedEnd ?: false + val canFinish = !isCreating && hasScrolledToBottom + val stateDesc = if (canFinish) null else getString(string.msg_scroll_to_create_project) + + binding.finish.isEnabled = !isCreating && hasScrolledToBottom + binding.scrollIndicator.isVisible = !hasScrolledToBottom + ViewCompat.setStateDescription(binding.finish, stateDesc) + } + + private fun startBlinkingIndicator() { + blinkAnimator = + ObjectAnimator.ofFloat(binding.scrollIndicator, View.ALPHA, 1f, 0.2f, 1f).apply { + duration = 1200 + interpolator = LinearInterpolator() + repeatCount = ObjectAnimator.INFINITE + start() + } + } } From 2962955c0ff6b892bd8325d2d5c83861bd34bb51 Mon Sep 17 00:00:00 2001 From: Akash Yadav Date: Wed, 2 Sep 2026 15:59:31 +0000 Subject: [PATCH 2/5] ADFA-5199: Stop the template blink animator when its view stops The scroll-indicator blink repeats forever, and the existing cancel() in onDestroyView never ran: activity_main.xml declares this fragment with android:name on a FragmentContainerView, so it is created during setContentView on every launch whether or not the template screen is ever visited, and opening the editor only stops MainActivity rather than destroying it. A GONE container and an invisible indicator do not stop an animator either. Animators do not pause when their activity stops, and AnimationHandler is per-main-thread and process-global, so the blink kept re-posting a vsync callback on the thread the editor runs on, producing no draws and burning main thread time for the life of the process. Measured at 81 main plus 21 Jit ticks per 20 s on an otherwise idle editor, which is exactly what it costs while legitimately visible. Tying it to viewLifecycleOwner leaves it running whenever the screen can be seen and stops it otherwise. --- .../fragments/TemplateDetailsFragment.kt | 37 +++++++++++++++++-- 1 file changed, 34 insertions(+), 3 deletions(-) diff --git a/app/src/main/java/com/itsaky/androidide/fragments/TemplateDetailsFragment.kt b/app/src/main/java/com/itsaky/androidide/fragments/TemplateDetailsFragment.kt index 4143734dae..9369ac6432 100644 --- a/app/src/main/java/com/itsaky/androidide/fragments/TemplateDetailsFragment.kt +++ b/app/src/main/java/com/itsaky/androidide/fragments/TemplateDetailsFragment.kt @@ -23,6 +23,8 @@ import android.view.View import android.view.animation.LinearInterpolator import androidx.core.view.ViewCompat import androidx.core.view.isVisible +import androidx.lifecycle.DefaultLifecycleObserver +import androidx.lifecycle.LifecycleOwner import androidx.lifecycle.lifecycleScope import androidx.recyclerview.widget.LinearLayoutManager import androidx.transition.TransitionManager @@ -65,6 +67,24 @@ class TemplateDetailsFragment : private val projectCreationManager by lazy { ProjectCreationManager(requireContext()) } private var blinkAnimator: ObjectAnimator? = null + /** + * The blink is an endlessly repeating animator, and an animator keeps the main thread's + * Choreographer loop alive process-wide -- it does not pause when this fragment's activity is + * merely stopped. This fragment is inflated by activity_main.xml on every launch and is never + * removed, so tying the blink to anything but the view lifecycle leaves it running behind the + * editor forever. + */ + private val blinkWhileStarted = + object : DefaultLifecycleObserver { + override fun onStart(owner: LifecycleOwner) { + startBlinkingIndicator() + } + + override fun onStop(owner: LifecycleOwner) { + stopBlinkingIndicator() + } + } + override fun onViewCreated( view: View, savedInstanceState: Bundle?, @@ -75,14 +95,13 @@ class TemplateDetailsFragment : setupTooltips() setupObservers() setupClickListeners() - startBlinkingIndicator() + viewLifecycleOwner.lifecycle.addObserver(blinkWhileStarted) } override fun onDestroyView() { super.onDestroyView() - blinkAnimator?.cancel() - blinkAnimator = null + stopBlinkingIndicator() scrollGateKeeper?.detach() scrollGateKeeper = null @@ -209,6 +228,10 @@ class TemplateDetailsFragment : } private fun startBlinkingIndicator() { + if (blinkAnimator != null) { + return + } + blinkAnimator = ObjectAnimator.ofFloat(binding.scrollIndicator, View.ALPHA, 1f, 0.2f, 1f).apply { duration = 1200 @@ -217,4 +240,12 @@ class TemplateDetailsFragment : start() } } + + private fun stopBlinkingIndicator() { + blinkAnimator?.cancel() + blinkAnimator = null + + // cancelling mid-repeat leaves the indicator at whatever alpha it had reached + _binding?.scrollIndicator?.alpha = 1f + } } From 08f4155168fc1ffb58de09a871c3ecbb6ab9bce9 Mon Sep 17 00:00:00 2001 From: Akash Yadav Date: Thu, 10 Sep 2026 11:23:20 +0000 Subject: [PATCH 3/5] ADFA-5199: Blink only while the scroll indicator is on screen A started view lifecycle is not visibility here. activity_main.xml declares every one of MainActivity's screens as a sibling container in one FrameLayout and onScreenChanged swaps them by View visibility, so this fragment's view reaches STARTED at cold start and stays there for as long as MainActivity is started. Tying the blink to the view lifecycle alone therefore left it running through the whole project-list session of a user who never opened the new-project flow, re-posting a vsync callback per frame with nothing drawn. The indicator also hides itself once the form has been scrolled to the bottom, which is a third way for it to be off screen while this is the current screen, and nothing cancelled the animator for that either. shouldBlinkScrollIndicator takes all three inputs, and is driven from the view lifecycle observer, a currentScreen observer and updateFinishEnabledState. It is a top-level function so the decision is unit-testable without standing a fragment up; the three call sites that feed it are not pinned by any test. Also moves the onDestroyView teardown above super. FragmentWithBinding nulls _binding before calling up, so a stop placed after it cannot touch the binding at all. Nothing was broken by that -- the view lifecycle dispatches ON_STOP before onDestroyView, so the reset had already run -- but the call was dead where it sat, and detaching the scroll gatekeeper before the view comes down makes the viewTreeObserver still-alive check hold by construction rather than by luck. --- .../fragments/TemplateDetailsFragment.kt | 77 ++++++++++-- .../fragments/ScrollIndicatorBlinkTest.kt | 115 ++++++++++++++++++ 2 files changed, 179 insertions(+), 13 deletions(-) create mode 100644 app/src/test/java/com/itsaky/androidide/fragments/ScrollIndicatorBlinkTest.kt diff --git a/app/src/main/java/com/itsaky/androidide/fragments/TemplateDetailsFragment.kt b/app/src/main/java/com/itsaky/androidide/fragments/TemplateDetailsFragment.kt index 9369ac6432..14ce68800c 100644 --- a/app/src/main/java/com/itsaky/androidide/fragments/TemplateDetailsFragment.kt +++ b/app/src/main/java/com/itsaky/androidide/fragments/TemplateDetailsFragment.kt @@ -21,6 +21,7 @@ import android.animation.ObjectAnimator import android.os.Bundle import android.view.View import android.view.animation.LinearInterpolator +import androidx.annotation.VisibleForTesting import androidx.core.view.ViewCompat import androidx.core.view.isVisible import androidx.lifecycle.DefaultLifecycleObserver @@ -67,21 +68,20 @@ class TemplateDetailsFragment : private val projectCreationManager by lazy { ProjectCreationManager(requireContext()) } private var blinkAnimator: ObjectAnimator? = null - /** - * The blink is an endlessly repeating animator, and an animator keeps the main thread's - * Choreographer loop alive process-wide -- it does not pause when this fragment's activity is - * merely stopped. This fragment is inflated by activity_main.xml on every launch and is never - * removed, so tying the blink to anything but the view lifecycle leaves it running behind the - * editor forever. - */ - private val blinkWhileStarted = + /** Whether this fragment's view is started, one of the three [shouldBlinkScrollIndicator] inputs. */ + private var isViewStarted = false + + /** Keeps [isViewStarted] current and re-evaluates the blink whenever the view starts or stops. */ + private val blinkWhileOnScreen = object : DefaultLifecycleObserver { override fun onStart(owner: LifecycleOwner) { - startBlinkingIndicator() + isViewStarted = true + updateBlinkState() } override fun onStop(owner: LifecycleOwner) { - stopBlinkingIndicator() + isViewStarted = false + updateBlinkState() } } @@ -95,16 +95,22 @@ class TemplateDetailsFragment : setupTooltips() setupObservers() setupClickListeners() - viewLifecycleOwner.lifecycle.addObserver(blinkWhileStarted) + viewLifecycleOwner.lifecycle.addObserver(blinkWhileOnScreen) } override fun onDestroyView() { - super.onDestroyView() - + /* + * stopBlinkingIndicator touches the binding, and FragmentWithBinding.onDestroyView nulls + * _binding before calling up, so it has to run before super to keep doing anything at all. + * The gatekeeper teardown holds its own view reference and does not care either way; it is + * kept alongside so this fragment's teardown reads as one block. + */ stopBlinkingIndicator() scrollGateKeeper?.detach() scrollGateKeeper = null + + super.onDestroyView() } private fun setupRecyclerView() { @@ -118,6 +124,8 @@ class TemplateDetailsFragment : } private fun setupObservers() { + viewModel.currentScreen.observe(viewLifecycleOwner) { updateBlinkState() } + viewModel.template.observe(viewLifecycleOwner) { binding.widgets.adapter = null scrollGateKeeper?.reset() @@ -225,6 +233,26 @@ class TemplateDetailsFragment : binding.finish.isEnabled = !isCreating && hasScrolledToBottom binding.scrollIndicator.isVisible = !hasScrolledToBottom ViewCompat.setStateDescription(binding.finish, stateDesc) + + updateBlinkState() + } + + /** + * Starts or stops the blink to match [shouldBlinkScrollIndicator]. + */ + private fun updateBlinkState() { + val shouldBlink = + shouldBlinkScrollIndicator( + isViewStarted = isViewStarted, + currentScreen = viewModel.currentScreen.value, + isIndicatorVisible = _binding?.scrollIndicator?.isVisible == true, + ) + + if (shouldBlink) { + startBlinkingIndicator() + } else { + stopBlinkingIndicator() + } } private fun startBlinkingIndicator() { @@ -249,3 +277,26 @@ class TemplateDetailsFragment : _binding?.scrollIndicator?.alpha = 1f } } + +/** + * Whether the scroll indicator's blink should be running. + * + * The blink repeats forever, and an endlessly repeating animator keeps the main thread's + * Choreographer loop alive process-wide: it re-posts a vsync callback every frame whether or not + * anything is drawn, and it does not pause when its activity merely stops. So it may run only + * while the indicator can actually be seen, which takes all three inputs here. + * + * A started view lifecycle is not on its own a proxy for that. `activity_main.xml` declares every + * one of MainActivity's screens as a sibling container in one `FrameLayout`, and switching screens + * only flips their `View` visibility, so this fragment's view reaches `STARTED` at cold start and + * stays there for as long as MainActivity is started -- including the whole time the user sits on + * the project list having never opened the new-project flow. The indicator also hides itself once + * the form has been scrolled to the bottom, which is a third way for it to be off screen while + * this screen is the current one. + */ +@VisibleForTesting +internal fun shouldBlinkScrollIndicator( + isViewStarted: Boolean, + currentScreen: Int?, + isIndicatorVisible: Boolean, +): Boolean = isViewStarted && currentScreen == MainViewModel.SCREEN_TEMPLATE_DETAILS && isIndicatorVisible diff --git a/app/src/test/java/com/itsaky/androidide/fragments/ScrollIndicatorBlinkTest.kt b/app/src/test/java/com/itsaky/androidide/fragments/ScrollIndicatorBlinkTest.kt new file mode 100644 index 0000000000..ee1e95abae --- /dev/null +++ b/app/src/test/java/com/itsaky/androidide/fragments/ScrollIndicatorBlinkTest.kt @@ -0,0 +1,115 @@ +/* + * 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.fragments + +import com.google.common.truth.Truth.assertThat +import com.itsaky.androidide.viewmodel.MainViewModel +import org.junit.Test + +/** + * The three inputs that decide whether the template scroll indicator's endless blink may run, as + * documented on [shouldBlinkScrollIndicator]. + * + * Scope: this pins the decision only, not the wiring that feeds it. The three call sites that + * re-evaluate it -- the view-lifecycle observer, the `currentScreen` observer and + * `updateFinishEnabledState` -- are unpinned, so deleting one would leave every case here passing. + * Pinning them needs a host activity and Koin scaffolding to stand the fragment up, since the + * module has no `fragment-testing` dependency. + */ +class ScrollIndicatorBlinkTest { + @Test + fun `blinks only when started, on the details screen, with the indicator showing`() { + assertThat( + shouldBlinkScrollIndicator( + isViewStarted = true, + currentScreen = MainViewModel.SCREEN_TEMPLATE_DETAILS, + isIndicatorVisible = true, + ), + ).isTrue() + } + + @Test + fun `does not blink behind another screen sharing the container`() { + val offScreens = + listOf( + MainViewModel.SCREEN_MAIN, + MainViewModel.SCREEN_TEMPLATE_LIST, + MainViewModel.SCREEN_SAVED_PROJECTS, + MainViewModel.SCREEN_DELETE_PROJECTS, + MainViewModel.SCREEN_CLONE_REPO, + MainViewModel.TOOLTIPS_WEB_VIEW, + ) + + for (screen in offScreens) { + assertThat( + shouldBlinkScrollIndicator( + isViewStarted = true, + currentScreen = screen, + isIndicatorVisible = true, + ), + ).isFalse() + } + } + + @Test + fun `does not blink while the screen sentinel is unset`() { + /* + * -1, not null: MainViewModel backs currentScreen with MutableLiveData(-1) and documents -1 + * as the "no screen yet" sentinel, so the value is never null. It is live between + * MainActivity's setContentView, which creates this fragment, and its first setScreen. + */ + assertThat( + shouldBlinkScrollIndicator( + isViewStarted = true, + currentScreen = -1, + isIndicatorVisible = true, + ), + ).isFalse() + + // null only because LiveData.getValue() is platform-nullable at the call site + assertThat( + shouldBlinkScrollIndicator( + isViewStarted = true, + currentScreen = null, + isIndicatorVisible = true, + ), + ).isFalse() + } + + @Test + fun `does not blink once the form has been scrolled to the bottom`() { + assertThat( + shouldBlinkScrollIndicator( + isViewStarted = true, + currentScreen = MainViewModel.SCREEN_TEMPLATE_DETAILS, + isIndicatorVisible = false, + ), + ).isFalse() + } + + @Test + fun `does not blink while the view is stopped`() { + assertThat( + shouldBlinkScrollIndicator( + isViewStarted = false, + currentScreen = MainViewModel.SCREEN_TEMPLATE_DETAILS, + isIndicatorVisible = true, + ), + ).isFalse() + } +} From 6d1554a3112ada82442a8c3056f36068b66a3822 Mon Sep 17 00:00:00 2001 From: Akash Yadav Date: Thu, 10 Sep 2026 14:15:21 +0000 Subject: [PATCH 4/5] ADFA-5199: Pin the blink teardown with a fragment lifecycle test The predicate test could not go red against the pre-fix code: shouldBlinkScrollIndicator is new in this PR. Standing the fragment up under Robolectric does, so the two wiring call sites are now pinned - both cases fail on the pre-fix fragment with the animator still alive. --- .../fragments/ScrollIndicatorBlinkTest.kt | 9 +- .../TemplateDetailsBlinkLifecycleTest.kt | 130 ++++++++++++++++++ 2 files changed, 134 insertions(+), 5 deletions(-) create mode 100644 app/src/test/java/com/itsaky/androidide/fragments/TemplateDetailsBlinkLifecycleTest.kt diff --git a/app/src/test/java/com/itsaky/androidide/fragments/ScrollIndicatorBlinkTest.kt b/app/src/test/java/com/itsaky/androidide/fragments/ScrollIndicatorBlinkTest.kt index ee1e95abae..2c5a1320e7 100644 --- a/app/src/test/java/com/itsaky/androidide/fragments/ScrollIndicatorBlinkTest.kt +++ b/app/src/test/java/com/itsaky/androidide/fragments/ScrollIndicatorBlinkTest.kt @@ -25,11 +25,10 @@ import org.junit.Test * The three inputs that decide whether the template scroll indicator's endless blink may run, as * documented on [shouldBlinkScrollIndicator]. * - * Scope: this pins the decision only, not the wiring that feeds it. The three call sites that - * re-evaluate it -- the view-lifecycle observer, the `currentScreen` observer and - * `updateFinishEnabledState` -- are unpinned, so deleting one would leave every case here passing. - * Pinning them needs a host activity and Koin scaffolding to stand the fragment up, since the - * module has no `fragment-testing` dependency. + * Scope: this pins the decision only. Of the three call sites that re-evaluate it, + * [TemplateDetailsBlinkLifecycleTest] pins the view-lifecycle observer and the `currentScreen` + * observer; the `updateFinishEnabledState` one, which needs a real scroll to the form's end, is not + * pinned. */ class ScrollIndicatorBlinkTest { @Test diff --git a/app/src/test/java/com/itsaky/androidide/fragments/TemplateDetailsBlinkLifecycleTest.kt b/app/src/test/java/com/itsaky/androidide/fragments/TemplateDetailsBlinkLifecycleTest.kt new file mode 100644 index 0000000000..cac29c81d4 --- /dev/null +++ b/app/src/test/java/com/itsaky/androidide/fragments/TemplateDetailsBlinkLifecycleTest.kt @@ -0,0 +1,130 @@ +/* + * 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.fragments + +import android.animation.ObjectAnimator +import androidx.appcompat.app.AppCompatActivity +import com.google.common.truth.Truth.assertThat +import com.itsaky.androidide.app.BaseApplication +import com.itsaky.androidide.viewmodel.MainViewModel +import org.junit.After +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import org.koin.core.context.GlobalContext +import org.koin.core.context.GlobalContext.startKoin +import org.koin.core.context.GlobalContext.stopKoin +import org.koin.core.module.dsl.viewModel +import org.koin.dsl.module +import org.robolectric.Robolectric +import org.robolectric.RobolectricTestRunner +import org.robolectric.annotation.Config + +/** + * Pins the wiring that feeds [shouldBlinkScrollIndicator]: the indicator's endlessly repeating + * animator must exist only while the fragment's view is started and this screen is current. + * + * The animator re-posts a vsync callback every frame for as long as it lives, so an animator that + * outlives the view being on screen burns frames for the rest of the process. Pre-fix the blink + * started once in `onViewCreated` and was only cancelled in `onDestroyView`, so both cases here go + * red: leaving the screen and stopping the activity each left it running. + */ +@RunWith(RobolectricTestRunner::class) +@Config(application = TemplateDetailsBlinkLifecycleTest.TestApp::class) +class TemplateDetailsBlinkLifecycleTest { + open class TestApp : BaseApplication() + + private var startedKoin = false + + /* + * activityViewModel resolves through Koin, so a context has to exist. Another + * test's application may already have started one, in which case startKoin would throw + * KoinApplicationAlreadyStartedException -- join that context and leave it to its owner. + */ + @Before + fun setUp() { + val binding = module { viewModel { MainViewModel() } } + val existing = GlobalContext.getOrNull() + if (existing == null) { + startedKoin = true + startKoin { modules(binding) } + } else { + existing.loadModules(listOf(binding)) + } + } + + @After + fun tearDown() { + if (startedKoin) { + stopKoin() + } + } + + @Test + fun `blink stops when the view lifecycle stops`() { + val controller = Robolectric.buildActivity(AppCompatActivity::class.java).setup() + val fragment = controller.get().showTemplateDetails() + + assertThat(fragment.blinkAnimator()?.isRunning).isTrue() + + controller.pause().stop() + + assertThat(fragment.blinkAnimator()).isNull() + } + + @Test + fun `blink stops when another screen becomes current`() { + val controller = Robolectric.buildActivity(AppCompatActivity::class.java).setup() + val fragment = controller.get().showTemplateDetails() + + assertThat(fragment.blinkAnimator()?.isRunning).isTrue() + + fragment.viewModel().setScreen(MainViewModel.SCREEN_MAIN) + + assertThat(fragment.blinkAnimator()).isNull() + } + + private fun AppCompatActivity.showTemplateDetails(): TemplateDetailsFragment { + val fragment = TemplateDetailsFragment() + supportFragmentManager + .beginTransaction() + .add(android.R.id.content, fragment) + .commitNow() + + fragment.viewModel().setScreen(MainViewModel.SCREEN_TEMPLATE_DETAILS) + return fragment + } + + private fun TemplateDetailsFragment.viewModel(): MainViewModel = + readPrivate("viewModel\$delegate").let { delegate -> + @Suppress("UNCHECKED_CAST") + (delegate as Lazy).value + } + + private fun TemplateDetailsFragment.blinkAnimator(): ObjectAnimator? = readPrivate("blinkAnimator") as ObjectAnimator? + + /* + * The animator field and the view-model delegate are private production state with no reason to + * be otherwise; reading them reflectively keeps the test-only access in the test. + */ + private fun TemplateDetailsFragment.readPrivate(name: String): Any? = + TemplateDetailsFragment::class.java + .getDeclaredField(name) + .apply { isAccessible = true } + .get(this) +} From bc6fc2c0dffb99ac98cbba4a1e096d6c4b6d6f19 Mon Sep 17 00:00:00 2001 From: Akash Yadav Date: Thu, 10 Sep 2026 14:15:21 +0000 Subject: [PATCH 5/5] ADFA-5199: Correct the blink teardown comment, drop VisibleForTesting --- .../androidide/fragments/TemplateDetailsFragment.kt | 10 ++++------ 1 file changed, 4 insertions(+), 6 deletions(-) diff --git a/app/src/main/java/com/itsaky/androidide/fragments/TemplateDetailsFragment.kt b/app/src/main/java/com/itsaky/androidide/fragments/TemplateDetailsFragment.kt index 14ce68800c..e1ff047d4c 100644 --- a/app/src/main/java/com/itsaky/androidide/fragments/TemplateDetailsFragment.kt +++ b/app/src/main/java/com/itsaky/androidide/fragments/TemplateDetailsFragment.kt @@ -21,7 +21,6 @@ import android.animation.ObjectAnimator import android.os.Bundle import android.view.View import android.view.animation.LinearInterpolator -import androidx.annotation.VisibleForTesting import androidx.core.view.ViewCompat import androidx.core.view.isVisible import androidx.lifecycle.DefaultLifecycleObserver @@ -100,10 +99,10 @@ class TemplateDetailsFragment : override fun onDestroyView() { /* - * stopBlinkingIndicator touches the binding, and FragmentWithBinding.onDestroyView nulls - * _binding before calling up, so it has to run before super to keep doing anything at all. - * The gatekeeper teardown holds its own view reference and does not care either way; it is - * kept alongside so this fragment's teardown reads as one block. + * [blinkWhileOnScreen] is what guarantees the animator is gone: performDestroyView drives the + * view lifecycle to DESTROYED before calling here, and that backward pass dispatches ON_STOP. + * This call is a no-op belt-and-braces for a teardown that skipped the observer; it still has + * to precede super, since FragmentWithBinding.onDestroyView nulls _binding before calling up. */ stopBlinkingIndicator() @@ -294,7 +293,6 @@ class TemplateDetailsFragment : * the form has been scrolled to the bottom, which is a third way for it to be off screen while * this screen is the current one. */ -@VisibleForTesting internal fun shouldBlinkScrollIndicator( isViewStarted: Boolean, currentScreen: Int?,