diff --git a/app/src/main/java/org/zotero/android/screens/share/ShareErrorProcessor.kt b/app/src/main/java/org/zotero/android/screens/share/ShareErrorProcessor.kt new file mode 100644 index 00000000..73b89e09 --- /dev/null +++ b/app/src/main/java/org/zotero/android/screens/share/ShareErrorProcessor.kt @@ -0,0 +1,187 @@ +package org.zotero.android.screens.share + +import android.content.Context +import org.zotero.android.BuildConfig +import org.zotero.android.api.network.CustomResult +import org.zotero.android.database.DbWrapper +import org.zotero.android.database.requests.ReadGroupDbRequest +import org.zotero.android.sync.LibraryIdentifier +import org.zotero.android.sync.Parsing +import org.zotero.android.sync.SchemaError +import org.zotero.android.translator.data.AttachmentState +import org.zotero.android.translator.data.TranslationWebViewError +import org.zotero.android.uicomponents.Strings +import timber.log.Timber +import javax.inject.Inject +import javax.inject.Singleton + +@Singleton +class ShareErrorProcessor @Inject constructor( + private val context: Context, + private val dbWrapper: DbWrapper +) { + fun errorMessage(error: AttachmentState.Error): String? { + return when (error) { + AttachmentState.Error.apiFailure -> { + context.getString(Strings.errors_shareext_api_error) + } + + AttachmentState.Error.cantLoadSchema -> { + context.getString(Strings.errors_shareext_cant_load_schema) + } + + AttachmentState.Error.cantLoadWebData -> { + context.getString(Strings.errors_shareext_cant_load_data) + } + + AttachmentState.Error.downloadFailed -> { + context.getString(Strings.errors_shareext_download_failed) + } + + AttachmentState.Error.downloadedFileNotPdf -> { + null + } + + AttachmentState.Error.expired -> { + context.getString(Strings.errors_shareext_unknown) + } + + AttachmentState.Error.fileMissing -> { + context.getString(Strings.errors_shareext_missing_file) + } + + AttachmentState.Error.itemsNotFound -> { + context.getString(Strings.errors_shareext_items_not_found) + } + + AttachmentState.Error.md5Missing -> { + null + } + + AttachmentState.Error.mtimeMissing -> { + null + } + + is AttachmentState.Error.parseError -> { + context.getString(Strings.errors_shareext_parsing_error) + } + + is AttachmentState.Error.quotaLimit -> { + when (error.libraryIdentifier) { + is LibraryIdentifier.custom -> { + context.getString(Strings.errors_shareext_personal_quota_reached) + } + + is LibraryIdentifier.group -> { + val groupId = error.libraryIdentifier.groupId + val group = + dbWrapper.realmDbStorage.perform(ReadGroupDbRequest(identifier = groupId)) + val groupName = group?.name ?: "$groupId" + return context.getString( + Strings.errors_shareext_group_quota_reached, + groupName + ) + } + } + } + + is AttachmentState.Error.schemaError -> { + context.getString(Strings.errors_shareext_schema_error) + } + + AttachmentState.Error.unknown -> { + context.getString(Strings.errors_shareext_unknown) + } + + AttachmentState.Error.webDavFailure -> { + context.getString(Strings.errors_shareext_webdav_error) + } + + AttachmentState.Error.webDavNotVerified -> { + context.getString(Strings.errors_shareext_webdav_not_verified) + } + + is AttachmentState.Error.webViewError -> { + return when (error.error) { + TranslationWebViewError.cantFindFile -> { + context.getString(Strings.errors_shareext_missing_base_files) + } + + TranslationWebViewError.incompatibleItem -> { + context.getString(Strings.errors_shareext_incompatible_item) + } + + TranslationWebViewError.javascriptCallMissingResult -> { + context.getString(Strings.errors_shareext_javascript_failed) + } + + TranslationWebViewError.noSuccessfulTranslators -> { + null + } + + TranslationWebViewError.webExtractionMissingData -> { + context.getString(Strings.errors_shareext_response_missing_data) + } + + TranslationWebViewError.webExtractionMissingJs -> { + context.getString(Strings.errors_shareext_missing_base_files) + } + } + } + } + } + + fun attachmentError( + generalError: CustomResult.GeneralError, + libraryId: LibraryIdentifier? + ): AttachmentState.Error { + when (generalError) { + is CustomResult.GeneralError.CodeError -> { + val error = generalError.throwable + if (error is AttachmentState.Error) { + return error + } + if (error is Parsing.Error) { + Timber.e(error, "ExtensionViewModel: could not parse item") + return AttachmentState.Error.parseError(error) + } + + if (error is SchemaError) { + Timber.e(error, "ExtensionViewModel: schema failed") + return AttachmentState.Error.schemaError(error) + } + if (error is TranslationWebViewError) { + return AttachmentState.Error.webViewError(error) + } + } + + is CustomResult.GeneralError.NetworkError -> { + return networkErrorRequiresAbort( + error = generalError, + url = BuildConfig.BASE_API_URL, + libraryId = libraryId + ) + } + } + return AttachmentState.Error.unknown + } + + private fun networkErrorRequiresAbort( + error: CustomResult.GeneralError.NetworkError, + url: String?, + libraryId: LibraryIdentifier? + ): AttachmentState.Error { + val defaultError = if ((url ?: "").contains(BuildConfig.BASE_API_URL)) { + AttachmentState.Error.apiFailure + } else { + AttachmentState.Error.webDavFailure + } + + val code = error.httpCode + if (code == 413 && libraryId != null) { + return AttachmentState.Error.quotaLimit(libraryId) + } + return defaultError + } + +} \ No newline at end of file diff --git a/app/src/main/java/org/zotero/android/screens/share/ShareFileDownloader.kt b/app/src/main/java/org/zotero/android/screens/share/ShareFileDownloader.kt new file mode 100644 index 00000000..9b92430e --- /dev/null +++ b/app/src/main/java/org/zotero/android/screens/share/ShareFileDownloader.kt @@ -0,0 +1,89 @@ +package org.zotero.android.screens.share + +import com.google.common.io.ByteProcessor +import com.google.common.io.ByteStreams +import com.google.common.io.Closeables +import org.zotero.android.api.NoAuthenticationApi +import org.zotero.android.api.network.CustomResult +import org.zotero.android.api.network.safeApiCall +import timber.log.Timber +import java.io.File +import java.io.FileOutputStream +import java.io.IOException +import javax.inject.Inject +import javax.inject.Singleton + +@Singleton +class ShareFileDownloader @Inject constructor( + private val noAuthenticationApi: NoAuthenticationApi +) { + + suspend fun download( + url: String, + file: File, + cookies: String?, + userAgent: String?, + referrer: String?, + updateProgressBar: (progress: Int) -> Unit, + ) { + val headers: MutableMap = LinkedHashMap() + if (userAgent != null) { + headers["User-Agent"] = userAgent + } + if (referrer != null) { + headers["Referer"] = referrer + } + if (cookies != null) { + headers["Cookie"] = cookies + } + val networkResult = safeApiCall { + noAuthenticationApi.downloadFileStreaming(url = url, headers = headers) + } + when (networkResult) { + is CustomResult.GeneralSuccess -> { + val byteStream = networkResult.value!!.byteStream() + val total = networkResult.value!!.contentLength() + var progress = 0L + val out = FileOutputStream(file); + try { + ByteStreams.readBytes(byteStream, + object : ByteProcessor { + @Throws(IOException::class) + override fun processBytes( + buffer: ByteArray, + offset: Int, + length: Int + ): Boolean { + out.write(buffer, offset, length) + progress += length + val progressResult = (progress / total.toDouble() * 100).toInt() + if (progressResult > 0) { + println() + } + updateProgressBar(progressResult) + return true + } + + override fun getResult(): Void? { + return null + } + }) + } catch (e: Exception) { + Timber.e(e, "Could not download $url") + throw e + } finally { + Closeables.close(out, true) + } + } + + is CustomResult.GeneralError.CodeError -> { + throw networkResult.throwable + } + + is CustomResult.GeneralError.NetworkError -> { + throw Exception(networkResult.stringResponse) + } + } + } + +} \ No newline at end of file diff --git a/app/src/main/java/org/zotero/android/screens/share/ShareItemSubmitter.kt b/app/src/main/java/org/zotero/android/screens/share/ShareItemSubmitter.kt new file mode 100644 index 00000000..8e071fc1 --- /dev/null +++ b/app/src/main/java/org/zotero/android/screens/share/ShareItemSubmitter.kt @@ -0,0 +1,400 @@ +package org.zotero.android.screens.share + +import org.zotero.android.api.network.CustomResult +import org.zotero.android.api.pojo.sync.ItemResponse +import org.zotero.android.api.pojo.sync.TagResponse +import org.zotero.android.backgrounduploader.BackgroundUpload +import org.zotero.android.database.DbWrapper +import org.zotero.android.database.objects.Attachment +import org.zotero.android.database.objects.FieldKeys +import org.zotero.android.database.objects.ItemTypes +import org.zotero.android.database.requests.CreateAttachmentDbRequest +import org.zotero.android.database.requests.CreateBackendItemDbRequest +import org.zotero.android.database.requests.CreateItemWithAttachmentDbRequest +import org.zotero.android.database.requests.MarkAttachmentUploadedDbRequest +import org.zotero.android.database.requests.UpdateCollectionLastUsedDbRequest +import org.zotero.android.database.requests.UpdateVersionType +import org.zotero.android.database.requests.UpdateVersionsDbRequest +import org.zotero.android.database.requests.key +import org.zotero.android.files.FileStore +import org.zotero.android.screens.share.backgroundprocessor.BackgroundUploadProcessor +import org.zotero.android.screens.share.data.CreateItemsResult +import org.zotero.android.screens.share.data.CreateResult +import org.zotero.android.screens.share.data.UploadData +import org.zotero.android.screens.share.sharecollectionpicker.data.ShareSubmissionData +import org.zotero.android.sync.DateParser +import org.zotero.android.sync.LibraryIdentifier +import org.zotero.android.sync.SchemaController +import org.zotero.android.sync.SyncObject +import org.zotero.android.sync.syncactions.AuthorizeUploadSyncAction +import org.zotero.android.sync.syncactions.SubmitUpdateSyncAction +import org.zotero.android.sync.syncactions.data.AuthorizeUploadResponse +import org.zotero.android.translator.data.AttachmentState +import timber.log.Timber +import java.io.File +import java.util.Date +import javax.inject.Inject +import javax.inject.Singleton + +@Singleton +class ShareItemSubmitter @Inject constructor( + private val dbWrapper: DbWrapper, + private val schemaController: SchemaController, + private val dateParser: DateParser, + private val fileStore: FileStore, + private val backgroundUploadProcessor: BackgroundUploadProcessor, +) { + + fun createItem( + item: ItemResponse, + libraryId: LibraryIdentifier, + schemaController: SchemaController, + dateParser: DateParser + ): Pair, Map>> { + var changeUuids: MutableMap> = mutableMapOf() + var parameters: MutableMap = mutableMapOf() + dbWrapper.realmDbStorage.perform { coordinator -> + val collectionKey = item.collectionKeys.firstOrNull() + if (collectionKey != null) { + coordinator.perform( + request = UpdateCollectionLastUsedDbRequest( + key = collectionKey, + libraryId = libraryId + ) + ) + } + + val request = CreateBackendItemDbRequest( + item = item, + schemaController = schemaController, + dateParser = dateParser + ) + val item = coordinator.perform(request = request) + parameters = item.updateParameters?.toMutableMap() ?: mutableMapOf() + changeUuids = mutableMapOf(item.key to item.changes.map { it.identifier }) + + coordinator.invalidate() + } + + return parameters to changeUuids + } + + private fun createItems(item: ItemResponse, attachment: Attachment): CreateItemsResult { + Timber.i("ShareViewModel: create item and attachment db items") + val parameters: MutableList> = mutableListOf() + var changeUuids: MutableMap> = mutableMapOf() + var mtime: Long? = null + var md5: String? = null + dbWrapper.realmDbStorage.perform { coordinator -> + val collectionKey = item.collectionKeys.firstOrNull() + if (collectionKey != null) { + coordinator.perform( + request = UpdateCollectionLastUsedDbRequest( + key = collectionKey, + libraryId = attachment.libraryId + ) + ) + } + val request = CreateItemWithAttachmentDbRequest( + item = item, + attachment = attachment, + schemaController = this.schemaController, + dateParser = this.dateParser, + fileStore = this.fileStore + ) + val (item, attachment) = coordinator.perform(request = request) + val itemUpdateParameters = item.updateParameters + if (itemUpdateParameters != null) { + parameters.add(itemUpdateParameters) + } + val updateParameters = attachment.updateParameters + if (updateParameters != null) { + parameters.add(updateParameters) + } + changeUuids = mutableMapOf(item.key to item.changes.map { it.identifier }, + attachment.key to attachment.changes.map { it.identifier }) + + mtime = attachment.fields.where().key(FieldKeys.Item.Attachment.mtime) + .findFirst()?.value?.toLongOrNull() + md5 = attachment.fields.where().key(FieldKeys.Item.Attachment.md5).findFirst()?.value + + coordinator.invalidate() + } + if (mtime == null) { + throw AttachmentState.Error.mtimeMissing + } + if (md5 == null) { + throw AttachmentState.Error.md5Missing + } + return CreateItemsResult(parameters, changeUuids, md5!!, mtime!!) + } + + fun create( + attachment: Attachment, + collections: Set, + tags: List + ): CreateResult { + Timber.i("Create attachment db item") + + val localizedType = + this.schemaController.localizedItemType(itemType = ItemTypes.attachment) ?: "" + + var updateParameters: Map? = null + var changeUuids: Map>? = null + var md5: String? = null + var mtime: Long? = null + + dbWrapper.realmDbStorage.perform { coordinator -> + val collectionKey = collections.firstOrNull() + if (collectionKey != null) { + coordinator.perform( + request = UpdateCollectionLastUsedDbRequest( + key = collectionKey, + libraryId = attachment.libraryId + ) + ) + } + + val request = CreateAttachmentDbRequest( + attachment = attachment, + parentKey = null, + localizedType = localizedType, + includeAccessDate = attachment.hasUrl, + collections = collections, + tags = tags, + fileStore = fileStore + ) + val attachment = coordinator.perform(request = request) + + updateParameters = attachment.updateParameters?.toMutableMap() + changeUuids = mutableMapOf(attachment.key to attachment.changes.map { it.identifier }) + mtime = attachment.fields.where().key(FieldKeys.Item.Attachment.mtime) + .findFirst()?.value?.toLongOrNull() + md5 = attachment.fields.where().key(FieldKeys.Item.Attachment.md5).findFirst()?.value + + coordinator.invalidate() + } + + mtime ?: throw AttachmentState.Error.mtimeMissing + md5 ?: throw AttachmentState.Error.md5Missing + return CreateResult( + updateParameters = updateParameters ?: emptyMap(), + changeUuids = changeUuids ?: emptyMap(), + md5 = md5!!, + mtime = mtime!! + ) + } + + private suspend fun prepareAndSubmit( + attachment: Attachment, + collections: Set, + tags: List, + file: File, + tmpFile: File, + libraryId: LibraryIdentifier, + userId: Long, + ): CustomResult { + val filesize = moveFile(tmpFile, file) + val data: ShareSubmissionData + val parameters: Map + val changeUuids: Map> + try { + val (params, uuids, md5, mtime) = create( + attachment = attachment, + collections = collections, + tags = tags + ) + parameters = params + changeUuids = uuids + data = ShareSubmissionData(filesize = filesize, md5 = md5, mtime = mtime) + } catch (e: Exception) { + file.delete() + return CustomResult.GeneralError.CodeError(e) + } + val result = SubmitUpdateSyncAction( + parameters = listOf(parameters), + changeUuids = changeUuids, + sinceVersion = null, + objectS = SyncObject.item, + libraryId = libraryId, + userId = userId, + updateLibraryVersion = false + ).result() + if (result is CustomResult.GeneralSuccess) { + return CustomResult.GeneralSuccess(data) + } + return result as CustomResult.GeneralError + } + + private suspend fun prepareAndSubmit( + item: ItemResponse, + attachment: Attachment, + file: File, + tmpFile: File, + libraryId: LibraryIdentifier, + userId: Long, + ): CustomResult { + val filesize = moveFile(tmpFile, file) + val data: ShareSubmissionData + val parameters: List> + val changeUuids: Map> + try { + val (params, uuids, md5, mtime) = createItems(item = item, attachment = attachment) + parameters = params + changeUuids = uuids + data = ShareSubmissionData(filesize = filesize, md5 = md5, mtime = mtime) + } catch (e: Exception) { + file.delete() + return CustomResult.GeneralError.CodeError(e) + } + + val result = SubmitUpdateSyncAction( + parameters = parameters, + changeUuids = changeUuids, + sinceVersion = null, + objectS = SyncObject.item, + libraryId = libraryId, + userId = userId, + updateLibraryVersion = false + ).result() + if (result is CustomResult.GeneralSuccess) { + return CustomResult.GeneralSuccess(data) + } + return result as CustomResult.GeneralError + } + + private suspend fun submit(data: UploadData): CustomResult { + when (val type = data.type) { + is UploadData.Kind.file -> { + val location = type.location + val collections = type.collections + val tags = type.tags + Timber.i("ShareViewModel: prepare upload for local file") + return prepareAndSubmit( + attachment = data.attachment, + collections = collections, + tags = tags, + file = data.file, + tmpFile = location, + libraryId = data.libraryId, + userId = data.userId, + ) + } + + is UploadData.Kind.translated -> { + val item = type.item + val location = type.location + Timber.i("ShareViewModel: prepare upload for local file") + return prepareAndSubmit( + item = item, + attachment = data.attachment, + file = data.file, + tmpFile = location, + libraryId = data.libraryId, + userId = data.userId + ) + } + } + } + + private fun moveFile(fromFile: File, toFile: File): Long { + Timber.i("ShareViewModel: move file to attachment folder") + + try { + val size = fromFile.length() + if (size == 0L) { + throw AttachmentState.Error.fileMissing + } + if (fromFile.renameTo(toFile)) { + return size + } else { + throw AttachmentState.Error.fileMissing + } + } catch (error: Exception) { + Timber.e(error, "ShareViewModel: can't move file") + fromFile.delete() + throw error + } + } + + suspend fun uploadToZotero( + data: UploadData, + attachmentKey: String, + defaultMimetype: String, + processUploadToZoteroException: ( + error: CustomResult.GeneralError, + data: UploadData + ) -> Unit, + onBack: () -> Unit, + ) { + try { + val submissionDataResult = submit(data = data) + if (submissionDataResult is CustomResult.GeneralError) { + processUploadToZoteroException(submissionDataResult, data) + return + } + val submissionData = (submissionDataResult as CustomResult.GeneralSuccess).value!! + val uploadSyncResult = AuthorizeUploadSyncAction( + key = data.attachment.key, + filename = data.filename, + filesize = submissionData.filesize, + md5 = submissionData.md5, + mtime = submissionData.mtime, + libraryId = data.libraryId, + userId = data.userId, + oldMd5 = null + ).result() + if (uploadSyncResult is CustomResult.GeneralError) { + processUploadToZoteroException(uploadSyncResult, data) + return + } + uploadSyncResult as CustomResult.GeneralSuccess + val response = uploadSyncResult.value!! + val md5 = submissionData.md5 + when (response) { + is AuthorizeUploadResponse.exists -> { + Timber.i("ShareViewModel: file exists remotely") + val request = MarkAttachmentUploadedDbRequest( + libraryId = data.libraryId, + key = data.attachment.key, + version = response.version + ) + val request2 = UpdateVersionsDbRequest( + version = response.version, + libraryId = data.libraryId, + type = UpdateVersionType.objectS(SyncObject.item) + ) + dbWrapper.realmDbStorage.perform(listOf(request, request2)) + } + + is AuthorizeUploadResponse.new -> { + val response = response.authorizeNewUploadResponse + Timber.i("ShareViewModel: upload authorized") + + val upload = BackgroundUpload( + type = BackgroundUpload.Kind.zotero(uploadKey = response.uploadKey), + key = attachmentKey, + libraryId = data.libraryId, + userId = data.userId, + remoteUrl = response.url, + fileUrl = data.file, + md5 = md5, + date = Date() + ) + backgroundUploadProcessor.startAsync( + upload = upload, + filename = data.filename, + mimeType = defaultMimetype, + parameters = response.params, + headers = mapOf("If-None-Match" to "*") + ) + + } + } + onBack() + } catch (e: Exception) { + Timber.e(e, "Could not submit item or attachment") + processUploadToZoteroException(CustomResult.GeneralError.CodeError(e), data) + } + } +} \ No newline at end of file diff --git a/app/src/main/java/org/zotero/android/screens/share/ShareViewModel.kt b/app/src/main/java/org/zotero/android/screens/share/ShareViewModel.kt index 13fc332d..d00a9171 100644 --- a/app/src/main/java/org/zotero/android/screens/share/ShareViewModel.kt +++ b/app/src/main/java/org/zotero/android/screens/share/ShareViewModel.kt @@ -1,13 +1,9 @@ package org.zotero.android.screens.share -import android.content.Context import android.net.Uri import android.webkit.MimeTypeMap import androidx.core.net.toUri import androidx.lifecycle.viewModelScope -import com.google.common.io.ByteProcessor -import com.google.common.io.ByteStreams -import com.google.common.io.Closeables import com.google.gson.JsonArray import com.google.gson.JsonObject import dagger.hilt.android.lifecycle.HiltViewModel @@ -19,13 +15,10 @@ import kotlinx.coroutines.launch import org.greenrobot.eventbus.EventBus import org.greenrobot.eventbus.Subscribe import org.greenrobot.eventbus.ThreadMode -import org.zotero.android.BuildConfig -import org.zotero.android.api.NoAuthenticationApi import org.zotero.android.api.mappers.CreatorResponseMapper import org.zotero.android.api.mappers.ItemResponseMapper import org.zotero.android.api.mappers.TagResponseMapper import org.zotero.android.api.network.CustomResult -import org.zotero.android.api.network.safeApiCall import org.zotero.android.api.pojo.sync.ItemResponse import org.zotero.android.api.pojo.sync.KeyBaseKeyPair import org.zotero.android.api.pojo.sync.LibraryResponse @@ -37,27 +30,16 @@ import org.zotero.android.architecture.ScreenArguments import org.zotero.android.architecture.ViewEffect import org.zotero.android.architecture.ViewState import org.zotero.android.architecture.coroutines.Dispatchers -import org.zotero.android.backgrounduploader.BackgroundUpload import org.zotero.android.database.DbWrapper import org.zotero.android.database.objects.Attachment import org.zotero.android.database.objects.FieldKeys import org.zotero.android.database.objects.ItemTypes import org.zotero.android.database.objects.RCustomLibraryType -import org.zotero.android.database.requests.CreateAttachmentDbRequest -import org.zotero.android.database.requests.CreateBackendItemDbRequest -import org.zotero.android.database.requests.CreateItemWithAttachmentDbRequest -import org.zotero.android.database.requests.MarkAttachmentUploadedDbRequest import org.zotero.android.database.requests.ReadCollectionAndLibraryDbRequest -import org.zotero.android.database.requests.ReadGroupDbRequest import org.zotero.android.database.requests.ReadRecentCollections -import org.zotero.android.database.requests.UpdateCollectionLastUsedDbRequest -import org.zotero.android.database.requests.UpdateVersionType -import org.zotero.android.database.requests.UpdateVersionsDbRequest -import org.zotero.android.database.requests.key import org.zotero.android.files.FileStore import org.zotero.android.helpers.GetUriDetailsUseCase import org.zotero.android.helpers.formatter.iso8601DateFormatV2 -import org.zotero.android.screens.share.backgroundprocessor.BackgroundUploadProcessor import org.zotero.android.screens.share.data.CollectionPickerState import org.zotero.android.screens.share.data.ItemPickerState import org.zotero.android.screens.share.data.ProcessedAttachment @@ -65,7 +47,6 @@ import org.zotero.android.screens.share.data.RecentData import org.zotero.android.screens.share.data.UploadData import org.zotero.android.screens.share.sharecollectionpicker.data.ShareCollectionPickerArgs import org.zotero.android.screens.share.sharecollectionpicker.data.ShareCollectionPickerResults -import org.zotero.android.screens.share.sharecollectionpicker.data.ShareSubmissionData import org.zotero.android.screens.tagpicker.data.TagPickerArgs import org.zotero.android.screens.tagpicker.data.TagPickerResult import org.zotero.android.sync.Collection @@ -75,29 +56,21 @@ import org.zotero.android.sync.KeyGenerator import org.zotero.android.sync.Libraries import org.zotero.android.sync.Library import org.zotero.android.sync.LibraryIdentifier -import org.zotero.android.sync.Parsing import org.zotero.android.sync.SchemaController -import org.zotero.android.sync.SchemaError import org.zotero.android.sync.SyncKind import org.zotero.android.sync.SyncObject import org.zotero.android.sync.SyncObservableEventStream import org.zotero.android.sync.SyncScheduler import org.zotero.android.sync.Tag -import org.zotero.android.sync.syncactions.AuthorizeUploadSyncAction import org.zotero.android.sync.syncactions.SubmitUpdateSyncAction -import org.zotero.android.sync.syncactions.data.AuthorizeUploadResponse import org.zotero.android.translator.data.AttachmentState import org.zotero.android.translator.data.RawAttachment -import org.zotero.android.translator.data.TranslationWebViewError import org.zotero.android.translator.data.TranslatorAction import org.zotero.android.translator.data.TranslatorActionEventStream import org.zotero.android.translator.web.TranslatorWebCallChainExecutor import org.zotero.android.translator.web.TranslatorWebExtractionExecutor -import org.zotero.android.uicomponents.Strings import timber.log.Timber import java.io.File -import java.io.FileOutputStream -import java.io.IOException import java.util.Date import javax.inject.Inject @@ -119,10 +92,10 @@ internal class ShareViewModel @Inject constructor( private val tagResponseMapper: TagResponseMapper, private val creatorResponseMapper: CreatorResponseMapper, private val defaults: Defaults, - private val context: Context, private val dateParser: DateParser, - private val backgroundUploadProcessor: BackgroundUploadProcessor, - private val noAuthenticationApi: NoAuthenticationApi, + private val shareFileDownloader: ShareFileDownloader, + private val shareErrorProcessor: ShareErrorProcessor, + private val shareItemSubmitter: ShareItemSubmitter, ) : BaseViewModel2(ShareViewState()) { private val defaultLibraryId: LibraryIdentifier = LibraryIdentifier.custom(RCustomLibraryType.myLibrary) @@ -147,7 +120,7 @@ internal class ShareViewModel @Inject constructor( @Subscribe(threadMode = ThreadMode.MAIN) fun onEvent(result: ShareCollectionPickerResults) { - set(collection = result.collection, library = result.library) + set(collection = result.collection, library = result.library) } fun init() = initOnce { @@ -169,7 +142,7 @@ internal class ShareViewModel @Inject constructor( Timber.e(e, "ExtensionViewModel: could not load attachment") updateAttachmentState( AttachmentState.failed( - attachmentError( + shareErrorProcessor.attachmentError( generalError = CustomResult.GeneralError.CodeError(e), libraryId = null ) @@ -193,7 +166,7 @@ internal class ShareViewModel @Inject constructor( updateState { copy( attachmentState = AttachmentState.failed( - attachmentError( + shareErrorProcessor.attachmentError( CustomResult.GeneralError.CodeError( event.exception ), libraryId = null @@ -285,7 +258,7 @@ internal class ShareViewModel @Inject constructor( updateState { copy( attachmentState = AttachmentState.failed( - attachmentError( + shareErrorProcessor.attachmentError( CustomResult.GeneralError.CodeError( error ), libraryId = null @@ -344,12 +317,13 @@ internal class ShareViewModel @Inject constructor( ioCoroutineScope.launch { try { - download( + shareFileDownloader.download( url = url, file = file, cookies = cookies, userAgent = userAgent, - referrer = referrer + referrer = referrer, + updateProgressBar = ::updateProgressBar, ) processDownload( attachment = attachment, @@ -493,7 +467,7 @@ internal class ShareViewModel @Inject constructor( Timber.e(e, "ExtensionViewModel: webview could not load data") updateAttachmentState( AttachmentState.failed( - attachmentError( + shareErrorProcessor.attachmentError( generalError = CustomResult.GeneralError.CodeError(e), libraryId = null ) @@ -557,12 +531,13 @@ internal class ShareViewModel @Inject constructor( } try { - download( + shareFileDownloader.download( url = url, file = file, cookies = cookies, userAgent = userAgent, - referrer = referrer + referrer = referrer, + updateProgressBar = ::updateProgressBar, ) viewModelScope.launch { @@ -604,73 +579,6 @@ internal class ShareViewModel @Inject constructor( } } - private suspend fun download( - url: String, - file: File, - cookies: String?, - userAgent: String?, - referrer: String? - ) { - val headers: MutableMap = LinkedHashMap() - if (userAgent != null) { - headers["User-Agent"] = userAgent - } - if (referrer != null) { - headers["Referer"] = referrer - } - if (cookies != null) { - headers["Cookie"] = cookies - } - val networkResult = safeApiCall { - noAuthenticationApi.downloadFileStreaming(url = url, headers = headers) - } - when (networkResult) { - is CustomResult.GeneralSuccess -> { - val byteStream = networkResult.value!!.byteStream() - val total = networkResult.value!!.contentLength() - var progress = 0L - val out = FileOutputStream(file); - try { - ByteStreams.readBytes(byteStream, - object : ByteProcessor { - @Throws(IOException::class) - override fun processBytes( - buffer: ByteArray, - offset: Int, - length: Int - ): Boolean { - out.write(buffer, offset, length) - progress += length - val progressResult = (progress / total.toDouble() * 100).toInt() - if (progressResult > 0) { - println() - } - updateProgressBar(progressResult) - return true - } - - override fun getResult(): Void? { - return null - } - }) - } catch (e: Exception) { - Timber.e(e, "Could not download $url") - throw e - } finally { - Closeables.close(out, true) - } - } - - is CustomResult.GeneralError.CodeError -> { - throw networkResult.throwable - } - - is CustomResult.GeneralError.NetworkError -> { - throw Exception(networkResult.stringResponse) - } - } - } - private fun updateProgressBar(progress: Int) { viewModelScope.launch { if (viewState.attachmentState is AttachmentState.downloading) { @@ -756,59 +664,6 @@ internal class ShareViewModel @Inject constructor( } } - private fun attachmentError( - generalError: CustomResult.GeneralError, - libraryId: LibraryIdentifier? - ): AttachmentState.Error { - when (generalError) { - is CustomResult.GeneralError.CodeError -> { - val error = generalError.throwable - if (error is AttachmentState.Error) { - return error - } - if (error is Parsing.Error) { - Timber.e(error, "ExtensionViewModel: could not parse item") - return AttachmentState.Error.parseError(error) - } - - if (error is SchemaError) { - Timber.e(error, "ExtensionViewModel: schema failed") - return AttachmentState.Error.schemaError(error) - } - if (error is TranslationWebViewError) { - return AttachmentState.Error.webViewError(error) - } - } - - is CustomResult.GeneralError.NetworkError -> { - return networkErrorRequiresAbort( - error = generalError, - url = BuildConfig.BASE_API_URL, - libraryId = libraryId - ) - } - } - return AttachmentState.Error.unknown - } - - private fun networkErrorRequiresAbort( - error: CustomResult.GeneralError.NetworkError, - url: String?, - libraryId: LibraryIdentifier? - ): AttachmentState.Error { - val defaultError = if ((url ?: "").contains(BuildConfig.BASE_API_URL)) { - AttachmentState.Error.apiFailure - } else { - AttachmentState.Error.webDavFailure - } - - val code = error.httpCode - if (code == 413 && libraryId != null) { - return AttachmentState.Error.quotaLimit(libraryId) - } - return defaultError - } - fun setFromRecent(collection: Collection?, library: Library) { updateSelected(collection = collection, library= library) } @@ -1058,7 +913,7 @@ internal class ShareViewModel @Inject constructor( userId: Long, ) { try { - val (parameters, changeUuids) = createItem( + val (parameters, changeUuids) = shareItemSubmitter.createItem( item, libraryId = libraryId, schemaController = schemaController, @@ -1081,7 +936,7 @@ internal class ShareViewModel @Inject constructor( updateState { copy( attachmentState = AttachmentState.failed( - attachmentError( + shareErrorProcessor.attachmentError( generalError = result, libraryId = libraryId ) @@ -1095,7 +950,7 @@ internal class ShareViewModel @Inject constructor( updateState { copy( attachmentState = AttachmentState.failed( - attachmentError( + shareErrorProcessor.attachmentError( generalError = CustomResult.GeneralError.CodeError(e), libraryId = libraryId ) @@ -1106,358 +961,30 @@ internal class ShareViewModel @Inject constructor( } } - private fun createItem(item: ItemResponse, libraryId: LibraryIdentifier, schemaController: SchemaController, dateParser: DateParser): Pair, Map>> { - var changeUuids: MutableMap> = mutableMapOf() - var parameters: MutableMap = mutableMapOf() - dbWrapper.realmDbStorage.perform { coordinator -> - val collectionKey = item.collectionKeys.firstOrNull() - if (collectionKey != null) { - coordinator.perform( - request = UpdateCollectionLastUsedDbRequest( - key = collectionKey, - libraryId = libraryId - ) - ) - } - - val request = CreateBackendItemDbRequest(item = item, schemaController = schemaController, dateParser = dateParser) - val item = coordinator.perform(request = request) - parameters = item.updateParameters?.toMutableMap() ?: mutableMapOf() - changeUuids = mutableMapOf(item.key to item.changes.map{ it.identifier }) - - coordinator.invalidate() - } - - return parameters to changeUuids - } - - private fun createItems(item: ItemResponse, attachment: Attachment): CreateItemsResult { - Timber.i("ShareViewModel: create item and attachment db items") - val parameters: MutableList> = mutableListOf() - var changeUuids: MutableMap> = mutableMapOf() - var mtime: Long? = null - var md5: String? = null - dbWrapper.realmDbStorage.perform {coordinator -> - val collectionKey = item.collectionKeys.firstOrNull() - if (collectionKey != null) { - coordinator.perform( - request = UpdateCollectionLastUsedDbRequest( - key = collectionKey, - libraryId = attachment.libraryId - ) - ) - } - val request = CreateItemWithAttachmentDbRequest( - item = item, - attachment = attachment, - schemaController = this.schemaController, - dateParser = this.dateParser, - fileStore = this.fileStore - ) - val(item, attachment) = coordinator.perform(request= request) - val itemUpdateParameters = item.updateParameters - if (itemUpdateParameters != null) { - parameters.add(itemUpdateParameters) - } - val updateParameters = attachment.updateParameters - if (updateParameters != null){ - parameters.add(updateParameters) - } - changeUuids = mutableMapOf(item.key to item.changes.map{ it.identifier }, attachment.key to attachment.changes.map{ it.identifier }) - - mtime = attachment.fields.where().key(FieldKeys.Item.Attachment.mtime).findFirst()?.value?.toLongOrNull() - md5 = attachment.fields.where().key(FieldKeys.Item.Attachment.md5).findFirst()?.value - - coordinator.invalidate() - } - if (mtime == null) { - throw AttachmentState.Error.mtimeMissing - } - if (md5 == null) { - throw AttachmentState.Error.md5Missing - } - return CreateItemsResult(parameters, changeUuids, md5!!, mtime!!) - } - - - - private fun create( - attachment: Attachment, - collections: Set, - tags: List - ): CreateResult { - Timber.i("Create attachment db item") - - val localizedType = - this.schemaController.localizedItemType(itemType = ItemTypes.attachment) ?: "" - - var updateParameters: Map? = null - var changeUuids: Map>? = null - var md5: String? = null - var mtime: Long? = null - - dbWrapper.realmDbStorage.perform { coordinator -> - val collectionKey = collections.firstOrNull() - if (collectionKey != null) { - coordinator.perform( - request = UpdateCollectionLastUsedDbRequest( - key = collectionKey, - libraryId = attachment.libraryId - ) - ) - } - - val request = CreateAttachmentDbRequest( - attachment = attachment, - parentKey = null, - localizedType = localizedType, - includeAccessDate = attachment.hasUrl, - collections = collections, - tags = tags, - fileStore = fileStore - ) - val attachment = coordinator.perform(request = request) - - updateParameters = attachment.updateParameters?.toMutableMap() - changeUuids = mutableMapOf(attachment.key to attachment.changes.map { it.identifier }) - mtime = attachment.fields.where().key(FieldKeys.Item.Attachment.mtime) - .findFirst()?.value?.toLongOrNull() - md5 = attachment.fields.where().key(FieldKeys.Item.Attachment.md5).findFirst()?.value - - coordinator.invalidate() - } - - mtime ?: throw AttachmentState.Error.mtimeMissing - md5 ?: throw AttachmentState.Error.md5Missing - return CreateResult( - updateParameters = updateParameters ?: emptyMap(), - changeUuids = changeUuids ?: emptyMap(), - md5 = md5!!, - mtime = mtime!! - ) - } - - private data class CreateResult(val updateParameters: Map, val changeUuids: Map>, val md5: String, val mtime: Long) - private data class CreateItemsResult(val parameters: List>, val changeUuids: Map>, val md5: String, val mtime: Long) - - private fun moveFile(fromFile: File, toFile: File): Long { - Timber.i("ShareViewModel: move file to attachment folder") - - try { - val size = fromFile.length() - if (size == 0L) { - throw AttachmentState.Error.fileMissing - } - if (fromFile.renameTo(toFile)) { - return size - } else { - throw AttachmentState.Error.fileMissing - } - } catch (error: Exception) { - Timber.e(error, "ShareViewModel: can't move file") - fromFile.delete() - throw error - } - } - - private suspend fun prepareAndSubmit( - attachment: Attachment, - collections: Set, - tags: List, - file: File, - tmpFile: File, - libraryId: LibraryIdentifier, - userId: Long, - ): CustomResult { - val filesize = moveFile(tmpFile, file) - val data: ShareSubmissionData - val parameters: Map - val changeUuids: Map> - try { - val (params, uuids, md5, mtime) = create( - attachment = attachment, - collections = collections, - tags = tags - ) - parameters = params - changeUuids = uuids - data = ShareSubmissionData(filesize = filesize, md5 = md5, mtime = mtime) - } catch (e: Exception) { - file.delete() - return CustomResult.GeneralError.CodeError(e) - } - val result = SubmitUpdateSyncAction( - parameters = listOf(parameters), - changeUuids = changeUuids, - sinceVersion = null, - objectS = SyncObject.item, - libraryId = libraryId, - userId = userId, - updateLibraryVersion = false - ).result() - if (result is CustomResult.GeneralSuccess) { - return CustomResult.GeneralSuccess(data) - } - return result as CustomResult.GeneralError - } - - private suspend fun prepareAndSubmit( - item: ItemResponse, - attachment: Attachment, - file: File, - tmpFile: File, - libraryId: LibraryIdentifier, - userId: Long, - ): CustomResult { - val filesize = moveFile(tmpFile, file) - val data: ShareSubmissionData - val parameters: List> - val changeUuids: Map> - try { - val (params, uuids, md5, mtime) = createItems(item = item, attachment = attachment) - parameters = params - changeUuids = uuids - data = ShareSubmissionData(filesize = filesize, md5 = md5, mtime = mtime) - } catch (e: Exception) { - file.delete() - return CustomResult.GeneralError.CodeError(e) - } - - val result = SubmitUpdateSyncAction( - parameters = parameters, - changeUuids = changeUuids, - sinceVersion = null, - objectS = SyncObject.item, - libraryId = libraryId, - userId = userId, - updateLibraryVersion = false - ).result() - if (result is CustomResult.GeneralSuccess) { - return CustomResult.GeneralSuccess(data) - } - return result as CustomResult.GeneralError - } - - private suspend fun submit(data: UploadData): CustomResult { - when (val type = data.type) { - is UploadData.Kind.file -> { - val location = type.location - val collections = type.collections - val tags = type.tags - Timber.i("ShareViewModel: prepare upload for local file") - return prepareAndSubmit( - attachment = data.attachment, - collections = collections, - tags = tags, - file = data.file, - tmpFile = location, - libraryId = data.libraryId, - userId = data.userId, - ) - } - - is UploadData.Kind.translated -> { - val item = type.item - val location = type.location - Timber.i("ShareViewModel: prepare upload for local file") - return prepareAndSubmit( - item = item, - attachment = data.attachment, - file = data.file, - tmpFile = location, - libraryId = data.libraryId, - userId = data.userId - ) - } - } - } private suspend fun upload(data: UploadData) { //TODO implement Upload to WebDav Support - uploadToZotero(data = data) - } - - private suspend fun uploadToZotero(data: UploadData) { - try { - val submissionDataResult = submit(data = data) - if (submissionDataResult is CustomResult.GeneralError) { - processUploadToZoteroException(submissionDataResult, data) - return - } - val submissionData = (submissionDataResult as CustomResult.GeneralSuccess).value!! - val uploadSyncResult = AuthorizeUploadSyncAction( - key = data.attachment.key, - filename = data.filename, - filesize = submissionData.filesize, - md5 = submissionData.md5, - mtime = submissionData.mtime, - libraryId = data.libraryId, - userId = data.userId, - oldMd5 = null - ).result() - if (uploadSyncResult is CustomResult.GeneralError) { - processUploadToZoteroException(uploadSyncResult, data) - return - } - uploadSyncResult as CustomResult.GeneralSuccess - val response = uploadSyncResult.value!! - val md5 = submissionData.md5 - when (response) { - is AuthorizeUploadResponse.exists -> { - Timber.i("ShareViewModel: file exists remotely") - val request = MarkAttachmentUploadedDbRequest( - libraryId = data.libraryId, - key = data.attachment.key, - version = response.version - ) - val request2 = UpdateVersionsDbRequest( - version = response.version, - libraryId = data.libraryId, - type = UpdateVersionType.objectS(SyncObject.item) - ) - dbWrapper.realmDbStorage.perform(listOf(request, request2)) - } - - is AuthorizeUploadResponse.new -> { - val response = response.authorizeNewUploadResponse - Timber.i("ShareViewModel: upload authorized") - - val upload = BackgroundUpload( - type = BackgroundUpload.Kind.zotero(uploadKey = response.uploadKey), - key = this.attachmentKey, - libraryId = data.libraryId, - userId = data.userId, - remoteUrl = response.url, - fileUrl = data.file, - md5 = md5, - date = Date() - ) - backgroundUploadProcessor.startAsync( - upload = upload, - filename = data.filename, - mimeType = this.defaultMimetype, - parameters = response.params, - headers = mapOf("If-None-Match" to "*") - ) - - } + shareItemSubmitter.uploadToZotero( + data = data, + attachmentKey = this.attachmentKey, + defaultMimetype = this.defaultMimetype, + processUploadToZoteroException = ::processUploadToZoteroException, + onBack = { + triggerEffect(ShareViewEffect.NavigateBack) } - triggerEffect(ShareViewEffect.NavigateBack) - } catch (e: Exception) { - Timber.e(e, "Could not submit item or attachment") - processUploadToZoteroException(CustomResult.GeneralError.CodeError(e), data) - } + ) } + private fun processUploadToZoteroException( - error : CustomResult.GeneralError, + error: CustomResult.GeneralError, data: UploadData ) { updateState { copy( attachmentState = AttachmentState.failed( - attachmentError( + shareErrorProcessor.attachmentError( error, libraryId = data.libraryId ) ), @@ -1468,93 +995,7 @@ internal class ShareViewModel @Inject constructor( } fun errorMessage(error: AttachmentState.Error): String? { - return when (error) { - AttachmentState.Error.apiFailure -> { - context.getString(Strings.errors_shareext_api_error) - } - AttachmentState.Error.cantLoadSchema -> { - context.getString(Strings.errors_shareext_cant_load_schema) - } - AttachmentState.Error.cantLoadWebData -> { - context.getString(Strings.errors_shareext_cant_load_data) - } - AttachmentState.Error.downloadFailed -> { - context.getString(Strings.errors_shareext_download_failed) - } - AttachmentState.Error.downloadedFileNotPdf -> { - null - } - AttachmentState.Error.expired -> { - context.getString(Strings.errors_shareext_unknown) - } - AttachmentState.Error.fileMissing -> { - context.getString(Strings.errors_shareext_missing_file) - } - AttachmentState.Error.itemsNotFound -> { - context.getString(Strings.errors_shareext_items_not_found) - } - AttachmentState.Error.md5Missing -> { - null - } - AttachmentState.Error.mtimeMissing -> { - null - } - is AttachmentState.Error.parseError -> { - context.getString(Strings.errors_shareext_parsing_error) - } - is AttachmentState.Error.quotaLimit -> { - when (error.libraryIdentifier) { - is LibraryIdentifier.custom -> { - context.getString(Strings.errors_shareext_personal_quota_reached) - } - is LibraryIdentifier.group -> { - val groupId = error.libraryIdentifier.groupId - val group = - dbWrapper.realmDbStorage.perform(ReadGroupDbRequest(identifier = groupId)) - val groupName = group?.name ?: "$groupId" - return context.getString( - Strings.errors_shareext_group_quota_reached, - groupName - ) - } - } - } - - is AttachmentState.Error.schemaError -> { - context.getString(Strings.errors_shareext_schema_error) - } - AttachmentState.Error.unknown -> { - context.getString(Strings.errors_shareext_unknown) - } - AttachmentState.Error.webDavFailure -> { - context.getString(Strings.errors_shareext_webdav_error) - } - AttachmentState.Error.webDavNotVerified -> { - context.getString(Strings.errors_shareext_webdav_not_verified) - } - is AttachmentState.Error.webViewError -> { - return when (error.error) { - TranslationWebViewError.cantFindFile -> { - context.getString(Strings.errors_shareext_missing_base_files) - } - TranslationWebViewError.incompatibleItem -> { - context.getString(Strings.errors_shareext_incompatible_item) - } - TranslationWebViewError.javascriptCallMissingResult -> { - context.getString(Strings.errors_shareext_javascript_failed) - } - TranslationWebViewError.noSuccessfulTranslators -> { - null - } - TranslationWebViewError.webExtractionMissingData -> { - context.getString(Strings.errors_shareext_response_missing_data) - } - TranslationWebViewError.webExtractionMissingJs -> { - context.getString(Strings.errors_shareext_missing_base_files) - } - } - } - } + return shareErrorProcessor.errorMessage(error) } } diff --git a/app/src/main/java/org/zotero/android/screens/share/data/CreateItemsResult.kt b/app/src/main/java/org/zotero/android/screens/share/data/CreateItemsResult.kt new file mode 100644 index 00000000..06fc34b2 --- /dev/null +++ b/app/src/main/java/org/zotero/android/screens/share/data/CreateItemsResult.kt @@ -0,0 +1,8 @@ +package org.zotero.android.screens.share.data + +data class CreateItemsResult( + val parameters: List>, + val changeUuids: Map>, + val md5: String, + val mtime: Long +) \ No newline at end of file diff --git a/app/src/main/java/org/zotero/android/screens/share/data/CreateResult.kt b/app/src/main/java/org/zotero/android/screens/share/data/CreateResult.kt new file mode 100644 index 00000000..37946832 --- /dev/null +++ b/app/src/main/java/org/zotero/android/screens/share/data/CreateResult.kt @@ -0,0 +1,8 @@ +package org.zotero.android.screens.share.data + +data class CreateResult( + val updateParameters: Map, + val changeUuids: Map>, + val md5: String, + val mtime: Long +) \ No newline at end of file diff --git a/buildSrc/src/main/kotlin/BuildConfig.kt b/buildSrc/src/main/kotlin/BuildConfig.kt index 1f2aa645..0db5f943 100644 --- a/buildSrc/src/main/kotlin/BuildConfig.kt +++ b/buildSrc/src/main/kotlin/BuildConfig.kt @@ -4,7 +4,7 @@ object BuildConfig { const val compileSdkVersion = 34 const val targetSdk = 33 - val versionCode = 57 // Must be updated on every build + val versionCode = 58 // Must be updated on every build val version = Version( major = 1, minor = 0,