This commit is contained in:
@@ -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"
|
||||
|
||||
@@ -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"
|
||||
|
||||
+392
-63
@@ -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 }
|
||||
}
|
||||
|
||||
+189
-28
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
+405
-106
@@ -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,
|
||||
)
|
||||
|
||||
+22
-14
@@ -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)
|
||||
|
||||
+55
-1
@@ -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 {
|
||||
|
||||
+4
@@ -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
|
||||
|
||||
+23
-7
@@ -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) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+4
-8
@@ -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"
|
||||
|
||||
+200
-87
@@ -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() }
|
||||
}
|
||||
}
|
||||
|
||||
+29
@@ -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)
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
+40
-4
@@ -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
|
||||
|
||||
+214
@@ -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)
|
||||
}
|
||||
+65
-13
@@ -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,
|
||||
)
|
||||
|
||||
+486
@@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
+15
-1
@@ -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
|
||||
|
||||
+30
@@ -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
|
||||
|
||||
+22
-15
@@ -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
|
||||
|
||||
+78
@@ -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
|
||||
+5
-3
@@ -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(
|
||||
|
||||
+498
-171
@@ -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
|
||||
}
|
||||
|
||||
+33
-10
@@ -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"
|
||||
|
||||
+27
@@ -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,
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
+27
@@ -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))
|
||||
}
|
||||
}
|
||||
+74
@@ -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)
|
||||
}
|
||||
}
|
||||
+43
@@ -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)
|
||||
}
|
||||
}
|
||||
+362
@@ -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,
|
||||
)
|
||||
}
|
||||
|
||||
+23
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
+5
@@ -29,5 +29,10 @@ interface LocalFolderBackupDataSource {
|
||||
|
||||
fun saveFolderBackupConfiguration(folderBackUpConfiguration: FolderBackUpConfiguration)
|
||||
|
||||
fun updateLastSyncTimestamp(
|
||||
expectedConfiguration: FolderBackUpConfiguration,
|
||||
lastSyncTimestamp: Long,
|
||||
): Boolean
|
||||
|
||||
fun resetFolderBackupConfigurationByName(name: String)
|
||||
}
|
||||
|
||||
+15
@@ -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)
|
||||
}
|
||||
|
||||
+22
@@ -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
|
||||
"""
|
||||
}
|
||||
}
|
||||
|
||||
+5
@@ -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)
|
||||
|
||||
|
||||
+31
@@ -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)
|
||||
|
||||
+15
@@ -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)
|
||||
|
||||
+5
@@ -29,5 +29,10 @@ interface FolderBackupRepository {
|
||||
|
||||
fun saveFolderBackupConfiguration(folderBackUpConfiguration: FolderBackUpConfiguration)
|
||||
|
||||
fun updateLastSyncTimestamp(
|
||||
expectedConfiguration: FolderBackUpConfiguration,
|
||||
lastSyncTimestamp: Long,
|
||||
): Boolean
|
||||
|
||||
fun resetFolderBackupConfigurationByName(name: String)
|
||||
}
|
||||
|
||||
+2
-2
@@ -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
|
||||
}
|
||||
|
||||
+7
-5
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+21
-3
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user