diff --git a/app/build.gradle b/app/build.gradle index 2fd28dad..c64aa7c2 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -138,6 +138,9 @@ dependencies { implementation 'com.google.firebase:firebase-crashlytics-ktx' implementation 'com.google.firebase:firebase-messaging-ktx' + // Background work using WorkManager + implementation "androidx.work:work-runtime-ktx:2.5.0" // Kotlin + coroutines + // Leak analysis debugImplementation Libs.leakcanary_android diff --git a/app/src/main/java/com/laixer/swabbr/Modules.kt b/app/src/main/java/com/laixer/swabbr/Modules.kt index 7a94843d..ba7961c0 100644 --- a/app/src/main/java/com/laixer/swabbr/Modules.kt +++ b/app/src/main/java/com/laixer/swabbr/Modules.kt @@ -3,7 +3,6 @@ package com.laixer.swabbr import com.google.firebase.analytics.FirebaseAnalytics import com.google.firebase.crashlytics.FirebaseCrashlytics import com.google.firebase.messaging.FirebaseMessaging -import com.laixer.swabbr.utils.cache.Cache import com.laixer.swabbr.data.api.* import com.laixer.swabbr.data.cache.* import com.laixer.swabbr.data.datasource.* @@ -11,20 +10,19 @@ import com.laixer.swabbr.data.interfaces.* import com.laixer.swabbr.data.repository.* import com.laixer.swabbr.domain.interfaces.* import com.laixer.swabbr.domain.usecase.* -import com.laixer.swabbr.services.okhttp.AuthInterceptor import com.laixer.swabbr.presentation.auth.AuthViewModel -import com.laixer.swabbr.services.users.UserManager import com.laixer.swabbr.presentation.likeoverview.LikeOverviewViewModel import com.laixer.swabbr.presentation.profile.ProfileViewModel import com.laixer.swabbr.presentation.reaction.list.ReactionListViewModel import com.laixer.swabbr.presentation.reaction.playback.ReactionViewModel -import com.laixer.swabbr.presentation.reaction.recording.RecordReactionViewModel import com.laixer.swabbr.presentation.search.SearchViewModel import com.laixer.swabbr.presentation.vlogs.list.VlogListViewModel import com.laixer.swabbr.presentation.vlogs.playback.VlogViewModel -import com.laixer.swabbr.presentation.vlogs.recording.VlogRecordingViewModel import com.laixer.swabbr.services.moshi.buildWithCustomAdapters +import com.laixer.swabbr.services.okhttp.AuthInterceptor import com.laixer.swabbr.services.okhttp.CacheInterceptor +import com.laixer.swabbr.services.users.UserManager +import com.laixer.swabbr.utils.cache.Cache import com.squareup.moshi.Moshi import io.reactivex.schedulers.Schedulers import okhttp3.OkHttpClient @@ -59,6 +57,7 @@ private val loadFeature by lazy { ) } +// TODO This means our firebase instance is injectable! val firebaseModule: Module = module { single { FirebaseCrashlytics.getInstance() } single { FirebaseAnalytics.getInstance(androidContext()) } @@ -88,11 +87,9 @@ val viewModelModule: Module = module { } viewModel { VlogListViewModel(usersVlogsUseCase = get(), vlogUseCase = get()) } viewModel { VlogViewModel(authUserUseCase = get(), reactionsUseCase = get(), vlogUseCase = get()) } - viewModel { VlogRecordingViewModel(mHttpClient = get(), vlogUseCase = get()) } viewModel { SearchViewModel(usersUseCase = get(), followUseCase = get()) } viewModel { ReactionViewModel(reactionsUseCase = get()) } viewModel { ReactionListViewModel(reactionsUseCase = get()) } - viewModel { RecordReactionViewModel(mHttpClient = get(), reactionsUseCase = get()) } } val useCaseModule: Module = module { @@ -152,9 +149,14 @@ val networkModule: Module = module { .addInterceptor(get()) .addInterceptor(get()) .addNetworkInterceptor(HttpLoggingInterceptor().apply { - this.level = HttpLoggingInterceptor.Level.BASIC // TODO Put back + this.level = HttpLoggingInterceptor.Level.BASIC }) - .cache(okhttp3.Cache(File(androidContext().cacheDir, "http-cache"), 10 * 1024 * 1024)) // 10Mb cache TODO Do we want this? + .cache( + okhttp3.Cache( + File(androidContext().cacheDir, "http-cache"), + 10 * 1024 * 1024 + ) + ) // 10Mb cache TODO Do we want this? .connectTimeout(5, TimeUnit.SECONDS) // TODO Fix for production .readTimeout(300, TimeUnit.SECONDS) .callTimeout(300, TimeUnit.SECONDS) diff --git a/app/src/main/java/com/laixer/swabbr/presentation/auth/AuthViewModel.kt b/app/src/main/java/com/laixer/swabbr/presentation/auth/AuthViewModel.kt index 393a1476..f0fcfe9f 100644 --- a/app/src/main/java/com/laixer/swabbr/presentation/auth/AuthViewModel.kt +++ b/app/src/main/java/com/laixer/swabbr/presentation/auth/AuthViewModel.kt @@ -1,6 +1,8 @@ package com.laixer.swabbr.presentation.auth +import android.content.Context import androidx.lifecycle.MutableLiveData +import androidx.work.WorkManager import com.google.android.gms.tasks.Tasks.await import com.google.firebase.messaging.FirebaseMessaging import com.laixer.swabbr.utils.resources.Resource @@ -11,10 +13,13 @@ import com.laixer.swabbr.domain.usecase.AuthUseCase import com.laixer.swabbr.presentation.model.RegistrationItem import com.laixer.swabbr.presentation.model.mapToDomain import com.laixer.swabbr.presentation.abstraction.ViewModelBase +import com.laixer.swabbr.services.uploading.ReactionUploadWorker +import com.laixer.swabbr.services.uploading.VlogUploadWorker import com.laixer.swabbr.services.users.UserManager import io.reactivex.Single import io.reactivex.schedulers.Schedulers import java.util.* +import kotlin.coroutines.coroutineContext /** * View model for managing user login, logout and registration. @@ -122,13 +127,23 @@ open class AuthViewModel constructor( * Logs the user out. Note that this will also disable any * future notifications through firebase. */ - fun logout() = + fun logout(context: Context) { + // Scoped function to also cancel existing jobs. + fun onLogout() { + // First cancel, then logout. These jobs expect us to be logged in. + WorkManager.getInstance(context).cancelAllWorkByTag(ReactionUploadWorker.WORK_TAG) + WorkManager.getInstance(context).cancelAllWorkByTag(VlogUploadWorker.WORK_TAG) + + userManager.logout() + } + compositeDisposable.add(authUseCase .logout() .subscribeOn(Schedulers.io()) .subscribe( - { userManager.logout() }, - { userManager.logout() } + { onLogout() }, + { onLogout() } ) ) + } } diff --git a/app/src/main/java/com/laixer/swabbr/presentation/profile/ProfileDetailsFragment.kt b/app/src/main/java/com/laixer/swabbr/presentation/profile/ProfileDetailsFragment.kt index 3b452901..d5078b2d 100644 --- a/app/src/main/java/com/laixer/swabbr/presentation/profile/ProfileDetailsFragment.kt +++ b/app/src/main/java/com/laixer/swabbr/presentation/profile/ProfileDetailsFragment.kt @@ -158,7 +158,7 @@ class ProfileDetailsFragment(private val userId: UUID) : AuthFragment() { button_profile_details_save.setOnClickListener { confirmChanges() } button_profile_logout.setOnClickListener { // Perform logout operation which take us back to the login screen. - authVm.logout() + authVm.logout(requireContext()) } // Setup spinner values diff --git a/app/src/main/java/com/laixer/swabbr/presentation/reaction/recording/RecordReactionFragment.kt b/app/src/main/java/com/laixer/swabbr/presentation/reaction/recording/RecordReactionFragment.kt index c1956192..2cbcde3d 100644 --- a/app/src/main/java/com/laixer/swabbr/presentation/reaction/recording/RecordReactionFragment.kt +++ b/app/src/main/java/com/laixer/swabbr/presentation/reaction/recording/RecordReactionFragment.kt @@ -1,11 +1,13 @@ package com.laixer.swabbr.presentation.reaction.recording +import android.media.MediaScannerConnection import android.os.Bundle import androidx.lifecycle.lifecycleScope import androidx.navigation.fragment.navArgs import com.laixer.swabbr.extensions.goBack import com.laixer.swabbr.presentation.recording.RecordVideoWithPreviewFragment import com.laixer.swabbr.presentation.types.VideoRecordingState +import com.laixer.swabbr.services.uploading.ReactionUploadWorker import kotlinx.android.synthetic.main.fragment_record_video_minmax.* import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch @@ -13,12 +15,10 @@ import org.koin.androidx.viewmodel.ext.android.viewModel import java.time.Duration import java.util.* - /** * Fragment for recording a reaction. */ class RecordReactionFragment : RecordVideoWithPreviewFragment() { - private val vm: RecordReactionViewModel by viewModel() private val args: RecordReactionFragmentArgs by navArgs() private val targetVlogId: UUID by lazy { UUID.fromString(args.targetVlogId) } @@ -52,12 +52,25 @@ class RecordReactionFragment : RecordVideoWithPreviewFragment() { override fun onPreviewConfirmed() { super.onPreviewConfirmed() - // Dispatch the post reaction operation. - vm.postReaction( + // TODO This doesn't seem to do anything currently + // Broadcasts the media file to the rest of the system. + // Note that does not broadcast to the media gallery, + // which it should be doing. + MediaScannerConnection.scanFile( + requireContext(), + arrayOf(outputFile.absolutePath), + arrayOf(VIDEO_MIME_TYPE), + null + ) + + // FUTURE: Implement isPrivate + // Dispatch the uploading process + ReactionUploadWorker.enqueue( context = requireContext(), - isPrivate = false, + userId = getSelfId(), + videoFile = outputFile, targetVlogId = targetVlogId, - videoFile = outputFile + isPrivate = false ) // Go back, which should take us back to the vlog we are posting to. diff --git a/app/src/main/java/com/laixer/swabbr/presentation/reaction/recording/RecordReactionViewModel.kt b/app/src/main/java/com/laixer/swabbr/presentation/reaction/recording/RecordReactionViewModel.kt deleted file mode 100644 index 72bc9fcc..00000000 --- a/app/src/main/java/com/laixer/swabbr/presentation/reaction/recording/RecordReactionViewModel.kt +++ /dev/null @@ -1,83 +0,0 @@ -package com.laixer.swabbr.presentation.reaction.recording - -import android.content.Context -import android.util.Log -import androidx.core.net.toUri -import com.laixer.swabbr.domain.usecase.ReactionUseCase -import com.laixer.swabbr.presentation.model.ReactionItem -import com.laixer.swabbr.presentation.model.mapToDomain -import com.laixer.swabbr.presentation.recording.UploadVideoViewModel -import com.laixer.swabbr.utils.files.ThumbnailHelper -import com.laixer.swabbr.utils.media.MediaConstants -import io.reactivex.Completable -import io.reactivex.schedulers.Schedulers -import okhttp3.OkHttpClient -import java.io.File -import java.util.* - -// TODO Pretty much a duplicate of VlogRecordingViewModel. All todos are located there. -/** - * View model containing functionality for posting reactions. - */ -class RecordReactionViewModel constructor( - mHttpClient: OkHttpClient, - private val reactionsUseCase: ReactionUseCase -) : UploadVideoViewModel(mHttpClient) { - /** - * Uploads a [ReactionItem] including thumbnail and posts the - * reaction to the backend. - * - * @param context Caller context. - * @param videoFile Local stored video file. - * @param targetVlogId The vlog id we react to. - * @param isPrivate Accessibility of the video. - */ - fun postReaction( - context: Context, - videoFile: File, - targetVlogId: UUID, - isPrivate: Boolean - ) = compositeDisposable.add( - reactionsUseCase.generateUploadWrapper() - .map { uploadWrapper -> - Completable.fromCallable { - // First generate thumbnail, then upload - val thumbnailFile = ThumbnailHelper.createThumbnailFromVideoFile(context, videoFile) - - uploadFile( - context, - videoFile.toUri(), - uploadWrapper.videoUploadUri, - MediaConstants.VIDEO_MP4_MIME_TYPE - ) - uploadFile( - context, - thumbnailFile.toUri(), - uploadWrapper.thumbnailUploadUri, - MediaConstants.IMAGE_JPEG_MIME_TYPE - ) - } - .andThen( - reactionsUseCase.postReaction( - ReactionItem.createForPosting( - id = uploadWrapper.id, - targetVlogId = targetVlogId, - isPrivate = isPrivate - ).mapToDomain() - ) - ) - .subscribeOn(Schedulers.io()) - .subscribe( - { Log.d(TAG, "Reaction posted") }, - { Log.e(TAG, "Could not upload reaction. Message: ${it.message}") }) - } - .subscribeOn(Schedulers.io()) - .subscribe( - { Log.d(TAG, "Reaction wrapper created") }, - { Log.e(TAG, "Could not generate reaction upload wrapper. Message: ${it.message}") }) - ) - - companion object { - private val TAG = RecordReactionViewModel::class.java.simpleName - } -} diff --git a/app/src/main/java/com/laixer/swabbr/presentation/recording/RecordVideoFragment.kt b/app/src/main/java/com/laixer/swabbr/presentation/recording/RecordVideoFragment.kt index ce2c1740..ccbafb49 100644 --- a/app/src/main/java/com/laixer/swabbr/presentation/recording/RecordVideoFragment.kt +++ b/app/src/main/java/com/laixer/swabbr/presentation/recording/RecordVideoFragment.kt @@ -5,6 +5,7 @@ import android.content.Context import android.hardware.camera2.* import android.media.MediaRecorder import android.media.MediaScannerConnection +import android.net.Uri import android.os.Bundle import android.os.Handler import android.os.HandlerThread @@ -215,9 +216,7 @@ abstract class RecordVideoFragment : RecordVideoInnerMethods() { /** * Override this method to listen to [state] changes. */ - protected open fun onStateChanged(state: VideoRecordingState) { - Log.d(TAG, "State changed to ${state.value}") - } + protected open fun onStateChanged(state: VideoRecordingState) { } /** * Called when we can't obtain the required permissions for whatever reason. @@ -396,15 +395,6 @@ abstract class RecordVideoFragment : RecordVideoInnerMethods() { recorderSurface?.release() tryCleanupCameraResources() - // Broadcasts the media file to the rest of the system. - // Note that does not broadcast to the media gallery. - MediaScannerConnection.scanFile( - requireContext(), - arrayOf(outputFile.absolutePath), - arrayOf(VIDEO_MIME_TYPE), - null - ) - state.postValue(VideoRecordingState.DONE_RECORDING) } catch (e: Exception) { Log.e(TAG, "Error while trying to stop recording", e) @@ -521,9 +511,9 @@ abstract class RecordVideoFragment : RecordVideoInnerMethods() { // TODO Move to some config file // Video constants - private const val VIDEO_BASE_NAME = "video" - private const val VIDEO_MIME_TYPE = MediaConstants.VIDEO_MP4_MIME_TYPE - private val PREFERRED_VIDEO_SIZE = MediaConstants.SIZE_1080p - private val CAMERA_BEGIN_DIRECTION = CameraDirection.FRONT + internal const val VIDEO_BASE_NAME = "video" + internal const val VIDEO_MIME_TYPE = MediaConstants.VIDEO_MP4_MIME_TYPE + internal val PREFERRED_VIDEO_SIZE = MediaConstants.SIZE_1080p + internal val CAMERA_BEGIN_DIRECTION = CameraDirection.FRONT } } diff --git a/app/src/main/java/com/laixer/swabbr/presentation/recording/RecordVideoInnerMethods.kt b/app/src/main/java/com/laixer/swabbr/presentation/recording/RecordVideoInnerMethods.kt index e963e679..71148e47 100644 --- a/app/src/main/java/com/laixer/swabbr/presentation/recording/RecordVideoInnerMethods.kt +++ b/app/src/main/java/com/laixer/swabbr/presentation/recording/RecordVideoInnerMethods.kt @@ -45,7 +45,7 @@ abstract class RecordVideoInnerMethods : FixedOrientationFragment(ActivityInfo.S setVideoSource(MediaRecorder.VideoSource.SURFACE) setOutputFormat(MediaRecorder.OutputFormat.MPEG_4) setVideoFrameRate(30) - setVideoEncodingBitRate(5_000_000) + setVideoEncodingBitRate(3_000_000) setAudioEncodingBitRate(192_000) setVideoEncoder(MediaRecorder.VideoEncoder.H264) setAudioEncoder(MediaRecorder.AudioEncoder.AAC) diff --git a/app/src/main/java/com/laixer/swabbr/presentation/recording/UploadVideoViewModel.kt b/app/src/main/java/com/laixer/swabbr/presentation/recording/UploadVideoViewModel.kt deleted file mode 100644 index 6c36be9d..00000000 --- a/app/src/main/java/com/laixer/swabbr/presentation/recording/UploadVideoViewModel.kt +++ /dev/null @@ -1,132 +0,0 @@ -package com.laixer.swabbr.presentation.recording - -import android.content.Context -import android.net.Uri -import android.util.Log -import com.laixer.swabbr.presentation.abstraction.ViewModelBase -import okhttp3.MediaType -import okhttp3.OkHttpClient -import okhttp3.Request -import okhttp3.RequestBody -import java.io.BufferedInputStream -import java.math.RoundingMode -import java.util.* -import kotlin.math.ceil - -// TODO Refactor this by a lot. -/** - * View model containing functionality for uploading a video. - */ -open class UploadVideoViewModel constructor( - private val mHttpClient: OkHttpClient -) : ViewModelBase() { - // TODO Move upload functionality to a helper. - /** - * Uploads a file to an external uri. - * - * @param localVideoUri The local uri to the recorded video file. - * @param uploadUri The uri to which the video file should be uploaded. - * @param contentTypeString The MIME type. - */ - protected fun uploadFile(context: Context, localVideoUri: Uri, uploadUri: Uri, contentTypeString: String) { - context.contentResolver.openInputStream(localVideoUri)?.let { - val bis = BufferedInputStream(it) - val blockIds = emptyList().toMutableList() - - val contentType = MediaType.get(contentTypeString) - - var counter = 1 - val total = bis.available() - while (bis.available() > 0) { - val blockSize = 4 * 1024 * 1024 // 4 MB - val bufferLength = if (bis.available() > blockSize) blockSize else bis.available() - - val buffer = ByteArray(bufferLength) - bis.read(buffer, 0, buffer.size) - - val blockId = - Base64.getEncoder().encodeToString(("Block-${counter++}").toByteArray(Charsets.UTF_8)) - - val available = bis.available() - Log.d( - TAG, - "Uploading chunk $counter/${ - ceil( - total.toDouble().div(blockSize) - ).toInt() + 1 - } (${ - (available.toDouble() / 1_000_000).toBigDecimal() - .setScale(1, RoundingMode.HALF_EVEN) - }MB remaining)" - ) - - uploadBlock(uploadUri.toString(), buffer, blockId, contentType) - blockIds.add(blockId) - } - - commitBlockList(uploadUri.toString(), contentType, blockIds) - - bis.close() - it.close() - } - } - - /** - * Upload a block of bytes. - * - * @param baseUri The uri to upload to. - * @param blockContents The contents to upload. - * @param blockId The id of this block. - * @param contentType MIME type. - */ - private fun uploadBlock( - baseUri: String, - blockContents: ByteArray, - blockId: String, - contentType: MediaType - ) { - val body = RequestBody.create(contentType, blockContents) - - Log.d(TAG, body.toString()) - val uploadBlockUri = "$baseUri&comp=block&blockId=$blockId" - val request = Request.Builder() - .url(uploadBlockUri) - .put(body) - .addHeader("x-ms-version", X_MS_VERSION) - .addHeader("x-ms-blob-type", X_MS_BLOB_TYPE) - .addHeader("No-Authentication", true.toString()) - .build() - - mHttpClient.newCall(request).execute() - } - - private fun commitBlockList(baseUri: String, contentType: MediaType, blockIds: List) { - val blockIdsPayload = StringBuilder().apply { - append("") - for (blockId in blockIds) { - append("$blockId") - } - append("") - } - - Log.d(TAG, blockIdsPayload.toString()) - - val putBlockListUrl = "$baseUri&comp=blockList" - val body = RequestBody.create(contentType, blockIdsPayload.toString()) - - val request = Request.Builder() - .url(putBlockListUrl) - .put(body) - .addHeader("x-ms-version", X_MS_VERSION) - .addHeader("No-Authentication", true.toString()) - .build() - - mHttpClient.newCall(request).execute() - } - - companion object { - private val TAG = UploadVideoViewModel::class.java.simpleName - private const val X_MS_VERSION = "2019-02-02" - private const val X_MS_BLOB_TYPE = "BlockBlob" - } -} diff --git a/app/src/main/java/com/laixer/swabbr/presentation/utils/FixedOrientationFragment.kt b/app/src/main/java/com/laixer/swabbr/presentation/utils/FixedOrientationFragment.kt index f57484b4..fc43002e 100644 --- a/app/src/main/java/com/laixer/swabbr/presentation/utils/FixedOrientationFragment.kt +++ b/app/src/main/java/com/laixer/swabbr/presentation/utils/FixedOrientationFragment.kt @@ -4,6 +4,7 @@ import android.content.pm.ActivityInfo import android.os.Bundle import android.util.Log import androidx.fragment.app.Fragment +import com.laixer.swabbr.presentation.auth.AuthFragment // TODO Do we really want this to behave like this? Maybe not, maybe do this in the activity? ... /** @@ -13,7 +14,7 @@ import androidx.fragment.app.Fragment * @param orientation One of [ActivityInfo.SCREEN_ORIENTATION_PORTRAIT] * or [ActivityInfo.SCREEN_ORIENTATION_LANDSCAPE]. */ -abstract class FixedOrientationFragment(private val orientation: Int) : Fragment() { +abstract class FixedOrientationFragment(private val orientation: Int) : AuthFragment() { /** * This stores the original orientation before modifying it here. */ diff --git a/app/src/main/java/com/laixer/swabbr/presentation/vlogs/recording/RecordVlogFragment.kt b/app/src/main/java/com/laixer/swabbr/presentation/vlogs/recording/RecordVlogFragment.kt index fc1b5636..0708a7ec 100644 --- a/app/src/main/java/com/laixer/swabbr/presentation/vlogs/recording/RecordVlogFragment.kt +++ b/app/src/main/java/com/laixer/swabbr/presentation/vlogs/recording/RecordVlogFragment.kt @@ -1,8 +1,8 @@ package com.laixer.swabbr.presentation.vlogs.recording +import android.media.MediaScannerConnection import android.os.Bundle import android.os.CountDownTimer -import android.provider.MediaStore import android.view.LayoutInflater import android.view.View import android.view.ViewGroup @@ -14,6 +14,8 @@ import com.laixer.swabbr.presentation.recording.RecordVideoWithPreviewFragment import com.laixer.swabbr.presentation.types.VideoRecordingState import com.laixer.swabbr.presentation.utils.todosortme.gone import com.laixer.swabbr.presentation.utils.todosortme.visible +import com.laixer.swabbr.services.uploading.ReactionUploadWorker +import com.laixer.swabbr.services.uploading.VlogUploadWorker import kotlinx.android.synthetic.main.fragment_record_video_minmax.* import kotlinx.android.synthetic.main.fragment_record_vlog.* import kotlinx.coroutines.Dispatchers @@ -25,7 +27,6 @@ import java.time.Duration * Fragment for recording a vlog. */ class RecordVlogFragment : RecordVideoWithPreviewFragment() { - private val vm: VlogRecordingViewModel by viewModel() /** * Counter indicating how many retries we have had. @@ -85,11 +86,24 @@ class RecordVlogFragment : RecordVideoWithPreviewFragment() { override fun onPreviewConfirmed() { super.onPreviewConfirmed() - // Dispatch the post reaction operation. - vm.postVlog( + // TODO This doesn't seem to do anything currently + // Broadcasts the media file to the rest of the system. + // Note that does not broadcast to the media gallery, + // which it should be doing. + MediaScannerConnection.scanFile( + requireContext(), + arrayOf(outputFile.absolutePath), + arrayOf(VIDEO_MIME_TYPE), + null + ) + + // FUTURE: Implement isPrivate + // Dispatch the uploading process + VlogUploadWorker.enqueue( context = requireContext(), - isPrivate = false, - videoFile = outputFile + userId = getSelfId(), + videoFile = outputFile, + isPrivate = false ) // Go to our own vlogs. diff --git a/app/src/main/java/com/laixer/swabbr/presentation/vlogs/recording/RecordVlogFragmentNew.kt b/app/src/main/java/com/laixer/swabbr/presentation/vlogs/recording/RecordVlogFragmentNew.kt deleted file mode 100644 index 1dcc2780..00000000 --- a/app/src/main/java/com/laixer/swabbr/presentation/vlogs/recording/RecordVlogFragmentNew.kt +++ /dev/null @@ -1,31 +0,0 @@ -package com.laixer.swabbr.presentation.vlogs.recording - -import android.os.Bundle -import android.view.View -import com.laixer.swabbr.presentation.recording.RecordMinMaxVideoFragment -import java.time.Duration - -/** - * Used to record vlogs. - */ -class RecordVlogFragmentNew : RecordMinMaxVideoFragment() { - /** - * Assign constraints on creation. - */ - init { - setMinMaxDuration(vlogMinimumRecordingTime, vlogMaximumRecordingTime) - } - - companion object { - // TODO From config maybe? - /** - * Minimum vlog recording time. - */ - val vlogMinimumRecordingTime: Duration = Duration.ofSeconds(10) - - /** - * Maximum vlog recording time. - */ - val vlogMaximumRecordingTime: Duration = Duration.ofMinutes(10) - } -} diff --git a/app/src/main/java/com/laixer/swabbr/presentation/vlogs/recording/VlogRecordingViewModel.kt b/app/src/main/java/com/laixer/swabbr/presentation/vlogs/recording/VlogRecordingViewModel.kt deleted file mode 100644 index 6397fdcd..00000000 --- a/app/src/main/java/com/laixer/swabbr/presentation/vlogs/recording/VlogRecordingViewModel.kt +++ /dev/null @@ -1,84 +0,0 @@ -package com.laixer.swabbr.presentation.vlogs.recording - -import android.content.Context -import android.util.Log -import androidx.core.net.toUri -import com.laixer.swabbr.domain.usecase.VlogUseCase -import com.laixer.swabbr.presentation.model.ReactionItem -import com.laixer.swabbr.presentation.model.VlogItem -import com.laixer.swabbr.presentation.model.mapToDomain -import com.laixer.swabbr.presentation.reaction.recording.RecordReactionViewModel -import com.laixer.swabbr.presentation.recording.UploadVideoViewModel -import com.laixer.swabbr.utils.files.ThumbnailHelper -import com.laixer.swabbr.utils.media.MediaConstants -import io.reactivex.Completable -import io.reactivex.schedulers.Schedulers -import okhttp3.OkHttpClient -import java.io.File -import java.util.* - -// TODO Duplicate functionality with [RecordReactionViewModel]. Todos located here. -// TODO Refactor, https://github.com/Laixer/Swabbr-Android/issues/153 -/** - * View model containing functionality for recording vlogs. - * This includes uploading functionality. - */ -class VlogRecordingViewModel constructor( - mHttpClient: OkHttpClient, - private val vlogUseCase: VlogUseCase -) : UploadVideoViewModel(mHttpClient) { - /** - * Uploads a [VlogItem] including thumbnail and posts it to the backend. - * - * @param context Caller context. TODO Is this a resource leak? Is this the way to go? - * @param videoFile Local stored video file. - * @param isPrivate Accessibility of the video. - */ - fun postVlog( - context: Context, - videoFile: File, - isPrivate: Boolean - ) = compositeDisposable.add( - vlogUseCase.generateUploadWrapper() - .map { uploadWrapper -> - Completable.fromCallable { - // First generate thumbnail, then upload. - val thumbnailFile = ThumbnailHelper.createThumbnailFromVideoFile(context, videoFile) - - // TODO Mime types etc declared at multiple places. - uploadFile( - context, - videoFile.toUri(), - uploadWrapper.videoUploadUri, - MediaConstants.VIDEO_MP4_MIME_TYPE - ) - uploadFile( - context, - thumbnailFile.toUri(), - uploadWrapper.thumbnailUploadUri, - MediaConstants.IMAGE_JPEG_MIME_TYPE - ) - } - .andThen( - vlogUseCase.postVlog( - VlogItem.createForPosting( - id = uploadWrapper.id, - isPrivate = isPrivate - ).mapToDomain() - ) - ) - .subscribeOn(Schedulers.io()) - .subscribe({}, { - Log.e(TAG, "Could not upload vlog. Message: ${it.message}") - }) - } - .subscribeOn(Schedulers.io()) - .subscribe({ /* TODO Success feedback (if relevant after refactor)*/ }, { - Log.e(TAG, "Could not generate vlog upload wrapper. Message: ${it.message}") - }) - ) - - companion object { - private val TAG = VlogRecordingViewModel::class.java.simpleName - } -} diff --git a/app/src/main/java/com/laixer/swabbr/services/uploading/ReactionUploadWorker.kt b/app/src/main/java/com/laixer/swabbr/services/uploading/ReactionUploadWorker.kt new file mode 100644 index 00000000..be165cb5 --- /dev/null +++ b/app/src/main/java/com/laixer/swabbr/services/uploading/ReactionUploadWorker.kt @@ -0,0 +1,89 @@ +package com.laixer.swabbr.services.uploading + +import android.content.Context +import androidx.work.* +import com.laixer.swabbr.domain.model.UploadWrapper +import com.laixer.swabbr.domain.usecase.ReactionUseCase +import com.laixer.swabbr.presentation.model.ReactionItem +import com.laixer.swabbr.presentation.model.mapToDomain +import org.koin.core.inject +import java.io.File +import java.time.Duration +import java.util.* + +/** + * Worker for managing reaction uploads. + */ +class ReactionUploadWorker(appContext: Context, workerParameters: WorkerParameters) : + VideoUploadWorker(appContext, workerParameters) { + + private val reactionUseCase: ReactionUseCase by inject() + + /** + * Get a reaction upload wrapper. + */ + override fun getUploadWrapper(): UploadWrapper = reactionUseCase.generateUploadWrapper().blockingGet() + + // TODO This will never detect failure. + /** + * Post the reaction to the backend. + * + * @return Successful or not. + */ + override fun doAfterFilesUploaded(uploadWrapper: UploadWrapper): Boolean { + // TODO Do this check before upload. + val targetVlogId = UUID.fromString( + inputData.getString("targetVlogId") + ?: throw Exception("Input data did not contain target vlog id") + ) + val isPrivate = inputData.getBoolean("isPrivate", false) + + reactionUseCase.postReaction( + ReactionItem.createForPosting( + id = uploadWrapper.id, + targetVlogId = targetVlogId, + isPrivate = isPrivate + ).mapToDomain() + ).blockingAwait() + + return true + } + + companion object { + private val TAG = ReactionUploadWorker::class.java.simpleName + + /** + * Creates a new [WorkRequest] instance for this + * worker to execute and enqueues it right away. + */ + fun enqueue(context: Context, userId: UUID, videoFile: File, targetVlogId: UUID, isPrivate: Boolean) { + val uploadRequest: WorkRequest = OneTimeWorkRequestBuilder() + .addTag(WORK_TAG) + .setInputData( + workDataOf( + "userId" to userId.toString(), + "fileAbsolutePath" to videoFile.absolutePath, + "targetVlogId" to targetVlogId.toString(), + "isPrivate" to isPrivate + ) + ) + .build() + WorkManager.getInstance(context).enqueue(uploadRequest) + } + + /** + * Public tag for this kind of work. + */ + const val WORK_TAG = "UPLOAD_REACTION" + + /** + * Add the public/private flag to the input data using this key. + */ + const val KEY_IS_PRIVATE = "isPrivate" + + /** + * Add the target vlog id to the input data using this key. + */ + const val KEY_TARGET_VLOG_ID = "targetVlogId" + } +} diff --git a/app/src/main/java/com/laixer/swabbr/services/uploading/VideoUploadWorker.kt b/app/src/main/java/com/laixer/swabbr/services/uploading/VideoUploadWorker.kt new file mode 100644 index 00000000..d9b9acfe --- /dev/null +++ b/app/src/main/java/com/laixer/swabbr/services/uploading/VideoUploadWorker.kt @@ -0,0 +1,180 @@ +package com.laixer.swabbr.services.uploading + +import android.content.Context +import android.net.Uri +import android.util.Log +import androidx.work.Worker +import androidx.work.WorkerParameters +import com.laixer.swabbr.domain.model.UploadWrapper +import com.laixer.swabbr.services.users.UserManager +import com.laixer.swabbr.utils.files.ThumbnailHelper +import com.laixer.swabbr.utils.media.MediaConstants +import okhttp3.MediaType +import okhttp3.OkHttpClient +import okhttp3.Request +import okhttp3.RequestBody +import org.koin.core.KoinComponent +import org.koin.core.inject +import java.io.File +import java.util.* + +// TODO Always return failure instead of retry? +// TODO Cancellation (at logout) +/** + * Worker for managing content uploads. + */ +abstract class VideoUploadWorker(appContext: Context, workerParameters: WorkerParameters) : + Worker(appContext, workerParameters), KoinComponent { + + private val userManager: UserManager by inject() + + /** + * First validates if we should execute, then if we can execute. + * Then this calls [getUploadWrapper]. The video content is then + * uploaded, followed by a call to [doAfterFilesUploaded]. + * + * Do not override this method, implement [getUploadWrapper] and + * [doAfterFilesUploaded] instead. + */ + override fun doWork(): Result { + // Retry check + if (runAttemptCount >= MAX_RETRY_COUNT) { + return Result.failure() + } + + // First validate if we should execute this work in the first place. + if (userManager.getUserIdOrNull() == null) { + throw Exception("User isn't logged in - this should have been cancelled") + } + + val userId = UUID.fromString( + inputData.getString("userId") + ?: throw Exception("Input data did not contain user id") + ) + if (userId != userManager.getUserId()) { + throw Exception("Specified user id doesn't match current user") + } + + val fileAbsolutePath = inputData.getString("fileAbsolutePath") + ?: throw Exception("Input data did not contain file uri") + + val videoFile = File(fileAbsolutePath) + if (!videoFile.exists()) { + throw Exception("Video file does not exist") + } + + // TODO Dispatch on different thread? This is blocking now. + return try { + val uploadWrapper = getUploadWrapper() + + processAndUploadFiles(videoFile, uploadWrapper) + + doAfterFilesUploaded(uploadWrapper) + + Result.success() + + } catch (e: Exception) { + Log.e(TAG, "Exception in uploading process", e) + Result.retry() + } + } + + /** + * Override this to specify where we get our upload wrapper from. + */ + protected abstract fun getUploadWrapper(): UploadWrapper + + /** + * Extracts thumbnail, uploads files and returns. + * + * @return Successful or not. + */ + private fun processAndUploadFiles(videoFile: File, uploadWrapper: UploadWrapper): Boolean { + val thumbnailFile = ThumbnailHelper.createThumbnailFromVideoFile(applicationContext, videoFile) + + if (!uploadFile(videoFile, uploadWrapper.videoUploadUri, MediaConstants.VIDEO_MP4_MIME_TYPE)) { + return false + } + + if (!uploadFile(thumbnailFile, uploadWrapper.thumbnailUploadUri, MediaConstants.IMAGE_JPEG_MIME_TYPE)) { + return false + } + + return true + } + + /** + * Override this method to determine what should happen + * after the file upload has completed. + * + * @return Successful or not. + */ + protected abstract fun doAfterFilesUploaded(uploadWrapper: UploadWrapper): Boolean + + /** + * Uploads a file to an external uri. + * + * @param file The file to upload. + * @param uploadUri The uri to which the video file should be uploaded. + * @param contentTypeString The MIME type as string. + * + * @return Successful or not. + */ + private fun uploadFile(file: File, uploadUri: Uri, contentTypeString: String): Boolean { + try { + val contentType = MediaType.parse(contentTypeString) + if (contentType == null) { + Log.e(TAG, "Could not parse content type $contentTypeString") + return false + } + + // We only specify the actual content type here. All other + // content types reference to the multipart request type. + val body = RequestBody.create(contentType, file) + + val request = Request.Builder() + .url(uploadUri.toString()) + .put(body) + .addHeader("Content-Type", contentTypeString) + .build() + + val response = OkHttpClient.Builder() + .build() + .newCall(request) + .execute() + + if (!response.isSuccessful) { + val responseBody = response.body()?.string() + Log.e(TAG, "Upload failed. Response body: $responseBody") + return false + } else { + Log.d(TAG, "Upload succeeded for file ${file.absolutePath}") + } + + return true + + } catch (e: Exception) { + Log.e(TAG, "Exception in upload of file ${file.absolutePath}", e) + return false + } + } + + companion object { + private val TAG = VideoUploadWorker::class.java.simpleName + + /** + * Add the current user id to the input data using this key. + */ + const val KEY_USER_ID = "userId" + + /** + * Add the absolute file path to the input data using this key. + */ + const val KEY_FILE_ABSOLUTE_PATH = "fileAbsolutePath" + + /** + * Maximum retries for an upload. + */ + private const val MAX_RETRY_COUNT = 4 + } +} diff --git a/app/src/main/java/com/laixer/swabbr/services/uploading/VlogUploadWorker.kt b/app/src/main/java/com/laixer/swabbr/services/uploading/VlogUploadWorker.kt new file mode 100644 index 00000000..390cfa6c --- /dev/null +++ b/app/src/main/java/com/laixer/swabbr/services/uploading/VlogUploadWorker.kt @@ -0,0 +1,80 @@ +package com.laixer.swabbr.services.uploading + +import android.content.Context +import androidx.work.* +import com.laixer.swabbr.domain.model.UploadWrapper +import com.laixer.swabbr.domain.usecase.ReactionUseCase +import com.laixer.swabbr.domain.usecase.VlogUseCase +import com.laixer.swabbr.presentation.model.ReactionItem +import com.laixer.swabbr.presentation.model.VlogItem +import com.laixer.swabbr.presentation.model.mapToDomain +import org.koin.core.inject +import java.io.File +import java.time.Duration +import java.util.* + +/** + * Worker for managing vlog uploads. + */ +class VlogUploadWorker(appContext: Context, workerParameters: WorkerParameters) : + VideoUploadWorker(appContext, workerParameters) { + + private val vlogUseCase: VlogUseCase by inject() + + /** + * Get a vlog upload wrapper. + */ + override fun getUploadWrapper(): UploadWrapper = vlogUseCase.generateUploadWrapper().blockingGet() + + // TODO This will never detect failure. + /** + * Post the vlog to the backend. + * + * @return Successful or not. + */ + override fun doAfterFilesUploaded(uploadWrapper: UploadWrapper): Boolean { + // TODO Do this check before upload. + val isPrivate = inputData.getBoolean("isPrivate", false) + + vlogUseCase.postVlog( + VlogItem.createForPosting( + id = uploadWrapper.id, + isPrivate = isPrivate + ).mapToDomain() + ).blockingAwait() + + return true + } + + companion object { + private val TAG = VlogUploadWorker::class.java.simpleName + + /** + * Creates a new [WorkRequest] instance for this + * worker to execute and enqueues it right away. + */ + fun enqueue(context: Context, userId: UUID, videoFile: File, isPrivate: Boolean) { + val uploadRequest: WorkRequest = OneTimeWorkRequestBuilder() + .addTag(WORK_TAG) + .setInputData( + workDataOf( + "userId" to userId.toString(), + "fileAbsolutePath" to videoFile.absolutePath, + "isPrivate" to isPrivate + ) + ) + .build() + WorkManager.getInstance(context).enqueue(uploadRequest) + } + + /** + * Public tag for this kind of work. + */ + const val WORK_TAG = "UPLOAD_VLOG" + + /** + * Add the public/private flag to the input data using this key. + */ + const val KEY_IS_PRIVATE = "isPrivate" + } +} diff --git a/app/src/main/java/com/laixer/swabbr/services/users/UserManager.kt b/app/src/main/java/com/laixer/swabbr/services/users/UserManager.kt index f6f5be30..a18efad3 100644 --- a/app/src/main/java/com/laixer/swabbr/services/users/UserManager.kt +++ b/app/src/main/java/com/laixer/swabbr/services/users/UserManager.kt @@ -1,6 +1,7 @@ package com.laixer.swabbr.services.users import androidx.lifecycle.MutableLiveData +import androidx.work.WorkManager import com.auth0.android.jwt.JWT import com.laixer.swabbr.utils.cache.Cache import com.laixer.swabbr.utils.resources.Resource @@ -10,6 +11,7 @@ import java.time.ZoneId import java.time.ZonedDateTime import java.util.* +// TODO Should this be a background service? Maybe yes? // TODO Should this contain email? // TODO Invalidate should maybe also clear the refresh token? We should have // the ability to force a login by the user if refreshing also doesn't work. diff --git a/app/src/main/java/com/laixer/swabbr/utils/files/FileHelper.kt b/app/src/main/java/com/laixer/swabbr/utils/files/FileHelper.kt index 7ab5f467..58daf737 100644 --- a/app/src/main/java/com/laixer/swabbr/utils/files/FileHelper.kt +++ b/app/src/main/java/com/laixer/swabbr/utils/files/FileHelper.kt @@ -28,7 +28,7 @@ class FileHelper { val extension = MimeTypeMap.getSingleton().getExtensionFromMimeType(mimeType) ?: throw Exception("Could not find extension for mime type $mimeType") - val fileName = if (useTimeStamp) { + val fileName = if (!useTimeStamp) { "$name.$extension" } else { val sdf = SimpleDateFormat("yyyy_MM_dd_HH_mm_ss_SSS", Locale.ENGLISH).format(Date()) @@ -38,32 +38,6 @@ class FileHelper { return File(context.filesDir, fileName) } - /** - * Write a bitmap to an existing file. - * - * @return True if successful. - */ - fun writeBitmapToFile( - bitmap: Bitmap, - file: File, - compressFormat: Bitmap.CompressFormat = Bitmap.CompressFormat.JPEG, - quality: Int = 100 - ): Boolean { - return try { - val os = FileOutputStream(file) - bitmap.compress(compressFormat, quality, os) - os.flush() - os.close() - - true - } catch (e: Exception) { - Log.e(TAG, "Couldn't write bitmap to file $file") - - false - } - } - - // Log tag. private val TAG = FileHelper::class.java.simpleName } } diff --git a/app/src/main/java/com/laixer/swabbr/utils/files/ThumbnailHelper.kt b/app/src/main/java/com/laixer/swabbr/utils/files/ThumbnailHelper.kt index 3065d7ea..6ac88181 100644 --- a/app/src/main/java/com/laixer/swabbr/utils/files/ThumbnailHelper.kt +++ b/app/src/main/java/com/laixer/swabbr/utils/files/ThumbnailHelper.kt @@ -20,6 +20,7 @@ class ThumbnailHelper { fun createThumbnailFromVideoFile(context: Context, videoFile: File, size: Size = DEFAULT_SIZE): File { // TODO Use cancellation for timeout? val cancellationSignal = CancellationSignal() + val thumbnail = ThumbnailUtils.createVideoThumbnail(videoFile, size, cancellationSignal) val thumbnailFile = FileHelper.createFile(context, FILE_NAME_BASE, DEFAULT_MIME_TYPE, true)