Fix background uploads and redesign cloud UI
Android / test-and-build (push) Canceled after 18m0s

This commit is contained in:
Курнат Андрей
2026-07-16 18:29:05 +03:00
parent dd21285700
commit fceac09264
80 changed files with 4652 additions and 606 deletions
+1 -1
View File
@@ -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"
+2 -2
View File
@@ -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() + "\""
@@ -53,6 +53,9 @@
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
<uses-permission android:name="android.permission.RECEIVE_BOOT_COMPLETED" />
<uses-permission android:name="android.permission.WAKE_LOCK" />
<uses-permission
android:name="android.permission.REQUEST_IGNORE_BATTERY_OPTIMIZATIONS"
tools:ignore="BatteryLife" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_MEDIA_PLAYBACK" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_DATA_SYNC" />
@@ -205,6 +208,18 @@
tools:replace="android:resource" />
</provider>
<receiver
android:name=".receivers.AutomaticUploadsRecoveryReceiver"
android:directBootAware="false"
android:enabled="true"
android:exported="false">
<intent-filter>
<action android:name="android.intent.action.BOOT_COMPLETED" />
<action android:name="android.intent.action.USER_UNLOCKED" />
<action android:name="android.intent.action.MY_PACKAGE_REPLACED" />
</intent-filter>
</receiver>
<service android:name=".services.OperationsService" />
<service
android:name="androidx.work.impl.foreground.SystemForegroundService"
@@ -7,100 +7,427 @@ package eu.qsfera.android.presentation.cloud
import android.content.Context
import android.content.Intent
import android.net.Uri
import android.os.Bundle
import android.os.Parcelable
import android.view.View
import android.view.ViewGroup
import android.widget.EditText
import android.widget.ImageView
import android.widget.TextView
import android.widget.Toast
import androidx.activity.result.contract.ActivityResultContracts
import androidx.appcompat.app.AlertDialog
import androidx.appcompat.widget.SearchView
import androidx.core.view.updateLayoutParams
import androidx.core.view.updatePadding
import androidx.lifecycle.lifecycleScope
import com.google.android.material.bottomnavigation.BottomNavigationView
import com.google.android.material.bottomsheet.BottomSheetDialog
import eu.qsfera.android.R
import eu.qsfera.android.databinding.ActivityMainBinding
import eu.qsfera.android.domain.files.FileRepository
import eu.qsfera.android.domain.files.model.FileListOption
import eu.qsfera.android.lib.common.QSferaAccount
import eu.qsfera.android.presentation.accounts.ManageAccountsDialogFragment
import eu.qsfera.android.presentation.accounts.ManageAccountsDialogFragment.Companion.MANAGE_ACCOUNTS_DIALOG
import eu.qsfera.android.presentation.authentication.AccountUtils
import eu.qsfera.android.presentation.avatar.AvatarUtils
import eu.qsfera.android.presentation.settings.SettingsActivity
import eu.qsfera.android.presentation.thumbnails.ThumbnailsRequester
import eu.qsfera.android.presentation.transfers.TransfersViewModel
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.ui.activity.enableEdgeToEdgePostSetContentView
import eu.qsfera.android.ui.activity.enableEdgeToEdgePreSetContentView
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import org.koin.android.ext.android.inject
import org.koin.androidx.viewmodel.ext.android.viewModel
class CloudHomeActivity : FileActivity() {
private lateinit var binding: ActivityMainBinding
private lateinit var section: CloudSection
private val fileRepository: FileRepository by inject()
private val transfersViewModel: TransfersViewModel by viewModel()
private lateinit var currentSection: CloudSection
private lateinit var toolbarAvatar: ImageView
private lateinit var toolbarBack: View
private lateinit var toolbarTitle: TextView
private lateinit var toolbarSearchButton: View
private lateinit var toolbarSearch: SearchView
private lateinit var bottomNavigation: BottomNavigationView
private var uploadsWereRunning = false
private val filePicker = registerForActivityResult(ActivityResultContracts.OpenMultipleDocuments()) { uris ->
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<SearchView>(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<View>(R.id.cloud_toolbar).updatePadding(top = insets.top)
findViewById<View>(R.id.cloud_toolbar).updateLayoutParams {
height = resources.getDimensionPixelSize(R.dimen.cloud_toolbar_height) + insets.top
}
findViewById<View>(R.id.cloud_bottom_spacer).updateLayoutParams {
height = insets.bottom
}
findViewById<View>(R.id.nav_view_container).updateLayoutParams<ViewGroup.MarginLayoutParams> {
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<View>(R.id.cloud_add_folder).setOnClickListener {
dialog.dismiss()
showCreateFolderDialog()
}
content.findViewById<View>(R.id.cloud_add_files).setOnClickListener {
dialog.dismiss()
filePicker.launch(arrayOf("*/*"))
}
content.findViewById<View>(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<Uri>) {
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<View>(R.id.cloud_more_storage).setOnClickListener {
dialog.dismiss()
selectSection(CloudSection.FILES, updateNavigation = true)
}
content.findViewById<View>(R.id.cloud_more_transfers).setOnClickListener {
dialog.dismiss()
startActivity(Intent(this, UploadListActivity::class.java))
}
content.findViewById<View>(R.id.cloud_more_offline).setOnClickListener {
dialog.dismiss()
openFileList(FileListOption.AV_OFFLINE)
}
content.findViewById<View>(R.id.cloud_more_shares).setOnClickListener {
dialog.dismiss()
openFileList(FileListOption.SHARED_BY_LINK)
}
content.findViewById<View>(R.id.cloud_more_spaces).setOnClickListener {
dialog.dismiss()
openFileList(FileListOption.SPACES_LIST)
}
content.findViewById<View>(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<TextView>(R.id.cloud_profile_name).text = displayName
content.findViewById<TextView>(R.id.cloud_profile_account).text = currentAccount.name
content.findViewById<View>(R.id.cloud_profile_automatic_uploads).setOnClickListener {
dialog.dismiss()
startActivity(SettingsActivity.createIntent(this, SettingsActivity.NOTIFICATION_INTENT_AUTOMATIC_UPLOADS))
}
content.findViewById<View>(R.id.cloud_profile_accounts).setOnClickListener {
dialog.dismiss()
ManageAccountsDialogFragment.newInstance(currentAccount)
.show(supportFragmentManager, MANAGE_ACCOUNTS_DIALOG)
}
content.findViewById<View>(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 }
}
@@ -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<CloudHubAdapter.Holder>() {
private var rows: List<CloudHubRow> = emptyList()
fun updateAccount(newAccount: Account) {
if (account == newAccount) return
account = newAccount
notifyItemRangeChanged(0, itemCount)
}
fun submitRows(newRows: List<CloudHubRow>) {
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<TextView>(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<TextView>(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<TextView>(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<TextView>(R.id.cloud_feed_title).text = feedTitle
val mainImage = view.findViewById<ImageView>(R.id.cloud_feed_main_image)
items.firstOrNull()?.let { main ->
loadMediaImage(mainImage, main, PREVIEW_LARGE)
mainImage.setOnClickListener { onMediaClick(main) }
}
val thumbnails = view.findViewById<LinearLayout>(R.id.cloud_feed_thumbnails)
val firstThumbnail = view.findViewById<ImageView>(R.id.cloud_feed_thumb_one)
val secondThumbnail = view.findViewById<ImageView>(R.id.cloud_feed_thumb_two)
val thirdThumbnail = view.findViewById<ImageView>(R.id.cloud_feed_thumb_three)
val lastContainer = view.findViewById<FrameLayout>(R.id.cloud_feed_last_thumb_container)
val moreCount = view.findViewById<TextView>(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<View>(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<View>(R.id.cloud_shortcut_transfers).setOnClickListener {
onShortcutClick(CloudShortcut.TRANSFERS)
}
view.findViewById<View>(R.id.cloud_shortcut_offline).setOnClickListener {
onShortcutClick(CloudShortcut.OFFLINE)
}
view.findViewById<View>(R.id.cloud_shortcut_shares).setOnClickListener {
onShortcutClick(CloudShortcut.SHARES)
}
}
private fun bindPhotoStatus(view: View, status: CloudHubRow.PhotoStatus) {
view.findViewById<TextView>(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<ImageView>(R.id.cloud_media_image)
val video = view.findViewById<ImageView>(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<TextView>(R.id.cloud_storage_title).text = item.name
view.findViewById<ImageView>(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<TextView>(R.id.cloud_album_title).text = album.title
view.findViewById<TextView>(R.id.cloud_album_count).text =
view.context.getString(R.string.cloud_album_items, album.count)
view.findViewById<TextView>(R.id.cloud_album_count).text = view.resources.getQuantityString(
R.plurals.cloud_album_items,
album.count,
album.count,
)
val cover = view.findViewById<ImageView>(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<TextView>(R.id.cloud_status_title).text = status.title
view.findViewById<TextView>(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
}
}
@@ -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<CloudMediaItem> = emptyList()
private var storageItems: List<CloudStorageItem> = 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<RecyclerView>(R.id.cloud_list).apply {
recycler = view.findViewById<RecyclerView>(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<SwipeRefreshLayout>(R.id.cloud_refresh).apply {
setColorSchemeResources(R.color.qsfera_blue)
setOnRefreshListener(::reload)
isEnabled = section != CloudSection.MORE
}
fab = view.findViewById<FloatingActionButton>(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<CloudStorageItem> {
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<CloudStorageItem> { 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<CloudMediaItem> = allMedia.filter { media ->
query.isBlank() || media.name.contains(query, ignoreCase = true) ||
media.parentPath.contains(query, ignoreCase = true)
}
private fun feedRows(media: List<CloudMediaItem>): List<CloudHubRow> {
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<CloudMediaItem>, date: String): List<CloudHubRow> {
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<CloudHubRow>): List<CloudHubRow> =
if (mediaLoaded && !reachedEnd && !isLoading) {
rows + CloudHubRow.Status(
title = getString(R.string.cloud_media_load_more),
summary = "",
loadMore = true,
)
} else {
rows
}
private fun fileRows(): List<CloudHubRow> {
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<CloudMediaItem>): List<CloudHubRow> {
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<CloudMediaItem>): List<CloudHubRow> = 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<CloudHubRow> = 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<CloudMediaItem>,
val rawResultCount: Int,
)
private data class LoadedStorageFolder(
val items: List<CloudStorageItem>,
val spaceId: String?,
val usedCacheAfterRefreshFailure: Boolean,
)
@@ -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<PhotoView>(R.id.cloud_preview_photo).apply { visibility = View.VISIBLE }
val progress = findViewById<ProgressBar>(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)
@@ -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<CloudMediaItem>) : 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 {
@@ -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
@@ -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) }
}
}
}
@@ -48,8 +48,7 @@ class SettingsFragment : PreferenceFragmentCompat() {
private val releaseNotesViewModel by viewModel<ReleaseNotesViewModel>()
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"
@@ -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<AutomaticUploadMediaKind>
private val selectedSources = linkedSetOf<String>()
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<Toolbar>(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<String> = 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<AutomaticUploadMediaKind>,
selectedSources: Collection<String>,
): 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<AutomaticUploadMediaKind>
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<PhoneMediaFolder> = 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<List<PhoneMediaFolder>>,
) {
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<PhoneMediaFolder>,
): List<PhoneMediaFolder> {
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<PhoneMediaFolder> { it.displayName.lowercase() }
.thenBy { it.relativePath.lowercase() }
}
}
@@ -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)
}
}
@@ -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<String> = 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
@@ -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<String>? = 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<String>): 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<String>?,
val currentFingerprints: Set<String> = emptySet(),
val committedTimestamp: Long? = null,
) {
fun withCurrentFingerprints(fingerprints: Set<String>) = 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<ByteArray> { 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)
}
@@ -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<PhoneMediaItem>()
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<String> = 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<String>()
val selectionArgs = mutableListOf<String>()
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,
)
@@ -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<SettingsPictureUploadsViewModel>()
private val videosViewModel by viewModel<SettingsVideoUploadsViewModel>()
private val accountsViewModel by viewModel<ManageAccountsViewModel>()
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<AutomaticUploadMediaKind> = 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<AutomaticUploadMediaKind>): 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<AutomaticUploadMediaKind>,
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<AutomaticUploadMediaKind>) : PendingAction(kinds, false)
data class RepairPermissions(val kinds: Set<AutomaticUploadMediaKind>) : 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<AutomaticUploadMediaKind>,
)
internal class UnifiedWifiPolicyCoordinator {
private var requestedWifiOnly: Boolean? = null
private val updatesInFlight = mutableSetOf<AutomaticUploadMediaKind>()
fun request(wifiOnly: Boolean) {
requestedWifiOnly = wifiOnly
updatesInFlight.clear()
}
fun reconcile(currentPolicies: Map<AutomaticUploadMediaKind, Boolean>): 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,
)
}
}
@@ -84,7 +84,7 @@ class SettingsPictureUploadsViewModel(
}
}
fun enablePictureUploads(accountName: String) {
fun enablePictureUploads(accountName: String, sourcePaths: List<String> = 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
@@ -85,6 +85,18 @@ class SettingsVideoUploadsViewModel(
}
fun enableVideoUploads(accountName: String) {
enableVideoUploads(accountName, behavior = null)
}
fun enableVideoUploadsAndRemoveOriginal(accountName: String, sourcePaths: List<String> = emptyList()) {
enableVideoUploads(accountName, behavior = UploadBehavior.MOVE, sourcePaths = sourcePaths)
}
private fun enableVideoUploads(
accountName: String,
behavior: UploadBehavior?,
sourcePaths: List<String> = 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
@@ -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<AutomaticUploadsWorker>(
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<String> = 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<AutomaticUploadsWorker>()
@@ -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
@@ -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<Operation>,
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
@@ -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<Unit, UploadFileFromContentUriUseCase.Params>() {
) : BaseUseCase<Operation, UploadFileFromContentUriUseCase.Params>() {
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<RemoveSourceFileWorker>()
.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(
@@ -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<String> = 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<AutomaticUploadCandidate>()
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<AutomaticUploadMediaSource> = 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<String, Long> {
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<String, Long>,
changedSourceUris: Set<String>,
retrySourceUris: Set<String>,
generationBaseline: Long?,
knownFingerprints: Set<String>?,
): List<AutomaticUploadCandidate> {
// 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<AutomaticUploadCandidate> = 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<String>()
val completedUploadTimes = mutableMapOf<String, Long>()
val retrySourceUris = mutableSetOf<String>()
val failedTransfers = mutableMapOf<String, OCTransfer>()
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<Long>? = 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 <T> 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<String>,
val completedUploadTimesBySourceUri: Map<String, Long>,
val retrySourceUris: Set<String>,
val failedTransfersBySourceUri: Map<String, OCTransfer>,
)
private data class AutomaticUploadDiscovery(
val candidates: List<AutomaticUploadCandidate>,
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<String>,
retrySourceUris: Set<String>,
generationAdded: Long = 0L,
generationModified: Long = 0L,
generationBaseline: Long? = null,
inventoryFingerprint: String = "",
knownFingerprints: Set<String>? = 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<String>?,
currentFingerprints: Set<String>,
readyFingerprints: Set<String>,
deferredFingerprints: Set<String>,
): Set<String> = knownFingerprints?.let { known ->
known.intersect(currentFingerprints) + readyFingerprints
} ?: currentFingerprints - deferredFingerprints
internal fun isDurableAutomaticUploadTransfer(
transferStatus: TransferStatus,
workStates: Collection<WorkInfo.State>,
): 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<OCTransfer>,
accountName: String,
sourceType: UploadEnqueuedBy,
): Map<String, Long> =
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<String, Long>,
): Boolean {
val completedAt = completedUploadTimesBySourceUri[sourceUri] ?: return false
return lastModified > 0 && lastModified <= completedAt
val discoveryTimestamp = maxOf(lastModified, dateAdded)
return discoveryTimestamp > 0 && discoveryTimestamp <= completedAt
}
@@ -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
@@ -1,5 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<selector xmlns:android="http://schemas.android.com/apk/res/android">
<item android:color="#246BFD" android:state_checked="true" />
<item android:color="#73777F" />
<item android:color="#17181A" android:state_checked="true" />
<item android:color="#9299A9" />
</selector>
@@ -0,0 +1,4 @@
<?xml version="1.0" encoding="utf-8"?>
<shape xmlns:android="http://schemas.android.com/apk/res/android" android:shape="rectangle">
<gradient android:angle="90" android:endColor="#00000000" android:startColor="#99000000" />
</shape>
@@ -0,0 +1,4 @@
<?xml version="1.0" encoding="utf-8"?>
<shape xmlns:android="http://schemas.android.com/apk/res/android" android:shape="oval">
<solid android:color="@color/qsfera_surface_muted" />
</shape>
@@ -0,0 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<shape xmlns:android="http://schemas.android.com/apk/res/android" android:shape="rectangle">
<solid android:color="#F4F5F7" />
<corners android:radius="24dp" />
</shape>
@@ -0,0 +1,4 @@
<?xml version="1.0" encoding="utf-8"?>
<shape xmlns:android="http://schemas.android.com/apk/res/android" android:shape="rectangle">
<gradient android:angle="270" android:endColor="#00000000" android:startColor="#A6000000" />
</shape>
@@ -0,0 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<shape xmlns:android="http://schemas.android.com/apk/res/android" android:shape="rectangle">
<solid android:color="@color/qsfera_surface" />
<corners android:topLeftRadius="30dp" android:topRightRadius="30dp" />
</shape>
@@ -0,0 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<shape xmlns:android="http://schemas.android.com/apk/res/android" android:shape="rectangle">
<solid android:color="#AEB4C0" />
<corners android:radius="2dp" />
</shape>
@@ -0,0 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<shape xmlns:android="http://schemas.android.com/apk/res/android" android:shape="rectangle">
<solid android:color="#F4F5F7" />
<corners android:radius="22dp" />
</shape>
@@ -0,0 +1,10 @@
<?xml version="1.0" encoding="utf-8"?>
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24">
<path
android:fillColor="@color/qsfera_text_secondary"
android:pathData="M9.29,6.71a1,1 0,0 1,1.42 0l4.58,4.58a1,1 0,0 1,0 1.42l-4.58,4.58a1,1 0,1 1,-1.42 -1.42L13.17,12 9.29,8.12a1,1 0,0 1,0 -1.41z" />
</vector>
@@ -0,0 +1,4 @@
<?xml version="1.0" encoding="utf-8"?>
<vector xmlns:android="http://schemas.android.com/apk/res/android" android:width="24dp" android:height="24dp" android:viewportWidth="24" android:viewportHeight="24">
<path android:fillColor="#FFFFFFFF" android:pathData="M11,5h2v6h6v2h-6v6h-2v-6H5v-2h6z" />
</vector>
@@ -0,0 +1,4 @@
<?xml version="1.0" encoding="utf-8"?>
<vector xmlns:android="http://schemas.android.com/apk/res/android" android:width="24dp" android:height="24dp" android:viewportWidth="24" android:viewportHeight="24">
<path android:fillColor="@color/qsfera_text_primary" android:pathData="M20,11H7.83l5.59,-5.59L12,4l-8,8 8,8 1.42,-1.41L7.83,13H20z" />
</vector>
@@ -0,0 +1,4 @@
<?xml version="1.0" encoding="utf-8"?>
<vector xmlns:android="http://schemas.android.com/apk/res/android" android:width="42dp" android:height="42dp" android:viewportWidth="24" android:viewportHeight="24">
<path android:fillColor="#343740" android:pathData="M6,2h8l4,4v16H6zM13,3.5V7h3.5zM8,11h8v1.7H8zM8,15h8v1.7H8z" />
</vector>
@@ -0,0 +1,4 @@
<?xml version="1.0" encoding="utf-8"?>
<vector xmlns:android="http://schemas.android.com/apk/res/android" android:width="42dp" android:height="42dp" android:viewportWidth="24" android:viewportHeight="24">
<path android:fillColor="#343740" android:pathData="M3,5.5A1.5,1.5 0,0 1,4.5 4h5l2,2H19.5A1.5,1.5 0,0 1,21 7.5v10A1.5,1.5 0,0 1,19.5 19h-15A1.5,1.5 0,0 1,3 17.5z" />
</vector>
@@ -0,0 +1,4 @@
<?xml version="1.0" encoding="utf-8"?>
<vector xmlns:android="http://schemas.android.com/apk/res/android" android:width="22dp" android:height="22dp" android:viewportWidth="24" android:viewportHeight="24">
<path android:fillColor="#9399A5" android:pathData="M12,2a7,7 0,0 0,-7 7c0,5.25 7,13 7,13s7,-7.75 7,-13a7,7 0,0 0,-7 -7m-1,11l-3,-3 1.4,-1.4L11,10.17l3.6,-3.57L16,8l-5,5z" />
</vector>
@@ -0,0 +1,4 @@
<?xml version="1.0" encoding="utf-8"?>
<vector xmlns:android="http://schemas.android.com/apk/res/android" android:width="42dp" android:height="42dp" android:viewportWidth="24" android:viewportHeight="24">
<path android:fillColor="#343740" android:pathData="M4,4h16a2,2 0,0 1,2 2v12a2,2 0,0 1,-2 2H4a2,2 0,0 1,-2 -2V6a2,2 0,0 1,2 -2m0,14h16l-5,-6 -4,5 -3,-3zM7.5,7A2,2 0,1 0,7.5 11A2,2 0,1 0,7.5 7" />
</vector>
@@ -0,0 +1,4 @@
<?xml version="1.0" encoding="utf-8"?>
<vector xmlns:android="http://schemas.android.com/apk/res/android" android:width="24dp" android:height="24dp" android:viewportWidth="24" android:viewportHeight="24">
<path android:fillColor="#9096A3" android:pathData="M9.5,3a6.5,6.5 0,1 0,3.98 11.64L19.85,21 21,19.85l-6.36,-6.37A6.5,6.5 0,0 0,9.5 3zM5,9.5a4.5,4.5 0,1 1,9 0,4.5 4.5,0 0,1 -9,0z" />
</vector>
@@ -0,0 +1,4 @@
<?xml version="1.0" encoding="utf-8"?>
<vector xmlns:android="http://schemas.android.com/apk/res/android" android:width="22dp" android:height="22dp" android:viewportWidth="24" android:viewportHeight="24">
<path android:fillColor="#9399A5" android:pathData="M18,16.08c-0.76,0 -1.44,0.3 -1.96,0.77L8.91,12.7c0.05,-0.23 0.09,-0.46 0.09,-0.7s-0.04,-0.47 -0.09,-0.7l7.05,-4.11A3,3 0,1 0,15,5c0,0.24 0.04,0.47 0.09,0.7L8.04,9.81A3,3 0,1 0,8.04 14.19l7.12,4.16c-0.05,0.2 -0.08,0.41 -0.08,0.65A2.92,2.92 0,1 0,18 16.08z" />
</vector>
@@ -0,0 +1,4 @@
<?xml version="1.0" encoding="utf-8"?>
<vector xmlns:android="http://schemas.android.com/apk/res/android" android:width="24dp" android:height="24dp" android:viewportWidth="24" android:viewportHeight="24">
<path android:fillColor="@android:color/transparent" android:pathData="M12,2.5A9.5,9.5 0,1 0,12 21.5A9.5,9.5 0,1 0,12 2.5M7.5,12l3,3 6,-6" android:strokeColor="@color/qsfera_text_primary" android:strokeLineCap="round" android:strokeLineJoin="round" android:strokeWidth="2" />
</vector>
@@ -0,0 +1,4 @@
<?xml version="1.0" encoding="utf-8"?>
<vector xmlns:android="http://schemas.android.com/apk/res/android" android:width="22dp" android:height="22dp" android:viewportWidth="24" android:viewportHeight="24">
<path android:fillColor="#9399A5" android:pathData="M5,20h14v-2H5v2M19,9h-4V3H9v6H5l7,7 7,-7z" />
</vector>
@@ -0,0 +1,118 @@
<?xml version="1.0" encoding="utf-8"?>
<androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:background="@color/qsfera_surface">
<androidx.constraintlayout.widget.ConstraintLayout
android:id="@+id/cloud_toolbar"
android:layout_width="0dp"
android:layout_height="72dp"
android:background="@color/qsfera_surface"
android:paddingStart="16dp"
android:paddingEnd="12dp"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent">
<androidx.appcompat.widget.AppCompatImageView
android:id="@+id/cloud_toolbar_avatar"
android:layout_width="48dp"
android:layout_height="48dp"
android:background="@drawable/cloud_avatar_background"
android:contentDescription="@string/content_description_manage_accounts"
android:padding="7dp"
android:src="@drawable/ic_account_circle"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent" />
<androidx.appcompat.widget.AppCompatImageButton
android:id="@+id/cloud_toolbar_back"
android:layout_width="48dp"
android:layout_height="48dp"
android:background="?attr/selectableItemBackgroundBorderless"
android:contentDescription="@string/common_back"
android:padding="12dp"
android:src="@drawable/ic_cloud_back"
android:visibility="gone"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent" />
<TextView
android:id="@+id/cloud_toolbar_title"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginStart="80dp"
android:ellipsize="end"
android:maxLines="1"
android:textColor="@color/qsfera_text_primary"
android:textSize="30sp"
android:textStyle="bold"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toStartOf="@id/cloud_toolbar_search_button"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent" />
<androidx.appcompat.widget.AppCompatImageButton
android:id="@+id/cloud_toolbar_search_button"
android:layout_width="48dp"
android:layout_height="48dp"
android:background="?attr/selectableItemBackgroundBorderless"
android:contentDescription="@string/actionbar_search"
android:padding="11dp"
android:src="@drawable/ic_cloud_search"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintTop_toTopOf="parent" />
<androidx.appcompat.widget.SearchView
android:id="@+id/cloud_toolbar_search"
android:layout_width="0dp"
android:layout_height="48dp"
android:layout_marginStart="72dp"
android:background="@drawable/rounded_search_view"
android:visibility="gone"
app:iconifiedByDefault="false"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent"
app:queryHint="@string/actionbar_search" />
</androidx.constraintlayout.widget.ConstraintLayout>
<FrameLayout
android:id="@+id/cloud_content"
android:layout_width="0dp"
android:layout_height="0dp"
app:layout_constraintBottom_toTopOf="@id/cloud_bottom_navigation"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@id/cloud_toolbar" />
<com.google.android.material.bottomnavigation.BottomNavigationView
android:id="@+id/cloud_bottom_navigation"
android:layout_width="0dp"
android:layout_height="64dp"
android:background="@drawable/bg_bottom_navigation"
app:itemIconSize="24dp"
app:itemIconTint="@color/bottom_navigation_item_tint"
app:itemRippleColor="@color/bottom_navigation_ripple"
app:itemTextColor="@color/bottom_navigation_item_tint"
app:labelVisibilityMode="labeled"
app:layout_constraintBottom_toTopOf="@id/cloud_bottom_spacer"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:menu="@menu/bottom_navbar_menu" />
<View
android:id="@+id/cloud_bottom_spacer"
android:layout_width="0dp"
android:layout_height="0dp"
android:background="@color/qsfera_surface"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent" />
</androidx.constraintlayout.widget.ConstraintLayout>
@@ -1,17 +1,34 @@
<?xml version="1.0" encoding="utf-8"?>
<androidx.swiperefreshlayout.widget.SwipeRefreshLayout xmlns:android="http://schemas.android.com/apk/res/android"
android:id="@+id/cloud_refresh"
<FrameLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:background="@color/qsfera_surface">
<androidx.recyclerview.widget.RecyclerView
android:id="@+id/cloud_list"
<androidx.swiperefreshlayout.widget.SwipeRefreshLayout
android:id="@+id/cloud_refresh"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:clipToPadding="false"
android:paddingStart="12dp"
android:paddingTop="12dp"
android:paddingEnd="12dp"
android:paddingBottom="28dp" />
</androidx.swiperefreshlayout.widget.SwipeRefreshLayout>
android:layout_height="match_parent">
<androidx.recyclerview.widget.RecyclerView
android:id="@+id/cloud_list"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:clipToPadding="false"
android:paddingBottom="88dp" />
</androidx.swiperefreshlayout.widget.SwipeRefreshLayout>
<com.google.android.material.floatingactionbutton.FloatingActionButton
android:id="@+id/cloud_fab"
android:layout_width="56dp"
android:layout_height="56dp"
android:layout_gravity="bottom|end"
android:layout_marginEnd="18dp"
android:layout_marginBottom="20dp"
android:contentDescription="@string/cloud_add_content"
android:visibility="gone"
app:backgroundTint="#347FF6"
app:elevation="8dp"
app:srcCompat="@drawable/ic_cloud_add"
app:tint="@color/white" />
</FrameLayout>
@@ -0,0 +1,259 @@
<?xml version="1.0" encoding="utf-8"?>
<androidx.core.widget.NestedScrollView xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:background="@color/qsfera_surface_muted"
android:clipToPadding="false"
android:fillViewport="true"
android:paddingBottom="32dp"
tools:context=".presentation.settings.automaticuploads.SettingsAutomaticUploadsFragment">
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="vertical"
android:paddingStart="16dp"
android:paddingTop="8dp"
android:paddingEnd="16dp">
<TextView
android:id="@+id/automatic_upload_title"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginStart="4dp"
android:layout_marginTop="8dp"
android:layout_marginEnd="4dp"
android:layout_marginBottom="24dp"
android:fontFamily="sans-serif-medium"
android:text="@string/automatic_upload_title"
android:textColor="@color/qsfera_text_primary"
android:textSize="38sp" />
<androidx.cardview.widget.CardView
android:layout_width="match_parent"
android:layout_height="wrap_content"
app:cardBackgroundColor="@color/qsfera_surface"
app:cardCornerRadius="28dp"
app:cardElevation="0dp">
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="vertical"
android:paddingStart="20dp"
android:paddingEnd="16dp">
<LinearLayout
android:layout_width="match_parent"
android:layout_height="76dp"
android:gravity="center_vertical"
android:orientation="horizontal">
<TextView
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1"
android:text="@string/automatic_upload_photos"
android:textColor="@color/qsfera_text_primary"
android:textSize="18sp" />
<androidx.appcompat.widget.SwitchCompat
android:id="@+id/automatic_upload_photos_switch"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:contentDescription="@string/automatic_upload_photos" />
</LinearLayout>
<View
android:layout_width="match_parent"
android:layout_height="1dp"
android:background="@color/qsfera_divider" />
<LinearLayout
android:layout_width="match_parent"
android:layout_height="76dp"
android:gravity="center_vertical"
android:orientation="horizontal">
<TextView
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1"
android:text="@string/automatic_upload_videos"
android:textColor="@color/qsfera_text_primary"
android:textSize="18sp" />
<androidx.appcompat.widget.SwitchCompat
android:id="@+id/automatic_upload_videos_switch"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:contentDescription="@string/automatic_upload_videos" />
</LinearLayout>
</LinearLayout>
</androidx.cardview.widget.CardView>
<androidx.cardview.widget.CardView
android:id="@+id/automatic_upload_folders_card"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="12dp"
android:clickable="true"
android:focusable="true"
android:foreground="?attr/selectableItemBackground"
app:cardBackgroundColor="@color/qsfera_surface"
app:cardCornerRadius="28dp"
app:cardElevation="0dp">
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:gravity="center_vertical"
android:minHeight="96dp"
android:orientation="horizontal"
android:paddingStart="20dp"
android:paddingTop="16dp"
android:paddingEnd="16dp"
android:paddingBottom="16dp">
<LinearLayout
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1"
android:orientation="vertical">
<TextView
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:text="@string/automatic_upload_folders_title"
android:textColor="@color/qsfera_text_primary"
android:textSize="18sp" />
<TextView
android:id="@+id/automatic_upload_folders_summary"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="4dp"
android:textColor="@color/qsfera_text_secondary"
android:textSize="14sp"
tools:text="Камера включена автоматически" />
</LinearLayout>
<ImageView
android:layout_width="24dp"
android:layout_height="24dp"
android:contentDescription="@null"
android:src="@drawable/ic_chevron_right" />
</LinearLayout>
</androidx.cardview.widget.CardView>
<androidx.cardview.widget.CardView
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="12dp"
app:cardBackgroundColor="@color/qsfera_surface"
app:cardCornerRadius="28dp"
app:cardElevation="0dp">
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:gravity="center_vertical"
android:minHeight="96dp"
android:orientation="horizontal"
android:paddingStart="20dp"
android:paddingTop="16dp"
android:paddingEnd="16dp"
android:paddingBottom="16dp">
<LinearLayout
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1"
android:orientation="vertical">
<TextView
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:text="@string/automatic_upload_mobile_data"
android:textColor="@color/qsfera_text_primary"
android:textSize="18sp" />
<TextView
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="4dp"
android:text="@string/automatic_upload_mobile_data_summary"
android:textColor="@color/qsfera_text_secondary"
android:textSize="14sp" />
</LinearLayout>
<androidx.appcompat.widget.SwitchCompat
android:id="@+id/automatic_upload_mobile_data_switch"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:contentDescription="@string/automatic_upload_mobile_data" />
</LinearLayout>
</androidx.cardview.widget.CardView>
<androidx.cardview.widget.CardView
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="12dp"
app:cardBackgroundColor="@color/qsfera_info_surface"
app:cardCornerRadius="24dp"
app:cardElevation="0dp">
<TextView
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:padding="20dp"
android:text="@string/automatic_upload_remove_after_success"
android:textColor="@color/qsfera_text_primary"
android:textSize="15sp" />
</androidx.cardview.widget.CardView>
<androidx.cardview.widget.CardView
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="12dp"
app:cardBackgroundColor="@color/qsfera_surface"
app:cardCornerRadius="28dp"
app:cardElevation="0dp">
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="vertical"
android:padding="20dp">
<TextView
android:id="@+id/automatic_upload_background_status_title"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:textColor="@color/qsfera_text_primary"
android:textSize="18sp"
android:textStyle="bold"
tools:text="Фоновая работа разрешена" />
<TextView
android:id="@+id/automatic_upload_background_status_summary"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="6dp"
android:textColor="@color/qsfera_text_secondary"
android:textSize="14sp"
tools:text="Система не ограничивает фоновые проверки КуСферы." />
<androidx.appcompat.widget.AppCompatButton
android:id="@+id/automatic_upload_allow_background_button"
style="@style/Button.Secondary"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginTop="14dp"
android:text="@string/automatic_upload_allow_background" />
</LinearLayout>
</androidx.cardview.widget.CardView>
</LinearLayout>
</androidx.core.widget.NestedScrollView>
@@ -1,36 +1,50 @@
<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
<androidx.cardview.widget.CardView xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_margin="6dp"
android:background="@drawable/cloud_card_background"
android:layout_height="154dp"
android:layout_margin="5dp"
android:foreground="?attr/selectableItemBackground"
android:orientation="vertical"
android:padding="16dp">
app:cardCornerRadius="22dp"
app:cardElevation="0dp">
<ImageView
android:id="@+id/cloud_album_cover"
android:layout_width="64dp"
android:layout_height="64dp"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:contentDescription="@null"
android:src="@drawable/ic_qsfera_folder" />
android:scaleType="centerCrop"
android:src="@drawable/cloud_media_placeholder" />
<TextView
android:id="@+id/cloud_album_title"
<View
android:layout_width="match_parent"
android:layout_height="82dp"
android:layout_gravity="bottom"
android:background="@drawable/cloud_album_scrim" />
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="12dp"
android:ellipsize="end"
android:maxLines="1"
android:textColor="@color/qsfera_text_primary"
android:textSize="17sp"
android:textStyle="bold" />
android:layout_gravity="bottom"
android:orientation="vertical"
android:padding="16dp">
<TextView
android:id="@+id/cloud_album_count"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="2dp"
android:textColor="@color/qsfera_text_secondary"
android:textSize="14sp" />
</LinearLayout>
<TextView
android:id="@+id/cloud_album_title"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:ellipsize="end"
android:maxLines="1"
android:textColor="@color/white"
android:textSize="18sp"
android:textStyle="bold" />
<TextView
android:id="@+id/cloud_album_count"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="2dp"
android:textColor="@color/white"
android:textSize="14sp" />
</LinearLayout>
</androidx.cardview.widget.CardView>
@@ -0,0 +1,128 @@
<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="vertical"
android:paddingStart="16dp"
android:paddingTop="12dp"
android:paddingEnd="16dp"
android:paddingBottom="14dp">
<TextView
android:id="@+id/cloud_feed_date"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginStart="16dp"
android:layout_marginBottom="16dp"
android:textColor="@color/qsfera_text_primary"
android:textSize="19sp"
android:textStyle="bold" />
<androidx.cardview.widget.CardView
android:id="@+id/cloud_feed_card"
android:layout_width="match_parent"
android:layout_height="wrap_content"
app:cardCornerRadius="28dp"
app:cardElevation="0dp">
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="vertical">
<FrameLayout
android:layout_width="match_parent"
android:layout_height="260dp">
<ImageView
android:id="@+id/cloud_feed_main_image"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:contentDescription="@null"
android:scaleType="centerCrop" />
<View
android:layout_width="match_parent"
android:layout_height="150dp"
android:layout_gravity="top"
android:background="@drawable/cloud_feed_scrim" />
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="vertical"
android:paddingStart="18dp"
android:paddingTop="18dp"
android:paddingEnd="18dp">
<TextView
android:id="@+id/cloud_feed_title"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:textColor="@color/white"
android:textSize="27sp"
android:textStyle="bold" />
<TextView
android:id="@+id/cloud_feed_summary"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="8dp"
android:text="@string/cloud_feed_event_summary"
android:textColor="@color/white"
android:textSize="18sp" />
</LinearLayout>
</FrameLayout>
<LinearLayout
android:id="@+id/cloud_feed_thumbnails"
android:layout_width="match_parent"
android:layout_height="104dp"
android:layout_marginTop="2dp"
android:orientation="horizontal">
<ImageView
android:id="@+id/cloud_feed_thumb_one"
android:layout_width="0dp"
android:layout_height="match_parent"
android:layout_weight="1"
android:contentDescription="@null"
android:scaleType="centerCrop" />
<ImageView
android:id="@+id/cloud_feed_thumb_two"
android:layout_width="0dp"
android:layout_height="match_parent"
android:layout_marginHorizontal="2dp"
android:layout_weight="1"
android:contentDescription="@null"
android:scaleType="centerCrop" />
<FrameLayout
android:id="@+id/cloud_feed_last_thumb_container"
android:layout_width="0dp"
android:layout_height="match_parent"
android:layout_weight="1">
<ImageView
android:id="@+id/cloud_feed_thumb_three"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:contentDescription="@null"
android:scaleType="centerCrop" />
<TextView
android:id="@+id/cloud_feed_more_count"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:background="#66000000"
android:gravity="center"
android:textColor="@color/white"
android:textSize="24sp"
android:visibility="gone" />
</FrameLayout>
</LinearLayout>
</LinearLayout>
</androidx.cardview.widget.CardView>
</LinearLayout>
@@ -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" />
@@ -2,7 +2,7 @@
<eu.qsfera.android.presentation.security.passcode.SquareFrameLayout xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_margin="2dp"
android:layout_margin="1dp"
android:background="@drawable/cloud_media_placeholder"
android:clipToOutline="true"
android:foreground="?attr/selectableItemBackgroundBorderless">
@@ -0,0 +1,44 @@
<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginStart="16dp"
android:layout_marginTop="8dp"
android:layout_marginEnd="16dp"
android:layout_marginBottom="14dp"
android:background="@drawable/cloud_status_background"
android:gravity="center_vertical"
android:minHeight="76dp"
android:orientation="horizontal"
android:paddingHorizontal="18dp"
android:paddingVertical="14dp">
<ImageView
android:layout_width="30dp"
android:layout_height="30dp"
android:contentDescription="@null"
android:src="@drawable/ic_cloud_status_ok" />
<LinearLayout
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginStart="14dp"
android:layout_weight="1"
android:orientation="vertical">
<TextView
android:id="@+id/cloud_photo_status_title"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:textColor="@color/qsfera_text_primary"
android:textSize="18sp" />
<TextView
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="2dp"
android:text="@string/cloud_photo_status_summary"
android:textColor="@color/qsfera_text_secondary"
android:textSize="15sp" />
</LinearLayout>
</LinearLayout>
@@ -0,0 +1,38 @@
<?xml version="1.0" encoding="utf-8"?>
<HorizontalScrollView xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:clipToPadding="false"
android:fillViewport="false"
android:paddingStart="14dp"
android:paddingTop="8dp"
android:paddingEnd="14dp"
android:paddingBottom="18dp"
android:scrollbars="none">
<LinearLayout
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:orientation="horizontal">
<TextView
android:id="@+id/cloud_shortcut_transfers"
style="@style/CloudShortcutChip"
android:drawableStart="@drawable/ic_cloud_upload"
android:text="@string/cloud_shortcut_transfers" />
<TextView
android:id="@+id/cloud_shortcut_offline"
style="@style/CloudShortcutChip"
android:layout_marginStart="10dp"
android:drawableStart="@drawable/ic_cloud_offline"
android:text="@string/cloud_shortcut_offline" />
<TextView
android:id="@+id/cloud_shortcut_shares"
style="@style/CloudShortcutChip"
android:layout_marginStart="10dp"
android:drawableStart="@drawable/ic_cloud_share"
android:text="@string/cloud_shortcut_shares" />
</LinearLayout>
</HorizontalScrollView>
@@ -0,0 +1,32 @@
<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:foreground="?attr/selectableItemBackgroundBorderless"
android:gravity="center_horizontal"
android:minHeight="154dp"
android:orientation="vertical"
android:paddingStart="6dp"
android:paddingTop="10dp"
android:paddingEnd="6dp"
android:paddingBottom="8dp">
<ImageView
android:id="@+id/cloud_storage_icon"
android:layout_width="96dp"
android:layout_height="82dp"
android:contentDescription="@null"
android:scaleType="centerInside"
android:src="@drawable/ic_qsfera_folder" />
<TextView
android:id="@+id/cloud_storage_title"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="8dp"
android:ellipsize="end"
android:gravity="center"
android:maxLines="2"
android:textColor="@color/qsfera_text_primary"
android:textSize="16sp" />
</LinearLayout>
@@ -0,0 +1,52 @@
<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:background="@drawable/cloud_sheet_background"
android:orientation="vertical"
android:paddingStart="18dp"
android:paddingTop="12dp"
android:paddingEnd="18dp"
android:paddingBottom="28dp">
<View
android:layout_width="40dp"
android:layout_height="4dp"
android:layout_gravity="center_horizontal"
android:layout_marginBottom="14dp"
android:background="@drawable/cloud_sheet_handle" />
<TextView
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:paddingHorizontal="8dp"
android:paddingBottom="8dp"
android:text="@string/cloud_add_title"
android:textColor="@color/qsfera_text_primary"
android:textSize="24sp"
android:textStyle="bold" />
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="horizontal">
<TextView
android:id="@+id/cloud_add_folder"
style="@style/CloudSheetTile"
android:drawableTop="@drawable/ic_cloud_folder_action"
android:text="@string/cloud_add_folder" />
<TextView
android:id="@+id/cloud_add_files"
style="@style/CloudSheetTile"
android:drawableTop="@drawable/ic_cloud_file_action"
android:text="@string/cloud_add_files" />
<TextView
android:id="@+id/cloud_add_photos"
style="@style/CloudSheetTile"
android:drawableTop="@drawable/ic_cloud_photo_action"
android:text="@string/cloud_add_photos" />
</LinearLayout>
</LinearLayout>
@@ -0,0 +1,48 @@
<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:background="@drawable/cloud_sheet_background"
android:orientation="vertical"
android:paddingStart="18dp"
android:paddingTop="12dp"
android:paddingEnd="18dp"
android:paddingBottom="28dp">
<View
android:layout_width="40dp"
android:layout_height="4dp"
android:layout_gravity="center_horizontal"
android:layout_marginBottom="14dp"
android:background="@drawable/cloud_sheet_handle" />
<TextView
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:paddingHorizontal="8dp"
android:paddingBottom="8dp"
android:text="@string/cloud_more_title"
android:textColor="@color/qsfera_text_primary"
android:textSize="24sp"
android:textStyle="bold" />
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="horizontal">
<TextView android:id="@+id/cloud_more_storage" style="@style/CloudSheetTile" android:drawableTop="@drawable/ic_folder" android:text="@string/cloud_more_storage" />
<TextView android:id="@+id/cloud_more_transfers" style="@style/CloudSheetTile" android:drawableTop="@drawable/ic_uploads" android:text="@string/cloud_more_transfers" />
<TextView android:id="@+id/cloud_more_offline" style="@style/CloudSheetTile" android:drawableTop="@drawable/ic_available_offline" android:text="@string/cloud_more_offline" />
</LinearLayout>
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="horizontal">
<TextView android:id="@+id/cloud_more_shares" style="@style/CloudSheetTile" android:drawableTop="@drawable/ic_shared_by_link" android:text="@string/cloud_more_shares" />
<TextView android:id="@+id/cloud_more_spaces" style="@style/CloudSheetTile" android:drawableTop="@drawable/ic_spaces" android:text="@string/cloud_more_spaces" />
<TextView android:id="@+id/cloud_more_settings" style="@style/CloudSheetTile" android:drawableTop="@drawable/ic_settings" android:text="@string/cloud_more_settings" />
</LinearLayout>
</LinearLayout>
@@ -0,0 +1,91 @@
<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:background="@drawable/cloud_sheet_background"
android:orientation="vertical"
android:paddingStart="24dp"
android:paddingTop="12dp"
android:paddingEnd="24dp"
android:paddingBottom="30dp">
<View
android:layout_width="40dp"
android:layout_height="4dp"
android:layout_gravity="center_horizontal"
android:layout_marginBottom="22dp"
android:background="@drawable/cloud_sheet_handle" />
<ImageView
android:layout_width="76dp"
android:layout_height="76dp"
android:layout_gravity="center_horizontal"
android:background="@drawable/cloud_avatar_background"
android:contentDescription="@null"
android:padding="12dp"
android:src="@drawable/ic_account_circle" />
<TextView
android:id="@+id/cloud_profile_name"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="14dp"
android:gravity="center"
android:textColor="@color/qsfera_text_primary"
android:textSize="24sp"
android:textStyle="bold" />
<TextView
android:id="@+id/cloud_profile_account"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="4dp"
android:gravity="center"
android:textColor="@color/qsfera_text_secondary"
android:textSize="16sp" />
<TextView
android:id="@+id/cloud_profile_automatic_uploads"
android:layout_width="match_parent"
android:layout_height="64dp"
android:layout_marginTop="24dp"
android:background="@drawable/cloud_status_background"
android:drawableStart="@drawable/ic_picture_uploads"
android:drawableEnd="@drawable/ic_arrow_right"
android:drawablePadding="14dp"
android:gravity="center_vertical"
android:paddingHorizontal="18dp"
android:text="@string/cloud_profile_automatic_uploads"
android:textColor="@color/qsfera_text_primary"
android:textSize="18sp" />
<TextView
android:id="@+id/cloud_profile_accounts"
android:layout_width="match_parent"
android:layout_height="64dp"
android:layout_marginTop="10dp"
android:background="@drawable/cloud_status_background"
android:drawableStart="@drawable/ic_account_circle"
android:drawableEnd="@drawable/ic_arrow_right"
android:drawablePadding="14dp"
android:gravity="center_vertical"
android:paddingHorizontal="18dp"
android:text="@string/drawer_manage_accounts"
android:textColor="@color/qsfera_text_primary"
android:textSize="18sp" />
<TextView
android:id="@+id/cloud_profile_settings"
android:layout_width="match_parent"
android:layout_height="64dp"
android:layout_marginTop="10dp"
android:background="@drawable/cloud_status_background"
android:drawableStart="@drawable/ic_settings"
android:drawableEnd="@drawable/ic_arrow_right"
android:drawablePadding="14dp"
android:gravity="center_vertical"
android:paddingHorizontal="18dp"
android:text="@string/cloud_more_settings"
android:textColor="@color/qsfera_text_primary"
android:textSize="18sp" />
</LinearLayout>
@@ -1,11 +1,12 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<string name="cloud_feed_title">Лента</string>
<string name="cloud_files_title">Файлы</string>
<string name="cloud_photos_title">Фото</string>
<string name="cloud_albums_title">Альбомы</string>
<string name="cloud_more_title">Ещё</string>
<string name="cloud_feed_empty_title">Лента готова</string>
<string name="cloud_feed_empty_summary">Новые автозагрузки появятся здесь после сохранения в QSfera.</string>
<string name="cloud_feed_empty_summary">Новые автозагрузки появятся здесь после сохранения в КуСфере.</string>
<string name="cloud_photos_empty_title">Фотографий пока нет</string>
<string name="cloud_photos_empty_summary">Загрузите фото или включите автозагрузку в настройках.</string>
<string name="cloud_albums_empty_title">Альбомов пока нет</string>
@@ -13,6 +14,13 @@
<string name="cloud_media_loading">Загружаем ваше облако…</string>
<string name="cloud_media_load_error">Не удалось загрузить медиафайлы</string>
<string name="cloud_media_retry">Нажмите, чтобы повторить</string>
<string name="cloud_media_load_more">Загрузить ещё</string>
<plurals name="cloud_album_items">
<item quantity="one">%1$d файл</item>
<item quantity="few">%1$d файла</item>
<item quantity="many">%1$d файлов</item>
<item quantity="other">%1$d файла</item>
</plurals>
<string name="cloud_recent_uploads">Недавние загрузки</string>
<string name="cloud_all_photos">Все фото</string>
<string name="cloud_album_items">Объектов: %1$d</string>
@@ -27,5 +35,47 @@
<string name="cloud_more_settings">Настройки</string>
<string name="cloud_more_settings_summary">Автозагрузка, учётная запись и безопасность</string>
<string name="cloud_more_storage">Хранилище</string>
<string name="cloud_more_storage_summary">Управление файлами QSfera</string>
<string name="cloud_more_storage_summary">Управление файлами КуСферы</string>
<plurals name="cloud_feed_event_photos">
<item quantity="one">%1$d новое фото</item>
<item quantity="few">%1$d новых фото</item>
<item quantity="many">%1$d новых фото</item>
<item quantity="other">%1$d новых фото</item>
</plurals>
<plurals name="cloud_feed_event_videos">
<item quantity="one">%1$d новое видео</item>
<item quantity="few">%1$d новых видео</item>
<item quantity="many">%1$d новых видео</item>
<item quantity="other">%1$d новых видео</item>
</plurals>
<plurals name="cloud_feed_event_files">
<item quantity="one">%1$d новый файл</item>
<item quantity="few">%1$d новых файла</item>
<item quantity="many">%1$d новых файлов</item>
<item quantity="other">%1$d новых файлов</item>
</plurals>
<string name="cloud_feed_event_summary">Добавлено в КуСферу</string>
<string name="cloud_feed_more_count">+%1$d</string>
<string name="cloud_photo_status">Фото: %1$d, видео: %2$d</string>
<string name="cloud_photo_status_summary">Показаны файлы, сохранённые в КуСфере</string>
<string name="cloud_shortcut_transfers">Загрузки</string>
<string name="cloud_shortcut_offline">Офлайн</string>
<string name="cloud_shortcut_shares">Ссылки</string>
<string name="cloud_add_content">Добавить в КуСферу</string>
<string name="cloud_add_title">Добавить в КуСферу</string>
<string name="cloud_add_folder">Папка</string>
<string name="cloud_add_files">Файлы</string>
<string name="cloud_add_photos">Фото</string>
<string name="cloud_create_folder_title">Новая папка</string>
<string name="cloud_create_folder_hint">Название папки</string>
<string name="cloud_create_folder_invalid">Введите название без символа /</string>
<string name="cloud_create_folder_success">Папка создана</string>
<string name="cloud_create_folder_error">Не удалось создать папку</string>
<string name="cloud_upload_enqueued">Загрузка началась</string>
<string name="cloud_upload_destination_error">Не удалось определить папку для загрузки</string>
<string name="cloud_files_empty_title">В этой папке пока пусто</string>
<string name="cloud_files_empty_summary">Нажмите плюс, чтобы добавить файлы или создать папку.</string>
<string name="cloud_files_load_error">Не удалось загрузить файлы</string>
<string name="cloud_files_cached">Не удалось обновить данные по сети. Показаны сохранённые файлы.</string>
<string name="cloud_profile_automatic_uploads">Автозагрузка</string>
</resources>
@@ -570,12 +570,14 @@
<string name="automatic_upload_folders_loading">Ищем папки с медиафайлами…</string>
<string name="automatic_upload_folders_empty">Других папок с медиафайлами не найдено.</string>
<string name="automatic_upload_folders_error">Не удалось прочитать папки. Проверьте доступ к фото и видео.</string>
<string name="automatic_upload_android_10_unsupported">Текущий режим автопереноса КуСферы не может без подтверждения удалять файлы камеры в Android 10, поэтому он недоступен в этой версии Android.</string>
<string name="automatic_upload_folders_item_count">Медиафайлов: %1$d</string>
<string name="automatic_upload_folder_no_media">Сейчас медиафайлов нет</string>
<string name="automatic_upload_permission_title">Доступ к фото и файлам</string>
<string name="automatic_upload_permission_ready">Доступ разрешён. Автозагрузка камеры работает в фоне, а оригинал удаляется после успешной загрузки.</string>
<string name="automatic_upload_permission_media_ready">Доступ к медиафайлам разрешён.</string>
<string name="automatic_upload_permission_read_missing">Разрешите доступ к фото и видео, чтобы QSfera обнаруживала новые файлы.</string>
<string name="automatic_upload_permission_delete_missing">Разрешите полный доступ к файлам, чтобы QSfera удаляла оригинал только после успешной загрузки.</string>
<string name="automatic_upload_permission_read_missing">Разрешите доступ к фото и видео, чтобы КуСфера обнаруживала новые файлы.</string>
<string name="automatic_upload_permission_delete_missing">Разрешите полный доступ к файлам, чтобы КуСфера удаляла оригинал только после успешной загрузки.</string>
<string name="automatic_upload_permission_both_missing">Нужны два разрешения: читать фото и удалять успешно загруженный оригинал.</string>
<string name="automatic_upload_camera_and_folders_summary">Камера — автоматически%1$s</string>
<string name="automatic_upload_selected_folders_suffix">; выбрано других папок: %1$d</string>
@@ -584,6 +586,22 @@
<string name="automatic_upload_mobile_data_summary">Автозагрузка будет работать и без Wi-Fi</string>
<string name="automatic_upload_destination_category">Куда загружать</string>
<string name="automatic_upload_options_category">Параметры загрузки</string>
<string name="automatic_upload_title">Автозагрузка</string>
<string name="automatic_upload_settings_summary">Фото, видео, папки на телефоне и работа в фоне</string>
<string name="automatic_upload_photos">Автозагрузка с фото</string>
<string name="automatic_upload_videos">Автозагрузка с видео</string>
<string name="automatic_upload_camera_folder">Камера</string>
<string name="automatic_upload_camera_folder_summary">Всегда включена, когда работает автозагрузка</string>
<string name="automatic_upload_folders_disabled_summary">Включите автозагрузку фото или видео, чтобы выбрать папки</string>
<string name="automatic_upload_remove_after_success">КуСфера удаляет исходник с телефона только после того, как сервер подтвердит успешную загрузку файла. При ошибке загрузки исходник остаётся на месте.</string>
<string name="automatic_upload_background_ready">Ограничение Android снято</string>
<string name="automatic_upload_background_ready_summary">Системная оптимизация Android/Doze не ограничивает фоновые проверки КуСферы. Отдельные ограничения производителя телефона могут продолжать действовать.</string>
<string name="automatic_upload_background_limited">Ограничение Android включено</string>
<string name="automatic_upload_background_limited_summary">Разрешите работу без ограничений Android, чтобы новые файлы обнаруживались, когда КуСфера закрыта. Настройки производителя телефона проверяются отдельно.</string>
<string name="automatic_upload_allow_background">Разрешить работу в фоне</string>
<string name="automatic_upload_allow_access">Разрешить доступ</string>
<string name="automatic_upload_account_missing">Сначала подключите доступную учётную запись, затем включите автозагрузку.</string>
<string name="automatic_upload_permission_not_granted">Не выдан необходимый доступ к медиафайлам или их удалению. Автозагрузка не сможет работать, пока доступ не разрешён.</string>
<string name="confirmation_clear_camera_upload_sources_title">Очистить выбранные папки</string>
<string name="confirmation_clear_camera_upload_sources_message">Автозагрузка перестанет проверять папки телефона, пока вы снова не добавите папку.</string>
<string name="prefs_camera_upload_behaviour_dialog_title">Исходный файл будет</string>
@@ -1,11 +1,12 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<string name="cloud_feed_title">Feed</string>
<string name="cloud_files_title">Files</string>
<string name="cloud_photos_title">Photos</string>
<string name="cloud_albums_title">Albums</string>
<string name="cloud_more_title">More</string>
<string name="cloud_feed_empty_title">Your feed is ready</string>
<string name="cloud_feed_empty_summary">New automatic uploads will appear here after they reach QSfera.</string>
<string name="cloud_feed_empty_summary">New automatic uploads will appear here after they reach КуСфера.</string>
<string name="cloud_photos_empty_title">No photos found</string>
<string name="cloud_photos_empty_summary">Upload a photo or enable automatic uploads in Settings.</string>
<string name="cloud_albums_empty_title">No albums yet</string>
@@ -13,6 +14,11 @@
<string name="cloud_media_loading">Loading your cloud…</string>
<string name="cloud_media_load_error">Could not load cloud media</string>
<string name="cloud_media_retry">Tap to retry</string>
<string name="cloud_media_load_more">Load more</string>
<plurals name="cloud_album_items">
<item quantity="one">%1$d file</item>
<item quantity="other">%1$d files</item>
</plurals>
<string name="cloud_recent_uploads">Recent uploads</string>
<string name="cloud_all_photos">All photos</string>
<string name="cloud_album_items">%1$d items</string>
@@ -27,5 +33,41 @@
<string name="cloud_more_settings">Settings</string>
<string name="cloud_more_settings_summary">Automatic uploads, account and security</string>
<string name="cloud_more_storage">Storage</string>
<string name="cloud_more_storage_summary">Manage files in QSfera</string>
<string name="cloud_more_storage_summary">Manage files in КуСфера</string>
<plurals name="cloud_feed_event_photos">
<item quantity="one">%1$d new photo</item>
<item quantity="other">%1$d new photos</item>
</plurals>
<plurals name="cloud_feed_event_videos">
<item quantity="one">%1$d new video</item>
<item quantity="other">%1$d new videos</item>
</plurals>
<plurals name="cloud_feed_event_files">
<item quantity="one">%1$d new file</item>
<item quantity="other">%1$d new files</item>
</plurals>
<string name="cloud_feed_event_summary">Added to КуСфера</string>
<string name="cloud_feed_more_count">+%1$d</string>
<string name="cloud_photo_status">%1$d photos and %2$d videos</string>
<string name="cloud_photo_status_summary">Files currently stored in КуСфера are shown below</string>
<string name="cloud_shortcut_transfers">Transfers</string>
<string name="cloud_shortcut_offline">Offline</string>
<string name="cloud_shortcut_shares">Shared</string>
<string name="cloud_add_content">Add to КуСфера</string>
<string name="cloud_add_title">Add to КуСфера</string>
<string name="cloud_add_folder">Folder</string>
<string name="cloud_add_files">Files</string>
<string name="cloud_add_photos">Photos</string>
<string name="cloud_create_folder_title">New folder</string>
<string name="cloud_create_folder_hint">Folder name</string>
<string name="cloud_create_folder_invalid">Enter a name without slashes</string>
<string name="cloud_create_folder_success">Folder created</string>
<string name="cloud_create_folder_error">Could not create folder</string>
<string name="cloud_upload_enqueued">Upload started</string>
<string name="cloud_upload_destination_error">Could not determine the upload folder</string>
<string name="cloud_files_empty_title">This folder is empty</string>
<string name="cloud_files_empty_summary">Use the plus button to add files or create a folder.</string>
<string name="cloud_files_load_error">Could not load files</string>
<string name="cloud_files_cached">Network refresh failed. Showing saved files.</string>
<string name="cloud_profile_automatic_uploads">Automatic uploads</string>
</resources>
@@ -0,0 +1,30 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<style name="CloudShortcutChip" parent="Widget.AppCompat.TextView">
<item name="android:layout_width">wrap_content</item>
<item name="android:layout_height">48dp</item>
<item name="android:background">@drawable/cloud_chip_background</item>
<item name="android:drawablePadding">10dp</item>
<item name="android:drawableTint">@color/qsfera_text_primary</item>
<item name="android:foreground">?attr/selectableItemBackgroundBorderless</item>
<item name="android:gravity">center_vertical</item>
<item name="android:minWidth">124dp</item>
<item name="android:paddingStart">18dp</item>
<item name="android:paddingEnd">18dp</item>
<item name="android:textColor">@color/qsfera_text_primary</item>
<item name="android:textSize">16sp</item>
</style>
<style name="CloudSheetTile" parent="Widget.AppCompat.TextView">
<item name="android:layout_width">0dp</item>
<item name="android:layout_height">116dp</item>
<item name="android:layout_weight">1</item>
<item name="android:drawablePadding">10dp</item>
<item name="android:drawableTint">@color/qsfera_text_primary</item>
<item name="android:foreground">?attr/selectableItemBackgroundBorderless</item>
<item name="android:gravity">center</item>
<item name="android:padding">10dp</item>
<item name="android:textColor">@color/qsfera_text_primary</item>
<item name="android:textSize">15sp</item>
</style>
</resources>
@@ -32,6 +32,7 @@
<color name="qsfera_blue_pressed">#3566D1</color>
<color name="qsfera_surface">#FFFFFF</color>
<color name="qsfera_surface_muted">#F3F4F6</color>
<color name="qsfera_info_surface">#EAF1FF</color>
<color name="qsfera_text_primary">#17181A</color>
<color name="qsfera_text_secondary">#6D7178</color>
<color name="qsfera_divider">#E8E9EC</color>
@@ -36,6 +36,10 @@
<!-- Bottom navigation bar -->
<dimen name="bottom_navigation_bar_height">56dp</dimen>
<dimen name="cloud_toolbar_height">72dp</dimen>
<dimen name="cloud_files_side_padding">8dp</dimen>
<dimen name="cloud_albums_side_padding">10dp</dimen>
<dimen name="cloud_list_bottom_padding">88dp</dimen>
<!-- item file list dimens -->
<dimen name="item_file_list_min_height">72dp</dimen>
@@ -608,12 +608,14 @@
<string name="automatic_upload_folders_loading">Looking for media folders…</string>
<string name="automatic_upload_folders_empty">No other media folders were found.</string>
<string name="automatic_upload_folders_error">Folders could not be read. Check photo and video access.</string>
<string name="automatic_upload_android_10_unsupported">This КуСфера automatic-move mode cannot silently remove camera media on Android 10. It is unavailable on this Android version.</string>
<string name="automatic_upload_folders_item_count">%1$d media files</string>
<string name="automatic_upload_folder_no_media">No media files currently found</string>
<string name="automatic_upload_permission_title">Photo and file access</string>
<string name="automatic_upload_permission_ready">Access granted. Camera uploads can run in the background and remove originals after a successful upload.</string>
<string name="automatic_upload_permission_media_ready">Media access is allowed.</string>
<string name="automatic_upload_permission_read_missing">Allow access to photos and videos so QSfera can detect new media.</string>
<string name="automatic_upload_permission_delete_missing">Allow full file access so QSfera can remove an original only after its upload succeeds.</string>
<string name="automatic_upload_permission_read_missing">Allow access to photos and videos so КуСфера can detect new media.</string>
<string name="automatic_upload_permission_delete_missing">Allow full file access so КуСфера can remove an original only after its upload succeeds.</string>
<string name="automatic_upload_permission_both_missing">Two permissions are required: read photos and remove a successfully uploaded original.</string>
<string name="automatic_upload_camera_and_folders_summary">Camera automatically%1$s</string>
<string name="automatic_upload_selected_folders_suffix">; %1$d other folders selected</string>
@@ -622,6 +624,22 @@
<string name="automatic_upload_mobile_data_summary">Automatic uploads will also work when Wi-Fi is unavailable</string>
<string name="automatic_upload_destination_category">Upload destination</string>
<string name="automatic_upload_options_category">Upload options</string>
<string name="automatic_upload_title">Automatic upload</string>
<string name="automatic_upload_settings_summary">Photos, videos, phone folders and background work</string>
<string name="automatic_upload_photos">Automatic photo upload</string>
<string name="automatic_upload_videos">Automatic video upload</string>
<string name="automatic_upload_camera_folder">Camera</string>
<string name="automatic_upload_camera_folder_summary">Always included when automatic upload is enabled</string>
<string name="automatic_upload_folders_disabled_summary">Turn on photo or video automatic upload to choose folders</string>
<string name="automatic_upload_remove_after_success">КуСфера 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.</string>
<string name="automatic_upload_background_ready">Android restriction is removed</string>
<string name="automatic_upload_background_ready_summary">Android/Doze battery optimization does not restrict КуСфера background checks. Separate phone-vendor restrictions may still apply.</string>
<string name="automatic_upload_background_limited">Android restriction is enabled</string>
<string name="automatic_upload_background_limited_summary">Allow unrestricted Android battery use so new media can be detected while КуСфера is closed. Phone-vendor settings are checked separately.</string>
<string name="automatic_upload_allow_background">Allow background work</string>
<string name="automatic_upload_allow_access">Allow access</string>
<string name="automatic_upload_account_missing">Connect an available account before enabling automatic upload.</string>
<string name="automatic_upload_permission_not_granted">Required media or delete access was not granted. Automatic upload cannot work until access is allowed.</string>
<string name="confirmation_clear_camera_upload_sources_title">Clear selected folders</string>
<string name="confirmation_clear_camera_upload_sources_message">Automatic uploads will stop scanning phone folders until you add a folder again.</string>
<string name="prefs_camera_upload_behaviour_dialog_title">Original file will be</string>
@@ -41,18 +41,11 @@
<Preference
app:allowDividerAbove="true"
app:fragment="eu.qsfera.android.presentation.settings.automaticuploads.SettingsPictureUploadsFragment"
app:fragment="eu.qsfera.android.presentation.settings.automaticuploads.SettingsAutomaticUploadsFragment"
app:icon="@drawable/ic_picture_uploads"
app:key="picture_uploads_subsection"
app:summary="@string/prefs_subsection_picture_uploads_summary"
app:title="@string/prefs_subsection_picture_uploads" />
<Preference
app:fragment="eu.qsfera.android.presentation.settings.automaticuploads.SettingsVideoUploadsFragment"
app:icon="@drawable/ic_video_uploads"
app:key="video_uploads_subsection"
app:summary="@string/prefs_subsection_video_uploads_summary"
app:title="@string/prefs_subsection_video_uploads" />
app:key="automatic_uploads_subsection"
app:summary="@string/automatic_upload_settings_summary"
app:title="@string/automatic_upload_title" />
<Preference
app:allowDividerAbove="true"
@@ -37,4 +37,31 @@ class AutomaticUploadMediaSourceTest {
fun `unrelated configuration value is not parsed as media source`() {
assertNull(AutomaticUploadMediaSource.parse("content://tree/primary"))
}
@Test
fun `legacy primary SAF tree is migrated to MediaStore source`() {
val migrated = AutomaticUploadMediaSource.parseOrMigrateLegacyTree(
"content://com.android.externalstorage.documents/tree/primary%3APictures%2F%D0%A1%D0%B5%D0%BC%D1%8C%D1%8F",
AutomaticUploadMediaKind.IMAGE,
)
assertEquals(AutomaticUploadMediaKind.IMAGE, migrated?.kind)
assertEquals("Pictures/Семья/", migrated?.relativePath)
}
@Test
fun `legacy non-primary or root SAF trees are not migrated`() {
assertNull(
AutomaticUploadMediaSource.parseOrMigrateLegacyTree(
"content://com.android.externalstorage.documents/tree/0123-4567%3APictures",
AutomaticUploadMediaKind.IMAGE,
)
)
assertNull(
AutomaticUploadMediaSource.parseOrMigrateLegacyTree(
"content://com.android.externalstorage.documents/tree/primary%3A",
AutomaticUploadMediaKind.IMAGE,
)
)
}
}
@@ -0,0 +1,27 @@
/**
* qsfera Android client application
*
* Copyright (C) 2026 QSfera.
*/
package eu.qsfera.android.presentation.settings.automaticuploads
import android.os.Build
import org.junit.Assert.assertFalse
import org.junit.Assert.assertTrue
import org.junit.Test
class AutomaticUploadsPermissionsTest {
@Test
fun `Android 8 and 9 require legacy write permission for source removal`() {
assertTrue(requiresLegacyWritePermission(Build.VERSION_CODES.O))
assertTrue(requiresLegacyWritePermission(Build.VERSION_CODES.P))
assertFalse(requiresLegacyWritePermission(Build.VERSION_CODES.Q))
}
@Test
fun `Android 10 cannot silently remove media owned by another app`() {
assertFalse(supportsBackgroundSourceDeletion(Build.VERSION_CODES.Q))
assertTrue(supportsBackgroundSourceDeletion(Build.VERSION_CODES.P))
assertTrue(supportsBackgroundSourceDeletion(Build.VERSION_CODES.R))
}
}
@@ -0,0 +1,74 @@
/**
* qsfera Android client application
*
* Copyright (C) 2026 QSfera.
*/
package eu.qsfera.android.presentation.settings.automaticuploads
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertTrue
import org.junit.Test
class UnifiedWifiPolicyCoordinatorTest {
@Test
fun `partial save does not reverse mobile data choice`() {
val coordinator = UnifiedWifiPolicyCoordinator()
val wifiOnlyPolicies = mapOf(
AutomaticUploadMediaKind.IMAGE to true,
AutomaticUploadMediaKind.VIDEO to true,
)
coordinator.request(wifiOnly = false)
val requested = coordinator.reconcile(wifiOnlyPolicies)
val partiallySaved = coordinator.reconcile(
wifiOnlyPolicies + (AutomaticUploadMediaKind.IMAGE to false)
)
val fullySaved = coordinator.reconcile(
wifiOnlyPolicies.mapValues { false }
)
assertFalse(requested.wifiOnly)
assertEquals(wifiOnlyPolicies.keys, requested.configurationsToUpdate)
assertFalse(partiallySaved.wifiOnly)
assertTrue(partiallySaved.configurationsToUpdate.isEmpty())
assertFalse(fullySaved.wifiOnly)
assertTrue(fullySaved.configurationsToUpdate.isEmpty())
}
@Test
fun `latest choice wins over delayed previous save`() {
val coordinator = UnifiedWifiPolicyCoordinator()
val wifiOnlyPolicies = mapOf(
AutomaticUploadMediaKind.IMAGE to true,
AutomaticUploadMediaKind.VIDEO to true,
)
coordinator.request(wifiOnly = false)
coordinator.reconcile(wifiOnlyPolicies)
coordinator.request(wifiOnly = true)
coordinator.reconcile(wifiOnlyPolicies)
val delayedPreviousSave = coordinator.reconcile(
wifiOnlyPolicies.mapValues { false }
)
assertTrue(delayedPreviousSave.wifiOnly)
assertEquals(wifiOnlyPolicies.keys, delayedPreviousSave.configurationsToUpdate)
}
@Test
fun `legacy split policy is normalized to wifi only`() {
val coordinator = UnifiedWifiPolicyCoordinator()
val resolution = coordinator.reconcile(
mapOf(
AutomaticUploadMediaKind.IMAGE to false,
AutomaticUploadMediaKind.VIDEO to true,
)
)
assertTrue(resolution.wifiOnly)
assertEquals(setOf(AutomaticUploadMediaKind.IMAGE), resolution.configurationsToUpdate)
}
}
@@ -0,0 +1,43 @@
/**
* 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.Intent
import androidx.work.Operation
import com.google.common.util.concurrent.SettableFuture
import io.mockk.every
import io.mockk.mockk
import org.junit.Assert.assertFalse
import org.junit.Assert.assertTrue
import org.junit.Test
class AutomaticUploadsRecoveryReceiverTest {
@Test
fun `recovery events restore automatic uploads`() {
assertTrue(isAutomaticUploadsRecoveryAction(Intent.ACTION_BOOT_COMPLETED))
assertTrue(isAutomaticUploadsRecoveryAction(Intent.ACTION_USER_UNLOCKED))
assertTrue(isAutomaticUploadsRecoveryAction(Intent.ACTION_MY_PACKAGE_REPLACED))
}
@Test
fun `unrelated events are ignored`() {
assertFalse(isAutomaticUploadsRecoveryAction(Intent.ACTION_TIME_TICK))
assertFalse(isAutomaticUploadsRecoveryAction(null))
}
@Test
fun `recovery waits until enqueue operation is committed`() {
val operation = mockk<Operation>()
val committed = SettableFuture.create<Operation.State.SUCCESS>()
committed.set(Operation.SUCCESS)
every { operation.result } returns committed
awaitEnqueueOperations(listOf(operation), timeoutMillis = 1_000)
}
}
@@ -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<Unit>()
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,
)
}
@@ -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<Context>()
val contentResolver = mockk<ContentResolver>()
val uri = mockk<Uri>()
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)
}
}
@@ -29,5 +29,10 @@ interface LocalFolderBackupDataSource {
fun saveFolderBackupConfiguration(folderBackUpConfiguration: FolderBackUpConfiguration)
fun updateLastSyncTimestamp(
expectedConfiguration: FolderBackUpConfiguration,
lastSyncTimestamp: Long,
): Boolean
fun resetFolderBackupConfigurationByName(name: String)
}
@@ -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)
}
@@ -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
"""
}
}
@@ -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)
@@ -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)
@@ -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)
@@ -29,5 +29,10 @@ interface FolderBackupRepository {
fun saveFolderBackupConfiguration(folderBackUpConfiguration: FolderBackUpConfiguration)
fun updateLastSyncTimestamp(
expectedConfiguration: FolderBackUpConfiguration,
lastSyncTimestamp: Long,
): Boolean
fun resetFolderBackupConfigurationByName(name: String)
}
@@ -22,10 +22,10 @@ data class AutomaticUploadsConfiguration(
val pictureUploadsConfiguration: FolderBackUpConfiguration?,
val videoUploadsConfiguration: FolderBackUpConfiguration?
) {
fun areAutomaticUploadsDisabled() = pictureUploadsConfiguration == null && videoUploadsConfiguration == null
val sourcePaths: List<String>
get() = listOfNotNull(pictureUploadsConfiguration, videoUploadsConfiguration)
.flatMap { it.sourcePaths }
.distinct()
fun areAutomaticUploadsDisabled() = pictureUploadsConfiguration == null && videoUploadsConfiguration == null
}
@@ -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<String> =
sourcePath
@@ -60,7 +63,6 @@ data class FolderBackUpConfiguration(
.distinct()
.joinToString(SOURCE_PATH_SEPARATOR)
private const val SOURCE_PATH_SEPARATOR = "\n"
}
}
@@ -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,