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,