Skip to content

Commit c8bcd3a

Browse files
MozLandocsadilek
andcommitted
7801: Closes mozilla-mobile#7782: Fix crashes in addon installation dialog r=Amejia481 a=csadilek This fixes the following problems with `AddonInstallationDialogFragment`: - It didn't have a default constructor. So any attempt by Android to restore it caused a crash e.g. when rotating the device. - Since we're displaying the fragment as a result of an async operation it can happen that the "parent" fragment is still attached, our check of `runIfFragmentAttached` succeeds, but the activity is in the process of being destroyed. Then we run into the crash described in mozilla-mobile#7782. I've added the workaround here (seems pretty common based on my research) with a description of why it seems safe to me, but thoughts more than welcome. :) - The icon fetch job was returned but never cancelled. It's also not need to fetch the icon again when rotating. Co-authored-by: Christian Sadilek <[email protected]>
2 parents b1d3782 + 1d858c4 commit c8bcd3a

File tree

3 files changed

+114
-29
lines changed

3 files changed

+114
-29
lines changed

components/feature/addons/src/main/java/mozilla/components/feature/addons/ui/AddonInstallationDialogFragment.kt

+41-7
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ package mozilla.components.feature.addons.ui
66

77
import android.annotation.SuppressLint
88
import android.app.Dialog
9+
import android.graphics.Bitmap
910
import android.graphics.Color
1011
import android.graphics.drawable.BitmapDrawable
1112
import android.graphics.drawable.ColorDrawable
@@ -25,6 +26,7 @@ import androidx.annotation.VisibleForTesting
2526
import androidx.appcompat.app.AppCompatDialogFragment
2627
import androidx.appcompat.widget.AppCompatCheckBox
2728
import androidx.core.content.ContextCompat
29+
import androidx.fragment.app.FragmentManager
2830
import kotlinx.android.synthetic.main.mozac_feature_addons_fragment_dialog_addon_installed.view.*
2931
import kotlinx.coroutines.CoroutineScope
3032
import kotlinx.coroutines.Dispatchers
@@ -38,26 +40,33 @@ import mozilla.components.support.ktx.android.content.appName
3840
import mozilla.components.support.ktx.android.content.res.resolveAttribute
3941
import java.io.IOException
4042

43+
@VisibleForTesting internal const val KEY_INSTALLED_ADDON = "KEY_ADDON"
4144
private const val KEY_DIALOG_GRAVITY = "KEY_DIALOG_GRAVITY"
4245
private const val KEY_DIALOG_WIDTH_MATCH_PARENT = "KEY_DIALOG_WIDTH_MATCH_PARENT"
4346
private const val KEY_CONFIRM_BUTTON_BACKGROUND_COLOR = "KEY_CONFIRM_BUTTON_BACKGROUND_COLOR"
4447
private const val KEY_CONFIRM_BUTTON_TEXT_COLOR = "KEY_CONFIRM_BUTTON_TEXT_COLOR"
4548
private const val KEY_CONFIRM_BUTTON_RADIUS = "KEY_CONFIRM_BUTTON_RADIUS"
49+
@VisibleForTesting internal const val KEY_ICON = "KEY_ICON"
50+
4651
private const val DEFAULT_VALUE = Int.MAX_VALUE
4752

4853
/**
4954
* A dialog that shows [Addon] installation confirmation.
5055
*/
51-
class AddonInstallationDialogFragment(
52-
private val addonCollectionProvider: AddonCollectionProvider
53-
) : AppCompatDialogFragment() {
56+
class AddonInstallationDialogFragment : AppCompatDialogFragment() {
5457
private val scope = CoroutineScope(Dispatchers.IO)
58+
@VisibleForTesting internal var iconJob: Job? = null
5559
private val logger = Logger("AddonInstallationDialogFragment")
5660
/**
5761
* A lambda called when the confirm button is clicked.
5862
*/
5963
var onConfirmButtonClicked: ((Addon, Boolean) -> Unit)? = null
6064

65+
/**
66+
* Reference to the application's [AddonCollectionProvider] to fetch add-on icons.
67+
*/
68+
var addonCollectionProvider: AddonCollectionProvider? = null
69+
6170
private val safeArguments get() = requireNotNull(arguments)
6271

6372
internal val addon get() = requireNotNull(safeArguments.getParcelable<Addon>(KEY_ADDON))
@@ -91,6 +100,11 @@ class AddonInstallationDialogFragment(
91100
DEFAULT_VALUE
92101
)
93102

103+
override fun onStop() {
104+
super.onStop()
105+
iconJob?.cancel()
106+
}
107+
94108
override fun onCreateDialog(savedInstanceState: Bundle?): Dialog {
95109
val sheetDialog = Dialog(requireContext())
96110
sheetDialog.requestWindowFeature(Window.FEATURE_NO_TITLE)
@@ -144,7 +158,12 @@ class AddonInstallationDialogFragment(
144158
requireContext().appName
145159
)
146160

147-
fetchIcon(addon, rootView.icon)
161+
val icon = safeArguments.getParcelable<Bitmap>(KEY_ICON)
162+
if (icon != null) {
163+
rootView.icon.setImageDrawable(BitmapDrawable(resources, icon))
164+
} else {
165+
iconJob = fetchIcon(addon, rootView.icon)
166+
}
148167

149168
val allowedInPrivateBrowsing = rootView.findViewById<AppCompatCheckBox>(R.id.allow_in_private_browsing)
150169
allowedInPrivateBrowsing.setOnCheckedChangeListener { _, isChecked ->
@@ -188,9 +207,10 @@ class AddonInstallationDialogFragment(
188207
internal fun fetchIcon(addon: Addon, iconView: ImageView, scope: CoroutineScope = this.scope): Job {
189208
return scope.launch {
190209
try {
191-
val iconBitmap = addonCollectionProvider.getAddonIconBitmap(addon)
210+
val iconBitmap = addonCollectionProvider?.getAddonIconBitmap(addon)
192211
iconBitmap?.let {
193212
scope.launch(Dispatchers.Main) {
213+
safeArguments.putParcelable(KEY_ICON, it)
194214
iconView.setImageDrawable(BitmapDrawable(iconView.resources, it))
195215
}
196216
}
@@ -206,6 +226,19 @@ class AddonInstallationDialogFragment(
206226
}
207227
}
208228

229+
override fun show(manager: FragmentManager, tag: String?) {
230+
// This dialog is shown as a result of an async operation (installing
231+
// an add-on). Once installation succeeds, the activity may already be
232+
// in the process of being destroyed. Since the dialog doesn't have any
233+
// state we need to keep, and since it's also fine to not display the
234+
// dialog at all in case the user navigates away, we can simply use
235+
// commitAllowingStateLoss here to prevent crashing on commit:
236+
// https://github.com/mozilla-mobile/android-components/issues/7782
237+
val ft = manager.beginTransaction()
238+
ft.add(this, tag)
239+
ft.commitAllowingStateLoss()
240+
}
241+
209242
@Suppress("LongParameterList")
210243
companion object {
211244
/**
@@ -224,11 +257,11 @@ class AddonInstallationDialogFragment(
224257
onConfirmButtonClicked: ((Addon, Boolean) -> Unit)? = null
225258
): AddonInstallationDialogFragment {
226259

227-
val fragment = AddonInstallationDialogFragment(addonCollectionProvider)
260+
val fragment = AddonInstallationDialogFragment()
228261
val arguments = fragment.arguments ?: Bundle()
229262

230263
arguments.apply {
231-
putParcelable(KEY_ADDON, addon)
264+
putParcelable(KEY_INSTALLED_ADDON, addon)
232265

233266
promptsStyling?.gravity?.apply {
234267
putInt(KEY_DIALOG_GRAVITY, this)
@@ -246,6 +279,7 @@ class AddonInstallationDialogFragment(
246279
}
247280
fragment.onConfirmButtonClicked = onConfirmButtonClicked
248281
fragment.arguments = arguments
282+
fragment.addonCollectionProvider = addonCollectionProvider
249283
return fragment
250284
}
251285
}

components/feature/addons/src/test/java/mozilla/components/feature/addons/ui/AddonInstallationDialogFragmentTest.kt

+41-3
Original file line numberDiff line numberDiff line change
@@ -14,25 +14,32 @@ import androidx.appcompat.widget.AppCompatCheckBox
1414
import androidx.fragment.app.FragmentManager
1515
import androidx.fragment.app.FragmentTransaction
1616
import androidx.test.ext.junit.runners.AndroidJUnit4
17+
import kotlinx.coroutines.Job
1718
import kotlinx.coroutines.runBlocking
1819
import kotlinx.coroutines.test.TestCoroutineDispatcher
1920
import kotlinx.coroutines.test.TestCoroutineScope
2021
import mozilla.components.feature.addons.Addon
2122
import mozilla.components.feature.addons.R
2223
import mozilla.components.feature.addons.amo.AddonCollectionProvider
2324
import mozilla.components.feature.addons.ui.AddonInstallationDialogFragment
25+
import mozilla.components.feature.addons.ui.KEY_ICON
26+
import mozilla.components.feature.addons.ui.KEY_INSTALLED_ADDON
2427
import mozilla.components.feature.addons.ui.translatedName
2528
import mozilla.components.support.test.mock
2629
import mozilla.components.support.test.robolectric.testContext
2730
import mozilla.components.support.test.rule.MainCoroutineRule
2831
import mozilla.components.support.test.whenever
2932
import org.junit.Assert.assertFalse
33+
import org.junit.Assert.assertNotNull
34+
import org.junit.Assert.assertNull
35+
import org.junit.Assert.assertSame
3036
import org.junit.Assert.assertTrue
3137
import org.junit.Assert.fail
3238
import org.junit.Rule
3339
import org.junit.Test
3440
import org.junit.runner.RunWith
3541
import org.mockito.Mockito
42+
import org.mockito.Mockito.`when`
3643
import org.mockito.Mockito.doReturn
3744
import org.mockito.Mockito.spy
3845
import org.mockito.Mockito.verify
@@ -51,7 +58,10 @@ class AddonInstallationDialogFragmentTest {
5158
"id", translatableName = mapOf(Addon.DEFAULT_LOCALE to "my_addon"),
5259
permissions = listOf("privacy", "<all_urls>", "tabs")
5360
)
54-
val fragment = createAddonInstallationDialogFragment(addon, mock())
61+
val mockedCollectionProvider = mock<AddonCollectionProvider>()
62+
val fragment = createAddonInstallationDialogFragment(addon, mockedCollectionProvider)
63+
assertSame(mockedCollectionProvider, fragment.addonCollectionProvider)
64+
assertSame(addon, fragment.arguments?.getParcelable(KEY_INSTALLED_ADDON))
5565

5666
doReturn(testContext).`when`(fragment).requireContext()
5767
val dialog = fragment.onCreateDialog(null)
@@ -131,7 +141,7 @@ class AddonInstallationDialogFragmentTest {
131141
}
132142

133143
@Test
134-
fun `fetching the add-on icon() successfully `() {
144+
fun `fetching the add-on icon successfully`() {
135145
val addon = mock<Addon>()
136146
val bitmap = mock<Bitmap>()
137147
val mockedImageView = spy(ImageView(testContext))
@@ -140,13 +150,15 @@ class AddonInstallationDialogFragmentTest {
140150

141151
runBlocking {
142152
whenever(mockedCollectionProvider.getAddonIconBitmap(addon)).thenReturn(bitmap)
153+
assertNull(fragment.arguments?.getParcelable<Bitmap>(KEY_ICON))
143154
fragment.fetchIcon(addon, mockedImageView, scope).join()
155+
assertNotNull(fragment.arguments?.getParcelable<Bitmap>(KEY_ICON))
144156
verify(mockedImageView).setImageDrawable(Mockito.any())
145157
}
146158
}
147159

148160
@Test
149-
fun `handle errors while fetching the add-on icon() `() {
161+
fun `handle errors while fetching the add-on icon`() {
150162
val addon = mock<Addon>()
151163
val mockedImageView = spy(ImageView(testContext))
152164
val mockedCollectionProvider = mock<AddonCollectionProvider>()
@@ -165,6 +177,32 @@ class AddonInstallationDialogFragmentTest {
165177
}
166178
}
167179

180+
@Test
181+
fun `allows state loss when committing`() {
182+
val addon = mock<Addon>()
183+
val mockedCollectionProvider = mock<AddonCollectionProvider>()
184+
val fragment = createAddonInstallationDialogFragment(addon, mockedCollectionProvider)
185+
186+
val fragmentManager = mock<FragmentManager>()
187+
val fragmentTransaction = mock<FragmentTransaction>()
188+
`when`(fragmentManager.beginTransaction()).thenReturn(fragmentTransaction)
189+
190+
fragment.show(fragmentManager, "test")
191+
verify(fragmentTransaction).commitAllowingStateLoss()
192+
}
193+
194+
@Test
195+
fun `cancels icon job on stop`() {
196+
val addon = mock<Addon>()
197+
val mockedCollectionProvider = mock<AddonCollectionProvider>()
198+
val fragment = createAddonInstallationDialogFragment(addon, mockedCollectionProvider)
199+
200+
val job = mock<Job>()
201+
fragment.iconJob = job
202+
fragment.onStop()
203+
verify(job).cancel()
204+
}
205+
168206
private fun createAddonInstallationDialogFragment(
169207
addon: Addon,
170208
addonCollectionProvider: AddonCollectionProvider,

samples/browser/src/main/java/org/mozilla/samples/browser/addons/AddonsFragment.kt

+32-19
Original file line numberDiff line numberDiff line change
@@ -58,8 +58,13 @@ class AddonsFragment : Fragment(), AddonsManagerAdapterDelegate {
5858
bindRecyclerView(view)
5959
}
6060

61-
findPreviousDialogFragment()?.let { dialog ->
62-
dialog.onPositiveButtonClicked = onPositiveButtonClicked
61+
findPreviousPermissionDialogFragment()?.let { dialog ->
62+
dialog.onPositiveButtonClicked = onConfirmPermissionButtonClicked
63+
}
64+
65+
findPreviousInstallationDialogFragment()?.let { dialog ->
66+
dialog.onConfirmButtonClicked = onConfirmInstallationButtonClicked
67+
dialog.addonCollectionProvider = requireContext().components.addonCollectionProvider
6368
}
6469
}
6570

@@ -121,21 +126,25 @@ class AddonsFragment : Fragment(), AddonsManagerAdapterDelegate {
121126
}
122127

123128
private fun isAlreadyADialogCreated(): Boolean {
124-
return findPreviousDialogFragment() != null
129+
return findPreviousPermissionDialogFragment() != null && findPreviousInstallationDialogFragment() != null
125130
}
126131

127-
private fun findPreviousDialogFragment(): PermissionsDialogFragment? {
132+
private fun findPreviousPermissionDialogFragment(): PermissionsDialogFragment? {
128133
return fragmentManager?.findFragmentByTag(PERMISSIONS_DIALOG_FRAGMENT_TAG) as? PermissionsDialogFragment
129134
}
130135

136+
private fun findPreviousInstallationDialogFragment(): AddonInstallationDialogFragment? {
137+
return fragmentManager?.findFragmentByTag(INSTALLATION_DIALOG_FRAGMENT_TAG) as? AddonInstallationDialogFragment
138+
}
139+
131140
private fun showPermissionDialog(addon: Addon) {
132141
if (isInstallationInProgress) {
133142
return
134143
}
135144

136145
val dialog = PermissionsDialogFragment.newInstance(
137146
addon = addon,
138-
onPositiveButtonClicked = onPositiveButtonClicked
147+
onPositiveButtonClicked = onConfirmPermissionButtonClicked
139148
)
140149

141150
if (!isAlreadyADialogCreated() && fragmentManager != null) {
@@ -151,32 +160,36 @@ class AddonsFragment : Fragment(), AddonsManagerAdapterDelegate {
151160
val dialog = AddonInstallationDialogFragment.newInstance(
152161
addon = addon,
153162
addonCollectionProvider = addonCollectionProvider,
154-
onConfirmButtonClicked = { _, allowInPrivateBrowsing ->
155-
if (allowInPrivateBrowsing) {
156-
requireContext().components.addonManager.setAddonAllowedInPrivateBrowsing(
157-
addon,
158-
allowInPrivateBrowsing
159-
)
160-
}
161-
}
163+
onConfirmButtonClicked = onConfirmInstallationButtonClicked
162164
)
163165

164166
if (!isAlreadyADialogCreated() && fragmentManager != null) {
165167
dialog.show(requireFragmentManager(), INSTALLATION_DIALOG_FRAGMENT_TAG)
166168
}
167169
}
168170

169-
private val onPositiveButtonClicked: ((Addon) -> Unit) = { addon ->
171+
private val onConfirmInstallationButtonClicked: ((Addon, Boolean) -> Unit) = { addon, allowInPrivateBrowsing ->
172+
if (allowInPrivateBrowsing) {
173+
requireContext().components.addonManager.setAddonAllowedInPrivateBrowsing(
174+
addon,
175+
allowInPrivateBrowsing
176+
)
177+
}
178+
}
179+
180+
private val onConfirmPermissionButtonClicked: ((Addon) -> Unit) = { addon ->
170181
addonProgressOverlay.visibility = View.VISIBLE
171182
isInstallationInProgress = true
172183

173184
val installOperation = requireContext().components.addonManager.installAddon(
174185
addon,
175-
onSuccess = {
176-
adapter?.updateAddon(it)
177-
addonProgressOverlay.visibility = View.GONE
178-
isInstallationInProgress = false
179-
showInstallationDialog(it)
186+
onSuccess = { installedAddon ->
187+
context?.let {
188+
adapter?.updateAddon(installedAddon)
189+
addonProgressOverlay.visibility = View.GONE
190+
isInstallationInProgress = false
191+
showInstallationDialog(installedAddon)
192+
}
180193
},
181194
onError = { _, e ->
182195
// No need to display an error message if installation was cancelled by the user.

0 commit comments

Comments
 (0)