Skip to content

Commit 0946db5

Browse files
authored
Fix crash when the Prepublishing sheet is restored without a post (#23148)
* Fix crash when the Prepublishing sheet is restored without a post The sheet is a DialogFragment, so the FragmentManager restores it after a config change or process death regardless of whether the host activity has loaded a post, and PrepublishingHomeFragment then built its UI against an empty EditPostRepository. Dismiss the sheet instead, and stop EditPostRepository.status from laundering a null post through the unannotated PostStatus.fromPost. Fixes JETPACK-ANDROID-1EVE * Move the no-post guard up to PrepublishingViewModel The guard added in the previous commit only covered the HOME screen, but a restored sheet navigates to whatever screen was saved in KEY_SCREEN_STATE. On a restore into TAGS, CATEGORIES or PUBLISH the sheet stayed open over an empty EditPostRepository and still crashed on the first interaction, via requireNotNull(post) in updateAsync. Guarding in PrepublishingViewModel.start() instead covers every screen the sheet can restore into, and reuses the existing dismissBottomSheet channel, so the dismissSheet LiveData and the parentFragment cast are no longer needed. The PrepublishingHomeViewModel early return stays as a last line of defense, since the dismissal is asynchronous and the FragmentManager can restore that fragment before it commits. Also reverts the EditPostRepository.status leniency: with the guard in place its only reader is unreachable with a null post, so returning UNKNOWN just hid the missing post from the other callers. * Rebind the EditPostRepository when the posts list is recreated PostsListActivity has no configChanges, so it is recreated on rotation with a freshly injected EditPostRepository, while PostListMainViewModel survives. Its start() returned early on isStarted before assigning the repository and before loading the post into it, so after any rotation the ViewModel kept writing to the previous activity's dead repository while the restored Prepublishing sheet read the new empty one. That is the state the sheet guard was catching: publishing stayed broken for the rest of the activity's life, silently once the sheet began dismissing itself instead of crashing. Rebind on every start instead, and keep currentBottomSheetPostId in sync so the id onSaveInstanceState reads survives a second restore. Also makes PrepublishingViewModel.start()'s hasPost parameter required, so the guard can't be bypassed by omission, and corrects the comment: the guard dismisses on every restore path but does not stop the saved child fragment from being restored and started first, since the dismissal is async.
1 parent 3ab17e4 commit 0946db5

8 files changed

Lines changed: 146 additions & 31 deletions

File tree

WordPress/src/main/java/org/wordpress/android/ui/posts/EditPostRepository.kt

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -73,7 +73,7 @@ class EditPostRepository
7373
val password: String
7474
get() = post!!.password
7575
val status: PostStatus
76-
get() = fromPost(getPost())
76+
get() = fromPost(post!!)
7777
val isPage: Boolean
7878
get() = post!!.isPage
7979
val isLocalDraft: Boolean

WordPress/src/main/java/org/wordpress/android/ui/posts/PostListMainViewModel.kt

Lines changed: 28 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -244,9 +244,14 @@ class PostListMainViewModel @Inject constructor(
244244
currentBottomSheetPostId: LocalId,
245245
editPostRepository: EditPostRepository
246246
) {
247+
// The activity is recreated on rotation with a freshly injected EditPostRepository while this
248+
// ViewModel survives, so rebind on every start rather than only the first. Otherwise the
249+
// restored Prepublishing sheet reads an empty repository and this ViewModel goes on writing
250+
// to the previous activity's, which breaks publishing for the rest of the activity's life.
251+
bindEditPostRepository(editPostRepository, site, currentBottomSheetPostId)
252+
247253
if (isStarted) return
248254
this.site = site
249-
this.editPostRepository = editPostRepository
250255

251256
val authorFilterSelection: AuthorFilterSelection = if (isFilteringByAuthorSupported) {
252257
prefs.postListAuthorSelection
@@ -289,23 +294,34 @@ class PostListMainViewModel @Inject constructor(
289294
)
290295
_previewState.value = _previewState.value ?: initPreviewState
291296

292-
currentBottomSheetPostId.let { postId ->
293-
if (postId.value != 0) {
294-
editPostRepository.loadPostByLocalPostId(postId.value)
295-
}
296-
}
297-
298297
lifecycleOwner.lifecycleRegistry.currentState = Lifecycle.State.STARTED
299298

300299
uploadStarter.queueUploadFromSite(site)
301300

302-
editPostRepository.run {
303-
postChanged.observe(lifecycleOwner, Observer {
304-
savePostToDbUseCase.savePostToDb(editPostRepository, site)
305-
})
301+
isStarted = true
302+
}
303+
304+
private fun bindEditPostRepository(
305+
repository: EditPostRepository,
306+
site: SiteModel,
307+
bottomSheetPostId: LocalId
308+
) {
309+
if (this::editPostRepository.isInitialized) {
310+
if (this.editPostRepository === repository) return
311+
// drop the previous activity's repository, or its observer would pin it for our lifetime
312+
this.editPostRepository.postChanged.removeObservers(lifecycleOwner)
313+
}
314+
this.editPostRepository = repository
315+
316+
if (bottomSheetPostId.value != 0) {
317+
// keep the field in sync so onSaveInstanceState can round-trip it again
318+
currentBottomSheetPostId = bottomSheetPostId
319+
repository.loadPostByLocalPostId(bottomSheetPostId.value)
306320
}
307321

308-
isStarted = true
322+
repository.postChanged.observe(lifecycleOwner, Observer {
323+
savePostToDbUseCase.savePostToDb(repository, site)
324+
})
309325
}
310326

311327
override fun onCleared() {

WordPress/src/main/java/org/wordpress/android/ui/posts/prepublishing/PrepublishingBottomSheetFragment.kt

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -191,7 +191,7 @@ class PrepublishingBottomSheetFragment : WPBottomSheetDialogFragment(),
191191
KEY_SCREEN_STATE
192192
)
193193
val site = requireNotNull(arguments?.getSerializableCompat<SiteModel>(SITE))
194-
viewModel.start(site, prepublishingScreenState)
194+
viewModel.start(site, prepublishingScreenState, getEditorHook().editPostRepository.hasPost())
195195
}
196196

197197
private fun navigateToScreen(navigationTarget: PrepublishingNavigationTarget) {

WordPress/src/main/java/org/wordpress/android/ui/posts/prepublishing/PrepublishingViewModel.kt

Lines changed: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -62,10 +62,23 @@ class PrepublishingViewModel @Inject constructor(private val dispatcher: Dispatc
6262

6363
fun start(
6464
site: SiteModel,
65-
currentScreenFromSavedState: PrepublishingScreen?
65+
currentScreenFromSavedState: PrepublishingScreen?,
66+
hasPost: Boolean
6667
) {
6768
this.site = site
6869

70+
// The sheet is a DialogFragment, so the FragmentManager restores it after a config change or
71+
// process death even when the host hasn't loaded a post. Every restore passes through here,
72+
// whatever screen was saved in KEY_SCREEN_STATE, so this dismisses the sheet in all of them.
73+
// Note it does not stop the saved child fragment from being restored and started first: the
74+
// dismissal is async. The non-HOME screens tolerate an empty repository at start time, and
75+
// HOME has its own early return. A new screen that reads the post at start needs one too.
76+
if (!hasPost) {
77+
AppLog.e(T.POSTS, "Prepublishing sheet started without a post; dismissing.")
78+
_dismissBottomSheet.postValue(Event(Unit))
79+
return
80+
}
81+
6982
// Set screen: use saved state if available (config change), otherwise reset to HOME (dismissal + reopen)
7083
val targetScreen = currentScreenFromSavedState ?: HOME
7184
this.currentScreen = targetScreen

WordPress/src/main/java/org/wordpress/android/ui/posts/prepublishing/home/PrepublishingHomeViewModel.kt

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@ import org.wordpress.android.ui.posts.prepublishing.home.usecases.GetButtonUiSta
2323
import org.wordpress.android.ui.posts.trackPrepublishingNudges
2424
import org.wordpress.android.ui.utils.UiString.UiStringRes
2525
import org.wordpress.android.ui.utils.UiString.UiStringText
26+
import org.wordpress.android.util.AppLog
2627
import org.wordpress.android.util.StringUtils
2728
import org.wordpress.android.util.analytics.AnalyticsTrackerWrapper
2829
import org.wordpress.android.util.merge
@@ -68,6 +69,13 @@ class PrepublishingHomeViewModel @Inject constructor(
6869
fun start(editPostRepository: EditPostRepository, site: SiteModel) {
6970
this.editPostRepository = editPostRepository
7071
if (isStarted) return
72+
// PrepublishingViewModel already dismisses the sheet when the host has no post, but that
73+
// dismissal is asynchronous and the FragmentManager can restore this fragment and create its
74+
// view before it commits. Skip the UI rather than reading a post that isn't there.
75+
if (!editPostRepository.hasPost()) {
76+
AppLog.e(AppLog.T.POSTS, "Prepublishing home started without a post; skipping setup.")
77+
return
78+
}
7179
isStarted = true
7280

7381
setupHomeUiState(editPostRepository, site)

WordPress/src/test/java/org/wordpress/android/ui/posts/PostListMainViewModelTest.kt

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -159,6 +159,33 @@ class PostListMainViewModelTest : BaseUnitTest() {
159159
verify(editPostRepository, times(0)).loadPostByLocalPostId(any())
160160
}
161161

162+
@Test
163+
fun `when the activity is recreated, then the new EditPostRepository is rebound and reloaded`() {
164+
// arrange - the activity is recreated on rotation with a freshly injected repository
165+
val bottomSheetPostId = LocalId(2)
166+
viewModel.start(site, PostListRemotePreviewState.NONE, bottomSheetPostId, editPostRepository)
167+
val recreatedRepository = mock<EditPostRepository>()
168+
whenever(recreatedRepository.postChanged).thenReturn(MutableLiveData(Event(PostModel())))
169+
170+
// act - start() runs again on the surviving ViewModel
171+
viewModel.start(site, PostListRemotePreviewState.NONE, bottomSheetPostId, recreatedRepository)
172+
173+
// assert - the second repository must be loaded too, or the restored sheet reads an empty one
174+
verify(recreatedRepository, times(1)).loadPostByLocalPostId(bottomSheetPostId.value)
175+
}
176+
177+
@Test
178+
fun `given a restored bottom sheet post id, when started, then the id is kept for the next save`() {
179+
// arrange
180+
val bottomSheetPostId = LocalId(2)
181+
182+
// act
183+
viewModel.start(site, PostListRemotePreviewState.NONE, bottomSheetPostId, editPostRepository)
184+
185+
// assert - onSaveInstanceState reads this field, so it has to survive a restore round-trip
186+
assertThat(viewModel.currentBottomSheetPostId).isEqualTo(bottomSheetPostId)
187+
}
188+
162189
@Test
163190
fun `if post in EditPostRepository is modified then the savePostToDbUseCase should update the post`() {
164191
// arrange

WordPress/src/test/java/org/wordpress/android/ui/posts/PrepublishingHomeViewModelTest.kt

Lines changed: 32 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -71,6 +71,7 @@ class PrepublishingHomeViewModelTest : BaseUnitTest() {
7171
).doAnswer {
7272
PublishButtonUiState(it.arguments[2] as (PublishPost) -> Unit)
7373
}
74+
whenever(editPostRepository.hasPost()).thenReturn(true)
7475
whenever(editPostRepository.getEditablePost()).thenReturn(PostModel())
7576
whenever(postSettingsUtils.getPublishDateLabel(any())).thenReturn((""))
7677
whenever(site.name).thenReturn("")
@@ -86,7 +87,7 @@ class PrepublishingHomeViewModelTest : BaseUnitTest() {
8687
val expectedActionsAmount = 3
8788

8889
// act
89-
viewModel.start(mock(), site)
90+
viewModel.start(editPostRepository, site)
9091

9192
// assert
9293
assertThat(viewModel.uiState.value?.filterIsInstance(HomeUiState::class.java)?.size).isEqualTo(
@@ -163,7 +164,7 @@ class PrepublishingHomeViewModelTest : BaseUnitTest() {
163164
val expectedActionsAmount = 1
164165

165166
// act
166-
viewModel.start(mock(), site)
167+
viewModel.start(editPostRepository, site)
167168

168169
// assert
169170
assertThat(viewModel.uiState.value?.filterIsInstance(HeaderUiState::class.java)?.size).isEqualTo(
@@ -177,7 +178,7 @@ class PrepublishingHomeViewModelTest : BaseUnitTest() {
177178
val expectedActionsAmount = 1
178179

179180
// act
180-
viewModel.start(mock(), site)
181+
viewModel.start(editPostRepository, site)
181182

182183
// assert
183184
assertThat(viewModel.uiState.value?.filterIsInstance(ButtonUiState::class.java)?.size).isEqualTo(
@@ -191,7 +192,7 @@ class PrepublishingHomeViewModelTest : BaseUnitTest() {
191192
val expectedActionType = PrepublishingScreenNavigation.Publish
192193

193194
// act
194-
viewModel.start(mock(), site)
195+
viewModel.start(editPostRepository, site)
195196
val publishAction = getHomeUiState(expectedActionType)
196197
publishAction?.onNavigationActionClicked?.invoke(expectedActionType)
197198

@@ -205,7 +206,7 @@ class PrepublishingHomeViewModelTest : BaseUnitTest() {
205206
val expectedActionType = PrepublishingScreenNavigation.Tags
206207

207208
// act
208-
viewModel.start(mock(), site)
209+
viewModel.start(editPostRepository, site)
209210
val tagsAction = getHomeUiState(expectedActionType)
210211
tagsAction?.onNavigationActionClicked?.invoke(expectedActionType)
211212

@@ -397,6 +398,32 @@ class PrepublishingHomeViewModelTest : BaseUnitTest() {
397398
assertThat(uiSocialState).isEqualTo(SocialUiState.Hidden)
398399
}
399400

401+
@Test
402+
fun `given a repository with no post, when the viewModel is started, then no ui state is built`() {
403+
// arrange
404+
whenever(editPostRepository.hasPost()).thenReturn(false)
405+
406+
// act
407+
viewModel.start(editPostRepository, site)
408+
409+
// assert
410+
assertThat(viewModel.uiState.value).isNull()
411+
}
412+
413+
@Test
414+
fun `given a start with no post, when started again once the post loads, then the ui state is built`() {
415+
// arrange
416+
whenever(editPostRepository.hasPost()).thenReturn(false)
417+
viewModel.start(editPostRepository, site)
418+
419+
// act - the guard must not latch isStarted, or the sheet would stay empty forever
420+
whenever(editPostRepository.hasPost()).thenReturn(true)
421+
viewModel.start(editPostRepository, site)
422+
423+
// assert
424+
assertThat(viewModel.uiState.value).isNotNull
425+
}
426+
400427
private fun getHeaderUiState() = viewModel.uiState.value?.filterIsInstance(HeaderUiState::class.java)?.first()
401428

402429
private fun getButtonUiState(): ButtonUiState? {

WordPress/src/test/java/org/wordpress/android/ui/posts/prepublishing/PrepublishingViewModelTest.kt

Lines changed: 35 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -35,7 +35,7 @@ class PrepublishingViewModelTest : BaseUnitTest() {
3535
event = it
3636
}
3737

38-
viewModel.start(mock(), null)
38+
viewModel.start(mock(), null, hasPost = true)
3939

4040
assertThat(event?.peekContent()?.targetScreen).isEqualTo(expectedScreen)
4141
}
@@ -49,11 +49,35 @@ class PrepublishingViewModelTest : BaseUnitTest() {
4949
event = it
5050
}
5151

52-
viewModel.start(mock(), expectedScreen)
52+
viewModel.start(mock(), expectedScreen, hasPost = true)
5353

5454
assertThat(event?.peekContent()?.targetScreen).isEqualTo(expectedScreen)
5555
}
5656

57+
@Test
58+
fun `when viewModel start is called without a post, dismiss the sheet instead of navigating`() {
59+
var dismissEvent: Event<Unit>? = null
60+
var navigationEvent: Event<PrepublishingNavigationTarget>? = null
61+
viewModel.dismissBottomSheet.observeForever { dismissEvent = it }
62+
viewModel.navigationTarget.observeForever { navigationEvent = it }
63+
64+
// TAGS rather than HOME because a restored sheet can land on any screen
65+
viewModel.start(mock(), TAGS, hasPost = false)
66+
67+
assertThat(dismissEvent).isNotNull
68+
assertThat(navigationEvent).isNull()
69+
}
70+
71+
@Test
72+
fun `when viewModel start is called with a post, don't dismiss the sheet`() {
73+
var dismissEvent: Event<Unit>? = null
74+
viewModel.dismissBottomSheet.observeForever { dismissEvent = it }
75+
76+
viewModel.start(mock(), TAGS, hasPost = true)
77+
78+
assertThat(dismissEvent).isNull()
79+
}
80+
5781
@Test
5882
fun `when onBackClicked is pressed and currentScreen isn't HOME, navigate to HOME`() {
5983
val expectedScreen = HOME
@@ -63,7 +87,7 @@ class PrepublishingViewModelTest : BaseUnitTest() {
6387
event = it
6488
}
6589

66-
viewModel.start(mock(), TAGS)
90+
viewModel.start(mock(), TAGS, hasPost = true)
6791
viewModel.onBackClicked()
6892

6993
assertThat(event?.peekContent()?.targetScreen).isEqualTo(expectedScreen)
@@ -76,7 +100,7 @@ class PrepublishingViewModelTest : BaseUnitTest() {
76100
event = it
77101
}
78102

79-
viewModel.start(mock(), HOME)
103+
viewModel.start(mock(), HOME, hasPost = true)
80104
viewModel.onBackClicked()
81105

82106
assertThat(event).isNotNull
@@ -103,7 +127,7 @@ class PrepublishingViewModelTest : BaseUnitTest() {
103127
event = it
104128
}
105129

106-
viewModel.start(mock(), mock())
130+
viewModel.start(mock(), mock(), hasPost = true)
107131
viewModel.onActionClicked(PrepublishingScreenNavigation.Tags)
108132

109133
assertThat(event?.peekContent()?.targetScreen).isEqualTo(expectedScreen)
@@ -118,7 +142,7 @@ class PrepublishingViewModelTest : BaseUnitTest() {
118142
event = it
119143
}
120144

121-
viewModel.start(mock(), mock())
145+
viewModel.start(mock(), mock(), hasPost = true)
122146
viewModel.onActionClicked(PrepublishingScreenNavigation.Publish)
123147

124148
assertThat(event?.peekContent()?.targetScreen).isEqualTo(expectedScreen)
@@ -133,7 +157,7 @@ class PrepublishingViewModelTest : BaseUnitTest() {
133157
event = it
134158
}
135159

136-
viewModel.start(mock(), mock())
160+
viewModel.start(mock(), mock(), hasPost = true)
137161
viewModel.onActionClicked(PrepublishingScreenNavigation.Categories)
138162

139163
assertThat(event?.peekContent()?.targetScreen).isEqualTo(expectedScreen)
@@ -148,7 +172,7 @@ class PrepublishingViewModelTest : BaseUnitTest() {
148172
event = it
149173
}
150174

151-
viewModel.start(mock(), mock())
175+
viewModel.start(mock(), mock(), hasPost = true)
152176
viewModel.onActionClicked(PrepublishingScreenNavigation.AddCategory)
153177

154178
assertThat(event?.peekContent()?.targetScreen).isEqualTo(expectedScreen)
@@ -163,7 +187,7 @@ class PrepublishingViewModelTest : BaseUnitTest() {
163187
event = it
164188
}
165189

166-
viewModel.start(mock(), mock())
190+
viewModel.start(mock(), mock(), hasPost = true)
167191
viewModel.onActionClicked(PrepublishingScreenNavigation.Social)
168192

169193
assertThat(event?.peekContent()?.targetScreen).isEqualTo(expectedScreen)
@@ -177,15 +201,15 @@ class PrepublishingViewModelTest : BaseUnitTest() {
177201
event = it
178202
}
179203

180-
viewModel.start(mockSite, mock())
204+
viewModel.start(mockSite, mock(), hasPost = true)
181205
viewModel.onActionClicked(Action.NavigateToSharingSettings)
182206

183207
assertThat(event?.peekContent()).isEqualTo(mockSite)
184208
}
185209

186210
@Test
187211
fun `when onSubmitButtonClicked is triggered then bottom sheet should close and listener is triggered`() {
188-
viewModel.start(mock(), mock())
212+
viewModel.start(mock(), mock(), hasPost = true)
189213

190214
viewModel.onSubmitButtonClicked(true)
191215

0 commit comments

Comments
 (0)