diff --git a/Android/gradle/libs.versions.toml b/Android/gradle/libs.versions.toml index 3f006acc3..fb606e256 100644 --- a/Android/gradle/libs.versions.toml +++ b/Android/gradle/libs.versions.toml @@ -20,7 +20,7 @@ androidxTest = "1.6.1" androidxTestExt = "1.2.1" androidxTestMonitor = "1.7.2" androidxTestUiAutomator ="2.3.0" -androidxWork = "2.8.1" +androidxWork = "2.10.5" coil = "2.2.2" detekt = "1.23.8" dexopener = "2.0.5" diff --git a/Android/qsferaApp/build.gradle b/Android/qsferaApp/build.gradle index 6b646bf40..bbaade7f9 100644 --- a/Android/qsferaApp/build.gradle +++ b/Android/qsferaApp/build.gradle @@ -137,8 +137,8 @@ android { testInstrumentationRunner "eu.qsfera.android.utils.OCTestAndroidJUnitRunner" - versionCode = 33 - versionName = "1.3.5" + versionCode = 34 + versionName = "1.3.6" buildConfigField "String", gitRemote, "\"" + getGitOriginRemote() + "\"" buildConfigField "String", commitSHA1, "\"" + getLatestGitHash() + "\"" diff --git a/Android/qsferaApp/src/main/AndroidManifest.xml b/Android/qsferaApp/src/main/AndroidManifest.xml index ecb9d8f4e..469390ac1 100644 --- a/Android/qsferaApp/src/main/AndroidManifest.xml +++ b/Android/qsferaApp/src/main/AndroidManifest.xml @@ -53,6 +53,9 @@ + @@ -205,6 +208,18 @@ tools:replace="android:resource" /> + + + + + + + + + enqueueSelectedUris(uris) + } + private val mediaPicker = registerForActivityResult(ActivityResultContracts.OpenMultipleDocuments()) { uris -> + enqueueSelectedUris(uris) + } override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) - section = CloudSection.fromWireValue(intent.getStringExtra(EXTRA_SECTION)) + val requestedSection = CloudSection.fromWireValue( + savedInstanceState?.getString(STATE_SECTION) ?: intent.getStringExtra(EXTRA_SECTION) + ) + currentSection = requestedSection.takeUnless { it == CloudSection.MORE } ?: CloudSection.FEED enableEdgeToEdgePreSetContentView(false) - binding = ActivityMainBinding.inflate(layoutInflater) - setContentView(binding.root) - - setupRootToolbar( - title = getString(section.titleResource), - isSearchEnabled = section != CloudSection.MORE, - isAvatarRequested = true, - ) - setupDrawer() - setupNavigationBottomBar(section.menuResource) - - if (section != CloudSection.MORE) { - findViewById(R.id.root_toolbar_search_view).setOnQueryTextListener( - object : SearchView.OnQueryTextListener { - override fun onQueryTextSubmit(query: String?): Boolean { - cloudFragment()?.filter(query.orEmpty()) - return true - } - - override fun onQueryTextChange(newText: String?): Boolean { - cloudFragment()?.filter(newText.orEmpty()) - return true - } - } - ) - } + setContentView(R.layout.activity_cloud_home) + bindViews() + setupToolbar() + setupBottomNavigation() if (savedInstanceState == null) { supportFragmentManager.beginTransaction() - .replace(R.id.left_fragment_container, CloudHubFragment.newInstance(section)) - .commit() + .replace(R.id.cloud_content, CloudHubFragment.newInstance(currentSection)) + .commitNow() } + selectSection(currentSection, updateNavigation = true) + loadAvatar() + observeUploadCompletion() enableEdgeToEdgePostSetContentView { insets -> - binding.navCoordinatorLayout.appBarLayout.updatePadding( - top = insets.top, - left = insets.left, - right = insets.right, - ) - binding.navCoordinatorLayout.bottomNavViewSpacer.updateLayoutParams { + findViewById(R.id.cloud_toolbar).updatePadding(top = insets.top) + findViewById(R.id.cloud_toolbar).updateLayoutParams { + height = resources.getDimensionPixelSize(R.dimen.cloud_toolbar_height) + insets.top + } + findViewById(R.id.cloud_bottom_spacer).updateLayoutParams { height = insets.bottom } - findViewById(R.id.nav_view_container).updateLayoutParams { - bottomMargin = insets.bottom + } + + if (requestedSection == CloudSection.MORE) { + bottomNavigation.post(::showMoreSheet) + } + } + + private fun bindViews() { + toolbarAvatar = findViewById(R.id.cloud_toolbar_avatar) + toolbarBack = findViewById(R.id.cloud_toolbar_back) + toolbarTitle = findViewById(R.id.cloud_toolbar_title) + toolbarSearchButton = findViewById(R.id.cloud_toolbar_search_button) + toolbarSearch = findViewById(R.id.cloud_toolbar_search) + bottomNavigation = findViewById(R.id.cloud_bottom_navigation) + } + + private fun observeUploadCompletion() { + transfersViewModel.workInfosListLiveData.observe(this) { workInfos -> + val uploadsAreRunning = workInfos.isNotEmpty() + if (uploadsWereRunning && !uploadsAreRunning) { + cloudFragment()?.onUploadsCompleted() + } + uploadsWereRunning = uploadsAreRunning + } + } + + private fun setupToolbar() { + toolbarAvatar.setOnClickListener { showProfileSheet() } + toolbarBack.setOnClickListener { cloudFragment()?.navigateUp() } + toolbarSearchButton.setOnClickListener { + toolbarTitle.visibility = View.GONE + toolbarSearchButton.visibility = View.GONE + toolbarSearch.visibility = View.VISIBLE + toolbarSearch.requestFocus() + } + toolbarSearch.setOnQueryTextListener(object : SearchView.OnQueryTextListener { + override fun onQueryTextSubmit(query: String?): Boolean { + cloudFragment()?.filter(query.orEmpty()) + toolbarSearch.clearFocus() + return true + } + + override fun onQueryTextChange(newText: String?): Boolean { + cloudFragment()?.filter(newText.orEmpty()) + return true + } + }) + toolbarSearch.setOnCloseListener { + closeSearch() + true + } + } + + @Suppress("DEPRECATION") + private fun setupBottomNavigation() { + bottomNavigation.setOnNavigationItemSelectedListener { item -> + val section = when (item.itemId) { + R.id.nav_feed -> { CloudSection.FEED } + R.id.nav_all_files -> { CloudSection.FILES } + R.id.nav_photos -> { CloudSection.PHOTOS } + R.id.nav_albums -> { CloudSection.ALBUMS } + R.id.nav_more -> { + showMoreSheet() + return@setOnNavigationItemSelectedListener false + } + else -> { return@setOnNavigationItemSelectedListener false } + } + selectSection(section, updateNavigation = false) + true + } + } + + private fun selectSection(section: CloudSection, updateNavigation: Boolean) { + if (section == CloudSection.MORE) return + currentSection = section + closeSearch() + showRootTitle(section) + cloudFragment()?.showSection(section) + if (updateNavigation) { + bottomNavigation.menu.findItem(section.menuResource)?.isChecked = true + } + } + + private fun showRootTitle(section: CloudSection) { + toolbarBack.visibility = View.GONE + toolbarAvatar.visibility = View.VISIBLE + toolbarTitle.text = getString(section.titleResource) + } + + fun showNestedTitle(title: String) { + toolbarAvatar.visibility = View.GONE + toolbarBack.visibility = View.VISIBLE + toolbarTitle.text = title + } + + fun restoreSectionTitle() { + showRootTitle(currentSection) + } + + private fun closeSearch() { + if (!this::toolbarSearch.isInitialized) return + toolbarSearch.setQuery("", false) + toolbarSearch.clearFocus() + toolbarSearch.visibility = View.GONE + toolbarTitle.visibility = View.VISIBLE + toolbarSearchButton.visibility = View.VISIBLE + cloudFragment()?.filter("") + } + + override fun onSaveInstanceState(outState: Bundle) { + outState.putString(STATE_SECTION, currentSection.wireValue) + super.onSaveInstanceState(outState) + } + + @Deprecated("Deprecated in Java") + override fun onBackPressed() { + if (this::toolbarSearch.isInitialized && toolbarSearch.visibility == View.VISIBLE) { + closeSearch() + return + } + super.onBackPressed() + } + + fun showAddSheet() { + val dialog = BottomSheetDialog(this) + val content = layoutInflater.inflate(R.layout.sheet_cloud_add, null) + dialog.setContentView(content) + content.findViewById(R.id.cloud_add_folder).setOnClickListener { + dialog.dismiss() + showCreateFolderDialog() + } + content.findViewById(R.id.cloud_add_files).setOnClickListener { + dialog.dismiss() + filePicker.launch(arrayOf("*/*")) + } + content.findViewById(R.id.cloud_add_photos).setOnClickListener { + dialog.dismiss() + mediaPicker.launch(arrayOf("image/*", "video/*")) + } + dialog.show() + } + + private fun showCreateFolderDialog() { + val input = EditText(this).apply { + hint = getString(R.string.cloud_create_folder_hint) + setSingleLine(true) + setPadding(48, 8, 48, 8) + } + val dialog = AlertDialog.Builder(this) + .setTitle(R.string.cloud_create_folder_title) + .setView(input) + .setNegativeButton(android.R.string.cancel, null) + .setPositiveButton(android.R.string.ok, null) + .create() + dialog.setOnShowListener { + dialog.getButton(AlertDialog.BUTTON_POSITIVE).setOnClickListener { + val folderName = input.text.toString().trim() + if (folderName.isBlank() || folderName.contains('/') || folderName.contains('\\')) { + input.error = getString(R.string.cloud_create_folder_invalid) + } else { + dialog.dismiss() + createFolder(folderName) + } } } + dialog.show() + } + + private fun createFolder(folderName: String) { + val cloudFragment = cloudFragment() + val basePath = cloudFragment?.currentFolderPath.orEmpty().ifBlank { "/" } + val spaceId = cloudFragment?.currentFolderSpaceId + val remotePath = "${basePath.trimEnd('/')}/$folderName" + lifecycleScope.launch { + val result = runCatching { + withContext(Dispatchers.IO) { + val parentFolder = if (basePath.trimEnd('/').isEmpty()) { + fileRepository.getPersonalRootFolderForAccount(account.name) + } else { + fileRepository.getFileByRemotePath(basePath, account.name, spaceId) + ?: error("Parent folder is not available locally: $basePath") + } + fileRepository.createFolder(remotePath, parentFolder) + } + } + if (result.isSuccess) { + Toast.makeText(this@CloudHomeActivity, R.string.cloud_create_folder_success, Toast.LENGTH_SHORT).show() + cloudFragment()?.reloadCurrentFiles() + } else { + Toast.makeText(this@CloudHomeActivity, R.string.cloud_create_folder_error, Toast.LENGTH_LONG).show() + } + } + } + + private fun enqueueSelectedUris(uris: List) { + if (uris.isEmpty()) return + uris.forEach { uri -> + runCatching { + contentResolver.takePersistableUriPermission(uri, Intent.FLAG_GRANT_READ_URI_PERMISSION) + } + } + val accountName = account.name + val uploadFolderPath = cloudFragment()?.currentFolderPath.orEmpty().ifBlank { "/" } + val knownSpaceId = cloudFragment()?.currentFolderSpaceId + lifecycleScope.launch { + val destinationSpaceId = runCatching { + knownSpaceId ?: withContext(Dispatchers.IO) { + fileRepository.getPersonalRootFolderForAccount(accountName).spaceId + } + }.getOrElse { + Toast.makeText(this@CloudHomeActivity, R.string.cloud_upload_destination_error, Toast.LENGTH_LONG).show() + return@launch + } + if (account.name != accountName) { + Toast.makeText(this@CloudHomeActivity, R.string.cloud_upload_destination_error, Toast.LENGTH_LONG).show() + return@launch + } + transfersViewModel.uploadFilesFromContentUri( + accountName = accountName, + listOfContentUris = uris, + uploadFolderPath = uploadFolderPath, + spaceId = destinationSpaceId, + ) + Toast.makeText(this@CloudHomeActivity, R.string.cloud_upload_enqueued, Toast.LENGTH_SHORT).show() + } + } + + private fun showMoreSheet() { + val dialog = BottomSheetDialog(this) + val content = layoutInflater.inflate(R.layout.sheet_cloud_more, null) + dialog.setContentView(content) + content.findViewById(R.id.cloud_more_storage).setOnClickListener { + dialog.dismiss() + selectSection(CloudSection.FILES, updateNavigation = true) + } + content.findViewById(R.id.cloud_more_transfers).setOnClickListener { + dialog.dismiss() + startActivity(Intent(this, UploadListActivity::class.java)) + } + content.findViewById(R.id.cloud_more_offline).setOnClickListener { + dialog.dismiss() + openFileList(FileListOption.AV_OFFLINE) + } + content.findViewById(R.id.cloud_more_shares).setOnClickListener { + dialog.dismiss() + openFileList(FileListOption.SHARED_BY_LINK) + } + content.findViewById(R.id.cloud_more_spaces).setOnClickListener { + dialog.dismiss() + openFileList(FileListOption.SPACES_LIST) + } + content.findViewById(R.id.cloud_more_settings).setOnClickListener { + dialog.dismiss() + startActivity(Intent(this, SettingsActivity::class.java)) + } + dialog.setOnDismissListener { + bottomNavigation.menu.findItem(currentSection.menuResource)?.isChecked = true + } + dialog.show() + } + + private fun showProfileSheet() { + val dialog = BottomSheetDialog(this) + val content = layoutInflater.inflate(R.layout.sheet_cloud_profile, null) + dialog.setContentView(content) + val currentAccount = AccountUtils.getCurrentQSferaAccount(this) + val displayName = runCatching { QSferaAccount(currentAccount, this).displayName } + .getOrNull() + .orEmpty() + .ifBlank { currentAccount.name.substringBefore('@') } + content.findViewById(R.id.cloud_profile_name).text = displayName + content.findViewById(R.id.cloud_profile_account).text = currentAccount.name + content.findViewById(R.id.cloud_profile_automatic_uploads).setOnClickListener { + dialog.dismiss() + startActivity(SettingsActivity.createIntent(this, SettingsActivity.NOTIFICATION_INTENT_AUTOMATIC_UPLOADS)) + } + content.findViewById(R.id.cloud_profile_accounts).setOnClickListener { + dialog.dismiss() + ManageAccountsDialogFragment.newInstance(currentAccount) + .show(supportFragmentManager, MANAGE_ACCOUNTS_DIALOG) + } + content.findViewById(R.id.cloud_profile_settings).setOnClickListener { + dialog.dismiss() + startActivity(Intent(this, SettingsActivity::class.java)) + } + dialog.show() + } + + private fun loadAvatar() { + val currentAccount = AccountUtils.getCurrentQSferaAccount(this) ?: return + lifecycleScope.launch(Dispatchers.IO) { + val imageLoader = ThumbnailsRequester.getRevalidatingImageLoader(currentAccount) + withContext(Dispatchers.Main) { + AvatarUtils().loadAvatarForAccount( + imageView = toolbarAvatar, + account = currentAccount, + displayRadius = resources.getDimension(R.dimen.toolbar_avatar_radius), + imageLoader = imageLoader, + ) + } + } + } + + fun openStorageItem(item: CloudStorageItem) { + startActivity(Intent(this, FileDisplayActivity::class.java).apply { + putExtra(FileActivity.EXTRA_FILE, item.toOCFile()) + putExtra(FileActivity.EXTRA_FILE_LIST_OPTION, FileListOption.ALL_FILES as Parcelable) + }) } override fun onAccountSet(stateWasRecovered: Boolean) { super.onAccountSet(stateWasRecovered) - setAccountInDrawer(account) - cloudFragment()?.reload() + if (this::toolbarAvatar.isInitialized) loadAvatar() + if (this::toolbarSearch.isInitialized) closeSearch() + cloudFragment()?.onAccountChanged() + } + + override fun navigateToOption(fileListOption: FileListOption) { + openFileList(fileListOption) + } + + private fun openFileList(option: FileListOption) { + startActivity(Intent(this, FileDisplayActivity::class.java).apply { + putExtra(FileActivity.EXTRA_FILE_LIST_OPTION, option as Parcelable) + }) } private fun cloudFragment(): CloudHubFragment? = - supportFragmentManager.findFragmentById(R.id.left_fragment_container) as? CloudHubFragment - - override fun navigateToOption(fileListOption: FileListOption) { - if (fileListOption == FileListOption.ALL_FILES) { - startActivity(Intent(this, FileDisplayActivity::class.java).apply { - addFlags(Intent.FLAG_ACTIVITY_CLEAR_TOP) - putExtra(FileActivity.EXTRA_FILE_LIST_OPTION, fileListOption as Parcelable) - }) - } else { - super.navigateToOption(fileListOption) - } - } + supportFragmentManager.findFragmentById(R.id.cloud_content) as? CloudHubFragment companion object { private const val EXTRA_SECTION = "cloud_section" + private const val STATE_SECTION = "cloud_state_section" fun createIntent(context: Context, section: CloudSection): Intent = Intent(context, CloudHomeActivity::class.java).apply { @@ -112,16 +439,18 @@ class CloudHomeActivity : FileActivity() { private val CloudSection.titleResource: Int get() = when (this) { - CloudSection.FEED -> R.string.cloud_feed_title - CloudSection.PHOTOS -> R.string.cloud_photos_title - CloudSection.ALBUMS -> R.string.cloud_albums_title - CloudSection.MORE -> R.string.cloud_more_title + CloudSection.FEED -> { R.string.cloud_feed_title } + CloudSection.FILES -> { R.string.cloud_files_title } + CloudSection.PHOTOS -> { R.string.cloud_photos_title } + CloudSection.ALBUMS -> { R.string.cloud_albums_title } + CloudSection.MORE -> { R.string.cloud_more_title } } private val CloudSection.menuResource: Int get() = when (this) { - CloudSection.FEED -> R.id.nav_feed - CloudSection.PHOTOS -> R.id.nav_photos - CloudSection.ALBUMS -> R.id.nav_albums - CloudSection.MORE -> R.id.nav_more + CloudSection.FEED -> { R.id.nav_feed } + CloudSection.FILES -> { R.id.nav_all_files } + CloudSection.PHOTOS -> { R.id.nav_photos } + CloudSection.ALBUMS -> { R.id.nav_albums } + CloudSection.MORE -> { R.id.nav_more } } diff --git a/Android/qsferaApp/src/main/java/eu/qsfera/android/presentation/cloud/CloudHubAdapter.kt b/Android/qsferaApp/src/main/java/eu/qsfera/android/presentation/cloud/CloudHubAdapter.kt index 3f0e8670f..a28429694 100644 --- a/Android/qsferaApp/src/main/java/eu/qsfera/android/presentation/cloud/CloudHubAdapter.kt +++ b/Android/qsferaApp/src/main/java/eu/qsfera/android/presentation/cloud/CloudHubAdapter.kt @@ -9,8 +9,11 @@ import android.accounts.Account import android.view.LayoutInflater import android.view.View import android.view.ViewGroup +import android.widget.FrameLayout import android.widget.ImageView +import android.widget.LinearLayout import android.widget.TextView +import androidx.recyclerview.widget.DiffUtil import androidx.recyclerview.widget.RecyclerView import coil.dispose import coil.load @@ -19,65 +22,184 @@ import eu.qsfera.android.presentation.thumbnails.ThumbnailsRequester import eu.qsfera.android.utils.MimetypeIconUtil internal class CloudHubAdapter( - private val account: Account, + private var account: Account, private val onMediaClick: (CloudMediaItem) -> Unit, private val onAlbumClick: (String) -> Unit, + private val onStorageClick: (CloudStorageItem) -> Unit, + private val onShortcutClick: (CloudShortcut) -> Unit, private val onActionClick: (CloudAction) -> Unit, private val onRetry: () -> Unit, + private val onLoadMore: () -> Unit, + private val onFeedGroupClick: (String) -> Unit, ) : RecyclerView.Adapter() { private var rows: List = emptyList() + fun updateAccount(newAccount: Account) { + if (account == newAccount) return + account = newAccount + notifyItemRangeChanged(0, itemCount) + } + fun submitRows(newRows: List) { + val oldRows = rows + val result = DiffUtil.calculateDiff(object : DiffUtil.Callback() { + override fun getOldListSize(): Int = oldRows.size + + override fun getNewListSize(): Int = newRows.size + + override fun areItemsTheSame(oldItemPosition: Int, newItemPosition: Int): Boolean = + stableKey(oldRows[oldItemPosition]) == stableKey(newRows[newItemPosition]) + + override fun areContentsTheSame(oldItemPosition: Int, newItemPosition: Int): Boolean = + oldRows[oldItemPosition] == newRows[newItemPosition] + }) rows = newRows - notifyDataSetChanged() + result.dispatchUpdatesTo(this) } override fun getItemCount(): Int = rows.size override fun getItemViewType(position: Int): Int = when (rows[position]) { - is CloudHubRow.Header -> TYPE_HEADER - is CloudHubRow.Media -> TYPE_MEDIA - is CloudHubRow.Album -> TYPE_ALBUM - is CloudHubRow.Action -> TYPE_ACTION - is CloudHubRow.Status -> TYPE_STATUS + is CloudHubRow.Header -> { TYPE_HEADER } + is CloudHubRow.FeedCard -> { TYPE_FEED_CARD } + CloudHubRow.Shortcuts -> { TYPE_SHORTCUTS } + is CloudHubRow.PhotoStatus -> { TYPE_PHOTO_STATUS } + is CloudHubRow.Media -> { TYPE_MEDIA } + is CloudHubRow.Storage -> { TYPE_STORAGE } + is CloudHubRow.Album -> { TYPE_ALBUM } + is CloudHubRow.Action -> { TYPE_ACTION } + is CloudHubRow.Status -> { TYPE_STATUS } } fun spanSize(position: Int): Int = when (rows[position]) { - is CloudHubRow.Media -> 2 - is CloudHubRow.Album -> 3 + is CloudHubRow.Media, + is CloudHubRow.Storage -> { 2 } + is CloudHubRow.Album -> { 3 } is CloudHubRow.Header, + is CloudHubRow.FeedCard, + CloudHubRow.Shortcuts, + is CloudHubRow.PhotoStatus, is CloudHubRow.Action, - is CloudHubRow.Status -> 6 + is CloudHubRow.Status -> { 6 } } override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): Holder { val layout = when (viewType) { - TYPE_HEADER -> R.layout.item_cloud_header - TYPE_MEDIA -> R.layout.item_cloud_media - TYPE_ALBUM -> R.layout.item_cloud_album - TYPE_ACTION -> R.layout.item_cloud_action - else -> R.layout.item_cloud_status + TYPE_HEADER -> { R.layout.item_cloud_header } + TYPE_FEED_CARD -> { R.layout.item_cloud_feed_card } + TYPE_SHORTCUTS -> { R.layout.item_cloud_shortcuts } + TYPE_PHOTO_STATUS -> { R.layout.item_cloud_photo_status } + TYPE_MEDIA -> { R.layout.item_cloud_media } + TYPE_STORAGE -> { R.layout.item_cloud_storage } + TYPE_ALBUM -> { R.layout.item_cloud_album } + TYPE_ACTION -> { R.layout.item_cloud_action } + else -> { R.layout.item_cloud_status } } return Holder(LayoutInflater.from(parent.context).inflate(layout, parent, false)) } override fun onBindViewHolder(holder: Holder, position: Int) { when (val row = rows[position]) { - is CloudHubRow.Header -> holder.itemView.findViewById(R.id.cloud_header_text).text = row.title - is CloudHubRow.Media -> bindMedia(holder.itemView, row.item) - is CloudHubRow.Album -> bindAlbum(holder.itemView, row) - is CloudHubRow.Action -> bindAction(holder.itemView, row) - is CloudHubRow.Status -> bindStatus(holder.itemView, row) + is CloudHubRow.Header -> { + holder.itemView.findViewById(R.id.cloud_header_text).text = row.title + } + is CloudHubRow.FeedCard -> { bindFeedCard(holder.itemView, row) } + CloudHubRow.Shortcuts -> { bindShortcuts(holder.itemView) } + is CloudHubRow.PhotoStatus -> { bindPhotoStatus(holder.itemView, row) } + is CloudHubRow.Media -> { bindMedia(holder.itemView, row.item) } + is CloudHubRow.Storage -> { bindStorage(holder.itemView, row.item) } + is CloudHubRow.Album -> { bindAlbum(holder.itemView, row) } + is CloudHubRow.Action -> { bindAction(holder.itemView, row) } + is CloudHubRow.Status -> { bindStatus(holder.itemView, row) } } } + private fun bindFeedCard(view: View, card: CloudHubRow.FeedCard) { + val items = card.items + view.findViewById(R.id.cloud_feed_date).text = card.date + val feedTitle = when { + items.all(CloudMediaItem::isImage) -> { + view.resources.getQuantityString(R.plurals.cloud_feed_event_photos, items.size, items.size) + } + items.all(CloudMediaItem::isVideo) -> { + view.resources.getQuantityString(R.plurals.cloud_feed_event_videos, items.size, items.size) + } + else -> { + view.resources.getQuantityString(R.plurals.cloud_feed_event_files, items.size, items.size) + } + } + view.findViewById(R.id.cloud_feed_title).text = feedTitle + val mainImage = view.findViewById(R.id.cloud_feed_main_image) + items.firstOrNull()?.let { main -> + loadMediaImage(mainImage, main, PREVIEW_LARGE) + mainImage.setOnClickListener { onMediaClick(main) } + } + + val thumbnails = view.findViewById(R.id.cloud_feed_thumbnails) + val firstThumbnail = view.findViewById(R.id.cloud_feed_thumb_one) + val secondThumbnail = view.findViewById(R.id.cloud_feed_thumb_two) + val thirdThumbnail = view.findViewById(R.id.cloud_feed_thumb_three) + val lastContainer = view.findViewById(R.id.cloud_feed_last_thumb_container) + val moreCount = view.findViewById(R.id.cloud_feed_more_count) + thumbnails.visibility = if (items.size > 1) View.VISIBLE else View.GONE + bindOptionalThumbnail(firstThumbnail, items.getOrNull(1)) + bindOptionalThumbnail(secondThumbnail, items.getOrNull(2)) + val lastItem = items.getOrNull(3) + lastContainer.visibility = if (lastItem == null) View.GONE else View.VISIBLE + lastItem?.let { media -> + loadMediaImage(thirdThumbnail, media, PREVIEW_SMALL) + } + val hiddenCount = items.size - FEED_VISIBLE_MEDIA + moreCount.visibility = if (hiddenCount > 0) View.VISIBLE else View.GONE + if (hiddenCount > 0) moreCount.text = view.context.getString(R.string.cloud_feed_more_count, hiddenCount) + lastContainer.setOnClickListener { + if (hiddenCount > 0) onFeedGroupClick(card.date) else lastItem?.let(onMediaClick) + } + view.findViewById(R.id.cloud_feed_card).setOnClickListener { onFeedGroupClick(card.date) } + } + + private fun bindOptionalThumbnail(view: ImageView, item: CloudMediaItem?) { + view.visibility = if (item == null) View.GONE else View.VISIBLE + if (item != null) { + loadMediaImage(view, item, PREVIEW_SMALL) + view.setOnClickListener { onMediaClick(item) } + } else { + view.dispose() + view.setOnClickListener(null) + } + } + + private fun bindShortcuts(view: View) { + view.findViewById(R.id.cloud_shortcut_transfers).setOnClickListener { + onShortcutClick(CloudShortcut.TRANSFERS) + } + view.findViewById(R.id.cloud_shortcut_offline).setOnClickListener { + onShortcutClick(CloudShortcut.OFFLINE) + } + view.findViewById(R.id.cloud_shortcut_shares).setOnClickListener { + onShortcutClick(CloudShortcut.SHARES) + } + } + + private fun bindPhotoStatus(view: View, status: CloudHubRow.PhotoStatus) { + view.findViewById(R.id.cloud_photo_status_title).text = + view.context.getString(R.string.cloud_photo_status, status.photos, status.videos) + } + private fun bindMedia(view: View, item: CloudMediaItem) { val image = view.findViewById(R.id.cloud_media_image) val video = view.findViewById(R.id.cloud_media_video) video.visibility = if (item.isVideo) View.VISIBLE else View.GONE + loadMediaImage(image, item, PREVIEW_MEDIUM) + view.contentDescription = item.name + view.setOnClickListener { onMediaClick(item) } + } + + private fun loadMediaImage(image: ImageView, item: CloudMediaItem, size: Int) { if (item.isImage) { + image.scaleType = ImageView.ScaleType.CENTER_CROP image.load( - previewUri(item, 512), + previewUri(item, size), ThumbnailsRequester.getContentAddressedImageLoader(account), ) { placeholder(R.drawable.cloud_media_placeholder) @@ -86,29 +208,42 @@ internal class CloudHubAdapter( } } else { image.dispose() + image.scaleType = ImageView.ScaleType.CENTER_INSIDE image.setImageResource(MimetypeIconUtil.getFileTypeIconId(item.mimeType, item.name)) } + } + + private fun bindStorage(view: View, item: CloudStorageItem) { + view.findViewById(R.id.cloud_storage_title).text = item.name + view.findViewById(R.id.cloud_storage_icon).setImageResource( + if (item.isFolder) R.drawable.ic_qsfera_folder else MimetypeIconUtil.getFileTypeIconId(item.mimeType, item.name) + ) view.contentDescription = item.name - view.setOnClickListener { onMediaClick(item) } + view.setOnClickListener { onStorageClick(item) } } private fun bindAlbum(view: View, album: CloudHubRow.Album) { view.findViewById(R.id.cloud_album_title).text = album.title - view.findViewById(R.id.cloud_album_count).text = - view.context.getString(R.string.cloud_album_items, album.count) + view.findViewById(R.id.cloud_album_count).text = view.resources.getQuantityString( + R.plurals.cloud_album_items, + album.count, + album.count, + ) val cover = view.findViewById(R.id.cloud_album_cover) val coverItem = album.cover if (coverItem?.isImage == true) { + cover.scaleType = ImageView.ScaleType.CENTER_CROP cover.load( - previewUri(coverItem, 320), + previewUri(coverItem, PREVIEW_MEDIUM), ThumbnailsRequester.getContentAddressedImageLoader(account), ) { - placeholder(R.drawable.ic_qsfera_folder) + placeholder(R.drawable.cloud_media_placeholder) error(R.drawable.ic_qsfera_folder) crossfade(true) } } else { cover.dispose() + cover.scaleType = ImageView.ScaleType.CENTER_INSIDE cover.setImageResource(R.drawable.ic_qsfera_folder) } view.setOnClickListener { onAlbumClick(album.path) } @@ -137,8 +272,26 @@ internal class CloudHubAdapter( private fun bindStatus(view: View, status: CloudHubRow.Status) { view.findViewById(R.id.cloud_status_title).text = status.title view.findViewById(R.id.cloud_status_summary).text = status.summary - view.isClickable = status.retry - view.setOnClickListener(if (status.retry) View.OnClickListener { onRetry() } else null) + view.isClickable = status.retry || status.loadMore + view.setOnClickListener( + when { + status.loadMore -> View.OnClickListener { onLoadMore() } + status.retry -> View.OnClickListener { onRetry() } + else -> null + } + ) + } + + private fun stableKey(row: CloudHubRow): String = when (row) { + is CloudHubRow.Header -> { "header:${row.title}" } + is CloudHubRow.FeedCard -> { "feed:${row.date}" } + CloudHubRow.Shortcuts -> { "shortcuts" } + is CloudHubRow.PhotoStatus -> { "photo-status" } + is CloudHubRow.Media -> { "media:${row.item.spaceId.orEmpty()}:${row.item.remotePath}" } + is CloudHubRow.Storage -> { "storage:${row.item.remotePath}" } + is CloudHubRow.Album -> { "album:${row.path}" } + is CloudHubRow.Action -> { "action:${row.id}" } + is CloudHubRow.Status -> { "status:${row.title}" } } internal class Holder(itemView: View) : RecyclerView.ViewHolder(itemView) @@ -149,5 +302,13 @@ internal class CloudHubAdapter( private const val TYPE_ALBUM = 2 private const val TYPE_ACTION = 3 private const val TYPE_STATUS = 4 + private const val TYPE_FEED_CARD = 5 + private const val TYPE_SHORTCUTS = 6 + private const val TYPE_PHOTO_STATUS = 7 + private const val TYPE_STORAGE = 8 + private const val PREVIEW_SMALL = 320 + private const val PREVIEW_MEDIUM = 512 + private const val PREVIEW_LARGE = 1024 + private const val FEED_VISIBLE_MEDIA = 4 } } diff --git a/Android/qsferaApp/src/main/java/eu/qsfera/android/presentation/cloud/CloudHubFragment.kt b/Android/qsferaApp/src/main/java/eu/qsfera/android/presentation/cloud/CloudHubFragment.kt index 395840d38..c62bc1f13 100644 --- a/Android/qsferaApp/src/main/java/eu/qsfera/android/presentation/cloud/CloudHubFragment.kt +++ b/Android/qsferaApp/src/main/java/eu/qsfera/android/presentation/cloud/CloudHubFragment.kt @@ -9,28 +9,32 @@ import android.content.Intent import android.os.Bundle import android.os.Parcelable import android.view.View +import android.widget.Toast import androidx.activity.OnBackPressedCallback import androidx.core.os.bundleOf +import androidx.core.view.isVisible import androidx.fragment.app.Fragment import androidx.lifecycle.lifecycleScope import androidx.recyclerview.widget.GridLayoutManager import androidx.recyclerview.widget.RecyclerView import androidx.swiperefreshlayout.widget.SwipeRefreshLayout +import com.google.android.material.floatingactionbutton.FloatingActionButton import eu.qsfera.android.R import eu.qsfera.android.data.ClientManager import eu.qsfera.android.data.executeRemoteOperation import eu.qsfera.android.domain.files.model.FileListOption +import eu.qsfera.android.domain.files.FileRepository import eu.qsfera.android.domain.transfers.TransferRepository import eu.qsfera.android.domain.transfers.model.UploadEnqueuedBy import eu.qsfera.android.lib.common.accounts.AccountUtils as LibraryAccountUtils import eu.qsfera.android.lib.resources.files.search.MediaSearchRequest import eu.qsfera.android.lib.resources.files.search.MediaSearchType import eu.qsfera.android.presentation.authentication.AccountUtils -import eu.qsfera.android.presentation.settings.SettingsActivity import eu.qsfera.android.ui.activity.FileActivity import eu.qsfera.android.ui.activity.FileDisplayActivity import eu.qsfera.android.ui.activity.UploadListActivity import eu.qsfera.android.utils.MimetypeIconUtil +import kotlinx.coroutines.CancellationException import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Job import kotlinx.coroutines.launch @@ -42,31 +46,42 @@ import java.util.Locale class CloudHubFragment : Fragment(R.layout.fragment_cloud_hub) { private val clientManager: ClientManager by inject() + private val fileRepository: FileRepository by inject() private val transferRepository: TransferRepository by inject() private lateinit var section: CloudSection private lateinit var adapter: CloudHubAdapter private lateinit var refresh: SwipeRefreshLayout + private lateinit var recycler: RecyclerView + private lateinit var fab: FloatingActionButton private var allMedia: List = emptyList() + private var storageItems: List = emptyList() private var query: String = "" private var activeAlbumPath: String? = null + private var activeFeedDate: String? = null private var loadJob: Job? = null + private var loadGeneration = 0L private var nextOffset = 0 private var isLoading = false private var reachedEnd = false + private var mediaLoaded = false + private var filesLoaded = false - private val albumBackCallback = object : OnBackPressedCallback(false) { + var currentFolderPath: String = ROOT_PATH + private set + var currentFolderSpaceId: String? = null + private set + + private val nestedBackCallback = object : OnBackPressedCallback(false) { override fun handleOnBackPressed() { - activeAlbumPath = null - isEnabled = false - render() + navigateUp() } } override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) section = CloudSection.fromWireValue(arguments?.getString(ARG_SECTION)) - requireActivity().onBackPressedDispatcher.addCallback(this, albumBackCallback) + requireActivity().onBackPressedDispatcher.addCallback(this, nestedBackCallback) } override fun onViewCreated(view: View, savedInstanceState: Bundle?) { @@ -75,15 +90,15 @@ class CloudHubFragment : Fragment(R.layout.fragment_cloud_hub) { adapter = CloudHubAdapter( account = account, onMediaClick = ::openMedia, - onAlbumClick = { albumPath -> - activeAlbumPath = albumPath - albumBackCallback.isEnabled = true - render() - }, + onAlbumClick = ::openAlbum, + onStorageClick = ::openStorage, + onShortcutClick = ::openShortcut, onActionClick = ::openAction, onRetry = ::reload, + onLoadMore = { loadNextMediaPage(reset = false) }, + onFeedGroupClick = ::openFeedGroup, ) - view.findViewById(R.id.cloud_list).apply { + recycler = view.findViewById(R.id.cloud_list).apply { layoutManager = GridLayoutManager(requireContext(), GRID_SPANS).also { layout -> layout.spanSizeLookup = object : GridLayoutManager.SpanSizeLookup() { override fun getSpanSize(position: Int): Int = this@CloudHubFragment.adapter.spanSize(position) @@ -92,10 +107,11 @@ class CloudHubFragment : Fragment(R.layout.fragment_cloud_hub) { adapter = this@CloudHubFragment.adapter addOnScrollListener(object : RecyclerView.OnScrollListener() { override fun onScrolled(recyclerView: RecyclerView, dx: Int, dy: Int) { + if (section == CloudSection.FILES || section == CloudSection.MORE) return if (dy <= 0 || isLoading || reachedEnd) return val layout = recyclerView.layoutManager as? GridLayoutManager ?: return if (layout.findLastVisibleItemPosition() >= this@CloudHubFragment.adapter.itemCount - LOAD_MORE_THRESHOLD) { - loadNextPage(reset = false) + loadNextMediaPage(reset = false) } } }) @@ -103,51 +119,147 @@ class CloudHubFragment : Fragment(R.layout.fragment_cloud_hub) { refresh = view.findViewById(R.id.cloud_refresh).apply { setColorSchemeResources(R.color.qsfera_blue) setOnRefreshListener(::reload) - isEnabled = section != CloudSection.MORE } + fab = view.findViewById(R.id.cloud_fab).apply { + setOnClickListener { (activity as? CloudHomeActivity)?.showAddSheet() } + } + configureSection() reload() } + fun showSection(newSection: CloudSection) { + if (!this::adapter.isInitialized) { + section = newSection + return + } + invalidateActiveLoad() + if (currentFolderPath != ROOT_PATH) { + filesLoaded = false + storageItems = emptyList() + } + section = newSection + activeAlbumPath = null + activeFeedDate = null + currentFolderPath = ROOT_PATH + nestedBackCallback.isEnabled = false + (activity as? CloudHomeActivity)?.restoreSectionTitle() + configureSection() + when { + section == CloudSection.FILES && filesLoaded -> render() + section == CloudSection.FILES -> reloadFiles() + mediaLoaded -> render() + else -> reloadMedia() + } + } + + private fun configureSection() { + val sidePadding = when (section) { + CloudSection.FILES -> { resources.getDimensionPixelSize(R.dimen.cloud_files_side_padding) } + CloudSection.ALBUMS -> { resources.getDimensionPixelSize(R.dimen.cloud_albums_side_padding) } + CloudSection.FEED, + CloudSection.PHOTOS, + CloudSection.MORE -> { 0 } + } + recycler.setPadding(sidePadding, 0, sidePadding, resources.getDimensionPixelSize(R.dimen.cloud_list_bottom_padding)) + fab.isVisible = section == CloudSection.FILES || section == CloudSection.PHOTOS + refresh.isEnabled = section != CloudSection.MORE + } + fun reload() { if (!isAdded || !this::adapter.isInitialized) return - if (section == CloudSection.MORE) { - allMedia = emptyList() - render() - return + when (section) { + CloudSection.FILES -> { reloadFiles() } + CloudSection.MORE -> { adapter.submitRows(emptyList()) } + CloudSection.FEED, + CloudSection.PHOTOS, + CloudSection.ALBUMS -> { reloadMedia() } } + } - loadJob?.cancel() + fun reloadCurrentFiles() { + if (section == CloudSection.FILES) reloadFiles() + } + + fun onUploadsCompleted() { + if (!isAdded || !this::adapter.isInitialized) return + invalidateActiveLoad() + mediaLoaded = false + filesLoaded = false + when (section) { + CloudSection.FILES -> reloadFiles() + CloudSection.FEED, + CloudSection.PHOTOS, + CloudSection.ALBUMS -> reloadMedia() + CloudSection.MORE -> Unit + } + } + + fun onAccountChanged() { + if (!isAdded || !this::adapter.isInitialized) return + invalidateActiveLoad() + adapter.updateAccount(AccountUtils.getCurrentQSferaAccount(requireContext())) + allMedia = emptyList() + storageItems = emptyList() + query = "" + activeAlbumPath = null + activeFeedDate = null + currentFolderPath = ROOT_PATH + currentFolderSpaceId = null + nextOffset = 0 isLoading = false + reachedEnd = false + mediaLoaded = false + filesLoaded = false + nestedBackCallback.isEnabled = false + (activity as? CloudHomeActivity)?.restoreSectionTitle() + configureSection() + reload() + } + + private fun reloadMedia() { + invalidateActiveLoad() nextOffset = 0 reachedEnd = false allMedia = emptyList() + mediaLoaded = false refresh.isRefreshing = true - adapter.submitRows( - listOf(CloudHubRow.Status(getString(R.string.cloud_media_loading), "")) - ) - loadNextPage(reset = true) + adapter.submitRows(listOf(CloudHubRow.Status(getString(R.string.cloud_media_loading), ""))) + loadNextMediaPage(reset = true) } - private fun loadNextPage(reset: Boolean) { - if (isLoading || reachedEnd || section == CloudSection.MORE) return + private fun loadNextMediaPage(reset: Boolean) { + if (isLoading || reachedEnd || section == CloudSection.FILES || section == CloudSection.MORE) return isLoading = true val requestedOffset = if (reset) 0 else nextOffset + val requestedSection = section + val requestGeneration = ++loadGeneration loadJob = viewLifecycleOwner.lifecycleScope.launch { - val result = runCatching { withContext(Dispatchers.IO) { loadMediaPage(requestedOffset) } } - if (!isAdded) return@launch - isLoading = false - refresh.isRefreshing = false + val result = try { + Result.success(withContext(Dispatchers.IO) { loadMediaPage(requestedOffset) }) + } catch (cancelled: CancellationException) { + throw cancelled + } catch (throwable: Throwable) { + Result.failure(throwable) + } + if (!isAdded || requestGeneration != loadGeneration || section != requestedSection) return@launch result.onSuccess { page -> + isLoading = false + refresh.isRefreshing = false nextOffset = requestedOffset + page.rawResultCount reachedEnd = page.rawResultCount < MEDIA_PAGE_SIZE + mediaLoaded = true allMedia = (if (reset) page.items else allMedia + page.items) .distinctBy { it.webDavHref.ifBlank { "${it.spaceId.orEmpty()}:${it.remotePath}" } } .sortedByDescending { it.modifiedAt } render() }.onFailure { val fallback = withContext(Dispatchers.IO) { recentAutomaticUploads() } - if (reset && section == CloudSection.FEED && fallback.isNotEmpty()) { + if (!isAdded || requestGeneration != loadGeneration || section != requestedSection) return@launch + isLoading = false + refresh.isRefreshing = false + if (reset && fallback.isNotEmpty()) { allMedia = fallback + mediaLoaded = true reachedEnd = true render() } else if (reset) { @@ -165,6 +277,114 @@ class CloudHubFragment : Fragment(R.layout.fragment_cloud_hub) { } } + private fun reloadFiles() { + invalidateActiveLoad() + isLoading = true + filesLoaded = false + refresh.isRefreshing = true + adapter.submitRows(listOf(CloudHubRow.Status(getString(R.string.cloud_media_loading), ""))) + val requestedPath = currentFolderPath + val accountName = AccountUtils.getCurrentQSferaAccount(requireContext()).name + val requestGeneration = ++loadGeneration + loadJob = viewLifecycleOwner.lifecycleScope.launch { + val result = try { + Result.success( + withContext(Dispatchers.IO) { + loadStorageFolder(requestedPath, accountName) + } + ) + } catch (cancelled: CancellationException) { + throw cancelled + } catch (throwable: Throwable) { + Result.failure(throwable) + } + if ( + !isAdded || + requestGeneration != loadGeneration || + section != CloudSection.FILES || + requestedPath != currentFolderPath + ) return@launch + isLoading = false + refresh.isRefreshing = false + result.onSuccess { loadedFolder -> + storageItems = loadedFolder.items + currentFolderSpaceId = loadedFolder.spaceId + filesLoaded = true + render() + if (loadedFolder.usedCacheAfterRefreshFailure) { + Toast.makeText(requireContext(), R.string.cloud_files_cached, Toast.LENGTH_LONG).show() + } + }.onFailure { + adapter.submitRows( + listOf( + CloudHubRow.Status( + getString(R.string.cloud_files_load_error), + getString(R.string.cloud_media_retry), + retry = true, + ) + ) + ) + } + } + } + + private fun loadStorageFolder(requestedPath: String, accountName: String): LoadedStorageFolder { + val rootFolder = fileRepository.getPersonalRootFolderForAccount(accountName) + val cachedFolder = storedFolder(requestedPath, accountName, rootFolder.spaceId) + val refreshFailure = runCatching { + fileRepository.refreshFolder(requestedPath, accountName, rootFolder.spaceId) + }.exceptionOrNull() + val refreshedFolder = if (refreshFailure == null) { + storedFolder(requestedPath, accountName, rootFolder.spaceId) + } else { + null + } + val selectedFolder = refreshedFolder ?: cachedFolder + ?: throw (refreshFailure ?: IllegalStateException("Refreshed folder was not stored: $requestedPath")) + return LoadedStorageFolder( + items = storageItems(selectedFolder.id, requestedPath), + spaceId = selectedFolder.spaceId, + usedCacheAfterRefreshFailure = refreshFailure != null, + ) + } + + private fun storedFolder(requestedPath: String, accountName: String, spaceId: String?) = + if (sameRemotePath(requestedPath, ROOT_PATH)) { + fileRepository.getPersonalRootFolderForAccount(accountName) + } else { + fileRepository.getFileByRemotePath(requestedPath, accountName, spaceId) + } + + private fun storageItems(folderId: Long?, requestedPath: String): List { + val storedFolderId = folderId ?: error("Stored folder has no local id: $requestedPath") + return fileRepository.getFolderContent(storedFolderId) + .asSequence() + .filterNot { sameRemotePath(it.remotePath, requestedPath) } + .map { remote -> + CloudStorageItem( + remotePath = remote.remotePath, + mimeType = remote.mimeType, + size = remote.length, + modifiedAt = remote.modificationTimestamp, + owner = remote.owner, + etag = remote.remoteEtag.orEmpty().ifBlank { remote.etag.orEmpty() }, + remoteId = remote.remoteId, + permissions = remote.permissions, + spaceId = remote.spaceId, + ) + } + .sortedWith(compareByDescending { it.isFolder }.thenBy { it.name.lowercase() }) + .toList() + } + + private fun invalidateActiveLoad() { + loadJob?.cancel() + loadJob = null + loadGeneration++ + isLoading = false + if (this::refresh.isInitialized) refresh.isRefreshing = false + } + fun filter(newQuery: String) { query = newQuery.trim() render() @@ -195,9 +415,7 @@ class CloudHubFragment : Fragment(R.layout.fragment_cloud_hub) { } else { remote.path.removePrefix(davPrefix) } - val remotePath = pathWithoutDavPrefix.let { path -> - if (path.startsWith('/')) path else "/$path" - } + val remotePath = pathWithoutDavPrefix.let { path -> if (path.startsWith('/')) path else "/$path" } CloudMediaItem( webDavHref = remote.href, remotePath = remotePath, @@ -221,10 +439,9 @@ class CloudHubFragment : Fragment(R.layout.fragment_cloud_hub) { transfer.transferEndTimestamp != null } .map { transfer -> - val mimeType = MimetypeIconUtil.getBestMimeTypeByFilename(transfer.remotePath) CloudMediaItem( remotePath = transfer.remotePath, - mimeType = mimeType, + mimeType = MimetypeIconUtil.getBestMimeTypeByFilename(transfer.remotePath), size = transfer.fileSize, modifiedAt = transfer.transferEndTimestamp ?: 0L, ) @@ -238,24 +455,23 @@ class CloudHubFragment : Fragment(R.layout.fragment_cloud_hub) { private fun render() { if (!this::adapter.isInitialized) return - if (section == CloudSection.MORE) { - adapter.submitRows(moreRows()) - return - } - - val filtered = allMedia.filter { media -> - query.isBlank() || media.name.contains(query, ignoreCase = true) || - media.parentPath.contains(query, ignoreCase = true) - } val rows = when (section) { - CloudSection.FEED -> feedRows(filtered) - CloudSection.PHOTOS -> photoRows(filtered) - CloudSection.ALBUMS -> albumRows(filtered) - CloudSection.MORE -> moreRows() + CloudSection.FEED -> withMediaPagination( + activeFeedDate?.let { feedGroupRows(filteredMedia(), it) } ?: feedRows(filteredMedia()) + ) + CloudSection.FILES -> fileRows() + CloudSection.PHOTOS -> withMediaPagination(photoRows(filteredMedia())) + CloudSection.ALBUMS -> withMediaPagination(albumRows(filteredMedia())) + CloudSection.MORE -> emptyList() } adapter.submitRows(rows) } + private fun filteredMedia(): List = allMedia.filter { media -> + query.isBlank() || media.name.contains(query, ignoreCase = true) || + media.parentPath.contains(query, ignoreCase = true) + } + private fun feedRows(media: List): List { if (media.isEmpty()) return listOf( CloudHubRow.Status( @@ -264,24 +480,66 @@ class CloudHubFragment : Fragment(R.layout.fragment_cloud_hub) { ) ) val dateFormat = DateFormat.getDateInstance(DateFormat.LONG, Locale.getDefault()) + return media.groupBy { dateFormat.format(Date(it.modifiedAt)) } + .map { (date, items) -> CloudHubRow.FeedCard(date, items) } + } + + private fun feedGroupRows(media: List, date: String): List { + val dateFormat = DateFormat.getDateInstance(DateFormat.LONG, Locale.getDefault()) + val items = media.filter { dateFormat.format(Date(it.modifiedAt)) == date } + return if (items.isEmpty()) { + listOf( + CloudHubRow.Status( + getString(R.string.cloud_photos_empty_title), + getString(R.string.cloud_photos_empty_summary), + ) + ) + } else { + items.map(CloudHubRow::Media) + } + } + + private fun withMediaPagination(rows: List): List = + if (mediaLoaded && !reachedEnd && !isLoading) { + rows + CloudHubRow.Status( + title = getString(R.string.cloud_media_load_more), + summary = "", + loadMore = true, + ) + } else { + rows + } + + private fun fileRows(): List { + val filtered = storageItems.filter { item -> + query.isBlank() || item.name.contains(query, ignoreCase = true) + } return buildList { - media.groupBy { dateFormat.format(Date(it.modifiedAt)) }.forEach { (date, items) -> - add(CloudHubRow.Header(date)) - addAll(items.map { CloudHubRow.Media(it) }) + if (currentFolderPath == ROOT_PATH && query.isBlank()) add(CloudHubRow.Shortcuts) + if (filtered.isEmpty() && filesLoaded) { + add( + CloudHubRow.Status( + getString(R.string.cloud_files_empty_title), + getString(R.string.cloud_files_empty_summary), + ) + ) + } else { + addAll(filtered.map(CloudHubRow::Storage)) } } } - private fun photoRows(media: List): List { - if (media.isEmpty()) return listOf( - CloudHubRow.Status( - getString(R.string.cloud_photos_empty_title), - getString(R.string.cloud_photos_empty_summary), + private fun photoRows(media: List): List = buildList { + add(CloudHubRow.PhotoStatus(media.count(CloudMediaItem::isImage), media.count(CloudMediaItem::isVideo))) + if (media.isEmpty()) { + add( + CloudHubRow.Status( + getString(R.string.cloud_photos_empty_title), + getString(R.string.cloud_photos_empty_summary), + ) ) - ) - return buildList { - add(CloudHubRow.Header(getString(R.string.cloud_all_photos))) - addAll(media.map { CloudHubRow.Media(it) }) + } else { + addAll(media.map(CloudHubRow::Media)) } } @@ -289,11 +547,7 @@ class CloudHubFragment : Fragment(R.layout.fragment_cloud_hub) { val selectedAlbumKey = activeAlbumPath if (selectedAlbumKey != null) { val albumMedia = media.filter { it.albumKey == selectedAlbumKey } - val displayPath = albumMedia.firstOrNull()?.parentPath.orEmpty() - return buildList { - add(CloudHubRow.Header(displayPath.substringAfterLast('/').ifBlank { getString(R.string.cloud_albums_title) })) - addAll(albumMedia.map { CloudHubRow.Media(it) }) - } + return albumMedia.map(CloudHubRow::Media) } if (media.isEmpty()) return listOf( CloudHubRow.Status( @@ -315,54 +569,82 @@ class CloudHubFragment : Fragment(R.layout.fragment_cloud_hub) { } } - private fun moreRows(): List = listOf( - CloudHubRow.Action( - CloudAction.STORAGE, - getString(R.string.cloud_more_storage), - getString(R.string.cloud_more_storage_summary), - R.drawable.ic_folder, - ), - CloudHubRow.Action( - CloudAction.TRANSFERS, - getString(R.string.cloud_more_transfers), - getString(R.string.cloud_more_transfers_summary), - R.drawable.ic_uploads, - ), - CloudHubRow.Action( - CloudAction.OFFLINE, - getString(R.string.cloud_more_offline), - getString(R.string.cloud_more_offline_summary), - R.drawable.ic_available_offline, - ), - CloudHubRow.Action( - CloudAction.SHARES, - getString(R.string.cloud_more_shares), - getString(R.string.cloud_more_shares_summary), - R.drawable.ic_shared_by_link, - ), - CloudHubRow.Action( - CloudAction.SPACES, - getString(R.string.cloud_more_spaces), - getString(R.string.cloud_more_spaces_summary), - R.drawable.ic_spaces, - ), - CloudHubRow.Action( - CloudAction.SETTINGS, - getString(R.string.cloud_more_settings), - getString(R.string.cloud_more_settings_summary), - R.drawable.ic_settings, - ), - ) - private fun openMedia(media: CloudMediaItem) { startActivity(CloudMediaPreviewActivity.createIntent(requireContext(), media)) } + private fun openFeedGroup(date: String) { + activeFeedDate = date + nestedBackCallback.isEnabled = true + (activity as? CloudHomeActivity)?.showNestedTitle(date) + render() + } + + private fun openAlbum(albumPath: String) { + activeAlbumPath = albumPath + val first = allMedia.firstOrNull { it.albumKey == albumPath } + val title = first?.parentPath?.substringAfterLast('/').orEmpty().ifBlank { getString(R.string.cloud_albums_title) } + nestedBackCallback.isEnabled = true + (activity as? CloudHomeActivity)?.showNestedTitle(title) + render() + } + + private fun openStorage(item: CloudStorageItem) { + if (item.isFolder) { + currentFolderPath = item.remotePath.ensureFolderPath() + currentFolderSpaceId = item.spaceId + filesLoaded = false + nestedBackCallback.isEnabled = true + (activity as? CloudHomeActivity)?.showNestedTitle(item.name) + reloadFiles() + } else { + (activity as? CloudHomeActivity)?.openStorageItem(item) + } + } + + fun navigateUp() { + when { + activeFeedDate != null -> { + activeFeedDate = null + nestedBackCallback.isEnabled = false + (activity as? CloudHomeActivity)?.restoreSectionTitle() + render() + } + activeAlbumPath != null -> { + activeAlbumPath = null + nestedBackCallback.isEnabled = false + (activity as? CloudHomeActivity)?.restoreSectionTitle() + render() + } + section == CloudSection.FILES && currentFolderPath != ROOT_PATH -> { + currentFolderPath = parentFolder(currentFolderPath) + filesLoaded = false + if (currentFolderPath == ROOT_PATH) { + nestedBackCallback.isEnabled = false + (activity as? CloudHomeActivity)?.restoreSectionTitle() + } else { + (activity as? CloudHomeActivity)?.showNestedTitle( + currentFolderPath.trimEnd('/').substringAfterLast('/') + ) + } + reloadFiles() + } + } + } + + private fun openShortcut(shortcut: CloudShortcut) { + when (shortcut) { + CloudShortcut.TRANSFERS -> startActivity(Intent(requireContext(), UploadListActivity::class.java)) + CloudShortcut.OFFLINE -> openFileList(FileListOption.AV_OFFLINE) + CloudShortcut.SHARES -> openFileList(FileListOption.SHARED_BY_LINK) + } + } + private fun openAction(action: CloudAction) { when (action) { CloudAction.TRANSFERS -> startActivity(Intent(requireContext(), UploadListActivity::class.java)) - CloudAction.SETTINGS -> startActivity(Intent(requireContext(), SettingsActivity::class.java)) - CloudAction.STORAGE -> openFileList(FileListOption.ALL_FILES) + CloudAction.SETTINGS -> startActivity(Intent(requireContext(), eu.qsfera.android.presentation.settings.SettingsActivity::class.java)) + CloudAction.STORAGE -> (activity as? CloudHomeActivity)?.navigateToOption(FileListOption.ALL_FILES) CloudAction.OFFLINE -> openFileList(FileListOption.AV_OFFLINE) CloudAction.SHARES -> openFileList(FileListOption.SHARED_BY_LINK) CloudAction.SPACES -> openFileList(FileListOption.SPACES_LIST) @@ -380,10 +662,21 @@ class CloudHubFragment : Fragment(R.layout.fragment_cloud_hub) { private const val GRID_SPANS = 6 private const val MEDIA_PAGE_SIZE = 200 private const val LOAD_MORE_THRESHOLD = 18 + private const val ROOT_PATH = "/" fun newInstance(section: CloudSection): CloudHubFragment = CloudHubFragment().apply { arguments = bundleOf(ARG_SECTION to section.wireValue) } + + private fun sameRemotePath(left: String, right: String): Boolean = + left.trimEnd('/').ifBlank { "/" } == right.trimEnd('/').ifBlank { "/" } + + private fun String.ensureFolderPath(): String = if (endsWith('/')) this else "$this/" + + private fun parentFolder(path: String): String { + val parent = path.trimEnd('/').substringBeforeLast('/', missingDelimiterValue = "") + return if (parent.isBlank()) ROOT_PATH else "$parent/" + } } } @@ -391,3 +684,9 @@ private data class LoadedMediaPage( val items: List, val rawResultCount: Int, ) + +private data class LoadedStorageFolder( + val items: List, + val spaceId: String?, + val usedCacheAfterRefreshFailure: Boolean, +) diff --git a/Android/qsferaApp/src/main/java/eu/qsfera/android/presentation/cloud/CloudMediaPreviewActivity.kt b/Android/qsferaApp/src/main/java/eu/qsfera/android/presentation/cloud/CloudMediaPreviewActivity.kt index e1d9b460c..7b06ddccd 100644 --- a/Android/qsferaApp/src/main/java/eu/qsfera/android/presentation/cloud/CloudMediaPreviewActivity.kt +++ b/Android/qsferaApp/src/main/java/eu/qsfera/android/presentation/cloud/CloudMediaPreviewActivity.kt @@ -5,6 +5,7 @@ */ package eu.qsfera.android.presentation.cloud +import android.accounts.Account import android.content.Context import android.content.Intent import android.os.Build @@ -61,13 +62,7 @@ class CloudMediaPreviewActivity : AppCompatActivity() { val photo = findViewById(R.id.cloud_preview_photo).apply { visibility = View.VISIBLE } val progress = findViewById(R.id.cloud_preview_progress) photo.load( - ThumbnailsRequester.getPreviewUriForWebDavHref( - media.webDavHref, - account, - media.etag.ifBlank { media.modifiedAt.toString() }, - 2560, - 2560, - ), + previewUri(media, account, 2560, 2560), ThumbnailsRequester.getContentAddressedImageLoader(account), ) { crossfade(true) @@ -87,13 +82,7 @@ class CloudMediaPreviewActivity : AppCompatActivity() { val prepared = runCatching { withContext(Dispatchers.IO) { val client = clientManager.getClientForCoilThumbnails(account.name) - val contentUrl = ThumbnailsRequester.getPreviewUriForWebDavHref( - media.webDavHref, - account, - media.modifiedAt.toString(), - 1, - 1, - ).substringBefore('?') + val contentUrl = previewUri(media, account, 1, 1).substringBefore('?') contentUrl to client.credentials?.headerAuth.orEmpty() } }.getOrNull() ?: run { @@ -119,6 +108,25 @@ class CloudMediaPreviewActivity : AppCompatActivity() { } } + private fun previewUri(media: CloudMediaItem, account: Account, width: Int, height: Int): String = + if (media.webDavHref.isNotBlank()) { + ThumbnailsRequester.getPreviewUriForWebDavHref( + media.webDavHref, + account, + media.etag.ifBlank { media.modifiedAt.toString() }, + width, + height, + ) + } else { + ThumbnailsRequester.getPreviewUriForFile( + media.toOCFile(account.name), + account, + media.etag.ifBlank { media.modifiedAt.toString() }, + width, + height, + ) + } + @Suppress("DEPRECATION") private fun mediaExtra(): CloudMediaItem? = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { intent.getParcelableExtra(EXTRA_MEDIA, CloudMediaItem::class.java) diff --git a/Android/qsferaApp/src/main/java/eu/qsfera/android/presentation/cloud/CloudModels.kt b/Android/qsferaApp/src/main/java/eu/qsfera/android/presentation/cloud/CloudModels.kt index 1c97d948d..903995858 100644 --- a/Android/qsferaApp/src/main/java/eu/qsfera/android/presentation/cloud/CloudModels.kt +++ b/Android/qsferaApp/src/main/java/eu/qsfera/android/presentation/cloud/CloudModels.kt @@ -37,8 +37,47 @@ data class CloudMediaItem( ) } +data class CloudStorageItem( + val remotePath: String, + val mimeType: String, + val size: Long, + val modifiedAt: Long, + val owner: String, + val etag: String = "", + val remoteId: String? = null, + val permissions: String? = null, + val spaceId: String? = null, +) { + val name: String get() = remotePath.trimEnd('/').substringAfterLast('/').ifBlank { "/" } + val isFolder: Boolean get() = mimeType == "DIR" || mimeType == "httpd/unix-directory" + val isImage: Boolean get() = mimeType.startsWith("image/") + val isVideo: Boolean get() = mimeType.startsWith("video/") + + fun toOCFile(): OCFile = OCFile( + owner = owner, + remoteId = remoteId, + remotePath = if (isFolder && !remotePath.endsWith('/')) "$remotePath/" else remotePath, + length = size, + modificationTimestamp = modifiedAt, + mimeType = mimeType, + etag = etag, + remoteEtag = etag, + permissions = permissions, + spaceId = spaceId, + ) + + fun toCloudMedia(): CloudMediaItem = CloudMediaItem( + remotePath = remotePath, + mimeType = mimeType, + size = size, + modifiedAt = modifiedAt, + etag = etag, + ) +} + enum class CloudSection(val wireValue: String) { FEED("feed"), + FILES("files"), PHOTOS("photos"), ALBUMS("albums"), MORE("more"); @@ -50,10 +89,25 @@ enum class CloudSection(val wireValue: String) { internal sealed interface CloudHubRow { data class Header(val title: String) : CloudHubRow + data class FeedCard(val date: String, val items: List) : CloudHubRow + data object Shortcuts : CloudHubRow + data class PhotoStatus(val photos: Int, val videos: Int) : CloudHubRow data class Media(val item: CloudMediaItem) : CloudHubRow + data class Storage(val item: CloudStorageItem) : CloudHubRow data class Album(val path: String, val title: String, val count: Int, val cover: CloudMediaItem?) : CloudHubRow data class Action(val id: CloudAction, val title: String, val summary: String, val icon: Int) : CloudHubRow - data class Status(val title: String, val summary: String, val retry: Boolean = false) : CloudHubRow + data class Status( + val title: String, + val summary: String, + val retry: Boolean = false, + val loadMore: Boolean = false, + ) : CloudHubRow +} + +internal enum class CloudShortcut { + TRANSFERS, + OFFLINE, + SHARES, } internal enum class CloudAction { diff --git a/Android/qsferaApp/src/main/java/eu/qsfera/android/presentation/documentsprovider/DocumentsStorageProvider.kt b/Android/qsferaApp/src/main/java/eu/qsfera/android/presentation/documentsprovider/DocumentsStorageProvider.kt index a362c13ec..ee1bf18a0 100644 --- a/Android/qsferaApp/src/main/java/eu/qsfera/android/presentation/documentsprovider/DocumentsStorageProvider.kt +++ b/Android/qsferaApp/src/main/java/eu/qsfera/android/presentation/documentsprovider/DocumentsStorageProvider.kt @@ -691,6 +691,10 @@ class DocumentsStorageProvider : DocumentsProvider() { return false } val workInfo = workManager.getWorkInfoById(workerId).get() + if (workInfo == null) { + Timber.w("Download worker $workerId is no longer available") + return false + } Timber.d("Download worker $workerId state: ${workInfo.state}") when (workInfo.state) { WorkInfo.State.SUCCEEDED -> return true diff --git a/Android/qsferaApp/src/main/java/eu/qsfera/android/presentation/settings/SettingsActivity.kt b/Android/qsferaApp/src/main/java/eu/qsfera/android/presentation/settings/SettingsActivity.kt index 8fcb55aac..43b724fed 100644 --- a/Android/qsferaApp/src/main/java/eu/qsfera/android/presentation/settings/SettingsActivity.kt +++ b/Android/qsferaApp/src/main/java/eu/qsfera/android/presentation/settings/SettingsActivity.kt @@ -22,6 +22,7 @@ package eu.qsfera.android.presentation.settings +import android.content.Context import android.content.Intent import android.os.Bundle import android.view.MenuItem @@ -32,14 +33,15 @@ import androidx.constraintlayout.widget.ConstraintLayout import androidx.core.view.isVisible import androidx.core.view.updatePadding import eu.qsfera.android.R +import eu.qsfera.android.presentation.cloud.CloudHomeActivity +import eu.qsfera.android.presentation.cloud.CloudSection import eu.qsfera.android.presentation.settings.advanced.SettingsAdvancedFragment +import eu.qsfera.android.presentation.settings.automaticuploads.SettingsAutomaticUploadsFragment import eu.qsfera.android.presentation.settings.automaticuploads.SettingsPictureUploadsFragment import eu.qsfera.android.presentation.settings.automaticuploads.SettingsVideoUploadsFragment import eu.qsfera.android.presentation.settings.logging.SettingsLogsFragment import eu.qsfera.android.presentation.settings.more.SettingsMoreFragment import eu.qsfera.android.presentation.settings.security.SettingsSecurityFragment -import eu.qsfera.android.presentation.cloud.CloudHomeActivity -import eu.qsfera.android.presentation.cloud.CloudSection import eu.qsfera.android.ui.activity.enableEdgeToEdgePostSetContentView import eu.qsfera.android.ui.activity.enableEdgeToEdgePreSetContentView @@ -75,7 +77,8 @@ class SettingsActivity : AppCompatActivity() { } private fun updateToolbarTitle() { - val titleId = when (supportFragmentManager.fragments.lastOrNull()) { + val visibleFragment = supportFragmentManager.fragments.lastOrNull() + val titleId = when (visibleFragment) { is SettingsSecurityFragment -> R.string.prefs_subsection_security is SettingsLogsFragment -> R.string.prefs_subsection_logging is SettingsPictureUploadsFragment -> R.string.prefs_subsection_picture_uploads @@ -84,8 +87,13 @@ class SettingsActivity : AppCompatActivity() { is SettingsMoreFragment -> R.string.prefs_subsection_more else -> R.string.actionbar_settings } - setTitle(titleId) - supportActionBar?.setTitle(titleId) + if (visibleFragment is SettingsAutomaticUploadsFragment) { + title = "" + supportActionBar?.title = "" + } else { + setTitle(titleId) + supportActionBar?.setTitle(titleId) + } } override fun onOptionsItemSelected(item: MenuItem): Boolean { @@ -104,20 +112,28 @@ class SettingsActivity : AppCompatActivity() { private fun redirectToSubsection(intent: Intent?) { val fragment = when (intent?.getStringExtra(KEY_NOTIFICATION_INTENT)) { - NOTIFICATION_INTENT_PICTURES -> SettingsPictureUploadsFragment() - NOTIFICATION_INTENT_VIDEOS -> SettingsVideoUploadsFragment() + NOTIFICATION_INTENT_AUTOMATIC_UPLOADS, + NOTIFICATION_INTENT_PICTURES, + NOTIFICATION_INTENT_VIDEOS -> SettingsAutomaticUploadsFragment() else -> SettingsFragment() } supportFragmentManager .beginTransaction() .replace(R.id.settings_container, fragment) + .runOnCommit(::updateToolbarTitle) .commit() } companion object { const val KEY_NOTIFICATION_INTENT = "key_notification_intent" + const val NOTIFICATION_INTENT_AUTOMATIC_UPLOADS = "automatic_uploads" const val NOTIFICATION_INTENT_PICTURES = "picture_uploads" const val NOTIFICATION_INTENT_VIDEOS = "video_uploads" + + fun createIntent(context: Context, notificationIntent: String? = null): Intent = + Intent(context, SettingsActivity::class.java).apply { + notificationIntent?.let { putExtra(KEY_NOTIFICATION_INTENT, it) } + } } } diff --git a/Android/qsferaApp/src/main/java/eu/qsfera/android/presentation/settings/SettingsFragment.kt b/Android/qsferaApp/src/main/java/eu/qsfera/android/presentation/settings/SettingsFragment.kt index 2f5e7b18e..357b1e5b9 100644 --- a/Android/qsferaApp/src/main/java/eu/qsfera/android/presentation/settings/SettingsFragment.kt +++ b/Android/qsferaApp/src/main/java/eu/qsfera/android/presentation/settings/SettingsFragment.kt @@ -48,8 +48,7 @@ class SettingsFragment : PreferenceFragmentCompat() { private val releaseNotesViewModel by viewModel() private var settingsScreen: PreferenceScreen? = null - private var subsectionPictureUploads: Preference? = null - private var subsectionVideoUploads: Preference? = null + private var subsectionAutomaticUploads: Preference? = null private var subsectionMore: Preference? = null private var prefPrivacyPolicy: Preference? = null private var subsectionWhatsNew: Preference? = null @@ -61,8 +60,7 @@ class SettingsFragment : PreferenceFragmentCompat() { setPreferencesFromResource(R.xml.settings, rootKey) settingsScreen = findPreference(SCREEN_SETTINGS) - subsectionPictureUploads = findPreference(SUBSECTION_PICTURE_UPLOADS) - subsectionVideoUploads = findPreference(SUBSECTION_VIDEO_UPLOADS) + subsectionAutomaticUploads = findPreference(SUBSECTION_AUTOMATIC_UPLOADS) subsectionMore = findPreference(SUBSECTION_MORE) prefPrivacyPolicy = findPreference(PREFERENCE_PRIVACY_POLICY) subsectionWhatsNew = findPreference(SUBSECTION_WHATSNEW) @@ -70,8 +68,7 @@ class SettingsFragment : PreferenceFragmentCompat() { prefAboutApp = findPreference(PREFERENCE_ABOUT_APP) prefCheckUpdates = findPreference(PREFERENCE_CHECK_UPDATES) - subsectionPictureUploads?.isVisible = settingsViewModel.isThereAttachedAccount() - subsectionVideoUploads?.isVisible = settingsViewModel.isThereAttachedAccount() + subsectionAutomaticUploads?.isVisible = settingsViewModel.isThereAttachedAccount() subsectionMore?.isVisible = moreViewModel.shouldMoreSectionBeVisible() subsectionWhatsNew?.isVisible = releaseNotesViewModel.shouldWhatsNewSectionBeVisible() @@ -130,8 +127,7 @@ class SettingsFragment : PreferenceFragmentCompat() { private const val PREFERENCE_PRIVACY_POLICY = "privacyPolicy" private const val PREFERENCE_ABOUT_APP = "about_app" private const val PREFERENCE_CHECK_UPDATES = "check_updates" - private const val SUBSECTION_PICTURE_UPLOADS = "picture_uploads_subsection" - private const val SUBSECTION_VIDEO_UPLOADS = "video_uploads_subsection" + private const val SUBSECTION_AUTOMATIC_UPLOADS = "automatic_uploads_subsection" private const val SUBSECTION_MORE = "more_subsection" private const val SUBSECTION_NOTIFICATIONS = "notifications_subsection" private const val SUBSECTION_WHATSNEW = "whatsNew" diff --git a/Android/qsferaApp/src/main/java/eu/qsfera/android/presentation/settings/automaticuploads/AutomaticUploadFoldersActivity.kt b/Android/qsferaApp/src/main/java/eu/qsfera/android/presentation/settings/automaticuploads/AutomaticUploadFoldersActivity.kt index 7eb2777f0..ed64501bc 100644 --- a/Android/qsferaApp/src/main/java/eu/qsfera/android/presentation/settings/automaticuploads/AutomaticUploadFoldersActivity.kt +++ b/Android/qsferaApp/src/main/java/eu/qsfera/android/presentation/settings/automaticuploads/AutomaticUploadFoldersActivity.kt @@ -18,11 +18,11 @@ import androidx.activity.result.contract.ActivityResultContracts import androidx.appcompat.app.AppCompatActivity import androidx.appcompat.widget.Toolbar import androidx.constraintlayout.widget.ConstraintLayout +import androidx.core.content.ContextCompat import androidx.core.os.bundleOf import androidx.core.view.isVisible import androidx.lifecycle.lifecycleScope import androidx.preference.Preference -import androidx.preference.PreferenceCategory import androidx.preference.PreferenceFragmentCompat import androidx.preference.SwitchPreferenceCompat import eu.qsfera.android.R @@ -33,7 +33,7 @@ import kotlinx.coroutines.launch import kotlinx.coroutines.withContext class AutomaticUploadFoldersActivity : AppCompatActivity() { - private lateinit var mediaKind: AutomaticUploadMediaKind + private lateinit var mediaKinds: Set private val selectedSources = linkedSetOf() private val permissionLauncher = registerForActivityResult(ActivityResultContracts.RequestMultiplePermissions()) { @@ -45,14 +45,33 @@ class AutomaticUploadFoldersActivity : AppCompatActivity() { enableEdgeToEdgePreSetContentView(false) setContentView(R.layout.activity_settings) - mediaKind = intent.getStringExtra(EXTRA_MEDIA_KIND) - ?.let(AutomaticUploadMediaKind::fromWireValue) - ?: AutomaticUploadMediaKind.IMAGE - selectedSources += (savedInstanceState?.getStringArrayList(STATE_SELECTED_SOURCES) + mediaKinds = intent.getStringArrayListExtra(EXTRA_MEDIA_KINDS) + .orEmpty() + .mapNotNull(AutomaticUploadMediaKind::fromWireValue) + .toSet() + .ifEmpty { + setOf( + intent.getStringExtra(EXTRA_MEDIA_KIND) + ?.let(AutomaticUploadMediaKind::fromWireValue) + ?: AutomaticUploadMediaKind.IMAGE + ) + } + val restoredSources = (savedInstanceState?.getStringArrayList(STATE_SELECTED_SOURCES) ?: intent.getStringArrayListExtra(EXTRA_SELECTED_SOURCES).orEmpty()) - .mapNotNull(AutomaticUploadMediaSource::parse) - .filter { it.kind == mediaKind && !it.isCamera } - .map(AutomaticUploadMediaSource::encodedValue) + .mapNotNull { storedSource -> + val parsed = AutomaticUploadMediaSource.parse(storedSource) + when { + parsed != null && parsed.kind in mediaKinds -> parsed.relativePath + parsed != null -> null + else -> AutomaticUploadMediaSource.legacyTreeRelativePath(storedSource) + } + } + .filterNot(AutomaticUploadMediaSource::isCameraPath) + selectedSources += restoredSources + .distinctBy { it.lowercase() } + .flatMap { path -> + mediaKinds.map { kind -> AutomaticUploadMediaSource.create(kind, path).encodedValue } + } findViewById(R.id.standard_toolbar).apply { isVisible = true }.also { setSupportActionBar(it) @@ -73,7 +92,9 @@ class AutomaticUploadFoldersActivity : AppCompatActivity() { .replace( R.id.settings_container, AutomaticUploadFoldersFragment().apply { - arguments = bundleOf(ARG_MEDIA_KIND to mediaKind.wireValue) + arguments = bundleOf( + ARG_MEDIA_KINDS to ArrayList(mediaKinds.map(AutomaticUploadMediaKind::wireValue)) + ) }, ) .commit() @@ -107,22 +128,35 @@ class AutomaticUploadFoldersActivity : AppCompatActivity() { } internal fun requestReadPermission() { - permissionLauncher.launch(AutomaticUploadsPermissions.readPermissions(mediaKind)) + permissionLauncher.launch( + mediaKinds + .flatMap { AutomaticUploadsPermissions.readPermissions(it).asIterable() } + .distinct() + .toTypedArray() + ) } internal fun selectedRelativePaths(): Set = selectedSources.mapNotNull { encodedSource -> AutomaticUploadMediaSource.parse(encodedSource) - ?.takeIf { it.kind == mediaKind } + ?.takeIf { it.kind in mediaKinds } ?.relativePath }.toSet() - internal fun setFolderSelected(source: AutomaticUploadMediaSource, selected: Boolean) { + internal fun setFolderSelected(relativePath: String, selected: Boolean) { selectedSources.removeAll { existing -> AutomaticUploadMediaSource.parse(existing)?.let { - it.kind == source.kind && it.relativePath.equals(source.relativePath, ignoreCase = true) + it.kind in mediaKinds && it.relativePath.equals(relativePath, ignoreCase = true) } == true } - if (selected) selectedSources += source.encodedValue + if (selected) { + selectedSources += mediaKinds.map { + AutomaticUploadMediaSource.create(it, relativePath).encodedValue + } + } + } + + internal fun hasReadPermissions(): Boolean = mediaKinds.all { + AutomaticUploadsPermissions.hasReadPermission(this, it) } private fun currentFragment(): AutomaticUploadFoldersFragment? = @@ -131,8 +165,9 @@ class AutomaticUploadFoldersActivity : AppCompatActivity() { companion object { const val EXTRA_SELECTED_SOURCES = "selected_sources" private const val EXTRA_MEDIA_KIND = "media_kind" + private const val EXTRA_MEDIA_KINDS = "media_kinds" private const val STATE_SELECTED_SOURCES = "selected_sources_state" - internal const val ARG_MEDIA_KIND = "media_kind" + internal const val ARG_MEDIA_KINDS = "media_kinds" fun createIntent( context: Context, @@ -142,17 +177,29 @@ class AutomaticUploadFoldersActivity : AppCompatActivity() { putExtra(EXTRA_MEDIA_KIND, kind.wireValue) putStringArrayListExtra(EXTRA_SELECTED_SOURCES, ArrayList(selectedSources)) } + + fun createIntent( + context: Context, + kinds: Set, + selectedSources: Collection, + ): Intent = Intent(context, AutomaticUploadFoldersActivity::class.java).apply { + require(kinds.isNotEmpty()) { "At least one media kind is required" } + putStringArrayListExtra(EXTRA_MEDIA_KINDS, ArrayList(kinds.map(AutomaticUploadMediaKind::wireValue))) + putStringArrayListExtra(EXTRA_SELECTED_SOURCES, ArrayList(selectedSources)) + } } } class AutomaticUploadFoldersFragment : PreferenceFragmentCompat() { - private lateinit var mediaKind: AutomaticUploadMediaKind + private lateinit var mediaKinds: Set private var reloadGeneration = 0 override fun onCreatePreferences(savedInstanceState: Bundle?, rootKey: String?) { - mediaKind = arguments?.getString(AutomaticUploadFoldersActivity.ARG_MEDIA_KIND) - ?.let(AutomaticUploadMediaKind::fromWireValue) - ?: AutomaticUploadMediaKind.IMAGE + mediaKinds = arguments?.getStringArrayList(AutomaticUploadFoldersActivity.ARG_MEDIA_KINDS) + .orEmpty() + .mapNotNull(AutomaticUploadMediaKind::fromWireValue) + .toSet() + .ifEmpty { setOf(AutomaticUploadMediaKind.IMAGE) } preferenceScreen = preferenceManager.createPreferenceScreen(requireContext()) reload() } @@ -161,80 +208,146 @@ class AutomaticUploadFoldersFragment : PreferenceFragmentCompat() { if (!isAdded || preferenceScreen == null) return val generation = ++reloadGeneration val host = requireActivity() as AutomaticUploadFoldersActivity - preferenceScreen.removeAll() + resetFolderPreferences() - preferenceScreen.addPreference(Preference(requireContext()).apply { - isSelectable = false - summary = getString(R.string.automatic_upload_folders_camera_description) - }) - - if (!AutomaticUploadsPermissions.hasReadPermission(requireContext(), mediaKind)) { - preferenceScreen.addPreference(Preference(requireContext()).apply { - title = getString(R.string.automatic_upload_permission_title) - summary = getString(R.string.automatic_upload_permission_read_missing) - setOnPreferenceClickListener { - host.requestReadPermission() - true - } - }) + if (!host.hasReadPermissions()) { + addReadPermissionPreference(host) return } - preferenceScreen.addPreference(Preference(requireContext()).apply { - isSelectable = false - summary = getString(R.string.automatic_upload_folders_loading) - }) + addStatusPreference(R.string.automatic_upload_folders_loading) lifecycleScope.launch { - val foldersResult = runCatching { - withContext(Dispatchers.IO) { PhoneMediaStore(requireContext()).getFolders(mediaKind) } - } + val foldersResult = runCatching { loadFolders() } if (!isAdded || generation != reloadGeneration) return@launch - - preferenceScreen.removeAll() - preferenceScreen.addPreference(Preference(requireContext()).apply { - isSelectable = false - summary = getString(R.string.automatic_upload_folders_camera_description) - }) - - foldersResult.onFailure { - preferenceScreen.addPreference(Preference(requireContext()).apply { - isSelectable = false - summary = getString(R.string.automatic_upload_folders_error) - }) - }.onSuccess { folders -> - val otherFolders = folders.filterNot { - AutomaticUploadMediaSource.isCameraPath(it.relativePath) - } - if (otherFolders.isEmpty()) { - preferenceScreen.addPreference(Preference(requireContext()).apply { - isSelectable = false - summary = getString(R.string.automatic_upload_folders_empty) - }) - return@onSuccess - } - - preferenceScreen.addPreference(PreferenceCategory(requireContext()).apply { - title = getString(R.string.automatic_upload_folders_other) - otherFolders.forEach { folder -> - val source = AutomaticUploadMediaSource.create(mediaKind, folder.relativePath) - addPreference(SwitchPreferenceCompat(requireContext()).apply { - key = source.encodedValue - title = folder.displayName - summary = getString(R.string.automatic_upload_folders_item_count, folder.itemCount) - isChecked = host.selectedRelativePaths().any { - it.equals(source.relativePath, ignoreCase = true) - } - setOnPreferenceChangeListener { _, newValue -> - val isSelected = newValue as? Boolean - ?: return@setOnPreferenceChangeListener false - host.setFolderSelected(source, isSelected) - true - } - }) - } - }) - } + renderFolders(host, foldersResult) } } + + private fun resetFolderPreferences() { + preferenceScreen.removeAll() + addStatusPreference(R.string.automatic_upload_folders_camera_description) + addCameraPreference() + } + + private fun addReadPermissionPreference(host: AutomaticUploadFoldersActivity) { + preferenceScreen.addPreference(Preference(requireContext()).apply { + title = getString(R.string.automatic_upload_permission_title) + summary = getString(R.string.automatic_upload_permission_read_missing) + setOnPreferenceClickListener { + host.requestReadPermission() + true + } + }) + } + + private fun addStatusPreference(summaryRes: Int) { + preferenceScreen.addPreference(Preference(requireContext()).apply { + isSelectable = false + summary = getString(summaryRes) + }) + } + + private suspend fun loadFolders(): List = withContext(Dispatchers.IO) { + val mediaStore = PhoneMediaStore(requireContext()) + mediaKinds + .flatMap(mediaStore::getFolders) + .groupBy { it.relativePath.lowercase() } + .map { (_, matchingFolders) -> + val firstFolder = matchingFolders.first() + firstFolder.copy(itemCount = matchingFolders.sumOf(PhoneMediaFolder::itemCount)) + } + .sortedWith(folderComparator) + } + + private fun renderFolders( + host: AutomaticUploadFoldersActivity, + foldersResult: Result>, + ) { + resetFolderPreferences() + foldersResult.onFailure { + addStatusPreference(R.string.automatic_upload_folders_error) + }.onSuccess { folders -> + val otherFolders = mergeCurrentAndSelectedFolders(host, folders) + if (otherFolders.isEmpty()) { + addStatusPreference(R.string.automatic_upload_folders_empty) + return@onSuccess + } + otherFolders.forEach { addFolderPreference(host, it) } + } + } + + private fun mergeCurrentAndSelectedFolders( + host: AutomaticUploadFoldersActivity, + folders: List, + ): List { + val currentFolders = folders.filterNot { + AutomaticUploadMediaSource.isCameraPath(it.relativePath) + } + val currentPaths = currentFolders.mapTo(mutableSetOf()) { it.relativePath.lowercase() } + val retainedSelections = host.selectedRelativePaths() + .filterNot(AutomaticUploadMediaSource::isCameraPath) + .filterNot { it.lowercase() in currentPaths } + .map(::emptyFolder) + return (currentFolders + retainedSelections).sortedWith(folderComparator) + } + + private fun emptyFolder(relativePath: String) = PhoneMediaFolder( + displayName = relativePath.trimEnd('/').substringAfterLast('/'), + relativePath = relativePath, + itemCount = 0, + ) + + private fun addFolderPreference( + host: AutomaticUploadFoldersActivity, + folder: PhoneMediaFolder, + ) { + preferenceScreen.addPreference(SwitchPreferenceCompat(requireContext()).apply { + key = "folder:${folder.relativePath.lowercase()}" + title = folder.displayName + summary = folderSummary(folder) + icon = requireContext().getDrawable(R.drawable.ic_folder)?.mutate()?.apply { + setTint(ContextCompat.getColor(requireContext(), R.color.qsfera_text_secondary)) + } + isPersistent = false + isChecked = host.selectedRelativePaths().any { + it.equals(folder.relativePath, ignoreCase = true) + } + setOnPreferenceChangeListener { _, newValue -> + val isSelected = newValue as? Boolean + ?: return@setOnPreferenceChangeListener false + host.setFolderSelected(folder.relativePath, isSelected) + true + } + }) + } + + private fun folderSummary(folder: PhoneMediaFolder): String = buildString { + append(folder.relativePath) + append(" · ") + append( + if (folder.itemCount > 0) { + getString(R.string.automatic_upload_folders_item_count, folder.itemCount) + } else { + getString(R.string.automatic_upload_folder_no_media) + } + ) + } + + private fun addCameraPreference() { + preferenceScreen.addPreference(SwitchPreferenceCompat(requireContext()).apply { + key = "camera_always_enabled" + title = getString(R.string.automatic_upload_camera_folder) + summary = getString(R.string.automatic_upload_camera_folder_summary) + icon = requireContext().getDrawable(R.drawable.ic_picture_uploads) + isChecked = true + isPersistent = false + isSelectable = false + }) + } + + private companion object { + val folderComparator = compareBy { it.displayName.lowercase() } + .thenBy { it.relativePath.lowercase() } + } } diff --git a/Android/qsferaApp/src/main/java/eu/qsfera/android/presentation/settings/automaticuploads/AutomaticUploadMediaSource.kt b/Android/qsferaApp/src/main/java/eu/qsfera/android/presentation/settings/automaticuploads/AutomaticUploadMediaSource.kt index 96459cc6c..be648c718 100644 --- a/Android/qsferaApp/src/main/java/eu/qsfera/android/presentation/settings/automaticuploads/AutomaticUploadMediaSource.kt +++ b/Android/qsferaApp/src/main/java/eu/qsfera/android/presentation/settings/automaticuploads/AutomaticUploadMediaSource.kt @@ -9,6 +9,7 @@ package eu.qsfera.android.presentation.settings.automaticuploads import java.nio.charset.StandardCharsets +import java.net.URLDecoder import java.util.Base64 enum class AutomaticUploadMediaKind(val wireValue: String) { @@ -40,6 +41,7 @@ data class AutomaticUploadMediaSource( companion object { const val CAMERA_RELATIVE_PATH = "DCIM/Camera/" private const val PREFIX = "mediastore:" + private const val TREE_PATH_MARKER = "/tree/" fun create(kind: AutomaticUploadMediaKind, relativePath: String): AutomaticUploadMediaSource = AutomaticUploadMediaSource(kind, normalizeRelativePath(relativePath)) @@ -64,6 +66,32 @@ data class AutomaticUploadMediaSource( return decodedPath.takeIf { it.isNotBlank() }?.let { create(kind, it) } } + fun parseOrMigrateLegacyTree( + value: String, + kind: AutomaticUploadMediaKind, + ): AutomaticUploadMediaSource? = parse(value) ?: legacyTreeRelativePath(value)?.let { create(kind, it) } + + fun legacyTreeRelativePath(value: String): String? { + if (!value.startsWith("content://", ignoreCase = true)) return null + val treeMarkerIndex = value.indexOf(TREE_PATH_MARKER) + if (treeMarkerIndex < 0) return null + val encodedDocumentId = value + .substring(treeMarkerIndex + TREE_PATH_MARKER.length) + .substringBefore('/') + .substringBefore('?') + .substringBefore('#') + val documentId = runCatching { + URLDecoder.decode( + encodedDocumentId.replace("+", "%2B"), + StandardCharsets.UTF_8.name(), + ) + }.getOrNull() ?: return null + val volume = documentId.substringBefore(':', missingDelimiterValue = "") + val relativePath = documentId.substringAfter(':', missingDelimiterValue = "") + if (!volume.equals("primary", ignoreCase = true) || relativePath.isBlank()) return null + return normalizeRelativePath(relativePath) + } + fun normalizeRelativePath(path: String): String = path.replace('\\', '/').trim().trim('/').let { normalized -> if (normalized.isEmpty()) "" else "$normalized/" @@ -71,5 +99,6 @@ data class AutomaticUploadMediaSource( fun isCameraPath(path: String): Boolean = normalizeRelativePath(path).equals(CAMERA_RELATIVE_PATH, ignoreCase = true) + } } diff --git a/Android/qsferaApp/src/main/java/eu/qsfera/android/presentation/settings/automaticuploads/AutomaticUploadsPermissions.kt b/Android/qsferaApp/src/main/java/eu/qsfera/android/presentation/settings/automaticuploads/AutomaticUploadsPermissions.kt index 0a2645c68..3bef5e5f1 100644 --- a/Android/qsferaApp/src/main/java/eu/qsfera/android/presentation/settings/automaticuploads/AutomaticUploadsPermissions.kt +++ b/Android/qsferaApp/src/main/java/eu/qsfera/android/presentation/settings/automaticuploads/AutomaticUploadsPermissions.kt @@ -8,13 +8,14 @@ package eu.qsfera.android.presentation.settings.automaticuploads import android.Manifest import android.content.Context import android.content.Intent +import android.content.pm.PackageManager import android.net.Uri import android.os.Build import android.os.Environment -import android.provider.Settings +import android.os.PowerManager import android.provider.MediaStore +import android.provider.Settings import androidx.core.content.ContextCompat -import android.content.pm.PackageManager object AutomaticUploadsPermissions { fun readPermission(kind: AutomaticUploadMediaKind): String = when { @@ -25,17 +26,20 @@ object AutomaticUploadsPermissions { } fun hasReadPermission(context: Context, kind: AutomaticUploadMediaKind): Boolean = - ContextCompat.checkSelfPermission(context, readPermission(kind)) == PackageManager.PERMISSION_GRANTED + ContextCompat.checkSelfPermission(context, readPermission(kind)) == PackageManager.PERMISSION_GRANTED && + (!requiresLegacyWritePermission() || hasLegacyWritePermission(context)) fun readPermissions(kind: AutomaticUploadMediaKind): Array = buildList { add(readPermission(kind)) + if (requiresLegacyWritePermission()) add(Manifest.permission.WRITE_EXTERNAL_STORAGE) if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE) { add(Manifest.permission.READ_MEDIA_VISUAL_USER_SELECTED) } }.toTypedArray() fun hasDeletePermission(context: Context): Boolean = when { - Build.VERSION.SDK_INT < Build.VERSION_CODES.R -> true + requiresLegacyWritePermission() -> hasLegacyWritePermission(context) + !supportsBackgroundSourceDeletion() -> false !Environment.isExternalStorageManager() -> false Build.VERSION.SDK_INT < Build.VERSION_CODES.S -> true else -> MediaStore.canManageMedia(context) @@ -52,4 +56,36 @@ object AutomaticUploadsPermissions { } else { Intent(Settings.ACTION_APPLICATION_DETAILS_SETTINGS, Uri.parse("package:${context.packageName}")) } + + fun supportsBackgroundSourceDeletion(): Boolean = supportsBackgroundSourceDeletion(Build.VERSION.SDK_INT) + + private fun requiresLegacyWritePermission(): Boolean = requiresLegacyWritePermission(Build.VERSION.SDK_INT) + + private fun hasLegacyWritePermission(context: Context): Boolean = + ContextCompat.checkSelfPermission( + context, + Manifest.permission.WRITE_EXTERNAL_STORAGE, + ) == PackageManager.PERMISSION_GRANTED + + fun isBackgroundBatteryUsageUnrestricted(context: Context): Boolean { + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.M) return true + val powerManager = context.getSystemService(PowerManager::class.java) + return powerManager?.isIgnoringBatteryOptimizations(context.packageName) == true + } + + fun batteryOptimizationIntent(context: Context): Intent = + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M && + !isBackgroundBatteryUsageUnrestricted(context) + ) { + Intent( + Settings.ACTION_REQUEST_IGNORE_BATTERY_OPTIMIZATIONS, + Uri.parse("package:${context.packageName}"), + ) + } else { + Intent(Settings.ACTION_IGNORE_BATTERY_OPTIMIZATION_SETTINGS) + } } + +internal fun requiresLegacyWritePermission(sdkInt: Int): Boolean = sdkInt <= Build.VERSION_CODES.P + +internal fun supportsBackgroundSourceDeletion(sdkInt: Int): Boolean = sdkInt != Build.VERSION_CODES.Q diff --git a/Android/qsferaApp/src/main/java/eu/qsfera/android/presentation/settings/automaticuploads/MediaStoreGenerationLedger.kt b/Android/qsferaApp/src/main/java/eu/qsfera/android/presentation/settings/automaticuploads/MediaStoreGenerationLedger.kt new file mode 100644 index 000000000..88fb4e3bb --- /dev/null +++ b/Android/qsferaApp/src/main/java/eu/qsfera/android/presentation/settings/automaticuploads/MediaStoreGenerationLedger.kt @@ -0,0 +1,214 @@ +/** + * qsfera Android client application + * + * Copyright (C) 2026 QSfera. + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License version 2. + */ +package eu.qsfera.android.presentation.settings.automaticuploads + +import android.content.Context +import android.os.Build +import android.provider.MediaStore +import android.util.AtomicFile +import java.io.BufferedInputStream +import java.io.BufferedOutputStream +import java.io.DataInputStream +import java.io.DataOutputStream +import java.io.File +import java.nio.charset.StandardCharsets +import java.security.MessageDigest + +/** + * Durable MediaStore generation cursor used in addition to wall-clock timestamps. + * + * MediaStore dates have second precision and can preserve an old value when an item is + * moved into a watched folder. Generation numbers are monotonic on Android 11+. A compact + * item inventory is retained as a fallback for older Android versions and MediaStore database + * resets, where generation cursors are unavailable or invalidated. + */ +internal class MediaStoreGenerationLedger(context: Context) { + private val appContext = context.applicationContext + private val preferences = appContext.getSharedPreferences(PREFERENCES_NAME, Context.MODE_PRIVATE) + private val inventoryDirectory = File(appContext.filesDir, INVENTORY_DIRECTORY).apply { mkdirs() } + + fun checkpoint(key: String, configurationTimestamp: Long): MediaStoreGenerationCheckpoint { + val committedTimestampKey = committedTimestampKey(key) + val baselineMatchesConfiguration = preferences.contains(committedTimestampKey) && + preferences.getLong(committedTimestampKey, Long.MIN_VALUE) == configurationTimestamp + val generationState = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) runCatching { + val version = MediaStore.getVersion(appContext, MediaStore.VOLUME_EXTERNAL_PRIMARY) + val currentGeneration = MediaStore.getGeneration(appContext, MediaStore.VOLUME_EXTERNAL_PRIMARY) + val versionKey = versionKey(key) + val generationKey = generationKey(key) + val storedVersion = preferences.getString(versionKey, null) + val storedGeneration = if (preferences.contains(generationKey)) { + preferences.getLong(generationKey, currentGeneration) + } else { + null + } + val baseline = storedGeneration + ?.takeIf { + baselineMatchesConfiguration && storedVersion == version && it <= currentGeneration + } + ?: currentGeneration + + GenerationState(version, baseline, currentGeneration) + }.getOrNull() else null + + val knownFingerprints = if (baselineMatchesConfiguration) { + readInventory(key) + } else { + null + } + + return MediaStoreGenerationCheckpoint( + key = key, + version = generationState?.version, + baseline = generationState?.baseline, + current = generationState?.current, + knownFingerprints = knownFingerprints, + ) + } + + fun commit(checkpoint: MediaStoreGenerationCheckpoint): Boolean { + val committedTimestamp = checkpoint.committedTimestamp ?: return false + if (!writeInventory(checkpoint.key, checkpoint.currentFingerprints)) return false + + val editor = preferences.edit() + .putLong(committedTimestampKey(checkpoint.key), committedTimestamp) + if (checkpoint.version != null && checkpoint.current != null) { + editor + .putString(versionKey(checkpoint.key), checkpoint.version) + .putLong(generationKey(checkpoint.key), checkpoint.current) + } + return editor.commit() + } + + private fun versionKey(key: String): String = "${compactSha256(key)}.version" + + private fun generationKey(key: String): String = "${compactSha256(key)}.generation" + + private fun committedTimestampKey(key: String): String = "${compactSha256(key)}.timestamp" + + private fun inventoryFile(key: String) = AtomicFile( + File(inventoryDirectory, "${compactSha256(key)}.bin"), + ) + + private fun readInventory(key: String): Set? = runCatching { + val atomicFile = inventoryFile(key) + if (!atomicFile.baseFile.exists()) return null + + DataInputStream(BufferedInputStream(atomicFile.openRead())).use { input -> + check(input.readInt() == INVENTORY_MAGIC) { "Unsupported automatic-upload inventory" } + val itemCount = input.readInt() + check(itemCount in 0..MAX_INVENTORY_ITEMS) { "Invalid automatic-upload inventory size" } + buildSet(itemCount) { + repeat(itemCount) { + val bytes = ByteArray(FINGERPRINT_BYTES) + input.readFully(bytes) + add(bytes.toHex()) + } + } + } + }.getOrNull() + + private fun writeInventory(key: String, fingerprints: Set): Boolean { + val fingerprintBytes = fingerprints + .asSequence() + .mapNotNull(::hexToBytes) + .sortedWith(byteArrayComparator) + .toList() + if (fingerprintBytes.size != fingerprints.size || fingerprintBytes.size > MAX_INVENTORY_ITEMS) return false + + val atomicFile = inventoryFile(key) + val output = runCatching { atomicFile.startWrite() }.getOrNull() ?: return false + return try { + val dataOutput = DataOutputStream(BufferedOutputStream(output)) + dataOutput.writeInt(INVENTORY_MAGIC) + dataOutput.writeInt(fingerprintBytes.size) + fingerprintBytes.forEach(dataOutput::write) + dataOutput.flush() + atomicFile.finishWrite(output) + true + } catch (_: Exception) { + runCatching { atomicFile.failWrite(output) } + false + } + } + + private companion object { + const val PREFERENCES_NAME = "automatic_upload_media_generations" + const val INVENTORY_DIRECTORY = "automatic-upload-inventory" + const val INVENTORY_MAGIC = 0x51534931 + const val MAX_INVENTORY_ITEMS = 1_000_000 + } +} + +internal data class MediaStoreGenerationCheckpoint( + val key: String, + val version: String?, + val baseline: Long?, + val current: Long?, + val knownFingerprints: Set?, + val currentFingerprints: Set = emptySet(), + val committedTimestamp: Long? = null, +) { + fun withCurrentFingerprints(fingerprints: Set) = copy(currentFingerprints = fingerprints) + + fun withCommittedTimestamp(timestamp: Long) = copy(committedTimestamp = timestamp) +} + +private data class GenerationState( + val version: String, + val baseline: Long, + val current: Long, +) + +internal fun automaticUploadMediaFingerprint(item: PhoneMediaItem): String { + val identity = buildString { + append(item.relativePath) + append('\u001F') + append(item.displayName) + append('\u001F') + append(item.size) + } + return compactSha256(identity) +} + +private fun compactSha256(value: String): String { + val digest = MessageDigest.getInstance("SHA-256") + .digest(value.toByteArray(StandardCharsets.UTF_8)) + return digest.copyOf(FINGERPRINT_BYTES).toHex() +} + +private const val FINGERPRINT_BYTES = 16 +private const val HEX_CHARACTERS = "0123456789abcdef" + +private fun ByteArray.toHex(): String = buildString(size * 2) { + this@toHex.forEach { byte -> + val value = byte.toInt() and 0xff + append(HEX_CHARACTERS[value ushr 4]) + append(HEX_CHARACTERS[value and 0x0f]) + } +} + +private fun hexToBytes(value: String): ByteArray? { + if (value.length != FINGERPRINT_BYTES * 2) return null + return ByteArray(FINGERPRINT_BYTES) { index -> + val high = value[index * 2].digitToIntOrNull(16) ?: return null + val low = value[index * 2 + 1].digitToIntOrNull(16) ?: return null + ((high shl 4) or low).toByte() + } +} + +private val byteArrayComparator = Comparator { left, right -> + var comparison = 0 + var index = 0 + while (comparison == 0 && index < minOf(left.size, right.size)) { + comparison = (left[index].toInt() and 0xff).compareTo(right[index].toInt() and 0xff) + index += 1 + } + if (comparison != 0) comparison else left.size.compareTo(right.size) +} diff --git a/Android/qsferaApp/src/main/java/eu/qsfera/android/presentation/settings/automaticuploads/PhoneMediaStore.kt b/Android/qsferaApp/src/main/java/eu/qsfera/android/presentation/settings/automaticuploads/PhoneMediaStore.kt index 27771e13b..d9c4b1954 100644 --- a/Android/qsferaApp/src/main/java/eu/qsfera/android/presentation/settings/automaticuploads/PhoneMediaStore.kt +++ b/Android/qsferaApp/src/main/java/eu/qsfera/android/presentation/settings/automaticuploads/PhoneMediaStore.kt @@ -28,6 +28,10 @@ data class PhoneMediaItem( val mimeType: String, val size: Long, val lastModified: Long, + val dateAdded: Long, + val generationAdded: Long, + val generationModified: Long, + val relativePath: String, ) class PhoneMediaStore(context: Context) { @@ -63,11 +67,12 @@ class PhoneMediaStore(context: Context) { if (sources.isEmpty()) return emptyList() val kind = sources.first().kind require(sources.all { it.kind == kind }) { "All media sources must have the same kind" } - val selectedPaths = sources.map { it.relativePath.lowercase() }.toSet() + val selectedPaths = sources.map(AutomaticUploadMediaSource::relativePath).toSet() + val selectedPathsLowercase = selectedPaths.mapTo(mutableSetOf(), String::lowercase) val items = mutableListOf() - query(kind) { row -> - if (row.relativePath.lowercase() !in selectedPaths) return@query + query(kind, selectedPaths) { row -> + if (row.relativePath.lowercase() !in selectedPathsLowercase) return@query if (row.displayName.isBlank() || row.mimeType.isBlank()) return@query items += PhoneMediaItem( @@ -76,19 +81,32 @@ class PhoneMediaStore(context: Context) { mimeType = row.mimeType, size = row.size, lastModified = row.lastModified, + dateAdded = row.dateAdded, + generationAdded = row.generationAdded, + generationModified = row.generationModified, + relativePath = row.relativePath, ) } return items.sortedBy { it.lastModified } } - private fun query(kind: AutomaticUploadMediaKind, consume: (MediaRow) -> Unit) { + private fun query( + kind: AutomaticUploadMediaKind, + selectedPaths: Set = emptySet(), + consume: (MediaRow) -> Unit, + ) { val projection = buildList { add(MediaStore.MediaColumns._ID) add(MediaStore.MediaColumns.DISPLAY_NAME) add(MediaStore.MediaColumns.MIME_TYPE) add(MediaStore.MediaColumns.SIZE) add(MediaStore.MediaColumns.DATE_MODIFIED) + add(MediaStore.MediaColumns.DATE_ADDED) + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) { + add(MediaStore.MediaColumns.GENERATION_ADDED) + add(MediaStore.MediaColumns.GENERATION_MODIFIED) + } add(MediaStore.Images.Media.BUCKET_DISPLAY_NAME) if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { add(MediaStore.MediaColumns.RELATIVE_PATH) @@ -98,17 +116,22 @@ class PhoneMediaStore(context: Context) { } }.toTypedArray() - val selection = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { - "${MediaStore.MediaColumns.IS_PENDING}=0" - } else { - null + val selectionParts = mutableListOf() + val selectionArgs = mutableListOf() + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { + selectionParts += "${MediaStore.MediaColumns.IS_PENDING}=0" + if (selectedPaths.isNotEmpty()) { + val placeholders = selectedPaths.joinToString(",") { "?" } + selectionParts += "${MediaStore.MediaColumns.RELATIVE_PATH} COLLATE NOCASE IN ($placeholders)" + selectionArgs += selectedPaths + } } val cursor = contentResolver.query( collectionFor(kind), projection, - selection, - null, + selectionParts.joinToString(" AND ").ifBlank { null }, + selectionArgs.toTypedArray().takeIf { it.isNotEmpty() }, "${MediaStore.MediaColumns.DATE_MODIFIED} DESC", ) ?: throw IllegalStateException("MediaStore returned no cursor for $kind") @@ -118,6 +141,17 @@ class PhoneMediaStore(context: Context) { val mimeTypeColumn = cursor.getColumnIndexOrThrow(MediaStore.MediaColumns.MIME_TYPE) val sizeColumn = cursor.getColumnIndexOrThrow(MediaStore.MediaColumns.SIZE) val modifiedColumn = cursor.getColumnIndexOrThrow(MediaStore.MediaColumns.DATE_MODIFIED) + val addedColumn = cursor.getColumnIndexOrThrow(MediaStore.MediaColumns.DATE_ADDED) + val generationAddedColumn = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) { + cursor.getColumnIndex(MediaStore.MediaColumns.GENERATION_ADDED) + } else { + -1 + } + val generationModifiedColumn = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) { + cursor.getColumnIndex(MediaStore.MediaColumns.GENERATION_MODIFIED) + } else { + -1 + } val bucketColumn = cursor.getColumnIndexOrThrow(MediaStore.Images.Media.BUCKET_DISPLAY_NAME) val pathColumn = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { cursor.getColumnIndexOrThrow(MediaStore.MediaColumns.RELATIVE_PATH) @@ -141,6 +175,9 @@ class PhoneMediaStore(context: Context) { mimeType = cursor.getString(mimeTypeColumn).orEmpty(), size = cursor.getLong(sizeColumn), lastModified = cursor.getLong(modifiedColumn) * 1_000L, + dateAdded = cursor.getLong(addedColumn) * 1_000L, + generationAdded = generationAddedColumn.takeIf { it >= 0 }?.let(cursor::getLong) ?: 0L, + generationModified = generationModifiedColumn.takeIf { it >= 0 }?.let(cursor::getLong) ?: 0L, bucketDisplayName = cursor.getString(bucketColumn).orEmpty(), relativePath = relativePath, ) @@ -149,9 +186,21 @@ class PhoneMediaStore(context: Context) { } } - private fun collectionFor(kind: AutomaticUploadMediaKind): Uri = when (kind) { - AutomaticUploadMediaKind.IMAGE -> MediaStore.Images.Media.EXTERNAL_CONTENT_URI - AutomaticUploadMediaKind.VIDEO -> MediaStore.Video.Media.EXTERNAL_CONTENT_URI + private fun collectionFor(kind: AutomaticUploadMediaKind): Uri { + val volume = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { + MediaStore.VOLUME_EXTERNAL_PRIMARY + } else { + @Suppress("DEPRECATION") + return when (kind) { + AutomaticUploadMediaKind.IMAGE -> MediaStore.Images.Media.EXTERNAL_CONTENT_URI + AutomaticUploadMediaKind.VIDEO -> MediaStore.Video.Media.EXTERNAL_CONTENT_URI + } + } + + return when (kind) { + AutomaticUploadMediaKind.IMAGE -> MediaStore.Images.Media.getContentUri(volume) + AutomaticUploadMediaKind.VIDEO -> MediaStore.Video.Media.getContentUri(volume) + } } private fun relativePathFromLegacyData(dataPath: String): String { @@ -177,6 +226,9 @@ class PhoneMediaStore(context: Context) { val mimeType: String, val size: Long, val lastModified: Long, + val dateAdded: Long, + val generationAdded: Long, + val generationModified: Long, val bucketDisplayName: String, val relativePath: String, ) diff --git a/Android/qsferaApp/src/main/java/eu/qsfera/android/presentation/settings/automaticuploads/SettingsAutomaticUploadsFragment.kt b/Android/qsferaApp/src/main/java/eu/qsfera/android/presentation/settings/automaticuploads/SettingsAutomaticUploadsFragment.kt new file mode 100644 index 000000000..b254618a6 --- /dev/null +++ b/Android/qsferaApp/src/main/java/eu/qsfera/android/presentation/settings/automaticuploads/SettingsAutomaticUploadsFragment.kt @@ -0,0 +1,486 @@ +/** + * qsfera Android client application + * + * Copyright (C) 2026 QSfera. + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License version 2. + */ +package eu.qsfera.android.presentation.settings.automaticuploads + +import android.app.Activity +import android.content.Intent +import android.os.Bundle +import android.provider.Settings +import android.view.View +import android.widget.TextView +import android.widget.Toast +import androidx.activity.result.contract.ActivityResultContracts +import androidx.appcompat.widget.AppCompatButton +import androidx.appcompat.widget.SwitchCompat +import androidx.cardview.widget.CardView +import androidx.core.view.isVisible +import androidx.fragment.app.Fragment +import androidx.lifecycle.Lifecycle +import androidx.lifecycle.lifecycleScope +import androidx.lifecycle.repeatOnLifecycle +import eu.qsfera.android.R +import eu.qsfera.android.domain.automaticuploads.model.FolderBackUpConfiguration +import eu.qsfera.android.presentation.accounts.ManageAccountsViewModel +import kotlinx.coroutines.launch +import org.koin.androidx.viewmodel.ext.android.viewModel + +class SettingsAutomaticUploadsFragment : Fragment(R.layout.fragment_settings_automatic_uploads) { + private val picturesViewModel by viewModel() + private val videosViewModel by viewModel() + private val accountsViewModel by viewModel() + + private lateinit var photosSwitch: SwitchCompat + private lateinit var videosSwitch: SwitchCompat + private lateinit var mobileDataSwitch: SwitchCompat + private lateinit var foldersCard: CardView + private lateinit var foldersSummary: TextView + private lateinit var backgroundStatusTitle: TextView + private lateinit var backgroundStatusSummary: TextView + private lateinit var allowBackgroundButton: AppCompatButton + + private var pictureUploads: FolderBackUpConfiguration? = null + private var videoUploads: FolderBackUpConfiguration? = null + private var availableAccountName: String? = null + private var pendingAction: PendingAction? = null + private var requestedMediaManagement = false + private var renderingState = false + private val wifiPolicyCoordinator = UnifiedWifiPolicyCoordinator() + + private val folderPickerLauncher = + registerForActivityResult(ActivityResultContracts.StartActivityForResult()) { result -> + if (result.resultCode != Activity.RESULT_OK) return@registerForActivityResult + val sources = result.data + ?.getStringArrayListExtra(AutomaticUploadFoldersActivity.EXTRA_SELECTED_SOURCES) + .orEmpty() + .mapNotNull(AutomaticUploadMediaSource::parse) + + if (pictureUploads != null) { + picturesViewModel.replacePictureUploadsSourcePaths( + sources.filter { it.kind == AutomaticUploadMediaKind.IMAGE }.map { it.encodedValue } + ) + } + if (videoUploads != null) { + videosViewModel.replaceVideoUploadsSourcePaths( + sources.filter { it.kind == AutomaticUploadMediaKind.VIDEO }.map { it.encodedValue } + ) + } + scheduleUploads() + } + + private val mediaPermissionLauncher = + registerForActivityResult(ActivityResultContracts.RequestMultiplePermissions()) { + val requiredKinds = pendingAction?.requiredKinds.orEmpty() + if (requiredKinds.all { kind -> + AutomaticUploadsPermissions.hasReadPermission(requireContext(), kind) + } + ) { + continuePendingAction() + } else { + cancelPendingActionWithPermissionMessage() + } + } + + private val deletePermissionLauncher = + registerForActivityResult(ActivityResultContracts.StartActivityForResult()) { + if (AutomaticUploadsPermissions.hasDeletePermission(requireContext())) { + requestedMediaManagement = false + continuePendingAction() + } else if (!requestedMediaManagement && + AutomaticUploadsPermissions.deletePermissionIntent(requireContext()).action == + Settings.ACTION_REQUEST_MANAGE_MEDIA + ) { + requestDeletePermission() + } else { + requestedMediaManagement = false + cancelPendingActionWithPermissionMessage() + } + } + + private val batteryOptimizationLauncher = + registerForActivityResult(ActivityResultContracts.StartActivityForResult()) { + renderBackgroundStatus() + } + + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + super.onViewCreated(view, savedInstanceState) + bindViews(view) + bindActions() + observeState() + renderBackgroundStatus() + } + + override fun onResume() { + super.onResume() + if (::allowBackgroundButton.isInitialized) renderBackgroundStatus() + } + + override fun onStop() { + scheduleUploads() + super.onStop() + } + + private fun bindViews(view: View) { + photosSwitch = view.findViewById(R.id.automatic_upload_photos_switch) + videosSwitch = view.findViewById(R.id.automatic_upload_videos_switch) + mobileDataSwitch = view.findViewById(R.id.automatic_upload_mobile_data_switch) + foldersCard = view.findViewById(R.id.automatic_upload_folders_card) + foldersSummary = view.findViewById(R.id.automatic_upload_folders_summary) + backgroundStatusTitle = view.findViewById(R.id.automatic_upload_background_status_title) + backgroundStatusSummary = view.findViewById(R.id.automatic_upload_background_status_summary) + allowBackgroundButton = view.findViewById(R.id.automatic_upload_allow_background_button) + } + + private fun bindActions() { + photosSwitch.setOnCheckedChangeListener { _, isChecked -> + if (renderingState) return@setOnCheckedChangeListener + if (isChecked) { + beginAction(PendingAction.EnablePictures) + } else { + picturesViewModel.disablePictureUploads() + } + } + videosSwitch.setOnCheckedChangeListener { _, isChecked -> + if (renderingState) return@setOnCheckedChangeListener + if (isChecked) { + beginAction(PendingAction.EnableVideos) + } else { + videosViewModel.disableVideoUploads() + } + } + mobileDataSwitch.setOnCheckedChangeListener { _, isChecked -> + if (renderingState) return@setOnCheckedChangeListener + wifiPolicyCoordinator.request(wifiOnly = !isChecked) + renderUploadState() + } + foldersCard.setOnClickListener { + val kinds = activeMediaKinds() + if (kinds.isNotEmpty()) beginAction(PendingAction.OpenFolders(kinds)) + } + allowBackgroundButton.setOnClickListener { + val activeKinds = activeMediaKinds() + if (hasMissingMediaPermissions(activeKinds)) { + beginAction(PendingAction.RepairPermissions(activeKinds)) + } else { + requestUnrestrictedBackgroundWork() + } + } + } + + private fun observeState() { + viewLifecycleOwner.lifecycleScope.launch { + viewLifecycleOwner.repeatOnLifecycle(Lifecycle.State.STARTED) { + launch { + picturesViewModel.pictureUploads.collect { + pictureUploads = it + picturesViewModel.normalizeUnifiedAutomaticUploads() + renderUploadState() + } + } + launch { + videosViewModel.videoUploads.collect { + videoUploads = it + videosViewModel.normalizeUnifiedAutomaticUploads() + renderUploadState() + } + } + launch { + accountsViewModel.userQuotas.collect { quotas -> + val availableAccounts = quotas.filter { it.available != LIGHT_USER_QUOTA } + val currentAccountName = accountsViewModel.getCurrentAccount()?.name + availableAccountName = currentAccountName + ?.takeIf { current -> availableAccounts.any { it.accountName == current } } + ?: availableAccounts.firstOrNull()?.accountName + renderUploadState() + } + } + } + } + } + + private fun renderUploadState() { + if (!::photosSwitch.isInitialized) return + val activeConfigurations = listOfNotNull(pictureUploads, videoUploads) + val wifiPolicy = wifiPolicyCoordinator.reconcile( + buildMap { + pictureUploads?.let { put(AutomaticUploadMediaKind.IMAGE, it.wifiOnly) } + videoUploads?.let { put(AutomaticUploadMediaKind.VIDEO, it.wifiOnly) } + } + ) + wifiPolicy.configurationsToUpdate.forEach { mediaKind -> + when (mediaKind) { + AutomaticUploadMediaKind.IMAGE -> picturesViewModel.useWifiOnly(wifiPolicy.wifiOnly) + AutomaticUploadMediaKind.VIDEO -> videosViewModel.useWifiOnly(wifiPolicy.wifiOnly) + } + } + val selectedFolderCount = activeConfigurations + .flatMap(FolderBackUpConfiguration::sourcePaths) + .mapNotNull { sourcePath -> + AutomaticUploadMediaSource.parse(sourcePath)?.relativePath + ?: AutomaticUploadMediaSource.legacyTreeRelativePath(sourcePath) + } + .filterNot(AutomaticUploadMediaSource::isCameraPath) + .map(String::lowercase) + .distinct() + .size + + renderingState = true + photosSwitch.isChecked = pictureUploads != null + videosSwitch.isChecked = videoUploads != null + mobileDataSwitch.isEnabled = activeConfigurations.isNotEmpty() + mobileDataSwitch.isChecked = activeConfigurations.isNotEmpty() && !wifiPolicy.wifiOnly + foldersCard.isEnabled = activeConfigurations.isNotEmpty() + foldersCard.alpha = if (foldersCard.isEnabled) ENABLED_ALPHA else DISABLED_ALPHA + foldersSummary.text = if (activeConfigurations.isEmpty()) { + getString(R.string.automatic_upload_folders_disabled_summary) + } else { + val suffix = if (selectedFolderCount == 0) { + "" + } else { + getString(R.string.automatic_upload_selected_folders_suffix, selectedFolderCount) + } + getString(R.string.automatic_upload_camera_and_folders_summary, suffix) + } + renderingState = false + renderBackgroundStatus() + } + + private fun beginAction(action: PendingAction) { + if (!AutomaticUploadsPermissions.supportsBackgroundSourceDeletion()) { + pendingAction = null + Toast.makeText( + requireContext(), + R.string.automatic_upload_android_10_unsupported, + Toast.LENGTH_LONG, + ).show() + renderUploadState() + renderBackgroundStatus() + return + } + pendingAction = action + continuePendingAction() + } + + private fun continuePendingAction() { + val action = pendingAction ?: return + val missingReadPermissions = action.requiredKinds + .filterNot { AutomaticUploadsPermissions.hasReadPermission(requireContext(), it) } + .flatMap { AutomaticUploadsPermissions.readPermissions(it).asIterable() } + .distinct() + + if (missingReadPermissions.isNotEmpty()) { + mediaPermissionLauncher.launch(missingReadPermissions.toTypedArray()) + return + } + + if (action.requiresDeletePermission && !AutomaticUploadsPermissions.hasDeletePermission(requireContext())) { + requestDeletePermission() + return + } + + pendingAction = null + when (action) { + PendingAction.EnablePictures -> { + enablePictures() + } + + PendingAction.EnableVideos -> { + enableVideos() + } + + is PendingAction.OpenFolders -> { + openFolders() + } + + is PendingAction.RepairPermissions -> { + scheduleUploads() + renderBackgroundStatus() + } + } + } + + private fun enablePictures() { + val accountName = availableAccountName ?: return showAccountMissing() + val sharedSources = videoUploads?.sourcePaths.orEmpty().mapNotNull { sourcePath -> + val relativePath = AutomaticUploadMediaSource.parse(sourcePath)?.relativePath + ?: AutomaticUploadMediaSource.legacyTreeRelativePath(sourcePath) + relativePath?.let { AutomaticUploadMediaSource.create(AutomaticUploadMediaKind.IMAGE, it).encodedValue } + } + picturesViewModel.enablePictureUploads(accountName, sharedSources) + } + + private fun enableVideos() { + val accountName = availableAccountName ?: return showAccountMissing() + val sharedSources = pictureUploads?.sourcePaths.orEmpty().mapNotNull { sourcePath -> + val relativePath = AutomaticUploadMediaSource.parse(sourcePath)?.relativePath + ?: AutomaticUploadMediaSource.legacyTreeRelativePath(sourcePath) + relativePath?.let { AutomaticUploadMediaSource.create(AutomaticUploadMediaKind.VIDEO, it).encodedValue } + } + videosViewModel.enableVideoUploadsAndRemoveOriginal(accountName, sharedSources) + } + + private fun openFolders() { + val kinds = activeMediaKinds() + if (kinds.isEmpty()) return + val selectedSources = buildList { + if (AutomaticUploadMediaKind.IMAGE in kinds) addAll(picturesViewModel.getPictureUploadsSourcePaths()) + if (AutomaticUploadMediaKind.VIDEO in kinds) addAll(videosViewModel.getVideoUploadsSourcePaths()) + } + folderPickerLauncher.launch( + AutomaticUploadFoldersActivity.createIntent(requireContext(), kinds, selectedSources) + ) + } + + private fun activeMediaKinds(): Set = buildSet { + if (pictureUploads != null) add(AutomaticUploadMediaKind.IMAGE) + if (videoUploads != null) add(AutomaticUploadMediaKind.VIDEO) + } + + private fun requestDeletePermission() { + val permissionIntent = AutomaticUploadsPermissions.deletePermissionIntent(requireContext()) + requestedMediaManagement = permissionIntent.action == Settings.ACTION_REQUEST_MANAGE_MEDIA + runCatching { deletePermissionLauncher.launch(permissionIntent) } + .onFailure { cancelPendingActionWithPermissionMessage() } + } + + private fun requestUnrestrictedBackgroundWork() { + val intent = AutomaticUploadsPermissions.batteryOptimizationIntent(requireContext()) + runCatching { batteryOptimizationLauncher.launch(intent) } + .onFailure { + batteryOptimizationLauncher.launch(Intent(Settings.ACTION_IGNORE_BATTERY_OPTIMIZATION_SETTINGS)) + } + } + + private fun renderBackgroundStatus() { + if (!::allowBackgroundButton.isInitialized) return + val activeKinds = activeMediaKinds() + if (!AutomaticUploadsPermissions.supportsBackgroundSourceDeletion()) { + backgroundStatusTitle.setText(R.string.automatic_upload_permission_title) + backgroundStatusSummary.setText(R.string.automatic_upload_android_10_unsupported) + allowBackgroundButton.isVisible = false + return + } + val readPermissionMissing = activeKinds.any { + !AutomaticUploadsPermissions.hasReadPermission(requireContext(), it) + } + val deletePermissionMissing = activeKinds.isNotEmpty() && + !AutomaticUploadsPermissions.hasDeletePermission(requireContext()) + if (readPermissionMissing || deletePermissionMissing) { + backgroundStatusTitle.setText(R.string.automatic_upload_permission_title) + backgroundStatusSummary.setText( + when { + readPermissionMissing && deletePermissionMissing -> R.string.automatic_upload_permission_both_missing + readPermissionMissing -> R.string.automatic_upload_permission_read_missing + else -> R.string.automatic_upload_permission_delete_missing + } + ) + allowBackgroundButton.setText(R.string.automatic_upload_allow_access) + allowBackgroundButton.isVisible = true + return + } + val isUnrestricted = AutomaticUploadsPermissions.isBackgroundBatteryUsageUnrestricted(requireContext()) + backgroundStatusTitle.setText( + if (isUnrestricted) { + R.string.automatic_upload_background_ready + } else { + R.string.automatic_upload_background_limited + } + ) + backgroundStatusSummary.setText( + if (isUnrestricted) { + R.string.automatic_upload_background_ready_summary + } else { + R.string.automatic_upload_background_limited_summary + } + ) + allowBackgroundButton.setText(R.string.automatic_upload_allow_background) + allowBackgroundButton.isVisible = !isUnrestricted + } + + private fun hasMissingMediaPermissions(kinds: Set): Boolean = + kinds.isNotEmpty() && + (kinds.any { !AutomaticUploadsPermissions.hasReadPermission(requireContext(), it) } || + !AutomaticUploadsPermissions.hasDeletePermission(requireContext())) + + private fun showAccountMissing() { + renderUploadState() + Toast.makeText(requireContext(), R.string.automatic_upload_account_missing, Toast.LENGTH_LONG).show() + } + + private fun cancelPendingActionWithPermissionMessage() { + pendingAction = null + renderUploadState() + Toast.makeText( + requireContext(), + R.string.automatic_upload_permission_not_granted, + Toast.LENGTH_LONG, + ).show() + } + + private fun scheduleUploads() { + if (!::photosSwitch.isInitialized) return + picturesViewModel.schedulePictureUploads() + videosViewModel.scheduleVideoUploads() + } + + private sealed class PendingAction( + val requiredKinds: Set, + val requiresDeletePermission: Boolean, + ) { + data object EnablePictures : PendingAction(setOf(AutomaticUploadMediaKind.IMAGE), true) + data object EnableVideos : PendingAction(setOf(AutomaticUploadMediaKind.VIDEO), true) + data class OpenFolders(val kinds: Set) : PendingAction(kinds, false) + data class RepairPermissions(val kinds: Set) : PendingAction(kinds, true) + } + + companion object { + private const val LIGHT_USER_QUOTA = -4L + private const val ENABLED_ALPHA = 1f + private const val DISABLED_ALPHA = 0.55f + } +} + +internal data class UnifiedWifiPolicyResolution( + val wifiOnly: Boolean, + val configurationsToUpdate: Set, +) + +internal class UnifiedWifiPolicyCoordinator { + private var requestedWifiOnly: Boolean? = null + private val updatesInFlight = mutableSetOf() + + fun request(wifiOnly: Boolean) { + requestedWifiOnly = wifiOnly + updatesInFlight.clear() + } + + fun reconcile(currentPolicies: Map): UnifiedWifiPolicyResolution { + updatesInFlight.retainAll(currentPolicies.keys) + + if (requestedWifiOnly == null && currentPolicies.values.distinct().size > 1) { + requestedWifiOnly = true + updatesInFlight.clear() + } + + val targetWifiOnly = requestedWifiOnly ?: currentPolicies.values.firstOrNull() ?: true + currentPolicies + .filterValues { it == targetWifiOnly } + .keys + .forEach(updatesInFlight::remove) + + val configurationsToUpdate = currentPolicies + .filter { (mediaKind, wifiOnly) -> wifiOnly != targetWifiOnly && mediaKind !in updatesInFlight } + .keys + updatesInFlight.addAll(configurationsToUpdate) + + return UnifiedWifiPolicyResolution( + wifiOnly = targetWifiOnly, + configurationsToUpdate = configurationsToUpdate, + ) + } +} diff --git a/Android/qsferaApp/src/main/java/eu/qsfera/android/presentation/settings/automaticuploads/SettingsPictureUploadsViewModel.kt b/Android/qsferaApp/src/main/java/eu/qsfera/android/presentation/settings/automaticuploads/SettingsPictureUploadsViewModel.kt index 5ef6ea9b8..cda5ce0bc 100644 --- a/Android/qsferaApp/src/main/java/eu/qsfera/android/presentation/settings/automaticuploads/SettingsPictureUploadsViewModel.kt +++ b/Android/qsferaApp/src/main/java/eu/qsfera/android/presentation/settings/automaticuploads/SettingsPictureUploadsViewModel.kt @@ -84,7 +84,7 @@ class SettingsPictureUploadsViewModel( } } - fun enablePictureUploads(accountName: String) { + fun enablePictureUploads(accountName: String, sourcePaths: List = emptyList()) { // Use selected account as default. viewModelScope.launch(coroutinesDispatcherProvider.io) { getPersonalSpaceForAccount(accountName) @@ -92,6 +92,7 @@ class SettingsPictureUploadsViewModel( SavePictureUploadsConfigurationUseCase.Params( composePictureUploadsConfiguration( accountName = accountName, + sourcePath = encodeSourcePaths(sourcePaths), spaceId = pictureUploadsSpace?.id, ) ) @@ -123,6 +124,19 @@ class SettingsPictureUploadsViewModel( } } + fun normalizeUnifiedAutomaticUploads() { + val configuration = _pictureUploads.value ?: return + if (!configuration.chargingOnly && configuration.behavior == UploadBehavior.MOVE) return + + viewModelScope.launch(coroutinesDispatcherProvider.io) { + savePictureUploadsConfigurationUseCase( + SavePictureUploadsConfigurationUseCase.Params( + composePictureUploadsConfiguration(chargingOnly = false) + ) + ) + } + } + fun getPictureUploadsAccount() = _pictureUploads.value?.accountName fun getPictureUploadsPath() = _pictureUploads.value?.uploadPath ?: PREF__CAMERA_UPLOADS_DEFAULT_PATH diff --git a/Android/qsferaApp/src/main/java/eu/qsfera/android/presentation/settings/automaticuploads/SettingsVideoUploadsViewModel.kt b/Android/qsferaApp/src/main/java/eu/qsfera/android/presentation/settings/automaticuploads/SettingsVideoUploadsViewModel.kt index bab024a8d..aaebdeccf 100644 --- a/Android/qsferaApp/src/main/java/eu/qsfera/android/presentation/settings/automaticuploads/SettingsVideoUploadsViewModel.kt +++ b/Android/qsferaApp/src/main/java/eu/qsfera/android/presentation/settings/automaticuploads/SettingsVideoUploadsViewModel.kt @@ -85,6 +85,18 @@ class SettingsVideoUploadsViewModel( } fun enableVideoUploads(accountName: String) { + enableVideoUploads(accountName, behavior = null) + } + + fun enableVideoUploadsAndRemoveOriginal(accountName: String, sourcePaths: List = emptyList()) { + enableVideoUploads(accountName, behavior = UploadBehavior.MOVE, sourcePaths = sourcePaths) + } + + private fun enableVideoUploads( + accountName: String, + behavior: UploadBehavior?, + sourcePaths: List = emptyList(), + ) { // Use selected account as default. viewModelScope.launch(coroutinesDispatcherProvider.io) { getPersonalSpaceForAccount(accountName) @@ -92,6 +104,8 @@ class SettingsVideoUploadsViewModel( SaveVideoUploadsConfigurationUseCase.Params( composeVideoUploadsConfiguration( accountName = accountName, + behavior = behavior, + sourcePath = encodeSourcePaths(sourcePaths), spaceId = videoUploadsSpace?.id, ) ) @@ -123,6 +137,22 @@ class SettingsVideoUploadsViewModel( } } + fun normalizeUnifiedAutomaticUploads() { + val configuration = _videoUploads.value ?: return + if (!configuration.chargingOnly && configuration.behavior == UploadBehavior.MOVE) return + + viewModelScope.launch(coroutinesDispatcherProvider.io) { + saveVideoUploadsConfigurationUseCase( + SaveVideoUploadsConfigurationUseCase.Params( + composeVideoUploadsConfiguration( + chargingOnly = false, + behavior = UploadBehavior.MOVE, + ) + ) + ) + } + } + fun getVideoUploadsAccount() = _videoUploads.value?.accountName fun getVideoUploadsPath() = _videoUploads.value?.uploadPath ?: PREF__CAMERA_UPLOADS_DEFAULT_PATH diff --git a/Android/qsferaApp/src/main/java/eu/qsfera/android/providers/WorkManagerProvider.kt b/Android/qsferaApp/src/main/java/eu/qsfera/android/providers/WorkManagerProvider.kt index 0bb19b8c2..65a91307e 100644 --- a/Android/qsferaApp/src/main/java/eu/qsfera/android/providers/WorkManagerProvider.kt +++ b/Android/qsferaApp/src/main/java/eu/qsfera/android/providers/WorkManagerProvider.kt @@ -33,6 +33,7 @@ import androidx.work.ExistingPeriodicWorkPolicy import androidx.work.ExistingWorkPolicy import androidx.work.NetworkType import androidx.work.OneTimeWorkRequestBuilder +import androidx.work.Operation import androidx.work.PeriodicWorkRequestBuilder import androidx.work.WorkInfo import androidx.work.WorkManager @@ -52,21 +53,21 @@ import java.util.concurrent.TimeUnit class WorkManagerProvider( val context: Context ) { - fun enqueueAutomaticUploadsWorker() { + fun enqueueAutomaticUploadsWorker(): Operation { val automaticUploadsWorker = PeriodicWorkRequestBuilder( repeatInterval = AutomaticUploadsWorker.repeatInterval, repeatIntervalTimeUnit = AutomaticUploadsWorker.repeatIntervalTimeUnit ).addTag(AutomaticUploadsWorker.AUTOMATIC_UPLOADS_WORKER) .build() - WorkManager.getInstance(context) + return WorkManager.getInstance(context) .enqueueUniquePeriodicWork(AutomaticUploadsWorker.AUTOMATIC_UPLOADS_WORKER, ExistingPeriodicWorkPolicy.KEEP, automaticUploadsWorker) } fun enqueueMediaStoreAutomaticUploadsWorker( existingWorkPolicy: ExistingWorkPolicy = ExistingWorkPolicy.KEEP, sourcePaths: Collection = emptyList(), - ) { + ): Operation { val constraintsBuilder = Constraints.Builder() .addContentUriTrigger(MediaStore.Images.Media.EXTERNAL_CONTENT_URI, true) .addContentUriTrigger(MediaStore.Video.Media.EXTERNAL_CONTENT_URI, true) @@ -88,7 +89,7 @@ class WorkManagerProvider( ) .build() - WorkManager.getInstance(context).enqueueUniqueWork( + return WorkManager.getInstance(context).enqueueUniqueWork( AutomaticUploadsWorker.MEDIA_STORE_UPLOADS_WORKER, existingWorkPolicy, mediaStoreWorker @@ -118,19 +119,25 @@ class WorkManagerProvider( * Skips if either the periodic or immediate worker is already running to avoid * concurrent scans or redundant enqueues on rapid foreground/background switches. */ - fun enqueueImmediateAutomaticUploadsWorker() { + /** + * Enqueues the foreground safety scan. Recovery broadcasts skip the synchronous running-work + * lookup so their short execution window is spent only scheduling durable WorkManager work. + */ + fun enqueueImmediateAutomaticUploadsWorker(skipRunningWorkCheck: Boolean = false): Operation? { val wm = WorkManager.getInstance(context) - val periodicRunning = wm.getWorkInfosForUniqueWork(AutomaticUploadsWorker.AUTOMATIC_UPLOADS_WORKER) - .get().any { it.state == WorkInfo.State.RUNNING } - val immediateRunning = wm.getWorkInfosForUniqueWork(AutomaticUploadsWorker.IMMEDIATE_UPLOADS_WORKER) - .get().any { it.state == WorkInfo.State.RUNNING } - val mediaTriggerRunning = wm.getWorkInfosForUniqueWork(AutomaticUploadsWorker.MEDIA_STORE_UPLOADS_WORKER) - .get().any { it.state == WorkInfo.State.RUNNING } + if (!skipRunningWorkCheck) { + val periodicRunning = wm.getWorkInfosForUniqueWork(AutomaticUploadsWorker.AUTOMATIC_UPLOADS_WORKER) + .get().any { it.state == WorkInfo.State.RUNNING } + val immediateRunning = wm.getWorkInfosForUniqueWork(AutomaticUploadsWorker.IMMEDIATE_UPLOADS_WORKER) + .get().any { it.state == WorkInfo.State.RUNNING } + val mediaTriggerRunning = wm.getWorkInfosForUniqueWork(AutomaticUploadsWorker.MEDIA_STORE_UPLOADS_WORKER) + .get().any { it.state == WorkInfo.State.RUNNING } - if (periodicRunning || immediateRunning || mediaTriggerRunning) { - Timber.d("Automatic uploads worker already running, skipping immediate run") - return + if (periodicRunning || immediateRunning || mediaTriggerRunning) { + Timber.d("Automatic uploads worker already running, skipping immediate run") + return null + } } val immediateWorker = OneTimeWorkRequestBuilder() @@ -138,7 +145,7 @@ class WorkManagerProvider( .setInitialDelay(AutomaticUploadsWorker.WRITE_SAFETY_BUFFER_MS, java.util.concurrent.TimeUnit.MILLISECONDS) .build() - wm.enqueueUniqueWork( + return wm.enqueueUniqueWork( AutomaticUploadsWorker.IMMEDIATE_UPLOADS_WORKER, ExistingWorkPolicy.KEEP, immediateWorker diff --git a/Android/qsferaApp/src/main/java/eu/qsfera/android/receivers/AutomaticUploadsRecoveryReceiver.kt b/Android/qsferaApp/src/main/java/eu/qsfera/android/receivers/AutomaticUploadsRecoveryReceiver.kt new file mode 100644 index 000000000..1f996a64f --- /dev/null +++ b/Android/qsferaApp/src/main/java/eu/qsfera/android/receivers/AutomaticUploadsRecoveryReceiver.kt @@ -0,0 +1,78 @@ +/** + * qsfera Android client application + * + * Copyright (C) 2026 QSfera. + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License version 2. + */ +package eu.qsfera.android.receivers + +import android.content.BroadcastReceiver +import android.content.Context +import android.content.Intent +import androidx.work.ExistingWorkPolicy +import androidx.work.Operation +import eu.qsfera.android.providers.WorkManagerProvider +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.launch +import timber.log.Timber +import java.util.concurrent.TimeUnit + +/** + * Recreates the automatic-upload safety net after events that can invalidate scheduled jobs. + * + * WorkManager normally persists work across a reboot. This receiver deliberately restores the + * periodic scan and the one-shot MediaStore observer as an additional recovery path for devices + * whose job scheduler drops application jobs after a reboot or package update. + */ +class AutomaticUploadsRecoveryReceiver : BroadcastReceiver() { + override fun onReceive(context: Context, intent: Intent) { + if (!isAutomaticUploadsRecoveryAction(intent.action)) return + + val pendingResult = goAsync() + CoroutineScope(SupervisorJob() + Dispatchers.IO).launch { + try { + val workManagerProvider = WorkManagerProvider(context.applicationContext) + awaitEnqueueOperations( + listOfNotNull( + workManagerProvider.enqueueAutomaticUploadsWorker(), + workManagerProvider.enqueueMediaStoreAutomaticUploadsWorker( + existingWorkPolicy = ExistingWorkPolicy.REPLACE, + ), + workManagerProvider.enqueueImmediateAutomaticUploadsWorker(skipRunningWorkCheck = true), + ) + ) + Timber.i("Automatic uploads restored after %s", intent.action) + } catch (throwable: Throwable) { + Timber.e(throwable, "Automatic uploads could not be restored after %s", intent.action) + } finally { + pendingResult.finish() + } + } + } +} + +internal fun awaitEnqueueOperations( + operations: Collection, + timeoutMillis: Long = RECOVERY_ENQUEUE_TIMEOUT_MILLIS, +) { + val deadlineNanos = System.nanoTime() + TimeUnit.MILLISECONDS.toNanos(timeoutMillis) + operations.forEach { operation -> + val remainingNanos = deadlineNanos - System.nanoTime() + check(remainingNanos > 0) { "Timed out while restoring automatic uploads" } + operation.result.get(remainingNanos, TimeUnit.NANOSECONDS) + } +} + +internal fun isAutomaticUploadsRecoveryAction(action: String?): Boolean = action in automaticUploadsRecoveryActions + +private val automaticUploadsRecoveryActions = setOf( + Intent.ACTION_BOOT_COMPLETED, + Intent.ACTION_USER_UNLOCKED, + Intent.ACTION_MY_PACKAGE_REPLACED, +) + +private const val RECOVERY_ENQUEUE_TIMEOUT_MILLIS = 8_000L diff --git a/Android/qsferaApp/src/main/java/eu/qsfera/android/usecases/transfers/uploads/UploadFileFromContentUriUseCase.kt b/Android/qsferaApp/src/main/java/eu/qsfera/android/usecases/transfers/uploads/UploadFileFromContentUriUseCase.kt index e1f2f205e..fa428c439 100644 --- a/Android/qsferaApp/src/main/java/eu/qsfera/android/usecases/transfers/uploads/UploadFileFromContentUriUseCase.kt +++ b/Android/qsferaApp/src/main/java/eu/qsfera/android/usecases/transfers/uploads/UploadFileFromContentUriUseCase.kt @@ -29,6 +29,7 @@ import androidx.work.Data import androidx.work.ExistingWorkPolicy import androidx.work.NetworkType import androidx.work.OneTimeWorkRequestBuilder +import androidx.work.Operation import androidx.work.WorkManager import java.util.concurrent.TimeUnit import eu.qsfera.android.domain.BaseUseCase @@ -39,9 +40,9 @@ import timber.log.Timber class UploadFileFromContentUriUseCase( private val workManager: WorkManager -) : BaseUseCase() { +) : BaseUseCase() { - override fun run(params: Params) { + override fun run(params: Params): Operation { val inputDataUploadFileFromContentUriWorker = Data.Builder() .putString(UploadFileFromContentUriWorker.KEY_PARAM_ACCOUNT_NAME, params.accountName) .putString(UploadFileFromContentUriWorker.KEY_PARAM_BEHAVIOR, params.behavior) @@ -80,7 +81,7 @@ class UploadFileFromContentUriUseCase( val uniqueWorkName = "upload_content_uri_${params.uploadIdInStorageManager}" val behavior = UploadBehavior.fromString(params.behavior) - if (behavior == UploadBehavior.MOVE) { + val enqueueOperation = if (behavior == UploadBehavior.MOVE) { val removeSourceFileWorker = OneTimeWorkRequestBuilder() .setInputData(inputDataRemoveSourceFileWorker) .setBackoffCriteria( @@ -104,6 +105,7 @@ class UploadFileFromContentUriUseCase( } Timber.i("Plain upload of ${params.contentUri.path} has been enqueued with unique work name: $uniqueWorkName") + return enqueueOperation } data class Params( diff --git a/Android/qsferaApp/src/main/java/eu/qsfera/android/workers/AutomaticUploadsWorker.kt b/Android/qsferaApp/src/main/java/eu/qsfera/android/workers/AutomaticUploadsWorker.kt index ac7122f4b..3703e1fc3 100644 --- a/Android/qsferaApp/src/main/java/eu/qsfera/android/workers/AutomaticUploadsWorker.kt +++ b/Android/qsferaApp/src/main/java/eu/qsfera/android/workers/AutomaticUploadsWorker.kt @@ -23,31 +23,39 @@ package eu.qsfera.android.workers import android.content.Context import android.net.Uri -import android.provider.DocumentsContract -import androidx.core.net.toUri import androidx.work.CoroutineWorker import androidx.work.ExistingWorkPolicy +import androidx.work.Operation +import androidx.work.WorkInfo import androidx.work.WorkManager import androidx.work.WorkerParameters +import androidx.work.WorkQuery import eu.qsfera.android.R import eu.qsfera.android.domain.UseCaseResult +import eu.qsfera.android.domain.automaticuploads.FolderBackupRepository import eu.qsfera.android.domain.automaticuploads.model.FolderBackUpConfiguration import eu.qsfera.android.domain.automaticuploads.model.UploadBehavior import eu.qsfera.android.domain.automaticuploads.usecases.GetAutomaticUploadsConfigurationUseCase -import eu.qsfera.android.domain.automaticuploads.usecases.SavePictureUploadsConfigurationUseCase -import eu.qsfera.android.domain.automaticuploads.usecases.SaveVideoUploadsConfigurationUseCase import eu.qsfera.android.domain.transfers.TransferRepository import eu.qsfera.android.domain.transfers.model.OCTransfer +import eu.qsfera.android.domain.transfers.model.TransferResult import eu.qsfera.android.domain.transfers.model.TransferStatus -import eu.qsfera.android.presentation.settings.SettingsActivity import eu.qsfera.android.domain.transfers.model.UploadEnqueuedBy +import eu.qsfera.android.presentation.settings.SettingsActivity import eu.qsfera.android.presentation.settings.automaticuploads.AutomaticUploadMediaKind import eu.qsfera.android.presentation.settings.automaticuploads.AutomaticUploadMediaSource +import eu.qsfera.android.presentation.settings.automaticuploads.AutomaticUploadsPermissions +import eu.qsfera.android.presentation.settings.automaticuploads.MediaStoreGenerationCheckpoint +import eu.qsfera.android.presentation.settings.automaticuploads.MediaStoreGenerationLedger import eu.qsfera.android.presentation.settings.automaticuploads.PhoneMediaStore +import eu.qsfera.android.presentation.settings.automaticuploads.automaticUploadMediaFingerprint import eu.qsfera.android.providers.WorkManagerProvider import eu.qsfera.android.usecases.transfers.uploads.UploadFileFromContentUriUseCase import eu.qsfera.android.utils.NotificationUtils import eu.qsfera.android.utils.UPLOAD_NOTIFICATION_CHANNEL_ID +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock import org.koin.core.component.KoinComponent import org.koin.core.component.inject @@ -78,49 +86,70 @@ class AutomaticUploadsWorker( private val transferRepository: TransferRepository by inject() - override suspend fun doWork(): Result { + private val folderBackupRepository: FolderBackupRepository by inject() + + private val mediaStoreGenerationLedger = MediaStoreGenerationLedger(appContext) + + override suspend fun doWork(): Result = withSerializedAutomaticUploads { + runAutomaticUploads() + } + + private suspend fun runAutomaticUploads(): Result { Timber.i("Starting AutomaticUploadsWorker with UUID ${this.id}") var automaticUploadsEnabled = true var configuredSourcePaths: List = emptyList() - when (val useCaseResult = getAutomaticUploadsConfigurationUseCase(Unit)) { - is UseCaseResult.Success -> { - val cameraUploadsConfiguration = useCaseResult.data - if (cameraUploadsConfiguration == null || cameraUploadsConfiguration.areAutomaticUploadsDisabled()) { - cancelWorker() - automaticUploadsEnabled = false - } else { - configuredSourcePaths = cameraUploadsConfiguration.sourcePaths - cameraUploadsConfiguration.pictureUploadsConfiguration?.let { pictureUploadsConfiguration -> - try { - syncFolder(pictureUploadsConfiguration) - } catch (illegalArgumentException: IllegalArgumentException) { - Timber.e(illegalArgumentException, "Source path for picture uploads is not valid") - showNotificationToUpdateUri(SyncType.PICTURE_UPLOADS) - } catch (securityException: SecurityException) { - Timber.e(securityException, "Picture uploads cannot read phone media") - showNotificationToUpdateUri(SyncType.PICTURE_UPLOADS) + var workResult = Result.success() + try { + when (val useCaseResult = getAutomaticUploadsConfigurationUseCase(Unit)) { + is UseCaseResult.Success -> { + val cameraUploadsConfiguration = useCaseResult.data + if (cameraUploadsConfiguration == null || cameraUploadsConfiguration.areAutomaticUploadsDisabled()) { + cancelWorker() + automaticUploadsEnabled = false + } else { + configuredSourcePaths = cameraUploadsConfiguration.sourcePaths + cameraUploadsConfiguration.pictureUploadsConfiguration?.let { pictureUploadsConfiguration -> + try { + syncFolder(pictureUploadsConfiguration) + } catch (illegalArgumentException: IllegalArgumentException) { + Timber.e(illegalArgumentException, "Source path for picture uploads is not valid") + showNotificationToUpdateUri(SyncType.PICTURE_UPLOADS) + } catch (securityException: SecurityException) { + Timber.e(securityException, "Picture uploads cannot read phone media") + showNotificationToUpdateUri(SyncType.PICTURE_UPLOADS) + } } - } - cameraUploadsConfiguration.videoUploadsConfiguration?.let { videoUploadsConfiguration -> - try { - syncFolder(videoUploadsConfiguration) - } catch (illegalArgumentException: IllegalArgumentException) { - Timber.e(illegalArgumentException, "Source path for video uploads is not valid") - showNotificationToUpdateUri(SyncType.VIDEO_UPLOADS) - } catch (securityException: SecurityException) { - Timber.e(securityException, "Video uploads cannot read phone media") - showNotificationToUpdateUri(SyncType.VIDEO_UPLOADS) + cameraUploadsConfiguration.videoUploadsConfiguration?.let { videoUploadsConfiguration -> + try { + syncFolder(videoUploadsConfiguration) + } catch (illegalArgumentException: IllegalArgumentException) { + Timber.e(illegalArgumentException, "Source path for video uploads is not valid") + showNotificationToUpdateUri(SyncType.VIDEO_UPLOADS) + } catch (securityException: SecurityException) { + Timber.e(securityException, "Video uploads cannot read phone media") + showNotificationToUpdateUri(SyncType.VIDEO_UPLOADS) + } } } } + is UseCaseResult.Error -> { + Timber.e(useCaseResult.throwable, "Worker ${useCaseResult.throwable}") + } } - is UseCaseResult.Error -> { - Timber.e(useCaseResult.throwable, "Worker ${useCaseResult.throwable}") - } + Timber.i("Finishing CameraUploadsWorker with UUID ${this.id}") + } catch (cancelled: CancellationException) { + throw cancelled + } catch (throwable: Throwable) { + Timber.e(throwable, "Automatic upload scan failed; keeping the MediaStore observer alive") + } finally { + val rescheduled = runCatching { + rescheduleMediaStoreTriggerIfNeeded(automaticUploadsEnabled, configuredSourcePaths) + }.onFailure { + Timber.e(it, "MediaStore automatic-upload observer could not be rescheduled") + }.isSuccess + if (!rescheduled) workResult = Result.retry() } - rescheduleMediaStoreTriggerIfNeeded(automaticUploadsEnabled, configuredSourcePaths) - Timber.i("Finishing CameraUploadsWorker with UUID ${this.id}") - return Result.success() + return workResult } private fun rescheduleMediaStoreTriggerIfNeeded( @@ -133,7 +162,7 @@ class AutomaticUploadsWorker( WorkManagerProvider(appContext).enqueueMediaStoreAutomaticUploadsWorker( existingWorkPolicy = ExistingWorkPolicy.APPEND_OR_REPLACE, sourcePaths = sourcePaths, - ) + ).result.get(ENQUEUE_OPERATION_TIMEOUT_SECONDS, TimeUnit.SECONDS) } private fun cancelWorker() { @@ -141,75 +170,20 @@ class AutomaticUploadsWorker( } private fun syncFolder(folderBackUpConfiguration: FolderBackUpConfiguration) { - val syncType = when { - folderBackUpConfiguration.isPictureUploads -> SyncType.PICTURE_UPLOADS - folderBackUpConfiguration.isVideoUploads -> SyncType.VIDEO_UPLOADS - // Else should not happen for the moment. Maybe in upcoming features.. - else -> SyncType.PICTURE_UPLOADS - } - val mediaKind = when (syncType) { - SyncType.PICTURE_UPLOADS -> AutomaticUploadMediaKind.IMAGE - SyncType.VIDEO_UPLOADS -> AutomaticUploadMediaKind.VIDEO + if (!AutomaticUploadsPermissions.supportsBackgroundSourceDeletion()) { + Timber.w("Automatic move is unavailable on Android 10; skipping scan") + return } + val syncType = syncTypeFor(folderBackUpConfiguration) val effectiveBehavior = folderBackUpConfiguration.effectiveBehavior - val currentTimestamp = System.currentTimeMillis() - val automaticUploadSourceType = when (syncType) { - SyncType.PICTURE_UPLOADS -> UploadEnqueuedBy.ENQUEUED_AS_AUTOMATIC_UPLOAD_PICTURE - SyncType.VIDEO_UPLOADS -> UploadEnqueuedBy.ENQUEUED_AS_AUTOMATIC_UPLOAD_VIDEO - } - val completedUploadTimesBySourceUri = completedUploadTimesBySourceUri( - folderBackUpConfiguration, - effectiveBehavior, - automaticUploadSourceType, - ) - - val effectiveSourcePaths = buildList { - add(AutomaticUploadMediaSource.camera(mediaKind).encodedValue) - folderBackUpConfiguration.sourcePaths.forEach { configuredPath -> - val mediaSource = AutomaticUploadMediaSource.parse(configuredPath) - when { - mediaSource?.kind == mediaKind -> add(mediaSource.encodedValue) - mediaSource != null -> Unit - else -> migrateLegacyTreeSource(configuredPath, mediaKind)?.let(::add) - } - } - }.distinct() - val discoveryStartTimestamp = if (effectiveBehavior == UploadBehavior.MOVE) { - 0L - } else { - folderBackUpConfiguration.lastSyncTimestamp - } - var allScansSuccessful = true - val uploadCandidates = mutableListOf() - val mediaSources = effectiveSourcePaths.mapNotNull(AutomaticUploadMediaSource::parse) - if (mediaSources.isNotEmpty()) { - runCatching { - val allMediaFiles = PhoneMediaStore(applicationContext).getItems(mediaSources).map { mediaItem -> - AutomaticUploadCandidate( - uri = mediaItem.uri, - name = mediaItem.displayName, - mimeType = mediaItem.mimeType, - size = mediaItem.size, - lastModified = mediaItem.lastModified, - ) - } - filterFilesReadyToUpload( - syncType = syncType, - sourceLabel = mediaSources.joinToString { it.relativePath }, - allFiles = allMediaFiles, - lastSyncTimestamp = discoveryStartTimestamp, - currentTimestamp = currentTimestamp, - completedUploadTimesBySourceUri = completedUploadTimesBySourceUri, - ) - }.onSuccess(uploadCandidates::addAll).onFailure { - allScansSuccessful = false - Timber.e(it, "MediaStore scan failed for %s", syncType) - } - } + val discovery = discoverUploadCandidates(folderBackUpConfiguration, syncType, currentTimestamp) + val uploadCandidates = discovery.candidates + val transferRecovery = discovery.transferRecovery showNotification(syncType, uploadCandidates.size) + var allEnqueuesSuccessful = true for (candidate in uploadCandidates) { // Dedup: if this content URI already has a queued, in-progress, or succeeded transfer, // skip it. Without this, a worker killed mid-loop (before updateTimestamp) or a @@ -217,11 +191,18 @@ class AutomaticUploadsWorker( // enqueued with a new upload ID — leading to duplicate uploads or 0-byte files // when two workers race on the same cache path. val contentUri = candidate.uri.toString() - if (transferRepository.existsNonFailedTransferForUri(contentUri)) { + val activeTransferExists = contentUri in transferRecovery.activeSourceUris + val completedTransferMatchesSource = shouldRemovePreviouslyUploadedSource( + sourceUri = contentUri, + lastModified = candidate.lastModified, + dateAdded = candidate.dateAdded, + completedUploadTimesBySourceUri = transferRecovery.completedUploadTimesBySourceUri, + ) + if (activeTransferExists || completedTransferMatchesSource) { Timber.d("Skipping already-tracked file: %s", candidate.name) continue } - val uploadId = storeInUploadsDatabase( + val uploadId = storeOrResetUploadTransfer( candidate = candidate, uploadPath = folderBackUpConfiguration.uploadPath.plus(File.separator).plus(candidate.name), accountName = folderBackUpConfiguration.accountName, @@ -230,27 +211,183 @@ class AutomaticUploadsWorker( SyncType.PICTURE_UPLOADS -> UploadEnqueuedBy.ENQUEUED_AS_AUTOMATIC_UPLOAD_PICTURE SyncType.VIDEO_UPLOADS -> UploadEnqueuedBy.ENQUEUED_AS_AUTOMATIC_UPLOAD_VIDEO }, - spaceId = folderBackUpConfiguration.spaceId - ) - enqueueSingleUpload( - contentUri = candidate.uri, - uploadPath = folderBackUpConfiguration.uploadPath.plus(File.separator).plus(candidate.name), - lastModified = candidate.lastModified, - behavior = effectiveBehavior.toString(), - accountName = folderBackUpConfiguration.accountName, - uploadId = uploadId, - wifiOnly = folderBackUpConfiguration.wifiOnly, - chargingOnly = folderBackUpConfiguration.chargingOnly + spaceId = folderBackUpConfiguration.spaceId, + failedTransfer = transferRecovery.failedTransfersBySourceUri[contentUri], ) + val enqueueSucceeded = runCatching { + enqueueSingleUpload( + contentUri = candidate.uri, + uploadPath = folderBackUpConfiguration.uploadPath.plus(File.separator).plus(candidate.name), + lastModified = candidate.lastModified, + behavior = effectiveBehavior.toString(), + accountName = folderBackUpConfiguration.accountName, + uploadId = uploadId, + wifiOnly = folderBackUpConfiguration.wifiOnly, + chargingOnly = folderBackUpConfiguration.effectiveChargingOnly + ).result.get(ENQUEUE_OPERATION_TIMEOUT_SECONDS, TimeUnit.SECONDS) + }.onFailure { + Timber.e(it, "Upload work could not be durably enqueued for %s", contentUri) + }.isSuccess + if (!enqueueSucceeded) { + allEnqueuesSuccessful = false + if (!hasDurableWork(uploadId)) { + transferRepository.updateTransferWhenFinished( + id = uploadId, + status = TransferStatus.TRANSFER_FAILED, + transferEndTimestamp = System.currentTimeMillis(), + lastResult = TransferResult.SERVICE_INTERRUPTED, + ) + } + } } // Save safeTimestamp (not currentTimestamp) so that files skipped by the // write-safety buffer are re-evaluated on the next run instead of being lost. - if (allScansSuccessful) { + if (discovery.successful && allEnqueuesSuccessful) { val safeTimestamp = currentTimestamp - WRITE_SAFETY_BUFFER_MS - updateTimestamp(folderBackUpConfiguration, syncType, safeTimestamp) + val timestampUpdated = updateTimestamp(folderBackUpConfiguration, safeTimestamp) + if (!timestampUpdated) { + Timber.i("Automatic-upload configuration changed during scan; cursors were preserved") + } else if ( + discovery.generationCheckpoint + ?.withCommittedTimestamp(safeTimestamp) + ?.let(mediaStoreGenerationLedger::commit) == false + ) { + Timber.w("MediaStore change cursor could not be persisted; periodic recovery will retry") + } else { + Timber.d("Automatic-upload timestamp and MediaStore cursors advanced for the same configuration") + } } } + private fun discoverUploadCandidates( + configuration: FolderBackUpConfiguration, + syncType: SyncType, + currentTimestamp: Long, + ): AutomaticUploadDiscovery { + val mediaKind = when (syncType) { + SyncType.PICTURE_UPLOADS -> AutomaticUploadMediaKind.IMAGE + SyncType.VIDEO_UPLOADS -> AutomaticUploadMediaKind.VIDEO + } + val sourceType = sourceTypeFor(syncType) + val completedUploadTimes = completedUploadTimesBySourceUri( + configuration, + configuration.effectiveBehavior, + sourceType, + ) + val transferRecovery = recoverAutomaticUploadTransfers(configuration.accountName, sourceType) + val changedSourceUris = triggeredContentUris.mapTo(mutableSetOf(), Uri::toString) + val mediaSources = effectiveMediaSources(configuration, mediaKind) + if (mediaSources.isEmpty()) { + return AutomaticUploadDiscovery(emptyList(), transferRecovery, true, null) + } + val generationCheckpoint = mediaStoreGenerationLedger.checkpoint( + generationLedgerKey(configuration, mediaKind), + configuration.lastSyncTimestamp, + ) + + val scanResult = runCatching { + val allMediaFiles = PhoneMediaStore(applicationContext).getItems(mediaSources).map { mediaItem -> + AutomaticUploadCandidate( + uri = mediaItem.uri, + name = mediaItem.displayName, + mimeType = mediaItem.mimeType, + size = mediaItem.size, + lastModified = mediaItem.lastModified, + dateAdded = mediaItem.dateAdded, + generationAdded = mediaItem.generationAdded, + generationModified = mediaItem.generationModified, + fingerprint = automaticUploadMediaFingerprint(mediaItem), + ) + } + val candidates = filterFilesReadyToUpload( + syncType = syncType, + sourceLabel = mediaSources.joinToString { it.relativePath }, + allFiles = allMediaFiles, + lastSyncTimestamp = configuration.lastSyncTimestamp, + currentTimestamp = currentTimestamp, + completedUploadTimesBySourceUri = completedUploadTimes, + changedSourceUris = changedSourceUris, + retrySourceUris = transferRecovery.retrySourceUris, + generationBaseline = generationCheckpoint.baseline, + knownFingerprints = generationCheckpoint.knownFingerprints, + ) + val currentFingerprints = allMediaFiles.mapTo(mutableSetOf(), AutomaticUploadCandidate::fingerprint) + val readyFingerprints = candidates.mapTo(mutableSetOf(), AutomaticUploadCandidate::fingerprint) + val deferredFingerprints = allMediaFiles + .asSequence() + .filterNot { it.fingerprint in readyFingerprints } + .filter { it.mimeType.startsWith(syncType.prefixForType) } + .filter { + isAutomaticUploadCandidateDiscovered( + sourceUri = it.uri.toString(), + lastModified = it.lastModified, + dateAdded = it.dateAdded, + lastSyncTimestamp = configuration.lastSyncTimestamp, + safeTimestamp = Long.MAX_VALUE, + changedSourceUris = changedSourceUris, + retrySourceUris = transferRecovery.retrySourceUris, + generationAdded = it.generationAdded, + generationModified = it.generationModified, + generationBaseline = generationCheckpoint.baseline, + inventoryFingerprint = it.fingerprint, + knownFingerprints = generationCheckpoint.knownFingerprints, + currentTimestamp = currentTimestamp, + ) + } + .mapTo(mutableSetOf(), AutomaticUploadCandidate::fingerprint) + val committedFingerprints = automaticUploadInventoryAfterScan( + knownFingerprints = generationCheckpoint.knownFingerprints, + currentFingerprints = currentFingerprints, + readyFingerprints = readyFingerprints, + deferredFingerprints = deferredFingerprints, + ) + candidates to generationCheckpoint.withCurrentFingerprints(committedFingerprints) + } + scanResult.exceptionOrNull()?.let { + Timber.e(it, "MediaStore scan failed for %s", syncType) + if (it is SecurityException) showNotificationToUpdateUri(syncType) + } + val successfulScan = scanResult.getOrNull() + return AutomaticUploadDiscovery( + candidates = successfulScan?.first.orEmpty(), + transferRecovery = transferRecovery, + successful = scanResult.isSuccess, + generationCheckpoint = successfulScan?.second ?: generationCheckpoint, + ) + } + + private fun generationLedgerKey( + configuration: FolderBackUpConfiguration, + mediaKind: AutomaticUploadMediaKind, + ): String = listOf( + configuration.accountName, + configuration.name, + mediaKind.name, + configuration.sourcePath, + ).joinToString("\u001F") + + private fun effectiveMediaSources( + configuration: FolderBackUpConfiguration, + mediaKind: AutomaticUploadMediaKind, + ): List = buildList { + add(AutomaticUploadMediaSource.camera(mediaKind).encodedValue) + configuration.sourcePaths.forEach { configuredPath -> + val mediaSource = AutomaticUploadMediaSource.parseOrMigrateLegacyTree(configuredPath, mediaKind) + if (mediaSource?.kind == mediaKind) add(mediaSource.encodedValue) + } + }.distinct().mapNotNull(AutomaticUploadMediaSource::parse) + + private fun syncTypeFor(configuration: FolderBackUpConfiguration): SyncType = when { + configuration.isPictureUploads -> SyncType.PICTURE_UPLOADS + configuration.isVideoUploads -> SyncType.VIDEO_UPLOADS + else -> SyncType.PICTURE_UPLOADS + } + + private fun sourceTypeFor(syncType: SyncType): UploadEnqueuedBy = when (syncType) { + SyncType.PICTURE_UPLOADS -> UploadEnqueuedBy.ENQUEUED_AS_AUTOMATIC_UPLOAD_PICTURE + SyncType.VIDEO_UPLOADS -> UploadEnqueuedBy.ENQUEUED_AS_AUTOMATIC_UPLOAD_VIDEO + } + private fun completedUploadTimesBySourceUri( configuration: FolderBackUpConfiguration, behavior: UploadBehavior, @@ -258,16 +395,11 @@ class AutomaticUploadsWorker( ): Map { if (behavior != UploadBehavior.MOVE) return emptyMap() - return transferRepository.getFinishedTransfers() - .asSequence() - .filter { it.createdBy == sourceType && it.accountName == configuration.accountName } - .mapNotNull { transfer -> - val sourcePath = transfer.sourcePath - val completedAt = transfer.transferEndTimestamp - if (sourcePath != null && completedAt != null) sourcePath to completedAt else null - } - .groupBy(keySelector = { it.first }, valueTransform = { it.second }) - .mapValues { (_, completionTimes) -> completionTimes.maxOrNull()!! } + return completedMoveUploadTimesBySourceUri( + transfers = transferRepository.getFinishedTransfers(), + accountName = configuration.accountName, + sourceType = sourceType, + ) } private fun showNotification( @@ -300,17 +432,16 @@ class AutomaticUploadsWorker( SyncType.PICTURE_UPLOADS -> R.string.uploader_upload_picture_upload_error SyncType.VIDEO_UPLOADS -> R.string.uploader_upload_video_upload_error } - val notificationKey: String = when (syncType) { - SyncType.PICTURE_UPLOADS -> SettingsActivity.NOTIFICATION_INTENT_PICTURES - SyncType.VIDEO_UPLOADS -> SettingsActivity.NOTIFICATION_INTENT_VIDEOS - } NotificationUtils.createBasicNotification( context = appContext, contentTitle = appContext.getString(R.string.uploader_upload_camera_upload_source_path_error), contentText = appContext.getString(contentText), notificationChannelId = UPLOAD_NOTIFICATION_CHANNEL_ID, notificationId = syncType.getNotificationId(), - intent = NotificationUtils.composePendingIntentToAutomaticUploads(appContext, notificationKey), + intent = NotificationUtils.composePendingIntentToAutomaticUploads( + appContext, + SettingsActivity.NOTIFICATION_INTENT_AUTOMATIC_UPLOADS, + ), onGoing = false, timeOut = null ) @@ -318,25 +449,8 @@ class AutomaticUploadsWorker( private fun updateTimestamp( folderBackUpConfiguration: FolderBackUpConfiguration, - syncType: SyncType, currentTimestamp: Long, - ) { - - when (syncType) { - SyncType.PICTURE_UPLOADS -> { - val savePictureUploadsConfigurationUseCase: SavePictureUploadsConfigurationUseCase by inject() - savePictureUploadsConfigurationUseCase( - SavePictureUploadsConfigurationUseCase.Params(folderBackUpConfiguration.copy(lastSyncTimestamp = currentTimestamp)) - ) - } - SyncType.VIDEO_UPLOADS -> { - val saveVideoUploadsConfigurationUseCase: SaveVideoUploadsConfigurationUseCase by inject() - saveVideoUploadsConfigurationUseCase( - SaveVideoUploadsConfigurationUseCase.Params(folderBackUpConfiguration.copy(lastSyncTimestamp = currentTimestamp)) - ) - } - } - } + ): Boolean = folderBackupRepository.updateLastSyncTimestamp(folderBackUpConfiguration, currentTimestamp) private fun filterFilesReadyToUpload( syncType: SyncType, @@ -345,6 +459,10 @@ class AutomaticUploadsWorker( lastSyncTimestamp: Long, currentTimestamp: Long, completedUploadTimesBySourceUri: Map, + changedSourceUris: Set, + retrySourceUris: Set, + generationBaseline: Long?, + knownFingerprints: Set?, ): List { // Exclude files modified within the last few seconds. Camera apps may still be // writing the file (not all apps use atomic rename), so picking it up too early @@ -359,6 +477,7 @@ class AutomaticUploadsWorker( shouldRemovePreviouslyUploadedSource( sourceUri = candidate.uri.toString(), lastModified = candidate.lastModified, + dateAdded = candidate.dateAdded, completedUploadTimesBySourceUri = completedUploadTimesBySourceUri, ) } @@ -374,8 +493,23 @@ class AutomaticUploadsWorker( val filteredList: List = mediaFiles .filterNot { it.uri in previouslyUploadedUris } - .filter { it.lastModified >= lastSyncTimestamp } - .filter { it.lastModified < safeTimestamp } + .filter { candidate -> + isAutomaticUploadCandidateDiscovered( + sourceUri = candidate.uri.toString(), + lastModified = candidate.lastModified, + dateAdded = candidate.dateAdded, + generationAdded = candidate.generationAdded, + generationModified = candidate.generationModified, + lastSyncTimestamp = lastSyncTimestamp, + safeTimestamp = safeTimestamp, + changedSourceUris = changedSourceUris, + retrySourceUris = retrySourceUris, + generationBaseline = generationBaseline, + inventoryFingerprint = candidate.fingerprint, + knownFingerprints = knownFingerprints, + currentTimestamp = currentTimestamp, + ) + } Timber.i("Last sync ${syncType.name}: ${Date(lastSyncTimestamp)}") Timber.i("CurrentTimestamp ${Date(currentTimestamp)}") @@ -394,10 +528,10 @@ class AutomaticUploadsWorker( uploadId: Long, wifiOnly: Boolean, chargingOnly: Boolean - ) { + ): Operation { val lastModifiedInSeconds = (lastModified / 1000L).toString() - UploadFileFromContentUriUseCase(WorkManager.getInstance(appContext))( + return UploadFileFromContentUriUseCase(WorkManager.getInstance(appContext))( UploadFileFromContentUriUseCase.Params( accountName = accountName, contentUri = contentUri, @@ -411,15 +545,17 @@ class AutomaticUploadsWorker( ) } - private fun storeInUploadsDatabase( + private fun storeOrResetUploadTransfer( candidate: AutomaticUploadCandidate, uploadPath: String, accountName: String, behavior: UploadBehavior, createdByWorker: UploadEnqueuedBy, spaceId: String?, + failedTransfer: OCTransfer?, ): Long { val ocTransfer = OCTransfer( + id = failedTransfer?.id, localPath = candidate.uri.toString(), remotePath = uploadPath, accountName = accountName, @@ -432,24 +568,103 @@ class AutomaticUploadsWorker( sourcePath = candidate.uri.toString(), ) - return transferRepository.saveTransfer(ocTransfer) + return failedTransfer?.id?.also { transferRepository.updateTransfer(ocTransfer) } + ?: transferRepository.saveTransfer(ocTransfer) } - private fun migrateLegacyTreeSource( - sourcePath: String, - mediaKind: AutomaticUploadMediaKind, - ): String? { - val sourceUri = runCatching { sourcePath.toUri() }.getOrNull() ?: return null - if (sourceUri.scheme != "content" || !DocumentsContract.isTreeUri(sourceUri)) return null - - val documentId = runCatching { DocumentsContract.getTreeDocumentId(sourceUri) }.getOrNull() ?: return null - val volume = documentId.substringBefore(':', missingDelimiterValue = "") - val relativePath = documentId.substringAfter(':', missingDelimiterValue = "") - if (!volume.equals("primary", ignoreCase = true) || relativePath.isBlank()) return null - - return AutomaticUploadMediaSource.create(mediaKind, relativePath).encodedValue + private fun recoverAutomaticUploadTransfers( + accountName: String, + sourceType: UploadEnqueuedBy, + ): AutomaticUploadTransferRecovery { + val activeSourceUris = mutableSetOf() + val completedUploadTimes = mutableMapOf() + val retrySourceUris = mutableSetOf() + val failedTransfers = mutableMapOf() + val durableWorkIds = durableUploadWorkIds(accountName) + transferRepository.getAllTransfers() + .filter { it.accountName == accountName && it.createdBy == sourceType } + .forEach { transfer -> + val sourceUri = transfer.sourcePath ?: return@forEach + when (transfer.status) { + TransferStatus.TRANSFER_SUCCEEDED -> { + if (transfer.localBehaviour == UploadBehavior.MOVE) { + val completedAt = transfer.transferEndTimestamp ?: 0L + completedUploadTimes[sourceUri] = maxOf( + completedUploadTimes[sourceUri] ?: Long.MIN_VALUE, + completedAt, + ) + } + } + TransferStatus.TRANSFER_FAILED -> { + val current = failedTransfers[sourceUri] + if (current == null || transfer.isNewerThan(current)) failedTransfers[sourceUri] = transfer + if (transfer.lastResult == TransferResult.SERVICE_INTERRUPTED) retrySourceUris += sourceUri + } + TransferStatus.TRANSFER_QUEUED, + TransferStatus.TRANSFER_IN_PROGRESS -> { + val uploadId = transfer.id + if (uploadId != null && (durableWorkIds == null || uploadId in durableWorkIds)) { + activeSourceUris += sourceUri + } else if (uploadId != null) { + val finishedAt = System.currentTimeMillis() + transferRepository.updateTransferWhenFinished( + id = uploadId, + status = TransferStatus.TRANSFER_FAILED, + transferEndTimestamp = finishedAt, + lastResult = TransferResult.SERVICE_INTERRUPTED, + ) + failedTransfers[sourceUri] = transfer.copy( + status = TransferStatus.TRANSFER_FAILED, + transferEndTimestamp = finishedAt, + lastResult = TransferResult.SERVICE_INTERRUPTED, + ) + retrySourceUris += sourceUri + } + } + } + } + retrySourceUris.removeAll(activeSourceUris) + return AutomaticUploadTransferRecovery( + activeSourceUris = activeSourceUris, + completedUploadTimesBySourceUri = completedUploadTimes, + retrySourceUris = retrySourceUris, + failedTransfersBySourceUri = failedTransfers, + ) } + private fun hasDurableWork(uploadId: Long): Boolean = runCatching { + val workStates = WorkManager.getInstance(appContext) + .getWorkInfosByTag(uploadId.toString()) + .get(ENQUEUE_OPERATION_TIMEOUT_SECONDS, TimeUnit.SECONDS) + .mapNotNull { it?.state } + isDurableAutomaticUploadTransfer(TransferStatus.TRANSFER_QUEUED, workStates) + }.onFailure { + Timber.w(it, "Could not verify work for automatic upload %d; preserving transfer", uploadId) + }.getOrDefault(true) + + private fun durableUploadWorkIds(accountName: String): Set? = runCatching { + val durableStates = listOf( + WorkInfo.State.ENQUEUED, + WorkInfo.State.BLOCKED, + WorkInfo.State.RUNNING, + WorkInfo.State.SUCCEEDED, + ) + val query = WorkQuery.Builder + .fromTags(listOf(accountName)) + .addStates(durableStates) + .build() + WorkManager.getInstance(appContext) + .getWorkInfos(query) + .get(ENQUEUE_OPERATION_TIMEOUT_SECONDS, TimeUnit.SECONDS) + .asSequence() + .filter { UploadFileFromContentUriWorker::class.java.name in it.tags } + .flatMap { it.tags.asSequence() } + .mapNotNull(String::toLongOrNull) + .toSet() + }.onFailure { + Timber.w(it, "Could not load the automatic-upload work snapshot for %s", accountName) + }.getOrNull() + companion object { const val AUTOMATIC_UPLOADS_WORKER = "AUTOMATIC_UPLOADS_WORKER" const val IMMEDIATE_UPLOADS_WORKER = "IMMEDIATE_AUTOMATIC_UPLOADS_WORKER" @@ -461,22 +676,134 @@ class AutomaticUploadsWorker( private const val videoUploadsNotificationId = 102 const val WRITE_SAFETY_BUFFER_MS = 10_000L const val MEDIA_STORE_TRIGGER_MAX_DELAY_MS = 60_000L + private const val ENQUEUE_OPERATION_TIMEOUT_SECONDS = 5L } } +private val automaticUploadsMutex = Mutex() + +internal suspend fun withSerializedAutomaticUploads(block: suspend () -> T): T = + automaticUploadsMutex.withLock { block() } + private data class AutomaticUploadCandidate( val uri: Uri, val name: String, val mimeType: String, val size: Long, val lastModified: Long, + val dateAdded: Long, + val generationAdded: Long, + val generationModified: Long, + val fingerprint: String, ) +private data class AutomaticUploadTransferRecovery( + val activeSourceUris: Set, + val completedUploadTimesBySourceUri: Map, + val retrySourceUris: Set, + val failedTransfersBySourceUri: Map, +) + +private data class AutomaticUploadDiscovery( + val candidates: List, + val transferRecovery: AutomaticUploadTransferRecovery, + val successful: Boolean, + val generationCheckpoint: MediaStoreGenerationCheckpoint?, +) + +private fun OCTransfer.isNewerThan(other: OCTransfer): Boolean = + (transferEndTimestamp ?: id ?: Long.MIN_VALUE) > (other.transferEndTimestamp ?: other.id ?: Long.MIN_VALUE) + +internal fun isAutomaticUploadCandidateDiscovered( + sourceUri: String, + lastModified: Long, + dateAdded: Long, + lastSyncTimestamp: Long, + safeTimestamp: Long, + changedSourceUris: Set, + retrySourceUris: Set, + generationAdded: Long = 0L, + generationModified: Long = 0L, + generationBaseline: Long? = null, + inventoryFingerprint: String = "", + knownFingerprints: Set? = null, + currentTimestamp: Long = safeTimestamp + AutomaticUploadsWorker.WRITE_SAFETY_BUFFER_MS, +): Boolean { + val discoveryTimestamp = maxOf(lastModified, dateAdded) + val appearedSinceLastSync = lastModified >= lastSyncTimestamp || dateAdded >= lastSyncTimestamp + val explicitlyChanged = sourceUri in changedSourceUris + val requiresRetry = sourceUri in retrySourceUris + val appearedSinceInventory = knownFingerprints != null && inventoryFingerprint !in knownFingerprints + val addedSinceGeneration = generationBaseline != null && generationAdded > generationBaseline + val modifiedSinceGeneration = generationBaseline != null && + generationModified > generationBaseline && + (knownFingerprints == null || appearedSinceInventory) + val changedSinceGeneration = addedSinceGeneration || modifiedSinceGeneration + val explicitNewItem = explicitlyChanged && (knownFingerprints == null || appearedSinceInventory) + val eventDiscovered = explicitNewItem || requiresRetry || changedSinceGeneration || appearedSinceInventory + val suspiciouslyFuture = discoveryTimestamp > currentTimestamp + FUTURE_TIMESTAMP_TOLERANCE_MS + val readyByTimestamp = discoveryTimestamp < safeTimestamp + val timestampDiscovered = appearedSinceLastSync && + !suspiciouslyFuture && + (knownFingerprints == null || appearedSinceInventory) + return (timestampDiscovered && readyByTimestamp) || + (eventDiscovered && (readyByTimestamp || suspiciouslyFuture)) +} + +private const val FUTURE_TIMESTAMP_TOLERANCE_MS = 60_000L + +internal fun automaticUploadInventoryAfterScan( + knownFingerprints: Set?, + currentFingerprints: Set, + readyFingerprints: Set, + deferredFingerprints: Set, +): Set = knownFingerprints?.let { known -> + known.intersect(currentFingerprints) + readyFingerprints +} ?: currentFingerprints - deferredFingerprints + +internal fun isDurableAutomaticUploadTransfer( + transferStatus: TransferStatus, + workStates: Collection, +): Boolean = when (transferStatus) { + TransferStatus.TRANSFER_SUCCEEDED -> true + TransferStatus.TRANSFER_FAILED -> false + TransferStatus.TRANSFER_QUEUED, + TransferStatus.TRANSFER_IN_PROGRESS -> workStates.any { + it == WorkInfo.State.ENQUEUED || + it == WorkInfo.State.BLOCKED || + it == WorkInfo.State.RUNNING || + it == WorkInfo.State.SUCCEEDED + } +} + +internal fun completedMoveUploadTimesBySourceUri( + transfers: Iterable, + accountName: String, + sourceType: UploadEnqueuedBy, +): Map = + transfers + .asSequence() + .filter { + it.createdBy == sourceType && + it.accountName == accountName && + it.status == TransferStatus.TRANSFER_SUCCEEDED && + it.localBehaviour == UploadBehavior.MOVE + } + .mapNotNull { transfer -> + val sourcePath = transfer.sourcePath + val completedAt = transfer.transferEndTimestamp + if (sourcePath != null && completedAt != null) sourcePath to completedAt else null + } + .groupBy(keySelector = { it.first }, valueTransform = { it.second }) + .mapValues { (_, completionTimes) -> completionTimes.maxOrNull()!! } + internal fun shouldRemovePreviouslyUploadedSource( sourceUri: String, lastModified: Long, + dateAdded: Long, completedUploadTimesBySourceUri: Map, ): Boolean { val completedAt = completedUploadTimesBySourceUri[sourceUri] ?: return false - return lastModified > 0 && lastModified <= completedAt + val discoveryTimestamp = maxOf(lastModified, dateAdded) + return discoveryTimestamp > 0 && discoveryTimestamp <= completedAt } diff --git a/Android/qsferaApp/src/main/java/eu/qsfera/android/workers/RemoveSourceFileWorker.kt b/Android/qsferaApp/src/main/java/eu/qsfera/android/workers/RemoveSourceFileWorker.kt index 040e66748..6c63e09a4 100644 --- a/Android/qsferaApp/src/main/java/eu/qsfera/android/workers/RemoveSourceFileWorker.kt +++ b/Android/qsferaApp/src/main/java/eu/qsfera/android/workers/RemoveSourceFileWorker.kt @@ -21,6 +21,7 @@ package eu.qsfera.android.workers import android.content.Context +import android.database.Cursor import android.net.Uri import android.provider.MediaStore import android.os.Environment @@ -92,7 +93,7 @@ internal fun removeSourceUri(context: Context, uri: Uri): Boolean { null, null, null, - )?.use { cursor -> !cursor.moveToFirst() } != false + )?.use { cursor -> !cursor.moveToFirst() } == true } return removeSourceDocument(DocumentFile.fromSingleUri(context, uri)) @@ -100,15 +101,20 @@ internal fun removeSourceUri(context: Context, uri: Uri): Boolean { @Suppress("DEPRECATION") private fun removeMediaStoreFileByPath(context: Context, uri: Uri): Boolean { - val path = context.contentResolver.query( - uri, - arrayOf(MediaStore.MediaColumns.DATA), - null, - null, - null, - )?.use { cursor -> - if (!cursor.moveToFirst()) null else cursor.getString(0) - } ?: return true + val pathLookup = readMediaStoreDataPath( + context.contentResolver.query( + uri, + arrayOf(MediaStore.MediaColumns.DATA), + null, + null, + null, + ) + ) + val path = when (pathLookup) { + MediaStoreDataPath.Absent -> return true + MediaStoreDataPath.Unknown -> return false + is MediaStoreDataPath.Found -> pathLookup.path + } val file = java.io.File(path) val removed = !file.exists() || file.delete() @@ -116,6 +122,23 @@ private fun removeMediaStoreFileByPath(context: Context, uri: Uri): Boolean { return removed } +internal fun readMediaStoreDataPath(cursor: Cursor?): MediaStoreDataPath { + if (cursor == null) return MediaStoreDataPath.Unknown + return cursor.use { + if (!cursor.moveToFirst()) { + MediaStoreDataPath.Absent + } else { + cursor.getString(0)?.let(MediaStoreDataPath::Found) ?: MediaStoreDataPath.Unknown + } + } +} + +internal sealed interface MediaStoreDataPath { + data object Absent : MediaStoreDataPath + data object Unknown : MediaStoreDataPath + data class Found(val path: String) : MediaStoreDataPath +} + internal fun removeSourceDocument(documentFile: DocumentFile?): Boolean { if (documentFile == null) return false if (!documentFile.exists()) return true diff --git a/Android/qsferaApp/src/main/res/color/bottom_navigation_item_tint.xml b/Android/qsferaApp/src/main/res/color/bottom_navigation_item_tint.xml index 5a1944a5a..47d69b695 100644 --- a/Android/qsferaApp/src/main/res/color/bottom_navigation_item_tint.xml +++ b/Android/qsferaApp/src/main/res/color/bottom_navigation_item_tint.xml @@ -1,5 +1,5 @@ - - + + diff --git a/Android/qsferaApp/src/main/res/drawable/cloud_album_scrim.xml b/Android/qsferaApp/src/main/res/drawable/cloud_album_scrim.xml new file mode 100644 index 000000000..460b7a5c0 --- /dev/null +++ b/Android/qsferaApp/src/main/res/drawable/cloud_album_scrim.xml @@ -0,0 +1,4 @@ + + + + diff --git a/Android/qsferaApp/src/main/res/drawable/cloud_avatar_background.xml b/Android/qsferaApp/src/main/res/drawable/cloud_avatar_background.xml new file mode 100644 index 000000000..6c04d1c58 --- /dev/null +++ b/Android/qsferaApp/src/main/res/drawable/cloud_avatar_background.xml @@ -0,0 +1,4 @@ + + + + diff --git a/Android/qsferaApp/src/main/res/drawable/cloud_chip_background.xml b/Android/qsferaApp/src/main/res/drawable/cloud_chip_background.xml new file mode 100644 index 000000000..177f36ab0 --- /dev/null +++ b/Android/qsferaApp/src/main/res/drawable/cloud_chip_background.xml @@ -0,0 +1,5 @@ + + + + + diff --git a/Android/qsferaApp/src/main/res/drawable/cloud_feed_scrim.xml b/Android/qsferaApp/src/main/res/drawable/cloud_feed_scrim.xml new file mode 100644 index 000000000..f56e0ad2f --- /dev/null +++ b/Android/qsferaApp/src/main/res/drawable/cloud_feed_scrim.xml @@ -0,0 +1,4 @@ + + + + diff --git a/Android/qsferaApp/src/main/res/drawable/cloud_sheet_background.xml b/Android/qsferaApp/src/main/res/drawable/cloud_sheet_background.xml new file mode 100644 index 000000000..ab59bc67a --- /dev/null +++ b/Android/qsferaApp/src/main/res/drawable/cloud_sheet_background.xml @@ -0,0 +1,5 @@ + + + + + diff --git a/Android/qsferaApp/src/main/res/drawable/cloud_sheet_handle.xml b/Android/qsferaApp/src/main/res/drawable/cloud_sheet_handle.xml new file mode 100644 index 000000000..643fab6f1 --- /dev/null +++ b/Android/qsferaApp/src/main/res/drawable/cloud_sheet_handle.xml @@ -0,0 +1,5 @@ + + + + + diff --git a/Android/qsferaApp/src/main/res/drawable/cloud_status_background.xml b/Android/qsferaApp/src/main/res/drawable/cloud_status_background.xml new file mode 100644 index 000000000..0c6812796 --- /dev/null +++ b/Android/qsferaApp/src/main/res/drawable/cloud_status_background.xml @@ -0,0 +1,5 @@ + + + + + diff --git a/Android/qsferaApp/src/main/res/drawable/ic_chevron_right.xml b/Android/qsferaApp/src/main/res/drawable/ic_chevron_right.xml new file mode 100644 index 000000000..4d6cfb4d7 --- /dev/null +++ b/Android/qsferaApp/src/main/res/drawable/ic_chevron_right.xml @@ -0,0 +1,10 @@ + + + + diff --git a/Android/qsferaApp/src/main/res/drawable/ic_cloud_add.xml b/Android/qsferaApp/src/main/res/drawable/ic_cloud_add.xml new file mode 100644 index 000000000..9b44a7bdc --- /dev/null +++ b/Android/qsferaApp/src/main/res/drawable/ic_cloud_add.xml @@ -0,0 +1,4 @@ + + + + diff --git a/Android/qsferaApp/src/main/res/drawable/ic_cloud_back.xml b/Android/qsferaApp/src/main/res/drawable/ic_cloud_back.xml new file mode 100644 index 000000000..b67fa18d2 --- /dev/null +++ b/Android/qsferaApp/src/main/res/drawable/ic_cloud_back.xml @@ -0,0 +1,4 @@ + + + + diff --git a/Android/qsferaApp/src/main/res/drawable/ic_cloud_file_action.xml b/Android/qsferaApp/src/main/res/drawable/ic_cloud_file_action.xml new file mode 100644 index 000000000..90cd8d973 --- /dev/null +++ b/Android/qsferaApp/src/main/res/drawable/ic_cloud_file_action.xml @@ -0,0 +1,4 @@ + + + + diff --git a/Android/qsferaApp/src/main/res/drawable/ic_cloud_folder_action.xml b/Android/qsferaApp/src/main/res/drawable/ic_cloud_folder_action.xml new file mode 100644 index 000000000..ab0b4eaae --- /dev/null +++ b/Android/qsferaApp/src/main/res/drawable/ic_cloud_folder_action.xml @@ -0,0 +1,4 @@ + + + + diff --git a/Android/qsferaApp/src/main/res/drawable/ic_cloud_offline.xml b/Android/qsferaApp/src/main/res/drawable/ic_cloud_offline.xml new file mode 100644 index 000000000..6c5821d5e --- /dev/null +++ b/Android/qsferaApp/src/main/res/drawable/ic_cloud_offline.xml @@ -0,0 +1,4 @@ + + + + diff --git a/Android/qsferaApp/src/main/res/drawable/ic_cloud_photo_action.xml b/Android/qsferaApp/src/main/res/drawable/ic_cloud_photo_action.xml new file mode 100644 index 000000000..59b8137c0 --- /dev/null +++ b/Android/qsferaApp/src/main/res/drawable/ic_cloud_photo_action.xml @@ -0,0 +1,4 @@ + + + + diff --git a/Android/qsferaApp/src/main/res/drawable/ic_cloud_search.xml b/Android/qsferaApp/src/main/res/drawable/ic_cloud_search.xml new file mode 100644 index 000000000..916d28a87 --- /dev/null +++ b/Android/qsferaApp/src/main/res/drawable/ic_cloud_search.xml @@ -0,0 +1,4 @@ + + + + diff --git a/Android/qsferaApp/src/main/res/drawable/ic_cloud_share.xml b/Android/qsferaApp/src/main/res/drawable/ic_cloud_share.xml new file mode 100644 index 000000000..050982c14 --- /dev/null +++ b/Android/qsferaApp/src/main/res/drawable/ic_cloud_share.xml @@ -0,0 +1,4 @@ + + + + diff --git a/Android/qsferaApp/src/main/res/drawable/ic_cloud_status_ok.xml b/Android/qsferaApp/src/main/res/drawable/ic_cloud_status_ok.xml new file mode 100644 index 000000000..a2cfafdeb --- /dev/null +++ b/Android/qsferaApp/src/main/res/drawable/ic_cloud_status_ok.xml @@ -0,0 +1,4 @@ + + + + diff --git a/Android/qsferaApp/src/main/res/drawable/ic_cloud_upload.xml b/Android/qsferaApp/src/main/res/drawable/ic_cloud_upload.xml new file mode 100644 index 000000000..19f116788 --- /dev/null +++ b/Android/qsferaApp/src/main/res/drawable/ic_cloud_upload.xml @@ -0,0 +1,4 @@ + + + + diff --git a/Android/qsferaApp/src/main/res/layout/activity_cloud_home.xml b/Android/qsferaApp/src/main/res/layout/activity_cloud_home.xml new file mode 100644 index 000000000..4fadd3376 --- /dev/null +++ b/Android/qsferaApp/src/main/res/layout/activity_cloud_home.xml @@ -0,0 +1,118 @@ + + + + + + + + + + + + + + + + + + + + + + diff --git a/Android/qsferaApp/src/main/res/layout/fragment_cloud_hub.xml b/Android/qsferaApp/src/main/res/layout/fragment_cloud_hub.xml index cbe385e42..9d03231e4 100644 --- a/Android/qsferaApp/src/main/res/layout/fragment_cloud_hub.xml +++ b/Android/qsferaApp/src/main/res/layout/fragment_cloud_hub.xml @@ -1,17 +1,34 @@ - - - + android:layout_height="match_parent"> + + + + + + diff --git a/Android/qsferaApp/src/main/res/layout/fragment_settings_automatic_uploads.xml b/Android/qsferaApp/src/main/res/layout/fragment_settings_automatic_uploads.xml new file mode 100644 index 000000000..554142aa7 --- /dev/null +++ b/Android/qsferaApp/src/main/res/layout/fragment_settings_automatic_uploads.xml @@ -0,0 +1,259 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/Android/qsferaApp/src/main/res/layout/item_cloud_album.xml b/Android/qsferaApp/src/main/res/layout/item_cloud_album.xml index ce53edef9..db07b3bc9 100644 --- a/Android/qsferaApp/src/main/res/layout/item_cloud_album.xml +++ b/Android/qsferaApp/src/main/res/layout/item_cloud_album.xml @@ -1,36 +1,50 @@ - + app:cardCornerRadius="22dp" + app:cardElevation="0dp"> + android:scaleType="centerCrop" + android:src="@drawable/cloud_media_placeholder" /> - + + + android:layout_gravity="bottom" + android:orientation="vertical" + android:padding="16dp"> - - + + + + + diff --git a/Android/qsferaApp/src/main/res/layout/item_cloud_feed_card.xml b/Android/qsferaApp/src/main/res/layout/item_cloud_feed_card.xml new file mode 100644 index 000000000..fa609304c --- /dev/null +++ b/Android/qsferaApp/src/main/res/layout/item_cloud_feed_card.xml @@ -0,0 +1,128 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/Android/qsferaApp/src/main/res/layout/item_cloud_header.xml b/Android/qsferaApp/src/main/res/layout/item_cloud_header.xml index 6c5d9b9f1..6cd9f5120 100644 --- a/Android/qsferaApp/src/main/res/layout/item_cloud_header.xml +++ b/Android/qsferaApp/src/main/res/layout/item_cloud_header.xml @@ -3,10 +3,10 @@ android:id="@+id/cloud_header_text" android:layout_width="match_parent" android:layout_height="wrap_content" - android:paddingStart="4dp" + android:paddingStart="16dp" android:paddingTop="20dp" - android:paddingEnd="4dp" - android:paddingBottom="10dp" + android:paddingEnd="16dp" + android:paddingBottom="12dp" android:textColor="@color/qsfera_text_primary" - android:textSize="20sp" + android:textSize="22sp" android:textStyle="bold" /> diff --git a/Android/qsferaApp/src/main/res/layout/item_cloud_media.xml b/Android/qsferaApp/src/main/res/layout/item_cloud_media.xml index 4d899ec84..17dd90b2c 100644 --- a/Android/qsferaApp/src/main/res/layout/item_cloud_media.xml +++ b/Android/qsferaApp/src/main/res/layout/item_cloud_media.xml @@ -2,7 +2,7 @@ diff --git a/Android/qsferaApp/src/main/res/layout/item_cloud_photo_status.xml b/Android/qsferaApp/src/main/res/layout/item_cloud_photo_status.xml new file mode 100644 index 000000000..e50abc112 --- /dev/null +++ b/Android/qsferaApp/src/main/res/layout/item_cloud_photo_status.xml @@ -0,0 +1,44 @@ + + + + + + + + + + + + diff --git a/Android/qsferaApp/src/main/res/layout/item_cloud_shortcuts.xml b/Android/qsferaApp/src/main/res/layout/item_cloud_shortcuts.xml new file mode 100644 index 000000000..f53b09a90 --- /dev/null +++ b/Android/qsferaApp/src/main/res/layout/item_cloud_shortcuts.xml @@ -0,0 +1,38 @@ + + + + + + + + + + + + diff --git a/Android/qsferaApp/src/main/res/layout/item_cloud_storage.xml b/Android/qsferaApp/src/main/res/layout/item_cloud_storage.xml new file mode 100644 index 000000000..9adcead8a --- /dev/null +++ b/Android/qsferaApp/src/main/res/layout/item_cloud_storage.xml @@ -0,0 +1,32 @@ + + + + + + + diff --git a/Android/qsferaApp/src/main/res/layout/sheet_cloud_add.xml b/Android/qsferaApp/src/main/res/layout/sheet_cloud_add.xml new file mode 100644 index 000000000..0930a2efc --- /dev/null +++ b/Android/qsferaApp/src/main/res/layout/sheet_cloud_add.xml @@ -0,0 +1,52 @@ + + + + + + + + + + + + + + + + diff --git a/Android/qsferaApp/src/main/res/layout/sheet_cloud_more.xml b/Android/qsferaApp/src/main/res/layout/sheet_cloud_more.xml new file mode 100644 index 000000000..4bc91706e --- /dev/null +++ b/Android/qsferaApp/src/main/res/layout/sheet_cloud_more.xml @@ -0,0 +1,48 @@ + + + + + + + + + + + + + + + + + + + + + diff --git a/Android/qsferaApp/src/main/res/layout/sheet_cloud_profile.xml b/Android/qsferaApp/src/main/res/layout/sheet_cloud_profile.xml new file mode 100644 index 000000000..6f75441e1 --- /dev/null +++ b/Android/qsferaApp/src/main/res/layout/sheet_cloud_profile.xml @@ -0,0 +1,91 @@ + + + + + + + + + + + + + + + + + diff --git a/Android/qsferaApp/src/main/res/values-ru/cloud_hub_strings.xml b/Android/qsferaApp/src/main/res/values-ru/cloud_hub_strings.xml index ae4cb8622..2564718fd 100644 --- a/Android/qsferaApp/src/main/res/values-ru/cloud_hub_strings.xml +++ b/Android/qsferaApp/src/main/res/values-ru/cloud_hub_strings.xml @@ -1,11 +1,12 @@ Лента + Файлы Фото Альбомы Ещё Лента готова - Новые автозагрузки появятся здесь после сохранения в QSfera. + Новые автозагрузки появятся здесь после сохранения в КуСфере. Фотографий пока нет Загрузите фото или включите автозагрузку в настройках. Альбомов пока нет @@ -13,6 +14,13 @@ Загружаем ваше облако… Не удалось загрузить медиафайлы Нажмите, чтобы повторить + Загрузить ещё + + %1$d файл + %1$d файла + %1$d файлов + %1$d файла + Недавние загрузки Все фото Объектов: %1$d @@ -27,5 +35,47 @@ Настройки Автозагрузка, учётная запись и безопасность Хранилище - Управление файлами QSfera + Управление файлами КуСферы + + %1$d новое фото + %1$d новых фото + %1$d новых фото + %1$d новых фото + + + %1$d новое видео + %1$d новых видео + %1$d новых видео + %1$d новых видео + + + %1$d новый файл + %1$d новых файла + %1$d новых файлов + %1$d новых файлов + + Добавлено в КуСферу + +%1$d + Фото: %1$d, видео: %2$d + Показаны файлы, сохранённые в КуСфере + Загрузки + Офлайн + Ссылки + Добавить в КуСферу + Добавить в КуСферу + Папка + Файлы + Фото + Новая папка + Название папки + Введите название без символа / + Папка создана + Не удалось создать папку + Загрузка началась + Не удалось определить папку для загрузки + В этой папке пока пусто + Нажмите плюс, чтобы добавить файлы или создать папку. + Не удалось загрузить файлы + Не удалось обновить данные по сети. Показаны сохранённые файлы. + Автозагрузка diff --git a/Android/qsferaApp/src/main/res/values-ru/strings.xml b/Android/qsferaApp/src/main/res/values-ru/strings.xml index 68e5c2061..c52da9c27 100644 --- a/Android/qsferaApp/src/main/res/values-ru/strings.xml +++ b/Android/qsferaApp/src/main/res/values-ru/strings.xml @@ -570,12 +570,14 @@ Ищем папки с медиафайлами… Других папок с медиафайлами не найдено. Не удалось прочитать папки. Проверьте доступ к фото и видео. + Текущий режим автопереноса КуСферы не может без подтверждения удалять файлы камеры в Android 10, поэтому он недоступен в этой версии Android. Медиафайлов: %1$d + Сейчас медиафайлов нет Доступ к фото и файлам Доступ разрешён. Автозагрузка камеры работает в фоне, а оригинал удаляется после успешной загрузки. Доступ к медиафайлам разрешён. - Разрешите доступ к фото и видео, чтобы QSfera обнаруживала новые файлы. - Разрешите полный доступ к файлам, чтобы QSfera удаляла оригинал только после успешной загрузки. + Разрешите доступ к фото и видео, чтобы КуСфера обнаруживала новые файлы. + Разрешите полный доступ к файлам, чтобы КуСфера удаляла оригинал только после успешной загрузки. Нужны два разрешения: читать фото и удалять успешно загруженный оригинал. Камера — автоматически%1$s ; выбрано других папок: %1$d @@ -584,6 +586,22 @@ Автозагрузка будет работать и без Wi-Fi Куда загружать Параметры загрузки + Автозагрузка + Фото, видео, папки на телефоне и работа в фоне + Автозагрузка с фото + Автозагрузка с видео + Камера + Всегда включена, когда работает автозагрузка + Включите автозагрузку фото или видео, чтобы выбрать папки + КуСфера удаляет исходник с телефона только после того, как сервер подтвердит успешную загрузку файла. При ошибке загрузки исходник остаётся на месте. + Ограничение Android снято + Системная оптимизация Android/Doze не ограничивает фоновые проверки КуСферы. Отдельные ограничения производителя телефона могут продолжать действовать. + Ограничение Android включено + Разрешите работу без ограничений Android, чтобы новые файлы обнаруживались, когда КуСфера закрыта. Настройки производителя телефона проверяются отдельно. + Разрешить работу в фоне + Разрешить доступ + Сначала подключите доступную учётную запись, затем включите автозагрузку. + Не выдан необходимый доступ к медиафайлам или их удалению. Автозагрузка не сможет работать, пока доступ не разрешён. Очистить выбранные папки Автозагрузка перестанет проверять папки телефона, пока вы снова не добавите папку. Исходный файл будет diff --git a/Android/qsferaApp/src/main/res/values/cloud_hub_strings.xml b/Android/qsferaApp/src/main/res/values/cloud_hub_strings.xml index 5ba8f835e..3f9d86eca 100644 --- a/Android/qsferaApp/src/main/res/values/cloud_hub_strings.xml +++ b/Android/qsferaApp/src/main/res/values/cloud_hub_strings.xml @@ -1,11 +1,12 @@ Feed + Files Photos Albums More Your feed is ready - New automatic uploads will appear here after they reach QSfera. + New automatic uploads will appear here after they reach КуСфера. No photos found Upload a photo or enable automatic uploads in Settings. No albums yet @@ -13,6 +14,11 @@ Loading your cloud… Could not load cloud media Tap to retry + Load more + + %1$d file + %1$d files + Recent uploads All photos %1$d items @@ -27,5 +33,41 @@ Settings Automatic uploads, account and security Storage - Manage files in QSfera + Manage files in КуСфера + + %1$d new photo + %1$d new photos + + + %1$d new video + %1$d new videos + + + %1$d new file + %1$d new files + + Added to КуСфера + +%1$d + %1$d photos and %2$d videos + Files currently stored in КуСфера are shown below + Transfers + Offline + Shared + Add to КуСфера + Add to КуСфера + Folder + Files + Photos + New folder + Folder name + Enter a name without slashes + Folder created + Could not create folder + Upload started + Could not determine the upload folder + This folder is empty + Use the plus button to add files or create a folder. + Could not load files + Network refresh failed. Showing saved files. + Automatic uploads diff --git a/Android/qsferaApp/src/main/res/values/cloud_styles.xml b/Android/qsferaApp/src/main/res/values/cloud_styles.xml new file mode 100644 index 000000000..bcab3e40d --- /dev/null +++ b/Android/qsferaApp/src/main/res/values/cloud_styles.xml @@ -0,0 +1,30 @@ + + + + + + diff --git a/Android/qsferaApp/src/main/res/values/colors.xml b/Android/qsferaApp/src/main/res/values/colors.xml index 209752f9a..da23bab0b 100644 --- a/Android/qsferaApp/src/main/res/values/colors.xml +++ b/Android/qsferaApp/src/main/res/values/colors.xml @@ -32,6 +32,7 @@ #3566D1 #FFFFFF #F3F4F6 + #EAF1FF #17181A #6D7178 #E8E9EC diff --git a/Android/qsferaApp/src/main/res/values/dims.xml b/Android/qsferaApp/src/main/res/values/dims.xml index 53ea5d21d..171203b64 100644 --- a/Android/qsferaApp/src/main/res/values/dims.xml +++ b/Android/qsferaApp/src/main/res/values/dims.xml @@ -36,6 +36,10 @@ 56dp + 72dp + 8dp + 10dp + 88dp 72dp diff --git a/Android/qsferaApp/src/main/res/values/strings.xml b/Android/qsferaApp/src/main/res/values/strings.xml index e08f6fe04..9078942d7 100644 --- a/Android/qsferaApp/src/main/res/values/strings.xml +++ b/Android/qsferaApp/src/main/res/values/strings.xml @@ -608,12 +608,14 @@ Looking for media folders… No other media folders were found. Folders could not be read. Check photo and video access. + This КуСфера automatic-move mode cannot silently remove camera media on Android 10. It is unavailable on this Android version. %1$d media files + No media files currently found Photo and file access Access granted. Camera uploads can run in the background and remove originals after a successful upload. Media access is allowed. - Allow access to photos and videos so QSfera can detect new media. - Allow full file access so QSfera can remove an original only after its upload succeeds. + Allow access to photos and videos so КуСфера can detect new media. + Allow full file access so КуСфера can remove an original only after its upload succeeds. Two permissions are required: read photos and remove a successfully uploaded original. Camera automatically%1$s ; %1$d other folders selected @@ -622,6 +624,22 @@ Automatic uploads will also work when Wi-Fi is unavailable Upload destination Upload options + Automatic upload + Photos, videos, phone folders and background work + Automatic photo upload + Automatic video upload + Camera + Always included when automatic upload is enabled + Turn on photo or video automatic upload to choose folders + КуСфера removes an original from the phone only after the server confirms that the file was uploaded successfully. If the upload fails, the original remains in place. + Android restriction is removed + Android/Doze battery optimization does not restrict КуСфера background checks. Separate phone-vendor restrictions may still apply. + Android restriction is enabled + Allow unrestricted Android battery use so new media can be detected while КуСфера is closed. Phone-vendor settings are checked separately. + Allow background work + Allow access + Connect an available account before enabling automatic upload. + Required media or delete access was not granted. Automatic upload cannot work until access is allowed. Clear selected folders Automatic uploads will stop scanning phone folders until you add a folder again. Original file will be diff --git a/Android/qsferaApp/src/main/res/xml/settings.xml b/Android/qsferaApp/src/main/res/xml/settings.xml index 86e79f817..74be01598 100644 --- a/Android/qsferaApp/src/main/res/xml/settings.xml +++ b/Android/qsferaApp/src/main/res/xml/settings.xml @@ -41,18 +41,11 @@ - - + app:key="automatic_uploads_subsection" + app:summary="@string/automatic_upload_settings_summary" + app:title="@string/automatic_upload_title" /> () + val committed = SettableFuture.create() + committed.set(Operation.SUCCESS) + every { operation.result } returns committed + + awaitEnqueueOperations(listOf(operation), timeoutMillis = 1_000) + } +} diff --git a/Android/qsferaApp/src/test/java/eu/qsfera/android/workers/AutomaticUploadsWorkerTest.kt b/Android/qsferaApp/src/test/java/eu/qsfera/android/workers/AutomaticUploadsWorkerTest.kt index da1c2d25a..de163d55a 100644 --- a/Android/qsferaApp/src/test/java/eu/qsfera/android/workers/AutomaticUploadsWorkerTest.kt +++ b/Android/qsferaApp/src/test/java/eu/qsfera/android/workers/AutomaticUploadsWorkerTest.kt @@ -10,9 +10,21 @@ package eu.qsfera.android.workers +import androidx.work.WorkInfo +import eu.qsfera.android.domain.automaticuploads.model.UploadBehavior +import eu.qsfera.android.domain.transfers.model.OCTransfer +import eu.qsfera.android.domain.transfers.model.TransferStatus +import eu.qsfera.android.domain.transfers.model.UploadEnqueuedBy +import kotlinx.coroutines.CompletableDeferred +import kotlinx.coroutines.CoroutineStart +import kotlinx.coroutines.async +import kotlinx.coroutines.awaitAll +import kotlinx.coroutines.runBlocking +import org.junit.Assert.assertEquals import org.junit.Assert.assertFalse import org.junit.Assert.assertTrue import org.junit.Test +import java.io.File class AutomaticUploadsWorkerTest { @@ -24,6 +36,7 @@ class AutomaticUploadsWorkerTest { shouldRemovePreviouslyUploadedSource( sourceUri = sourceUri, lastModified = 1_000, + dateAdded = 1_000, completedUploadTimesBySourceUri = mapOf(sourceUri to 2_000), ) ) @@ -35,6 +48,7 @@ class AutomaticUploadsWorkerTest { shouldRemovePreviouslyUploadedSource( sourceUri = sourceUri, lastModified = 3_000, + dateAdded = 3_000, completedUploadTimesBySourceUri = mapOf(sourceUri to 2_000), ) ) @@ -46,8 +60,356 @@ class AutomaticUploadsWorkerTest { shouldRemovePreviouslyUploadedSource( sourceUri = sourceUri, lastModified = 0, + dateAdded = 0, completedUploadTimesBySourceUri = mapOf(sourceUri to 2_000), ) ) } + + @Test + fun `legacy successful copy transfer never authorizes source deletion`() { + val completionTimes = completedMoveUploadTimesBySourceUri( + transfers = listOf(finishedTransfer(UploadBehavior.COPY)), + accountName = "account", + sourceType = UploadEnqueuedBy.ENQUEUED_AS_AUTOMATIC_UPLOAD_VIDEO, + ) + + assertFalse( + shouldRemovePreviouslyUploadedSource( + sourceUri = sourceUri, + lastModified = 1_000, + dateAdded = 1_000, + completedUploadTimesBySourceUri = completionTimes, + ) + ) + } + + @Test + fun `successful move transfer authorizes retrying source deletion`() { + val completionTimes = completedMoveUploadTimesBySourceUri( + transfers = listOf(finishedTransfer(UploadBehavior.MOVE)), + accountName = "account", + sourceType = UploadEnqueuedBy.ENQUEUED_AS_AUTOMATIC_UPLOAD_VIDEO, + ) + + assertTrue( + shouldRemovePreviouslyUploadedSource( + sourceUri = sourceUri, + lastModified = 1_000, + dateAdded = 1_000, + completedUploadTimesBySourceUri = completionTimes, + ) + ) + } + + @Test + fun `reused MediaStore uri with new date added is never deleted as old source`() { + assertFalse( + shouldRemovePreviouslyUploadedSource( + sourceUri = sourceUri, + lastModified = 1_000, + dateAdded = 3_000, + completedUploadTimesBySourceUri = mapOf(sourceUri to 2_000), + ) + ) + } + + @Test + fun `automatic upload scans are serialized`() = runBlocking { + val releaseFirstScan = CompletableDeferred() + val firstScan = async(start = CoroutineStart.UNDISPATCHED) { + withSerializedAutomaticUploads { releaseFirstScan.await() } + } + val secondScan = async(start = CoroutineStart.UNDISPATCHED) { + withSerializedAutomaticUploads { } + } + + assertFalse(secondScan.isCompleted) + releaseFirstScan.complete(Unit) + awaitAll(firstScan, secondScan) + assertTrue(secondScan.isCompleted) + } + + @Test + fun `new MediaStore row is discovered even when file modification time is old`() { + assertTrue( + isAutomaticUploadCandidateDiscovered( + sourceUri = sourceUri, + lastModified = 1_000, + dateAdded = 30_000, + lastSyncTimestamp = 20_000, + safeTimestamp = 40_000, + changedSourceUris = emptySet(), + retrySourceUris = emptySet(), + ) + ) + } + + @Test + fun `content trigger discovers file moved into watched folder with old timestamps`() { + assertTrue( + isAutomaticUploadCandidateDiscovered( + sourceUri = sourceUri, + lastModified = 1_000, + dateAdded = 1_000, + lastSyncTimestamp = 20_000, + safeTimestamp = 40_000, + changedSourceUris = setOf(sourceUri), + retrySourceUris = emptySet(), + ) + ) + } + + @Test + fun `generation ledger discovers moved file when content trigger URIs overflow`() { + assertTrue( + isAutomaticUploadCandidateDiscovered( + sourceUri = sourceUri, + lastModified = 1_000, + dateAdded = 1_000, + lastSyncTimestamp = 20_000, + safeTimestamp = 40_000, + changedSourceUris = emptySet(), + retrySourceUris = emptySet(), + generationAdded = 12, + generationModified = 31, + generationBaseline = 30, + ) + ) + } + + @Test + fun `generation ledger ignores unchanged old media`() { + assertFalse( + isAutomaticUploadCandidateDiscovered( + sourceUri = sourceUri, + lastModified = 1_000, + dateAdded = 1_000, + lastSyncTimestamp = 20_000, + safeTimestamp = 40_000, + changedSourceUris = emptySet(), + retrySourceUris = emptySet(), + generationAdded = 12, + generationModified = 30, + generationBaseline = 30, + ) + ) + } + + @Test + fun `generation added discovers a reinserted row with the same fingerprint`() { + assertTrue( + isAutomaticUploadCandidateDiscovered( + sourceUri = sourceUri, + lastModified = 1_000, + dateAdded = 1_000, + lastSyncTimestamp = 20_000, + safeTimestamp = 40_000, + changedSourceUris = emptySet(), + retrySourceUris = emptySet(), + generationAdded = 31, + generationModified = 31, + generationBaseline = 30, + inventoryFingerprint = "same-item", + knownFingerprints = setOf("same-item"), + ) + ) + } + + @Test + fun `metadata-only generation change does not reimport a known file`() { + assertFalse( + isAutomaticUploadCandidateDiscovered( + sourceUri = sourceUri, + lastModified = 1_000, + dateAdded = 1_000, + lastSyncTimestamp = 20_000, + safeTimestamp = 40_000, + changedSourceUris = emptySet(), + retrySourceUris = emptySet(), + generationAdded = 12, + generationModified = 31, + generationBaseline = 30, + inventoryFingerprint = "same-item", + knownFingerprints = setOf("same-item"), + ) + ) + } + + @Test + fun `inventory discovers old file after MediaStore database reset`() { + assertTrue( + isAutomaticUploadCandidateDiscovered( + sourceUri = sourceUri, + lastModified = 1_000, + dateAdded = 1_000, + lastSyncTimestamp = 20_000, + safeTimestamp = 40_000, + changedSourceUris = emptySet(), + retrySourceUris = emptySet(), + inventoryFingerprint = "new-item", + knownFingerprints = setOf("existing-item"), + ) + ) + } + + @Test + fun `first inventory snapshot does not upload the old media library`() { + assertFalse( + isAutomaticUploadCandidateDiscovered( + sourceUri = sourceUri, + lastModified = 1_000, + dateAdded = 1_000, + lastSyncTimestamp = 20_000, + safeTimestamp = 40_000, + changedSourceUris = emptySet(), + retrySourceUris = emptySet(), + inventoryFingerprint = "existing-item", + knownFingerprints = null, + ) + ) + } + + @Test + fun `event trigger accepts implausibly future-dated completed media`() { + assertTrue( + isAutomaticUploadCandidateDiscovered( + sourceUri = sourceUri, + lastModified = 500_000, + dateAdded = 500_000, + lastSyncTimestamp = 20_000, + safeTimestamp = 30_000, + changedSourceUris = setOf(sourceUri), + retrySourceUris = emptySet(), + currentTimestamp = 40_000, + ) + ) + } + + @Test + fun `future timestamp alone does not import an old untracked library item`() { + assertFalse( + isAutomaticUploadCandidateDiscovered( + sourceUri = sourceUri, + lastModified = 500_000, + dateAdded = 500_000, + lastSyncTimestamp = 20_000, + safeTimestamp = 30_000, + changedSourceUris = emptySet(), + retrySourceUris = emptySet(), + currentTimestamp = 40_000, + ) + ) + } + + @Test + fun `known inventory suppresses reset timestamps for the same file`() { + assertFalse( + isAutomaticUploadCandidateDiscovered( + sourceUri = sourceUri, + lastModified = 30_000, + dateAdded = 30_000, + lastSyncTimestamp = 20_000, + safeTimestamp = 40_000, + changedSourceUris = emptySet(), + retrySourceUris = emptySet(), + inventoryFingerprint = "existing-item", + knownFingerprints = setOf("existing-item"), + ) + ) + } + + @Test + fun `inventory keeps ready candidates but defers files still being written`() { + assertEquals( + setOf("known", "ready"), + automaticUploadInventoryAfterScan( + knownFingerprints = setOf("known", "deleted"), + currentFingerprints = setOf("known", "ready", "recent"), + readyFingerprints = setOf("ready"), + deferredFingerprints = setOf("recent"), + ) + ) + } + + @Test + fun `first inventory snapshot records existing media without importing it`() { + assertEquals( + setOf("existing"), + automaticUploadInventoryAfterScan( + knownFingerprints = null, + currentFingerprints = setOf("existing", "recent"), + readyFingerprints = emptySet(), + deferredFingerprints = setOf("recent"), + ) + ) + } + + @Test + fun `interrupted scheduling is discovered again after sync timestamp advances`() { + assertTrue( + isAutomaticUploadCandidateDiscovered( + sourceUri = sourceUri, + lastModified = 1_000, + dateAdded = 1_000, + lastSyncTimestamp = 20_000, + safeTimestamp = 40_000, + changedSourceUris = emptySet(), + retrySourceUris = setOf(sourceUri), + ) + ) + } + + @Test + fun `recent MediaStore row waits for write safety buffer`() { + assertFalse( + isAutomaticUploadCandidateDiscovered( + sourceUri = sourceUri, + lastModified = 1_000, + dateAdded = 35_000, + lastSyncTimestamp = 20_000, + safeTimestamp = 30_000, + changedSourceUris = setOf(sourceUri), + retrySourceUris = setOf(sourceUri), + ) + ) + } + + @Test + fun `queued transfer without WorkManager work is not durable`() { + assertFalse(isDurableAutomaticUploadTransfer(TransferStatus.TRANSFER_QUEUED, emptySet())) + } + + @Test + fun `running WorkManager upload makes queued transfer durable`() { + assertTrue( + isDurableAutomaticUploadTransfer( + TransferStatus.TRANSFER_QUEUED, + setOf(WorkInfo.State.RUNNING), + ) + ) + } + + @Test + fun `terminal failed transfer is not treated as durable work`() { + assertFalse( + isDurableAutomaticUploadTransfer( + TransferStatus.TRANSFER_FAILED, + setOf(WorkInfo.State.FAILED), + ) + ) + } + + private fun finishedTransfer(behavior: UploadBehavior) = OCTransfer( + localPath = sourceUri, + remotePath = "${File.separator}CameraUpload${File.separator}photo.jpg", + accountName = "account", + fileSize = 100, + status = TransferStatus.TRANSFER_SUCCEEDED, + localBehaviour = behavior, + forceOverwrite = false, + transferEndTimestamp = 2_000, + createdBy = UploadEnqueuedBy.ENQUEUED_AS_AUTOMATIC_UPLOAD_VIDEO, + sourcePath = sourceUri, + ) } diff --git a/Android/qsferaApp/src/test/java/eu/qsfera/android/workers/RemoveSourceFileWorkerTest.kt b/Android/qsferaApp/src/test/java/eu/qsfera/android/workers/RemoveSourceFileWorkerTest.kt index 5d3b80cb3..c96de0d15 100644 --- a/Android/qsferaApp/src/test/java/eu/qsfera/android/workers/RemoveSourceFileWorkerTest.kt +++ b/Android/qsferaApp/src/test/java/eu/qsfera/android/workers/RemoveSourceFileWorkerTest.kt @@ -10,6 +10,10 @@ package eu.qsfera.android.workers +import android.content.ContentResolver +import android.content.Context +import android.net.Uri +import android.provider.MediaStore import androidx.documentfile.provider.DocumentFile import io.mockk.every import io.mockk.mockk @@ -44,4 +48,23 @@ class RemoveSourceFileWorkerTest { assertFalse(removeSourceDocument(documentFile)) } + + @Test + fun `null MediaStore verification cursor does not confirm deletion`() { + val context = mockk() + val contentResolver = mockk() + val uri = mockk() + every { context.contentResolver } returns contentResolver + every { uri.scheme } returns "content" + every { uri.authority } returns MediaStore.AUTHORITY + every { contentResolver.delete(uri, null, null) } returns 0 + every { contentResolver.query(uri, any(), null, null, null) } returns null + + assertFalse(removeSourceUri(context, uri)) + } + + @Test + fun `null legacy path cursor is unknown rather than confirmed absent`() { + assertTrue(readMediaStoreDataPath(null) is MediaStoreDataPath.Unknown) + } } diff --git a/Android/qsferaData/src/main/java/eu/qsfera/android/data/folderbackup/datasources/LocalFolderBackupDataSource.kt b/Android/qsferaData/src/main/java/eu/qsfera/android/data/folderbackup/datasources/LocalFolderBackupDataSource.kt index 2b0687326..e5ecd7366 100644 --- a/Android/qsferaData/src/main/java/eu/qsfera/android/data/folderbackup/datasources/LocalFolderBackupDataSource.kt +++ b/Android/qsferaData/src/main/java/eu/qsfera/android/data/folderbackup/datasources/LocalFolderBackupDataSource.kt @@ -29,5 +29,10 @@ interface LocalFolderBackupDataSource { fun saveFolderBackupConfiguration(folderBackUpConfiguration: FolderBackUpConfiguration) + fun updateLastSyncTimestamp( + expectedConfiguration: FolderBackUpConfiguration, + lastSyncTimestamp: Long, + ): Boolean + fun resetFolderBackupConfigurationByName(name: String) } diff --git a/Android/qsferaData/src/main/java/eu/qsfera/android/data/folderbackup/datasources/implementation/OCLocalFolderBackupDataSource.kt b/Android/qsferaData/src/main/java/eu/qsfera/android/data/folderbackup/datasources/implementation/OCLocalFolderBackupDataSource.kt index 5ce3214df..e57816b29 100644 --- a/Android/qsferaData/src/main/java/eu/qsfera/android/data/folderbackup/datasources/implementation/OCLocalFolderBackupDataSource.kt +++ b/Android/qsferaData/src/main/java/eu/qsfera/android/data/folderbackup/datasources/implementation/OCLocalFolderBackupDataSource.kt @@ -53,6 +53,21 @@ class OCLocalFolderBackupDataSource( folderBackupDao.update(folderBackUpConfiguration.toEntity()) } + override fun updateLastSyncTimestamp( + expectedConfiguration: FolderBackUpConfiguration, + lastSyncTimestamp: Long, + ): Boolean = with(expectedConfiguration) { + folderBackupDao.updateLastSyncTimestamp( + name = name, + accountName = accountName, + sourcePath = sourcePath, + uploadPath = uploadPath, + spaceId = spaceId, + expectedLastSyncTimestamp = this.lastSyncTimestamp, + lastSyncTimestamp = lastSyncTimestamp, + ) == 1 + } + override fun resetFolderBackupConfigurationByName(name: String) { folderBackupDao.delete(name) } diff --git a/Android/qsferaData/src/main/java/eu/qsfera/android/data/folderbackup/db/FolderBackupDao.kt b/Android/qsferaData/src/main/java/eu/qsfera/android/data/folderbackup/db/FolderBackupDao.kt index b7b666e72..228c6f1fc 100644 --- a/Android/qsferaData/src/main/java/eu/qsfera/android/data/folderbackup/db/FolderBackupDao.kt +++ b/Android/qsferaData/src/main/java/eu/qsfera/android/data/folderbackup/db/FolderBackupDao.kt @@ -45,6 +45,17 @@ interface FolderBackupDao { @Query(DELETE) fun delete(name: String): Int + @Query(UPDATE_LAST_SYNC_TIMESTAMP) + fun updateLastSyncTimestamp( + name: String, + accountName: String, + sourcePath: String, + uploadPath: String, + spaceId: String?, + expectedLastSyncTimestamp: Long, + lastSyncTimestamp: Long, + ): Int + @Transaction fun update(folderBackUpEntity: FolderBackUpEntity): Long { delete(folderBackUpEntity.name) @@ -63,5 +74,16 @@ interface FolderBackupDao { FROM ${ProviderMeta.ProviderTableMeta.FOLDER_BACKUP_TABLE_NAME} WHERE name = :name """ + + private const val UPDATE_LAST_SYNC_TIMESTAMP = """ + UPDATE ${ProviderMeta.ProviderTableMeta.FOLDER_BACKUP_TABLE_NAME} + SET lastSyncTimestamp = :lastSyncTimestamp + WHERE name = :name + AND accountName = :accountName + AND sourcePath = :sourcePath + AND uploadPath = :uploadPath + AND spaceId IS :spaceId + AND lastSyncTimestamp = :expectedLastSyncTimestamp + """ } } diff --git a/Android/qsferaData/src/main/java/eu/qsfera/android/data/folderbackup/repository/OCFolderBackupRepository.kt b/Android/qsferaData/src/main/java/eu/qsfera/android/data/folderbackup/repository/OCFolderBackupRepository.kt index ab4cf7aae..f9a89e85f 100644 --- a/Android/qsferaData/src/main/java/eu/qsfera/android/data/folderbackup/repository/OCFolderBackupRepository.kt +++ b/Android/qsferaData/src/main/java/eu/qsfera/android/data/folderbackup/repository/OCFolderBackupRepository.kt @@ -38,6 +38,11 @@ class OCFolderBackupRepository( localFolderBackupDataSource.saveFolderBackupConfiguration(folderBackUpConfiguration) } + override fun updateLastSyncTimestamp( + expectedConfiguration: FolderBackUpConfiguration, + lastSyncTimestamp: Long, + ): Boolean = localFolderBackupDataSource.updateLastSyncTimestamp(expectedConfiguration, lastSyncTimestamp) + override fun resetFolderBackupConfigurationByName(name: String) = localFolderBackupDataSource.resetFolderBackupConfigurationByName(name) diff --git a/Android/qsferaData/src/test/java/eu/qsfera/android/data/folderbackup/datasources/implementation/OCLocalFolderBackupDataSourceTest.kt b/Android/qsferaData/src/test/java/eu/qsfera/android/data/folderbackup/datasources/implementation/OCLocalFolderBackupDataSourceTest.kt index 143f3dc94..5b218e5aa 100644 --- a/Android/qsferaData/src/test/java/eu/qsfera/android/data/folderbackup/datasources/implementation/OCLocalFolderBackupDataSourceTest.kt +++ b/Android/qsferaData/src/test/java/eu/qsfera/android/data/folderbackup/datasources/implementation/OCLocalFolderBackupDataSourceTest.kt @@ -30,6 +30,7 @@ import io.mockk.mockk import io.mockk.verify import junit.framework.TestCase.assertEquals import junit.framework.TestCase.assertNull +import junit.framework.TestCase.assertTrue import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.flowOf import kotlinx.coroutines.runBlocking @@ -102,6 +103,36 @@ class OCLocalFolderBackupDataSourceTest { } } + @Test + fun `updateLastSyncTimestamp updates only the matching configuration snapshot`() { + every { + folderBackupDao.updateLastSyncTimestamp( + name = OC_BACKUP.name, + accountName = OC_BACKUP.accountName, + sourcePath = OC_BACKUP.sourcePath, + uploadPath = OC_BACKUP.uploadPath, + spaceId = OC_BACKUP.spaceId, + expectedLastSyncTimestamp = OC_BACKUP.lastSyncTimestamp, + lastSyncTimestamp = 1234L, + ) + } returns 1 + + val updated = ocLocalFolderBackupDataSource.updateLastSyncTimestamp(OC_BACKUP, 1234L) + + assertTrue(updated) + verify(exactly = 1) { + folderBackupDao.updateLastSyncTimestamp( + name = OC_BACKUP.name, + accountName = OC_BACKUP.accountName, + sourcePath = OC_BACKUP.sourcePath, + uploadPath = OC_BACKUP.uploadPath, + spaceId = OC_BACKUP.spaceId, + expectedLastSyncTimestamp = OC_BACKUP.lastSyncTimestamp, + lastSyncTimestamp = 1234L, + ) + } + } + @Test fun `resetFolderBackupConfigurationByName removes current folder backup configuration correctly`() { ocLocalFolderBackupDataSource.resetFolderBackupConfigurationByName(FolderBackUpConfiguration.pictureUploadsName) diff --git a/Android/qsferaData/src/test/java/eu/qsfera/android/data/folderbackup/repository/OCFolderBackupRepositoryTest.kt b/Android/qsferaData/src/test/java/eu/qsfera/android/data/folderbackup/repository/OCFolderBackupRepositoryTest.kt index a0a5a784e..a992286ce 100644 --- a/Android/qsferaData/src/test/java/eu/qsfera/android/data/folderbackup/repository/OCFolderBackupRepositoryTest.kt +++ b/Android/qsferaData/src/test/java/eu/qsfera/android/data/folderbackup/repository/OCFolderBackupRepositoryTest.kt @@ -31,6 +31,7 @@ import kotlinx.coroutines.flow.flowOf import kotlinx.coroutines.test.runTest import org.junit.Assert.assertEquals import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue import org.junit.Test class OCFolderBackupRepositoryTest { @@ -103,6 +104,20 @@ class OCFolderBackupRepositoryTest { } } + @Test + fun `updateLastSyncTimestamp forwards an atomic timestamp update`() { + every { + localFolderBackupDataSource.updateLastSyncTimestamp(OC_BACKUP, 1234L) + } returns true + + val updated = ocFolderBackupRepository.updateLastSyncTimestamp(OC_BACKUP, 1234L) + + assertTrue(updated) + verify(exactly = 1) { + localFolderBackupDataSource.updateLastSyncTimestamp(OC_BACKUP, 1234L) + } + } + @Test fun `resetFolderBackupConfigurationByName resets a folder backup configuration by name correctly`() { ocFolderBackupRepository.resetFolderBackupConfigurationByName(OC_BACKUP.name) diff --git a/Android/qsferaDomain/src/main/java/eu/qsfera/android/domain/automaticuploads/FolderBackupRepository.kt b/Android/qsferaDomain/src/main/java/eu/qsfera/android/domain/automaticuploads/FolderBackupRepository.kt index 5306a672b..37bff9e6a 100644 --- a/Android/qsferaDomain/src/main/java/eu/qsfera/android/domain/automaticuploads/FolderBackupRepository.kt +++ b/Android/qsferaDomain/src/main/java/eu/qsfera/android/domain/automaticuploads/FolderBackupRepository.kt @@ -29,5 +29,10 @@ interface FolderBackupRepository { fun saveFolderBackupConfiguration(folderBackUpConfiguration: FolderBackUpConfiguration) + fun updateLastSyncTimestamp( + expectedConfiguration: FolderBackUpConfiguration, + lastSyncTimestamp: Long, + ): Boolean + fun resetFolderBackupConfigurationByName(name: String) } diff --git a/Android/qsferaDomain/src/main/java/eu/qsfera/android/domain/automaticuploads/model/AutomaticUploadsConfiguration.kt b/Android/qsferaDomain/src/main/java/eu/qsfera/android/domain/automaticuploads/model/AutomaticUploadsConfiguration.kt index eeb555f66..2bf4d2c72 100644 --- a/Android/qsferaDomain/src/main/java/eu/qsfera/android/domain/automaticuploads/model/AutomaticUploadsConfiguration.kt +++ b/Android/qsferaDomain/src/main/java/eu/qsfera/android/domain/automaticuploads/model/AutomaticUploadsConfiguration.kt @@ -22,10 +22,10 @@ data class AutomaticUploadsConfiguration( val pictureUploadsConfiguration: FolderBackUpConfiguration?, val videoUploadsConfiguration: FolderBackUpConfiguration? ) { - fun areAutomaticUploadsDisabled() = pictureUploadsConfiguration == null && videoUploadsConfiguration == null - val sourcePaths: List get() = listOfNotNull(pictureUploadsConfiguration, videoUploadsConfiguration) .flatMap { it.sourcePaths } .distinct() + + fun areAutomaticUploadsDisabled() = pictureUploadsConfiguration == null && videoUploadsConfiguration == null } diff --git a/Android/qsferaDomain/src/main/java/eu/qsfera/android/domain/automaticuploads/model/FolderBackUpConfiguration.kt b/Android/qsferaDomain/src/main/java/eu/qsfera/android/domain/automaticuploads/model/FolderBackUpConfiguration.kt index c36391b32..96bf65322 100644 --- a/Android/qsferaDomain/src/main/java/eu/qsfera/android/domain/automaticuploads/model/FolderBackUpConfiguration.kt +++ b/Android/qsferaDomain/src/main/java/eu/qsfera/android/domain/automaticuploads/model/FolderBackUpConfiguration.kt @@ -33,18 +33,21 @@ data class FolderBackUpConfiguration( val isPictureUploads get() = name == pictureUploadsName val isVideoUploads get() = name == videoUploadsName + val isAutomaticUploads get() = isPictureUploads || isVideoUploads val sourcePaths get() = parseSourcePaths(sourcePath) /** - * Picture uploads are an ingest operation: the source is removed only after - * the upload worker has completed successfully. Video uploads keep their - * explicitly configured behavior. + * Automatic uploads are an ingest operation: the source is removed only after + * the upload worker has completed successfully. Legacy COPY and charging-only + * settings are ignored even before the user opens the redesigned settings screen. */ - val effectiveBehavior get() = if (isPictureUploads) UploadBehavior.MOVE else behavior + val effectiveBehavior get() = if (isAutomaticUploads) UploadBehavior.MOVE else behavior + val effectiveChargingOnly get() = if (isAutomaticUploads) false else chargingOnly companion object { const val pictureUploadsName = "Picture uploads" const val videoUploadsName = "Video uploads" + private const val SOURCE_PATH_SEPARATOR = "\n" fun parseSourcePaths(sourcePath: String): List = sourcePath @@ -60,7 +63,6 @@ data class FolderBackUpConfiguration( .distinct() .joinToString(SOURCE_PATH_SEPARATOR) - private const val SOURCE_PATH_SEPARATOR = "\n" } } diff --git a/Android/qsferaDomain/src/test/java/eu/qsfera/android/domain/automaticuploads/model/FolderBackUpConfigurationTest.kt b/Android/qsferaDomain/src/test/java/eu/qsfera/android/domain/automaticuploads/model/FolderBackUpConfigurationTest.kt index 5bbd9d6ee..ad77fc3ee 100644 --- a/Android/qsferaDomain/src/test/java/eu/qsfera/android/domain/automaticuploads/model/FolderBackUpConfigurationTest.kt +++ b/Android/qsferaDomain/src/test/java/eu/qsfera/android/domain/automaticuploads/model/FolderBackUpConfigurationTest.kt @@ -71,25 +71,43 @@ class FolderBackUpConfigurationTest { } @Test - fun `video uploads preserve configured behavior`() { + fun `video uploads always remove source after successful upload`() { val configuration = folderBackUpConfiguration( name = FolderBackUpConfiguration.videoUploadsName, behavior = UploadBehavior.COPY, ) - assertEquals(UploadBehavior.COPY, configuration.effectiveBehavior) + assertEquals(UploadBehavior.MOVE, configuration.effectiveBehavior) + } + + @Test + fun `automatic uploads ignore legacy charging only setting`() { + val pictureConfiguration = folderBackUpConfiguration( + name = FolderBackUpConfiguration.pictureUploadsName, + behavior = UploadBehavior.COPY, + chargingOnly = true, + ) + val videoConfiguration = folderBackUpConfiguration( + name = FolderBackUpConfiguration.videoUploadsName, + behavior = UploadBehavior.COPY, + chargingOnly = true, + ) + + assertEquals(false, pictureConfiguration.effectiveChargingOnly) + assertEquals(false, videoConfiguration.effectiveChargingOnly) } private fun folderBackUpConfiguration( name: String, behavior: UploadBehavior, + chargingOnly: Boolean = false, ) = FolderBackUpConfiguration( accountName = "account", behavior = behavior, sourcePath = "content://source", uploadPath = "/CameraUpload", wifiOnly = false, - chargingOnly = false, + chargingOnly = chargingOnly, lastSyncTimestamp = 0, name = name, spaceId = null,