Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
32 commits
Select commit Hold shift + click to select a range
479cd19
Make LocalDraftUploadStarter a Singleton
shiki May 13, 2019
aa70205
Make LocalDraftUploadStarter a CoroutineScope
shiki May 13, 2019
6d70aed
LocalDraftUploadStarter: Add queueing methods
shiki May 13, 2019
4119440
Upload drafts from anywhere
shiki May 13, 2019
29e6f11
LocalDraftUploadStarter: Upload on foreground
shiki May 14, 2019
894563a
Revert PostListViewModel to extend ViewModel only
shiki May 14, 2019
71f9742
Fix PostListMainViewModelTest
shiki May 14, 2019
f8e6f92
Trigger upload drafts when post list is opened
shiki May 14, 2019
ffca6ef
LocalDraftUploadStarter: Query in parallel
shiki May 14, 2019
77e5a86
Add UploadServiceFacade for testability
shiki May 14, 2019
467e087
LocalDraftUploadStarter: Add job
shiki May 14, 2019
f5c491e
Add LocalDraftUploadStarterTest
shiki May 14, 2019
d3c1200
Add LiveData.skip()
shiki May 15, 2019
1bbc2a1
Fix queueUploadForAllSites called twice on boot
shiki May 15, 2019
cafe486
Clarify LocalDraftUploadStarter usage
shiki May 15, 2019
237a8ff
Rename LocalDraftUploadStarter
shiki May 15, 2019
52390a8
Fix LocalDraftUploadStarterTest failure
shiki May 15, 2019
b13c1ad
LocalDraftUploadStarterTest: Test app foreground
shiki May 15, 2019
4d6b93c
LocalDraftUploadStarterTest: Single-site uploading
shiki May 15, 2019
2bf479a
LocalDraftUploaderTest: Queueing validation
shiki May 15, 2019
f488e36
Remove unused imports
shiki May 15, 2019
1f2d489
Fix typo in LiveDataUtils.kt
shiki May 15, 2019
c8c90f3
Update 12.5 release notes for LocalDraftUploadStarter changes
shiki May 15, 2019
9d25687
Add injected Dispatchers.IO
shiki May 17, 2019
edf746a
Rename LocalDraftUploadStarter.queueUpload
shiki May 17, 2019
3c6705b
Simplify LiveData.skip()
shiki May 17, 2019
00ac385
Add comments
shiki May 17, 2019
3a11c0e
Add LocalDraftUploadStarterConcurrentTest
shiki May 20, 2019
2c6c2f2
Make LocalDraftUploadStarterTest deterministic
shiki May 20, 2019
3f384f1
Simplify LocalDraftUploadStarter.upload
shiki May 20, 2019
443666a
Add LocalDraftUploadStarter error handling
shiki May 20, 2019
3e675a7
LocalDraftUploadStarterConcurrentTest: Add comment
shiki May 21, 2019
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion RELEASE-NOTES.txt
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
12.5
-----
* Fixed local drafts not automatically pushed to the server.
* Fixed local drafts not automatically pushed to the server.
* Local Drafts will be automatically uploaded to the server as soon as internet connection is available.

12.4
-----
Expand Down
7 changes: 6 additions & 1 deletion WordPress/src/main/java/org/wordpress/android/WordPress.java
Original file line number Diff line number Diff line change
Expand Up @@ -74,13 +74,14 @@
import org.wordpress.android.ui.stats.StatsWidgetProvider;
import org.wordpress.android.ui.stats.datasets.StatsDatabaseHelper;
import org.wordpress.android.ui.stats.datasets.StatsTable;
import org.wordpress.android.ui.uploads.LocalDraftUploadStarter;
import org.wordpress.android.ui.uploads.UploadService;
import org.wordpress.android.util.CrashLoggingUtils;
import org.wordpress.android.util.AppLog;
import org.wordpress.android.util.AppLog.AppLogListener;
import org.wordpress.android.util.AppLog.LogLevel;
import org.wordpress.android.util.AppLog.T;
import org.wordpress.android.util.BitmapLruCache;
import org.wordpress.android.util.CrashLoggingUtils;
import org.wordpress.android.util.DateTimeUtils;
import org.wordpress.android.util.FluxCUtils;
import org.wordpress.android.util.LocaleManager;
Expand Down Expand Up @@ -141,6 +142,7 @@ public class WordPress extends MultiDexApplication implements HasServiceInjector
@Inject SiteStore mSiteStore;
@Inject MediaStore mMediaStore;
@Inject ZendeskHelper mZendeskHelper;
@Inject LocalDraftUploadStarter mLocalDraftUploadStarter;

@Inject @Named("custom-ssl") RequestQueue mRequestQueue;
public static RequestQueue sRequestQueue;
Expand Down Expand Up @@ -276,6 +278,9 @@ public void onLog(T tag, LogLevel logLevel, String message) {
mApplicationLifecycleMonitor = new ApplicationLifecycleMonitor();
ProcessLifecycleOwner.get().getLifecycle().addObserver(this);

// Make the UploadStarter observe the app process so it can auto-start uploads
mLocalDraftUploadStarter.activateAutoUploading((ProcessLifecycleOwner) ProcessLifecycleOwner.get());

initAnalytics(SystemClock.elapsedRealtime() - startDate);

createNotificationChannelsOnSdk26();
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ const val UI_SCOPE = "UI_SCOPE"
const val DEFAULT_SCOPE = "DEFAULT_SCOPE"
const val UI_THREAD = "UI_THREAD"
const val BG_THREAD = "BG_THREAD"
const val IO_THREAD = "IO_THREAD"

@Module
class ThreadModule {
Expand Down Expand Up @@ -39,6 +40,12 @@ class ThreadModule {
return Dispatchers.Default
}

@Provides
@Named(IO_THREAD)
fun provideIoDispatcher(): CoroutineDispatcher {
return Dispatchers.IO
}

@Provides
fun provideDebouncer(): Debouncer {
return Debouncer()
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,6 @@ import android.arch.lifecycle.LifecycleOwner
import android.arch.lifecycle.LifecycleRegistry
import android.arch.lifecycle.LiveData
import android.arch.lifecycle.MutableLiveData
import android.arch.lifecycle.Observer
import android.arch.lifecycle.ViewModel
import android.content.Intent
import kotlinx.coroutines.CoroutineDispatcher
Expand Down Expand Up @@ -44,7 +43,6 @@ import org.wordpress.android.util.NetworkUtilsWrapper
import org.wordpress.android.util.ToastUtils.Duration
import org.wordpress.android.util.analytics.AnalyticsUtils
import org.wordpress.android.viewmodel.SingleLiveEvent
import org.wordpress.android.viewmodel.helpers.ConnectionStatus
import org.wordpress.android.viewmodel.helpers.DialogHolder
import org.wordpress.android.viewmodel.helpers.ToastMessageHolder
import org.wordpress.android.viewmodel.posts.PostFetcher
Expand All @@ -68,11 +66,10 @@ class PostListMainViewModel @Inject constructor(
mediaStore: MediaStore,
private val networkUtilsWrapper: NetworkUtilsWrapper,
private val prefs: AppPrefsWrapper,
private val localDraftUploadStarter: LocalDraftUploadStarter,
private val connectionStatus: LiveData<ConnectionStatus>,
private val postListEventListenerFactory: PostListEventListener.Factory,
@Named(UI_THREAD) private val mainDispatcher: CoroutineDispatcher,
@Named(BG_THREAD) private val bgDispatcher: CoroutineDispatcher
@Named(BG_THREAD) private val bgDispatcher: CoroutineDispatcher,
private val localDraftUploadStarter: LocalDraftUploadStarter
) : ViewModel(), LifecycleOwner, CoroutineScope {
private val lifecycleRegistry = LifecycleRegistry(this)
override fun getLifecycle(): Lifecycle = lifecycleRegistry
Expand Down Expand Up @@ -217,9 +214,7 @@ class PostListMainViewModel @Inject constructor(
)
lifecycleRegistry.markState(Lifecycle.State.STARTED)

connectionStatus.observe(this, Observer {
localDraftUploadStarter.uploadLocalDrafts(scope = this@PostListMainViewModel, site = site)
})
localDraftUploadStarter.queueUploadFromSite(site)
}

override fun onCleared() {
Expand Down
Original file line number Diff line number Diff line change
@@ -1,41 +1,138 @@
package org.wordpress.android.ui.uploads

import android.arch.lifecycle.Lifecycle.Event
import android.arch.lifecycle.LifecycleObserver
import android.arch.lifecycle.LiveData
import android.arch.lifecycle.OnLifecycleEvent
import android.arch.lifecycle.ProcessLifecycleOwner
import android.content.Context
import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Job
import kotlinx.coroutines.coroutineScope
import kotlinx.coroutines.launch
import org.wordpress.android.fluxc.model.SiteModel
import org.wordpress.android.fluxc.store.PostStore
import org.wordpress.android.fluxc.store.SiteStore
import org.wordpress.android.modules.BG_THREAD
import org.wordpress.android.modules.IO_THREAD
import org.wordpress.android.util.CrashLoggingUtils
import org.wordpress.android.util.NetworkUtilsWrapper
import org.wordpress.android.util.skip
import org.wordpress.android.viewmodel.helpers.ConnectionStatus
import javax.inject.Inject
import javax.inject.Named
import javax.inject.Singleton
import kotlin.coroutines.CoroutineContext

/**
* Provides a way to find and upload all local drafts.
* Automatically uploads local drafts.
*
* Auto-uploads happen when the app is placed in the foreground or when the internet connection is restored. In
* addition to this, call sites can also request an immediate execution by calling [upload].
*
* The method [activateAutoUploading] must be called once, preferably during app creation, for the auto-uploads to work.
*/
@Singleton
class LocalDraftUploadStarter @Inject constructor(
/**
* The Application context
*/
private val context: Context,
private val postStore: PostStore,
private val siteStore: SiteStore,
@Named(BG_THREAD) private val bgDispatcher: CoroutineDispatcher,
@Named(IO_THREAD) private val ioDispatcher: CoroutineDispatcher,
private val uploadServiceFacade: UploadServiceFacade,
private val networkUtilsWrapper: NetworkUtilsWrapper,
private val connectionStatus: LiveData<ConnectionStatus>
) : CoroutineScope {
private val job = Job()

override val coroutineContext: CoroutineContext get() = job + bgDispatcher

/**
* The Coroutine dispatcher used for querying in FluxC.
* The hook for making this class automatically launch uploads whenever the app is placed in the foreground.
*/
@Named(BG_THREAD) private val bgDispatcher: CoroutineDispatcher,
private val networkUtilsWrapper: NetworkUtilsWrapper
) {
fun uploadLocalDrafts(scope: CoroutineScope, site: SiteModel) = scope.launch(bgDispatcher) {
private val processLifecycleObserver = object : LifecycleObserver {
@OnLifecycleEvent(Event.ON_START)
fun onAppComesFromBackground() {
queueUploadFromAllSites()
}
}

/**
* Activates the necessary observers for this class to start auto-uploading.
*
* This must be called during [org.wordpress.android.WordPress]' creation like so:
*
* ```
* mLocalDraftUploadStarter.activateAutoUploading(ProcessLifecycleOwner.get())
* ```
*/
fun activateAutoUploading(processLifecycleOwner: ProcessLifecycleOwner) {
// Since this class is meant to be a Singleton, it should be fine (I think) to use observeForever in here.
// We're skipping the first emitted value because the processLifecycleObserver below will also trigger an
// immediate upload.
connectionStatus.skip(1).observeForever {
queueUploadFromAllSites()
}

processLifecycleOwner.lifecycle.addObserver(processLifecycleObserver)
}

private fun queueUploadFromAllSites() = launch {
val sites = siteStore.sites
try {
checkConnectionAndUpload(sites = sites)
} catch (e: Exception) {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

How would you feel about catching a more specific exception type. I believe we should follow the Fail Fast principle and avoid hiding unexpected exceptions. Wdyt?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I initially thought of failing fast for this class. But realized that it's a class that will be executed on multiple scenarios like app start, app foreground, etc. If the crash is consistent, users would not be able to use the app anymore.

But if you feel that crashes are unlikely, we can remove the exception handling. I think they're unlikely but I opted to be on the safe side.

Copy link
Contributor Author

@shiki shiki May 21, 2019

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

As for the specific exception type handling, I only know of the documented @throws in context.startService. What exceptions should we expect from FluxC queries? I only saw RuntimeException from WellSql which is not as specific as I wanted it to be. 😬

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I initially thought of failing fast for this class. But realized that it's a class that will be executed on multiple scenarios like app start, app foreground, etc. If the crash is consistent, users would not be able to use the app anymore.

Good point 👍

As for the specific exception type handling, I only know of the documented @throws in context.startService. What exceptions should we expect from FluxC queries? In only saw RuntimeException from WellSql which is not as specific as I wanted it to be.

I'm a bit confused - this class doesn't perform any FluxC queries, right?

I think we can expect the documented @throws in context.startService and CancellationException. But I agree with your comment that we don't want to crash the app since this class will be started automatically on app-start, so we might actually want to keep the general "Exception" here.

CrashLoggingUtils.log(e)
}
}

/**
* Upload all local drafts from the given [site].
*/
fun queueUploadFromSite(site: SiteModel) = launch {
try {
checkConnectionAndUpload(sites = listOf(site))
} catch (e: Exception) {
CrashLoggingUtils.log(e)
}
}

/**
* If there is an internet connection, uploads all local drafts belonging to [sites].
*
* This coroutine will suspend until all the [upload] operations have completed. If one of them fails, all query
* and queuing attempts ([upload]) will be canceled. The exception will be thrown by this method.
*/
private suspend fun checkConnectionAndUpload(sites: List<SiteModel>) = coroutineScope {
if (!networkUtilsWrapper.isNetworkAvailable()) {
return@launch
return@coroutineScope
}

sites.forEach {
launch(ioDispatcher) {
upload(site = it)
}
}
}

/**
* This is meant to be used by [checkConnectionAndUpload] only.
*/
private fun upload(site: SiteModel) {
postStore.getLocalDraftPosts(site)
.filterNot { UploadService.isPostUploadingOrQueued(it) }
.filterNot { uploadServiceFacade.isPostUploadingOrQueued(it) }
.forEach { localDraft ->
val intent = UploadService.getUploadPostServiceIntent(context, localDraft, false, false, true)
context.startService(intent)
uploadServiceFacade.uploadPost(
context = context,
post = localDraft,
trackAnalytics = false,
publish = false,
isRetry = true
)
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
package org.wordpress.android.ui.uploads

import android.content.Context
import org.wordpress.android.fluxc.model.PostModel
import javax.inject.Inject

/**
* An injectable class built on top of [UploadService].
*
* The main purpose of this is to provide testability for classes that use [UploadService]. This should never
* contain any static methods.
*/
class UploadServiceFacade @Inject constructor() {
fun uploadPost(context: Context, post: PostModel, trackAnalytics: Boolean, publish: Boolean, isRetry: Boolean) {
val intent = UploadService.getUploadPostServiceIntent(context, post, trackAnalytics, publish, isRetry)
context.startService(intent)
}

fun isPostUploadingOrQueued(post: PostModel) = UploadService.isPostUploadingOrQueued(post)
}
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ package org.wordpress.android.util

import android.arch.lifecycle.LiveData
import android.arch.lifecycle.MediatorLiveData
import android.arch.lifecycle.Observer
import android.arch.lifecycle.Transformations
import kotlinx.coroutines.CoroutineScope
import org.wordpress.android.viewmodel.SingleMediatorLiveEvent
Expand Down Expand Up @@ -224,3 +225,33 @@ fun <T> LiveData<T>.filter(predicate: (T) -> Boolean): LiveData<T> {
}
return mediator
}

/**
* Suppresses the first n items by this [LiveData].
*
* Consider this for example:
*
* ```
* val connectionStatusLiveData = getConnectionStatusLiveData()
* connectionStatusLiveData.skip(1).observe(this, Observer {
* refresh()
* })
* ```
*
* The first value emitted by `connectionStatusLiveData` would be ignored and [Observer] will not be called.
*/
fun <T> LiveData<T>.skip(times: Int): LiveData<T> {
check(times > 0) { "The number of times to skip must be greater than 0" }

var skipped = 0
val mediator = MediatorLiveData<T>()
mediator.addSource(this) { value ->
skipped += 1

if (skipped > times) {
mediator.value = value
}
}

return mediator
}
Original file line number Diff line number Diff line change
Expand Up @@ -7,8 +7,8 @@ import android.arch.lifecycle.LifecycleRegistry
import android.arch.lifecycle.LiveData
import android.arch.lifecycle.MediatorLiveData
import android.arch.lifecycle.Observer
import android.arch.lifecycle.ViewModel
import android.arch.paging.PagedList
import kotlinx.coroutines.CoroutineDispatcher
import org.wordpress.android.fluxc.Dispatcher
import org.wordpress.android.fluxc.model.LocalOrRemoteId.LocalId
import org.wordpress.android.fluxc.model.PostModel
Expand All @@ -20,7 +20,6 @@ import org.wordpress.android.fluxc.model.list.PostListDescriptor.PostListDescrip
import org.wordpress.android.fluxc.store.AccountStore
import org.wordpress.android.fluxc.store.ListStore
import org.wordpress.android.fluxc.store.PostStore
import org.wordpress.android.modules.BG_THREAD
import org.wordpress.android.ui.posts.AuthorFilterSelection.EVERYONE
import org.wordpress.android.ui.posts.AuthorFilterSelection.ME
import org.wordpress.android.ui.posts.PostUtils
Expand All @@ -29,14 +28,12 @@ import org.wordpress.android.ui.uploads.LocalDraftUploadStarter
import org.wordpress.android.util.AppLog
import org.wordpress.android.util.NetworkUtilsWrapper
import org.wordpress.android.util.SiteUtils
import org.wordpress.android.viewmodel.ScopedViewModel
import org.wordpress.android.viewmodel.SingleLiveEvent
import org.wordpress.android.viewmodel.helpers.ConnectionStatus
import org.wordpress.android.viewmodel.posts.PostListEmptyUiState.RefreshError
import org.wordpress.android.viewmodel.posts.PostListItemIdentifier.LocalPostId
import org.wordpress.android.viewmodel.posts.PostListItemType.PostListItemUiState
import javax.inject.Inject
import javax.inject.Named

typealias PagedPostList = PagedList<PostListItemType>

Expand All @@ -49,9 +46,8 @@ class PostListViewModel @Inject constructor(
private val listItemUiStateHelper: PostListItemUiStateHelper,
private val networkUtilsWrapper: NetworkUtilsWrapper,
private val localDraftUploadStarter: LocalDraftUploadStarter,
connectionStatus: LiveData<ConnectionStatus>,
@Named(BG_THREAD) bgDispatcher: CoroutineDispatcher
) : ScopedViewModel(bgDispatcher), LifecycleOwner {
connectionStatus: LiveData<ConnectionStatus>
) : ViewModel(), LifecycleOwner {
private val isStatsSupported: Boolean by lazy {
SiteUtils.isAccessedViaWPComRest(connector.site) && connector.site.hasCapabilityViewStats
}
Expand Down Expand Up @@ -154,7 +150,7 @@ class PostListViewModel @Inject constructor(
// Public Methods

fun swipeToRefresh() {
localDraftUploadStarter.uploadLocalDrafts(scope = this, site = connector.site)
localDraftUploadStarter.queueUploadFromSite(connector.site)
fetchFirstPage()
}

Expand Down
Loading