Implement cloud media UI and reliable automatic uploads
This commit is contained in:
@@ -137,8 +137,8 @@ android {
|
||||
|
||||
testInstrumentationRunner "eu.qsfera.android.utils.OCTestAndroidJUnitRunner"
|
||||
|
||||
versionCode = 32
|
||||
versionName = "1.3.4"
|
||||
versionCode = 33
|
||||
versionName = "1.3.5"
|
||||
|
||||
buildConfigField "String", gitRemote, "\"" + getGitOriginRemote() + "\""
|
||||
buildConfigField "String", commitSHA1, "\"" + getLatestGitHash() + "\""
|
||||
|
||||
@@ -22,7 +22,19 @@
|
||||
WRITE_EXTERNAL_STORAGE may be enabled or disabled by the user after installation in
|
||||
API >= 23; the app needs to handle this
|
||||
-->
|
||||
<uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE" />
|
||||
<uses-permission
|
||||
android:name="android.permission.WRITE_EXTERNAL_STORAGE"
|
||||
android:maxSdkVersion="28" />
|
||||
<uses-permission
|
||||
android:name="android.permission.READ_EXTERNAL_STORAGE"
|
||||
android:maxSdkVersion="32" />
|
||||
<uses-permission android:name="android.permission.READ_MEDIA_IMAGES" />
|
||||
<uses-permission android:name="android.permission.READ_MEDIA_VIDEO" />
|
||||
<uses-permission android:name="android.permission.READ_MEDIA_VISUAL_USER_SELECTED" />
|
||||
<uses-permission
|
||||
android:name="android.permission.MANAGE_EXTERNAL_STORAGE"
|
||||
tools:ignore="ScopedStorage" />
|
||||
<uses-permission android:name="android.permission.MANAGE_MEDIA" />
|
||||
<!--
|
||||
Notifications are off by default since API 33;
|
||||
See note in https://developer.android.com/develop/ui/views/notifications/notification-permission
|
||||
@@ -78,6 +90,9 @@
|
||||
android:name=".presentation.settings.privacypolicy.PrivacyPolicyActivity"
|
||||
android:label="@string/actionbar_privacy_policy" />
|
||||
<activity android:name=".presentation.settings.SettingsActivity" />
|
||||
<activity
|
||||
android:name=".presentation.settings.automaticuploads.AutomaticUploadFoldersActivity"
|
||||
android:exported="false" />
|
||||
<activity android:name=".presentation.migration.StorageMigrationActivity"
|
||||
android:theme="@style/Theme.qsfera.Toolbar.Fill" />
|
||||
<activity
|
||||
@@ -95,6 +110,15 @@
|
||||
android:configChanges="orientation|screenSize"
|
||||
android:theme="@style/Theme.qsfera.Toolbar.Drawer"
|
||||
android:windowSoftInputMode="adjustPan" />
|
||||
<activity
|
||||
android:name=".presentation.cloud.CloudHomeActivity"
|
||||
android:configChanges="orientation|screenSize"
|
||||
android:theme="@style/Theme.qsfera.Toolbar.Drawer"
|
||||
android:windowSoftInputMode="adjustPan" />
|
||||
<activity
|
||||
android:name=".presentation.cloud.CloudMediaPreviewActivity"
|
||||
android:exported="false"
|
||||
android:theme="@style/Theme.qsfera.Toolbar.Drawer" />
|
||||
<activity
|
||||
android:name=".ui.activity.ReceiveExternalFilesActivity"
|
||||
android:configChanges="orientation|screenSize"
|
||||
|
||||
@@ -128,6 +128,14 @@ class MainApp : Application() {
|
||||
val getAutomaticUploadsConfigurationUseCase: GetAutomaticUploadsConfigurationUseCase by inject()
|
||||
var startedActivities = 0
|
||||
|
||||
// Some Android vendors can start this process for a content-triggered JobService and
|
||||
// then cancel the service bind while the cold process is still initializing. The
|
||||
// MediaStore change has already been consumed at that point, so recover it with one
|
||||
// delayed scan. WorkManagerProvider suppresses this when another scan is already running.
|
||||
CoroutineScope(Dispatchers.IO).launch {
|
||||
workManagerProvider.enqueueImmediateAutomaticUploadsWorker()
|
||||
}
|
||||
|
||||
// register global protection with pass code, pattern lock and biometric lock
|
||||
registerActivityLifecycleCallbacks(object : ActivityLifecycleCallbacks {
|
||||
override fun onActivityCreated(activity: Activity, savedInstanceState: Bundle?) {
|
||||
|
||||
+3
-5
@@ -47,7 +47,8 @@ import eu.qsfera.android.extensions.showErrorInSnackbar
|
||||
import eu.qsfera.android.presentation.authentication.AccountUtils
|
||||
import eu.qsfera.android.presentation.common.UIResult
|
||||
import eu.qsfera.android.ui.activity.FileActivity
|
||||
import eu.qsfera.android.ui.activity.FileDisplayActivity
|
||||
import eu.qsfera.android.presentation.cloud.CloudHomeActivity
|
||||
import eu.qsfera.android.presentation.cloud.CloudSection
|
||||
import eu.qsfera.android.ui.activity.ToolbarActivity
|
||||
import eu.qsfera.android.utils.PreferenceUtils
|
||||
import org.koin.androidx.viewmodel.ext.android.viewModel
|
||||
@@ -197,10 +198,7 @@ class ManageAccountsDialogFragment : DialogFragment(), ManageAccountsAdapter.Acc
|
||||
parentActivity.account = account
|
||||
// Refresh dependencies to be used in selected account
|
||||
MainApp.initDependencyInjection()
|
||||
val i = Intent(
|
||||
parentActivity.applicationContext,
|
||||
FileDisplayActivity::class.java
|
||||
)
|
||||
val i = CloudHomeActivity.createIntent(parentActivity.applicationContext, CloudSection.FEED)
|
||||
i.putExtra(FileActivity.EXTRA_ACCOUNT, account)
|
||||
i.addFlags(Intent.FLAG_ACTIVITY_CLEAR_TOP)
|
||||
parentActivity.startActivity(i)
|
||||
|
||||
+6
-3
@@ -83,6 +83,8 @@ import eu.qsfera.android.presentation.settings.SettingsActivity
|
||||
import eu.qsfera.android.providers.ContextProvider
|
||||
import eu.qsfera.android.providers.MdmProvider
|
||||
import eu.qsfera.android.ui.activity.FileDisplayActivity
|
||||
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
|
||||
import eu.qsfera.android.ui.dialog.SslUntrustedCertDialog
|
||||
@@ -322,9 +324,10 @@ class LoginActivity : AppCompatActivity(), SslUntrustedCertDialog.OnSslUntrusted
|
||||
return
|
||||
}
|
||||
|
||||
val newIntent = Intent(this, FileDisplayActivity::class.java)
|
||||
if (authenticationViewModel.launchedFromDeepLink) {
|
||||
newIntent.data = intent.data
|
||||
val newIntent = if (authenticationViewModel.launchedFromDeepLink) {
|
||||
Intent(this, FileDisplayActivity::class.java).apply { data = intent.data }
|
||||
} else {
|
||||
CloudHomeActivity.createIntent(this, CloudSection.FEED)
|
||||
}
|
||||
newIntent.addFlags(Intent.FLAG_ACTIVITY_CLEAR_TOP)
|
||||
startActivity(newIntent)
|
||||
|
||||
+127
@@ -0,0 +1,127 @@
|
||||
/**
|
||||
* qsfera Android client application
|
||||
*
|
||||
* Copyright (C) 2026 QSfera.
|
||||
*/
|
||||
package eu.qsfera.android.presentation.cloud
|
||||
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.os.Bundle
|
||||
import android.os.Parcelable
|
||||
import android.view.View
|
||||
import android.view.ViewGroup
|
||||
import androidx.appcompat.widget.SearchView
|
||||
import androidx.core.view.updateLayoutParams
|
||||
import androidx.core.view.updatePadding
|
||||
import eu.qsfera.android.R
|
||||
import eu.qsfera.android.databinding.ActivityMainBinding
|
||||
import eu.qsfera.android.domain.files.model.FileListOption
|
||||
import eu.qsfera.android.ui.activity.FileActivity
|
||||
import eu.qsfera.android.ui.activity.FileDisplayActivity
|
||||
import eu.qsfera.android.ui.activity.enableEdgeToEdgePostSetContentView
|
||||
import eu.qsfera.android.ui.activity.enableEdgeToEdgePreSetContentView
|
||||
|
||||
class CloudHomeActivity : FileActivity() {
|
||||
private lateinit var binding: ActivityMainBinding
|
||||
private lateinit var section: CloudSection
|
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
section = CloudSection.fromWireValue(intent.getStringExtra(EXTRA_SECTION))
|
||||
|
||||
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
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
if (savedInstanceState == null) {
|
||||
supportFragmentManager.beginTransaction()
|
||||
.replace(R.id.left_fragment_container, CloudHubFragment.newInstance(section))
|
||||
.commit()
|
||||
}
|
||||
|
||||
enableEdgeToEdgePostSetContentView { insets ->
|
||||
binding.navCoordinatorLayout.appBarLayout.updatePadding(
|
||||
top = insets.top,
|
||||
left = insets.left,
|
||||
right = insets.right,
|
||||
)
|
||||
binding.navCoordinatorLayout.bottomNavViewSpacer.updateLayoutParams {
|
||||
height = insets.bottom
|
||||
}
|
||||
findViewById<View>(R.id.nav_view_container).updateLayoutParams<ViewGroup.MarginLayoutParams> {
|
||||
bottomMargin = insets.bottom
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun onAccountSet(stateWasRecovered: Boolean) {
|
||||
super.onAccountSet(stateWasRecovered)
|
||||
setAccountInDrawer(account)
|
||||
cloudFragment()?.reload()
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
companion object {
|
||||
private const val EXTRA_SECTION = "cloud_section"
|
||||
|
||||
fun createIntent(context: Context, section: CloudSection): Intent =
|
||||
Intent(context, CloudHomeActivity::class.java).apply {
|
||||
putExtra(EXTRA_SECTION, section.wireValue)
|
||||
addFlags(Intent.FLAG_ACTIVITY_CLEAR_TOP)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
+153
@@ -0,0 +1,153 @@
|
||||
/**
|
||||
* qsfera Android client application
|
||||
*
|
||||
* Copyright (C) 2026 QSfera.
|
||||
*/
|
||||
package eu.qsfera.android.presentation.cloud
|
||||
|
||||
import android.accounts.Account
|
||||
import android.view.LayoutInflater
|
||||
import android.view.View
|
||||
import android.view.ViewGroup
|
||||
import android.widget.ImageView
|
||||
import android.widget.TextView
|
||||
import androidx.recyclerview.widget.RecyclerView
|
||||
import coil.dispose
|
||||
import coil.load
|
||||
import eu.qsfera.android.R
|
||||
import eu.qsfera.android.presentation.thumbnails.ThumbnailsRequester
|
||||
import eu.qsfera.android.utils.MimetypeIconUtil
|
||||
|
||||
internal class CloudHubAdapter(
|
||||
private val account: Account,
|
||||
private val onMediaClick: (CloudMediaItem) -> Unit,
|
||||
private val onAlbumClick: (String) -> Unit,
|
||||
private val onActionClick: (CloudAction) -> Unit,
|
||||
private val onRetry: () -> Unit,
|
||||
) : RecyclerView.Adapter<CloudHubAdapter.Holder>() {
|
||||
private var rows: List<CloudHubRow> = emptyList()
|
||||
|
||||
fun submitRows(newRows: List<CloudHubRow>) {
|
||||
rows = newRows
|
||||
notifyDataSetChanged()
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
fun spanSize(position: Int): Int = when (rows[position]) {
|
||||
is CloudHubRow.Media -> 2
|
||||
is CloudHubRow.Album -> 3
|
||||
is CloudHubRow.Header,
|
||||
is CloudHubRow.Action,
|
||||
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
|
||||
}
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
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
|
||||
if (item.isImage) {
|
||||
image.load(
|
||||
previewUri(item, 512),
|
||||
ThumbnailsRequester.getContentAddressedImageLoader(account),
|
||||
) {
|
||||
placeholder(R.drawable.cloud_media_placeholder)
|
||||
error(MimetypeIconUtil.getFileTypeIconId(item.mimeType, item.name))
|
||||
crossfade(true)
|
||||
}
|
||||
} else {
|
||||
image.dispose()
|
||||
image.setImageResource(MimetypeIconUtil.getFileTypeIconId(item.mimeType, item.name))
|
||||
}
|
||||
view.contentDescription = item.name
|
||||
view.setOnClickListener { onMediaClick(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)
|
||||
val cover = view.findViewById<ImageView>(R.id.cloud_album_cover)
|
||||
val coverItem = album.cover
|
||||
if (coverItem?.isImage == true) {
|
||||
cover.load(
|
||||
previewUri(coverItem, 320),
|
||||
ThumbnailsRequester.getContentAddressedImageLoader(account),
|
||||
) {
|
||||
placeholder(R.drawable.ic_qsfera_folder)
|
||||
error(R.drawable.ic_qsfera_folder)
|
||||
crossfade(true)
|
||||
}
|
||||
} else {
|
||||
cover.dispose()
|
||||
cover.setImageResource(R.drawable.ic_qsfera_folder)
|
||||
}
|
||||
view.setOnClickListener { onAlbumClick(album.path) }
|
||||
}
|
||||
|
||||
private fun previewUri(item: CloudMediaItem, size: Int): String =
|
||||
if (item.webDavHref.isNotBlank()) {
|
||||
ThumbnailsRequester.getPreviewUriForWebDavHref(
|
||||
item.webDavHref,
|
||||
account,
|
||||
item.etag.ifBlank { item.modifiedAt.toString() },
|
||||
size,
|
||||
size,
|
||||
)
|
||||
} else {
|
||||
ThumbnailsRequester.getPreviewUriForFile(item.toOCFile(account.name), account, item.etag, size, size)
|
||||
}
|
||||
|
||||
private fun bindAction(view: View, action: CloudHubRow.Action) {
|
||||
view.findViewById<ImageView>(R.id.cloud_action_icon).setImageResource(action.icon)
|
||||
view.findViewById<TextView>(R.id.cloud_action_title).text = action.title
|
||||
view.findViewById<TextView>(R.id.cloud_action_summary).text = action.summary
|
||||
view.setOnClickListener { onActionClick(action.id) }
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
|
||||
internal class Holder(itemView: View) : RecyclerView.ViewHolder(itemView)
|
||||
|
||||
companion object {
|
||||
private const val TYPE_HEADER = 0
|
||||
private const val TYPE_MEDIA = 1
|
||||
private const val TYPE_ALBUM = 2
|
||||
private const val TYPE_ACTION = 3
|
||||
private const val TYPE_STATUS = 4
|
||||
}
|
||||
}
|
||||
+393
@@ -0,0 +1,393 @@
|
||||
/**
|
||||
* qsfera Android client application
|
||||
*
|
||||
* Copyright (C) 2026 QSfera.
|
||||
*/
|
||||
package eu.qsfera.android.presentation.cloud
|
||||
|
||||
import android.content.Intent
|
||||
import android.os.Bundle
|
||||
import android.os.Parcelable
|
||||
import android.view.View
|
||||
import androidx.activity.OnBackPressedCallback
|
||||
import androidx.core.os.bundleOf
|
||||
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 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.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.Dispatchers
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
import org.koin.android.ext.android.inject
|
||||
import java.text.DateFormat
|
||||
import java.util.Date
|
||||
import java.util.Locale
|
||||
|
||||
class CloudHubFragment : Fragment(R.layout.fragment_cloud_hub) {
|
||||
private val clientManager: ClientManager by inject()
|
||||
private val transferRepository: TransferRepository by inject()
|
||||
|
||||
private lateinit var section: CloudSection
|
||||
private lateinit var adapter: CloudHubAdapter
|
||||
private lateinit var refresh: SwipeRefreshLayout
|
||||
private var allMedia: List<CloudMediaItem> = emptyList()
|
||||
private var query: String = ""
|
||||
private var activeAlbumPath: String? = null
|
||||
private var loadJob: Job? = null
|
||||
private var nextOffset = 0
|
||||
private var isLoading = false
|
||||
private var reachedEnd = false
|
||||
|
||||
private val albumBackCallback = object : OnBackPressedCallback(false) {
|
||||
override fun handleOnBackPressed() {
|
||||
activeAlbumPath = null
|
||||
isEnabled = false
|
||||
render()
|
||||
}
|
||||
}
|
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
section = CloudSection.fromWireValue(arguments?.getString(ARG_SECTION))
|
||||
requireActivity().onBackPressedDispatcher.addCallback(this, albumBackCallback)
|
||||
}
|
||||
|
||||
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
|
||||
super.onViewCreated(view, savedInstanceState)
|
||||
val account = AccountUtils.getCurrentQSferaAccount(requireContext())
|
||||
adapter = CloudHubAdapter(
|
||||
account = account,
|
||||
onMediaClick = ::openMedia,
|
||||
onAlbumClick = { albumPath ->
|
||||
activeAlbumPath = albumPath
|
||||
albumBackCallback.isEnabled = true
|
||||
render()
|
||||
},
|
||||
onActionClick = ::openAction,
|
||||
onRetry = ::reload,
|
||||
)
|
||||
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)
|
||||
}
|
||||
}
|
||||
adapter = this@CloudHubFragment.adapter
|
||||
addOnScrollListener(object : RecyclerView.OnScrollListener() {
|
||||
override fun onScrolled(recyclerView: RecyclerView, dx: Int, dy: Int) {
|
||||
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)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
refresh = view.findViewById<SwipeRefreshLayout>(R.id.cloud_refresh).apply {
|
||||
setColorSchemeResources(R.color.qsfera_blue)
|
||||
setOnRefreshListener(::reload)
|
||||
isEnabled = section != CloudSection.MORE
|
||||
}
|
||||
reload()
|
||||
}
|
||||
|
||||
fun reload() {
|
||||
if (!isAdded || !this::adapter.isInitialized) return
|
||||
if (section == CloudSection.MORE) {
|
||||
allMedia = emptyList()
|
||||
render()
|
||||
return
|
||||
}
|
||||
|
||||
loadJob?.cancel()
|
||||
isLoading = false
|
||||
nextOffset = 0
|
||||
reachedEnd = false
|
||||
allMedia = emptyList()
|
||||
refresh.isRefreshing = true
|
||||
adapter.submitRows(
|
||||
listOf(CloudHubRow.Status(getString(R.string.cloud_media_loading), ""))
|
||||
)
|
||||
loadNextPage(reset = true)
|
||||
}
|
||||
|
||||
private fun loadNextPage(reset: Boolean) {
|
||||
if (isLoading || reachedEnd || section == CloudSection.MORE) return
|
||||
isLoading = true
|
||||
val requestedOffset = if (reset) 0 else nextOffset
|
||||
loadJob = viewLifecycleOwner.lifecycleScope.launch {
|
||||
val result = runCatching { withContext(Dispatchers.IO) { loadMediaPage(requestedOffset) } }
|
||||
if (!isAdded) return@launch
|
||||
isLoading = false
|
||||
refresh.isRefreshing = false
|
||||
result.onSuccess { page ->
|
||||
nextOffset = requestedOffset + page.rawResultCount
|
||||
reachedEnd = page.rawResultCount < MEDIA_PAGE_SIZE
|
||||
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()) {
|
||||
allMedia = fallback
|
||||
reachedEnd = true
|
||||
render()
|
||||
} else if (reset) {
|
||||
adapter.submitRows(
|
||||
listOf(
|
||||
CloudHubRow.Status(
|
||||
getString(R.string.cloud_media_load_error),
|
||||
getString(R.string.cloud_media_retry),
|
||||
retry = true,
|
||||
)
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun filter(newQuery: String) {
|
||||
query = newQuery.trim()
|
||||
render()
|
||||
}
|
||||
|
||||
private fun loadMediaPage(offset: Int): LoadedMediaPage {
|
||||
val account = AccountUtils.getCurrentQSferaAccount(requireContext())
|
||||
val userId = LibraryAccountUtils.getUserId(account, requireContext())
|
||||
val remoteFiles = executeRemoteOperation {
|
||||
clientManager.getMediaSearchService(account.name).searchMedia(
|
||||
MediaSearchRequest(
|
||||
mediaTypes = setOf(MediaSearchType.IMAGE, MediaSearchType.VIDEO),
|
||||
limit = MEDIA_PAGE_SIZE,
|
||||
offset = offset,
|
||||
)
|
||||
)
|
||||
}
|
||||
val davPrefix = "/remote.php/dav/files/$userId"
|
||||
val items = remoteFiles.mapNotNull { remote ->
|
||||
val mimeType = remote.mimeType ?: return@mapNotNull null
|
||||
if (!mimeType.startsWith("image/") && !mimeType.startsWith("video/")) return@mapNotNull null
|
||||
val spacesPrefix = "/remote.php/dav/spaces/"
|
||||
val spaceReference = remote.path.substringAfter(spacesPrefix, missingDelimiterValue = "")
|
||||
.substringBefore('/', missingDelimiterValue = "")
|
||||
.takeIf { it.isNotBlank() }
|
||||
val pathWithoutDavPrefix = if (spaceReference != null) {
|
||||
remote.path.removePrefix(spacesPrefix).substringAfter('/', missingDelimiterValue = "")
|
||||
} else {
|
||||
remote.path.removePrefix(davPrefix)
|
||||
}
|
||||
val remotePath = pathWithoutDavPrefix.let { path ->
|
||||
if (path.startsWith('/')) path else "/$path"
|
||||
}
|
||||
CloudMediaItem(
|
||||
webDavHref = remote.href,
|
||||
remotePath = remotePath,
|
||||
mimeType = mimeType,
|
||||
size = remote.size ?: 0L,
|
||||
modifiedAt = remote.modifiedTimestamp ?: 0L,
|
||||
etag = remote.etag.orEmpty(),
|
||||
spaceId = spaceReference,
|
||||
)
|
||||
}
|
||||
return LoadedMediaPage(items = items, rawResultCount = remoteFiles.size)
|
||||
}
|
||||
|
||||
private fun recentAutomaticUploads(): List<CloudMediaItem> {
|
||||
val account = AccountUtils.getCurrentQSferaAccount(requireContext())
|
||||
return transferRepository.getFinishedTransfers()
|
||||
.asSequence()
|
||||
.filter { transfer ->
|
||||
transfer.accountName == account.name &&
|
||||
transfer.createdBy != UploadEnqueuedBy.ENQUEUED_BY_USER &&
|
||||
transfer.transferEndTimestamp != null
|
||||
}
|
||||
.map { transfer ->
|
||||
val mimeType = MimetypeIconUtil.getBestMimeTypeByFilename(transfer.remotePath)
|
||||
CloudMediaItem(
|
||||
remotePath = transfer.remotePath,
|
||||
mimeType = mimeType,
|
||||
size = transfer.fileSize,
|
||||
modifiedAt = transfer.transferEndTimestamp ?: 0L,
|
||||
)
|
||||
}
|
||||
.filter { it.isImage || it.isVideo }
|
||||
.distinctBy { it.remotePath }
|
||||
.sortedByDescending { it.modifiedAt }
|
||||
.take(120)
|
||||
.toList()
|
||||
}
|
||||
|
||||
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()
|
||||
}
|
||||
adapter.submitRows(rows)
|
||||
}
|
||||
|
||||
private fun feedRows(media: List<CloudMediaItem>): List<CloudHubRow> {
|
||||
if (media.isEmpty()) return listOf(
|
||||
CloudHubRow.Status(
|
||||
getString(R.string.cloud_feed_empty_title),
|
||||
getString(R.string.cloud_feed_empty_summary),
|
||||
)
|
||||
)
|
||||
val dateFormat = DateFormat.getDateInstance(DateFormat.LONG, Locale.getDefault())
|
||||
return buildList {
|
||||
media.groupBy { dateFormat.format(Date(it.modifiedAt)) }.forEach { (date, items) ->
|
||||
add(CloudHubRow.Header(date))
|
||||
addAll(items.map { CloudHubRow.Media(it) })
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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),
|
||||
)
|
||||
)
|
||||
return buildList {
|
||||
add(CloudHubRow.Header(getString(R.string.cloud_all_photos)))
|
||||
addAll(media.map { CloudHubRow.Media(it) })
|
||||
}
|
||||
}
|
||||
|
||||
private fun albumRows(media: List<CloudMediaItem>): List<CloudHubRow> {
|
||||
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) })
|
||||
}
|
||||
}
|
||||
if (media.isEmpty()) return listOf(
|
||||
CloudHubRow.Status(
|
||||
getString(R.string.cloud_albums_empty_title),
|
||||
getString(R.string.cloud_albums_empty_summary),
|
||||
)
|
||||
)
|
||||
return media.groupBy(CloudMediaItem::albumKey)
|
||||
.entries
|
||||
.sortedWith(compareByDescending<Map.Entry<String, List<CloudMediaItem>>> { it.value.size }.thenBy { it.key })
|
||||
.map { (albumKey, items) ->
|
||||
val path = items.first().parentPath
|
||||
CloudHubRow.Album(
|
||||
path = albumKey,
|
||||
title = path.substringAfterLast('/').ifBlank { getString(R.string.cloud_albums_title) },
|
||||
count = items.size,
|
||||
cover = items.firstOrNull(CloudMediaItem::isImage),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
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 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.OFFLINE -> openFileList(FileListOption.AV_OFFLINE)
|
||||
CloudAction.SHARES -> openFileList(FileListOption.SHARED_BY_LINK)
|
||||
CloudAction.SPACES -> openFileList(FileListOption.SPACES_LIST)
|
||||
}
|
||||
}
|
||||
|
||||
private fun openFileList(option: FileListOption) {
|
||||
startActivity(Intent(requireContext(), FileDisplayActivity::class.java).apply {
|
||||
putExtra(FileActivity.EXTRA_FILE_LIST_OPTION, option as Parcelable)
|
||||
})
|
||||
}
|
||||
|
||||
companion object {
|
||||
private const val ARG_SECTION = "cloud_section"
|
||||
private const val GRID_SPANS = 6
|
||||
private const val MEDIA_PAGE_SIZE = 200
|
||||
private const val LOAD_MORE_THRESHOLD = 18
|
||||
|
||||
fun newInstance(section: CloudSection): CloudHubFragment = CloudHubFragment().apply {
|
||||
arguments = bundleOf(ARG_SECTION to section.wireValue)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private data class LoadedMediaPage(
|
||||
val items: List<CloudMediaItem>,
|
||||
val rawResultCount: Int,
|
||||
)
|
||||
+141
@@ -0,0 +1,141 @@
|
||||
/**
|
||||
* qsfera Android client application
|
||||
*
|
||||
* Copyright (C) 2026 QSfera.
|
||||
*/
|
||||
package eu.qsfera.android.presentation.cloud
|
||||
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.os.Build
|
||||
import android.os.Bundle
|
||||
import android.view.View
|
||||
import android.widget.ProgressBar
|
||||
import androidx.annotation.OptIn
|
||||
import androidx.appcompat.app.AppCompatActivity
|
||||
import androidx.appcompat.widget.Toolbar
|
||||
import androidx.lifecycle.lifecycleScope
|
||||
import androidx.media3.common.MediaItem
|
||||
import androidx.media3.common.Player
|
||||
import androidx.media3.common.util.UnstableApi
|
||||
import androidx.media3.datasource.DefaultHttpDataSource
|
||||
import androidx.media3.exoplayer.ExoPlayer
|
||||
import androidx.media3.exoplayer.source.ProgressiveMediaSource
|
||||
import androidx.media3.ui.PlayerView
|
||||
import coil.load
|
||||
import com.github.chrisbanes.photoview.PhotoView
|
||||
import eu.qsfera.android.MainApp
|
||||
import eu.qsfera.android.R
|
||||
import eu.qsfera.android.data.ClientManager
|
||||
import eu.qsfera.android.presentation.authentication.AccountUtils
|
||||
import eu.qsfera.android.presentation.thumbnails.ThumbnailsRequester
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
import org.koin.android.ext.android.inject
|
||||
|
||||
class CloudMediaPreviewActivity : AppCompatActivity() {
|
||||
private val clientManager: ClientManager by inject()
|
||||
private var player: ExoPlayer? = null
|
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
setContentView(R.layout.activity_cloud_media_preview)
|
||||
val media = mediaExtra() ?: run {
|
||||
finish()
|
||||
return
|
||||
}
|
||||
|
||||
findViewById<Toolbar>(R.id.cloud_preview_toolbar).also { toolbar ->
|
||||
toolbar.title = media.name
|
||||
setSupportActionBar(toolbar)
|
||||
supportActionBar?.setDisplayHomeAsUpEnabled(true)
|
||||
toolbar.setNavigationOnClickListener { finish() }
|
||||
}
|
||||
|
||||
if (media.isVideo) showVideo(media) else showImage(media)
|
||||
}
|
||||
|
||||
private fun showImage(media: CloudMediaItem) {
|
||||
val account = AccountUtils.getCurrentQSferaAccount(this)
|
||||
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,
|
||||
),
|
||||
ThumbnailsRequester.getContentAddressedImageLoader(account),
|
||||
) {
|
||||
crossfade(true)
|
||||
listener(
|
||||
onSuccess = { _, _ -> progress.visibility = View.GONE },
|
||||
onError = { _, _ -> progress.visibility = View.GONE },
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@OptIn(UnstableApi::class)
|
||||
private fun showVideo(media: CloudMediaItem) {
|
||||
val account = AccountUtils.getCurrentQSferaAccount(this)
|
||||
val playerView = findViewById<PlayerView>(R.id.cloud_preview_player).apply { visibility = View.VISIBLE }
|
||||
val progress = findViewById<ProgressBar>(R.id.cloud_preview_progress)
|
||||
lifecycleScope.launch {
|
||||
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('?')
|
||||
contentUrl to client.credentials?.headerAuth.orEmpty()
|
||||
}
|
||||
}.getOrNull() ?: run {
|
||||
progress.visibility = View.GONE
|
||||
return@launch
|
||||
}
|
||||
val dataSourceFactory = DefaultHttpDataSource.Factory()
|
||||
.setUserAgent(MainApp.userAgent)
|
||||
.setDefaultRequestProperties(mapOf("Authorization" to prepared.second))
|
||||
val mediaSource = ProgressiveMediaSource.Factory(dataSourceFactory)
|
||||
.createMediaSource(MediaItem.fromUri(prepared.first))
|
||||
player = ExoPlayer.Builder(this@CloudMediaPreviewActivity).build().also { exoPlayer ->
|
||||
playerView.player = exoPlayer
|
||||
exoPlayer.addListener(object : Player.Listener {
|
||||
override fun onPlaybackStateChanged(playbackState: Int) {
|
||||
progress.visibility = if (playbackState == Player.STATE_BUFFERING) View.VISIBLE else View.GONE
|
||||
}
|
||||
})
|
||||
exoPlayer.setMediaSource(mediaSource)
|
||||
exoPlayer.prepare()
|
||||
exoPlayer.playWhenReady = true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Suppress("DEPRECATION")
|
||||
private fun mediaExtra(): CloudMediaItem? = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
|
||||
intent.getParcelableExtra(EXTRA_MEDIA, CloudMediaItem::class.java)
|
||||
} else {
|
||||
intent.getParcelableExtra(EXTRA_MEDIA)
|
||||
}
|
||||
|
||||
override fun onDestroy() {
|
||||
player?.release()
|
||||
player = null
|
||||
super.onDestroy()
|
||||
}
|
||||
|
||||
companion object {
|
||||
private const val EXTRA_MEDIA = "cloud_media"
|
||||
|
||||
fun createIntent(context: Context, media: CloudMediaItem): Intent =
|
||||
Intent(context, CloudMediaPreviewActivity::class.java).putExtra(EXTRA_MEDIA, media)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
/**
|
||||
* qsfera Android client application
|
||||
*
|
||||
* Copyright (C) 2026 QSfera.
|
||||
*/
|
||||
package eu.qsfera.android.presentation.cloud
|
||||
|
||||
import android.os.Parcelable
|
||||
import eu.qsfera.android.domain.files.model.OCFile
|
||||
import kotlinx.parcelize.Parcelize
|
||||
|
||||
@Parcelize
|
||||
data class CloudMediaItem(
|
||||
val webDavHref: String = "",
|
||||
val remotePath: String,
|
||||
val mimeType: String,
|
||||
val size: Long,
|
||||
val modifiedAt: Long,
|
||||
val etag: String = "",
|
||||
val spaceId: String? = null,
|
||||
) : Parcelable {
|
||||
val name: String get() = remotePath.trimEnd('/').substringAfterLast('/')
|
||||
val parentPath: String get() = remotePath.substringBeforeLast('/', missingDelimiterValue = "/").ifBlank { "/" }
|
||||
val isVideo: Boolean get() = mimeType.startsWith("video/")
|
||||
val isImage: Boolean get() = mimeType.startsWith("image/")
|
||||
val albumKey: String get() = "${spaceId.orEmpty()}::$parentPath"
|
||||
|
||||
fun toOCFile(owner: String): OCFile = OCFile(
|
||||
owner = owner,
|
||||
length = size,
|
||||
modificationTimestamp = modifiedAt,
|
||||
remotePath = remotePath,
|
||||
mimeType = mimeType,
|
||||
etag = etag,
|
||||
remoteEtag = etag,
|
||||
spaceId = spaceId,
|
||||
)
|
||||
}
|
||||
|
||||
enum class CloudSection(val wireValue: String) {
|
||||
FEED("feed"),
|
||||
PHOTOS("photos"),
|
||||
ALBUMS("albums"),
|
||||
MORE("more");
|
||||
|
||||
companion object {
|
||||
fun fromWireValue(value: String?): CloudSection = entries.firstOrNull { it.wireValue == value } ?: FEED
|
||||
}
|
||||
}
|
||||
|
||||
internal sealed interface CloudHubRow {
|
||||
data class Header(val title: String) : CloudHubRow
|
||||
data class Media(val item: CloudMediaItem) : 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
|
||||
}
|
||||
|
||||
internal enum class CloudAction {
|
||||
STORAGE,
|
||||
TRANSFERS,
|
||||
OFFLINE,
|
||||
SHARES,
|
||||
SPACES,
|
||||
SETTINGS,
|
||||
}
|
||||
+4
-7
@@ -38,7 +38,8 @@ import eu.qsfera.android.presentation.settings.automaticuploads.SettingsVideoUpl
|
||||
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.ui.activity.FileDisplayActivity
|
||||
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
|
||||
|
||||
@@ -55,9 +56,7 @@ class SettingsActivity : AppCompatActivity() {
|
||||
val toolbar = findViewById<Toolbar>(R.id.standard_toolbar).apply {
|
||||
isVisible = true
|
||||
}
|
||||
findViewById<ConstraintLayout>(R.id.root_toolbar).apply {
|
||||
isVisible = false
|
||||
}
|
||||
findViewById<ConstraintLayout>(R.id.root_toolbar).isVisible = false
|
||||
setSupportActionBar(toolbar)
|
||||
updateToolbarTitle()
|
||||
supportActionBar?.setDisplayHomeAsUpEnabled(true)
|
||||
@@ -95,9 +94,7 @@ class SettingsActivity : AppCompatActivity() {
|
||||
if (supportFragmentManager.backStackEntryCount > 0) {
|
||||
supportFragmentManager.popBackStack()
|
||||
} else {
|
||||
intent = Intent(this, FileDisplayActivity::class.java).apply {
|
||||
addFlags(Intent.FLAG_ACTIVITY_CLEAR_TOP)
|
||||
}
|
||||
intent = CloudHomeActivity.createIntent(this, CloudSection.MORE)
|
||||
startActivity(intent)
|
||||
}
|
||||
}
|
||||
|
||||
+240
@@ -0,0 +1,240 @@
|
||||
/**
|
||||
* 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.Context
|
||||
import android.content.Intent
|
||||
import android.os.Bundle
|
||||
import android.view.MenuItem
|
||||
import android.view.View
|
||||
import androidx.activity.result.contract.ActivityResultContracts
|
||||
import androidx.appcompat.app.AppCompatActivity
|
||||
import androidx.appcompat.widget.Toolbar
|
||||
import androidx.constraintlayout.widget.ConstraintLayout
|
||||
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
|
||||
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
|
||||
|
||||
class AutomaticUploadFoldersActivity : AppCompatActivity() {
|
||||
private lateinit var mediaKind: AutomaticUploadMediaKind
|
||||
private val selectedSources = linkedSetOf<String>()
|
||||
|
||||
private val permissionLauncher = registerForActivityResult(ActivityResultContracts.RequestMultiplePermissions()) {
|
||||
currentFragment()?.reload()
|
||||
}
|
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
enableEdgeToEdgePreSetContentView(false)
|
||||
setContentView(R.layout.activity_settings)
|
||||
|
||||
mediaKind = intent.getStringExtra(EXTRA_MEDIA_KIND)
|
||||
?.let(AutomaticUploadMediaKind::fromWireValue)
|
||||
?: AutomaticUploadMediaKind.IMAGE
|
||||
selectedSources += (savedInstanceState?.getStringArrayList(STATE_SELECTED_SOURCES)
|
||||
?: intent.getStringArrayListExtra(EXTRA_SELECTED_SOURCES).orEmpty())
|
||||
.mapNotNull(AutomaticUploadMediaSource::parse)
|
||||
.filter { it.kind == mediaKind && !it.isCamera }
|
||||
.map(AutomaticUploadMediaSource::encodedValue)
|
||||
|
||||
findViewById<Toolbar>(R.id.standard_toolbar).apply { isVisible = true }.also {
|
||||
setSupportActionBar(it)
|
||||
}
|
||||
findViewById<ConstraintLayout>(R.id.root_toolbar).isVisible = false
|
||||
supportActionBar?.apply {
|
||||
setDisplayHomeAsUpEnabled(true)
|
||||
setTitle(R.string.automatic_upload_folders_title)
|
||||
}
|
||||
|
||||
enableEdgeToEdgePostSetContentView { insets ->
|
||||
findViewById<View>(R.id.toolbar).setPadding(0, insets.top, 0, 0)
|
||||
findViewById<View>(android.R.id.content).setPadding(0, 0, 0, insets.bottom)
|
||||
}
|
||||
|
||||
if (savedInstanceState == null) {
|
||||
supportFragmentManager.beginTransaction()
|
||||
.replace(
|
||||
R.id.settings_container,
|
||||
AutomaticUploadFoldersFragment().apply {
|
||||
arguments = bundleOf(ARG_MEDIA_KIND to mediaKind.wireValue)
|
||||
},
|
||||
)
|
||||
.commit()
|
||||
}
|
||||
}
|
||||
|
||||
override fun onResume() {
|
||||
super.onResume()
|
||||
currentFragment()?.reload()
|
||||
}
|
||||
|
||||
override fun onSaveInstanceState(outState: Bundle) {
|
||||
outState.putStringArrayList(STATE_SELECTED_SOURCES, ArrayList(selectedSources))
|
||||
super.onSaveInstanceState(outState)
|
||||
}
|
||||
|
||||
override fun onOptionsItemSelected(item: MenuItem): Boolean {
|
||||
if (item.itemId == android.R.id.home) {
|
||||
finish()
|
||||
return true
|
||||
}
|
||||
return super.onOptionsItemSelected(item)
|
||||
}
|
||||
|
||||
override fun finish() {
|
||||
setResult(
|
||||
Activity.RESULT_OK,
|
||||
Intent().putStringArrayListExtra(EXTRA_SELECTED_SOURCES, ArrayList(selectedSources)),
|
||||
)
|
||||
super.finish()
|
||||
}
|
||||
|
||||
internal fun requestReadPermission() {
|
||||
permissionLauncher.launch(AutomaticUploadsPermissions.readPermissions(mediaKind))
|
||||
}
|
||||
|
||||
internal fun selectedRelativePaths(): Set<String> = selectedSources.mapNotNull { encodedSource ->
|
||||
AutomaticUploadMediaSource.parse(encodedSource)
|
||||
?.takeIf { it.kind == mediaKind }
|
||||
?.relativePath
|
||||
}.toSet()
|
||||
|
||||
internal fun setFolderSelected(source: AutomaticUploadMediaSource, selected: Boolean) {
|
||||
selectedSources.removeAll { existing ->
|
||||
AutomaticUploadMediaSource.parse(existing)?.let {
|
||||
it.kind == source.kind && it.relativePath.equals(source.relativePath, ignoreCase = true)
|
||||
} == true
|
||||
}
|
||||
if (selected) selectedSources += source.encodedValue
|
||||
}
|
||||
|
||||
private fun currentFragment(): AutomaticUploadFoldersFragment? =
|
||||
supportFragmentManager.findFragmentById(R.id.settings_container) as? AutomaticUploadFoldersFragment
|
||||
|
||||
companion object {
|
||||
const val EXTRA_SELECTED_SOURCES = "selected_sources"
|
||||
private const val EXTRA_MEDIA_KIND = "media_kind"
|
||||
private const val STATE_SELECTED_SOURCES = "selected_sources_state"
|
||||
internal const val ARG_MEDIA_KIND = "media_kind"
|
||||
|
||||
fun createIntent(
|
||||
context: Context,
|
||||
kind: AutomaticUploadMediaKind,
|
||||
selectedSources: Collection<String>,
|
||||
): Intent = Intent(context, AutomaticUploadFoldersActivity::class.java).apply {
|
||||
putExtra(EXTRA_MEDIA_KIND, kind.wireValue)
|
||||
putStringArrayListExtra(EXTRA_SELECTED_SOURCES, ArrayList(selectedSources))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
class AutomaticUploadFoldersFragment : PreferenceFragmentCompat() {
|
||||
private lateinit var mediaKind: AutomaticUploadMediaKind
|
||||
private var reloadGeneration = 0
|
||||
|
||||
override fun onCreatePreferences(savedInstanceState: Bundle?, rootKey: String?) {
|
||||
mediaKind = arguments?.getString(AutomaticUploadFoldersActivity.ARG_MEDIA_KIND)
|
||||
?.let(AutomaticUploadMediaKind::fromWireValue)
|
||||
?: AutomaticUploadMediaKind.IMAGE
|
||||
preferenceScreen = preferenceManager.createPreferenceScreen(requireContext())
|
||||
reload()
|
||||
}
|
||||
|
||||
fun reload() {
|
||||
if (!isAdded || preferenceScreen == null) return
|
||||
val generation = ++reloadGeneration
|
||||
val host = requireActivity() as AutomaticUploadFoldersActivity
|
||||
preferenceScreen.removeAll()
|
||||
|
||||
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
|
||||
}
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
preferenceScreen.addPreference(Preference(requireContext()).apply {
|
||||
isSelectable = false
|
||||
summary = getString(R.string.automatic_upload_folders_loading)
|
||||
})
|
||||
|
||||
lifecycleScope.launch {
|
||||
val foldersResult = runCatching {
|
||||
withContext(Dispatchers.IO) { PhoneMediaStore(requireContext()).getFolders(mediaKind) }
|
||||
}
|
||||
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
|
||||
}
|
||||
})
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+75
@@ -0,0 +1,75 @@
|
||||
/**
|
||||
* 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 java.nio.charset.StandardCharsets
|
||||
import java.util.Base64
|
||||
|
||||
enum class AutomaticUploadMediaKind(val wireValue: String) {
|
||||
IMAGE("image"),
|
||||
VIDEO("video");
|
||||
|
||||
companion object {
|
||||
fun fromWireValue(value: String): AutomaticUploadMediaKind? = entries.firstOrNull { it.wireValue == value }
|
||||
}
|
||||
}
|
||||
|
||||
data class AutomaticUploadMediaSource(
|
||||
val kind: AutomaticUploadMediaKind,
|
||||
val relativePath: String,
|
||||
) {
|
||||
val encodedValue: String
|
||||
get() {
|
||||
val encodedPath = Base64.getUrlEncoder().withoutPadding()
|
||||
.encodeToString(relativePath.toByteArray(StandardCharsets.UTF_8))
|
||||
return "$PREFIX${kind.wireValue}:$encodedPath"
|
||||
}
|
||||
|
||||
val displayPath: String
|
||||
get() = relativePath.trimEnd('/').substringAfterLast('/')
|
||||
|
||||
val isCamera: Boolean
|
||||
get() = isCameraPath(relativePath)
|
||||
|
||||
companion object {
|
||||
const val CAMERA_RELATIVE_PATH = "DCIM/Camera/"
|
||||
private const val PREFIX = "mediastore:"
|
||||
|
||||
fun create(kind: AutomaticUploadMediaKind, relativePath: String): AutomaticUploadMediaSource =
|
||||
AutomaticUploadMediaSource(kind, normalizeRelativePath(relativePath))
|
||||
|
||||
fun camera(kind: AutomaticUploadMediaKind): AutomaticUploadMediaSource = create(kind, CAMERA_RELATIVE_PATH)
|
||||
|
||||
fun parse(value: String): AutomaticUploadMediaSource? {
|
||||
if (!value.startsWith(PREFIX)) return null
|
||||
|
||||
val payload = value.removePrefix(PREFIX)
|
||||
val separatorIndex = payload.indexOf(':')
|
||||
if (separatorIndex <= 0 || separatorIndex == payload.lastIndex) return null
|
||||
|
||||
val kind = AutomaticUploadMediaKind.fromWireValue(payload.substring(0, separatorIndex)) ?: return null
|
||||
val decodedPath = runCatching {
|
||||
String(
|
||||
Base64.getUrlDecoder().decode(payload.substring(separatorIndex + 1)),
|
||||
StandardCharsets.UTF_8,
|
||||
)
|
||||
}.getOrNull() ?: return null
|
||||
|
||||
return decodedPath.takeIf { it.isNotBlank() }?.let { create(kind, it) }
|
||||
}
|
||||
|
||||
fun normalizeRelativePath(path: String): String =
|
||||
path.replace('\\', '/').trim().trim('/').let { normalized ->
|
||||
if (normalized.isEmpty()) "" else "$normalized/"
|
||||
}
|
||||
|
||||
fun isCameraPath(path: String): Boolean =
|
||||
normalizeRelativePath(path).equals(CAMERA_RELATIVE_PATH, ignoreCase = true)
|
||||
}
|
||||
}
|
||||
+55
@@ -0,0 +1,55 @@
|
||||
/**
|
||||
* qsfera Android client application
|
||||
*
|
||||
* Copyright (C) 2026 QSfera.
|
||||
*/
|
||||
package eu.qsfera.android.presentation.settings.automaticuploads
|
||||
|
||||
import android.Manifest
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.net.Uri
|
||||
import android.os.Build
|
||||
import android.os.Environment
|
||||
import android.provider.Settings
|
||||
import android.provider.MediaStore
|
||||
import androidx.core.content.ContextCompat
|
||||
import android.content.pm.PackageManager
|
||||
|
||||
object AutomaticUploadsPermissions {
|
||||
fun readPermission(kind: AutomaticUploadMediaKind): String = when {
|
||||
Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU && kind == AutomaticUploadMediaKind.IMAGE ->
|
||||
Manifest.permission.READ_MEDIA_IMAGES
|
||||
Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU -> Manifest.permission.READ_MEDIA_VIDEO
|
||||
else -> Manifest.permission.READ_EXTERNAL_STORAGE
|
||||
}
|
||||
|
||||
fun hasReadPermission(context: Context, kind: AutomaticUploadMediaKind): Boolean =
|
||||
ContextCompat.checkSelfPermission(context, readPermission(kind)) == PackageManager.PERMISSION_GRANTED
|
||||
|
||||
fun readPermissions(kind: AutomaticUploadMediaKind): Array<String> = buildList {
|
||||
add(readPermission(kind))
|
||||
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
|
||||
!Environment.isExternalStorageManager() -> false
|
||||
Build.VERSION.SDK_INT < Build.VERSION_CODES.S -> true
|
||||
else -> MediaStore.canManageMedia(context)
|
||||
}
|
||||
|
||||
fun deletePermissionIntent(context: Context): Intent =
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R && !Environment.isExternalStorageManager()) {
|
||||
Intent(
|
||||
Settings.ACTION_MANAGE_APP_ALL_FILES_ACCESS_PERMISSION,
|
||||
Uri.parse("package:${context.packageName}"),
|
||||
)
|
||||
} else if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S && !MediaStore.canManageMedia(context)) {
|
||||
Intent(Settings.ACTION_REQUEST_MANAGE_MEDIA, Uri.parse("package:${context.packageName}"))
|
||||
} else {
|
||||
Intent(Settings.ACTION_APPLICATION_DETAILS_SETTINGS, Uri.parse("package:${context.packageName}"))
|
||||
}
|
||||
}
|
||||
+183
@@ -0,0 +1,183 @@
|
||||
/**
|
||||
* 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.ContentUris
|
||||
import android.content.Context
|
||||
import android.net.Uri
|
||||
import android.os.Build
|
||||
import android.os.Environment
|
||||
import android.provider.MediaStore
|
||||
import java.io.File
|
||||
|
||||
data class PhoneMediaFolder(
|
||||
val displayName: String,
|
||||
val relativePath: String,
|
||||
val itemCount: Int,
|
||||
)
|
||||
|
||||
data class PhoneMediaItem(
|
||||
val uri: Uri,
|
||||
val displayName: String,
|
||||
val mimeType: String,
|
||||
val size: Long,
|
||||
val lastModified: Long,
|
||||
)
|
||||
|
||||
class PhoneMediaStore(context: Context) {
|
||||
private val contentResolver = context.applicationContext.contentResolver
|
||||
|
||||
fun getFolders(kind: AutomaticUploadMediaKind): List<PhoneMediaFolder> {
|
||||
val folders = linkedMapOf<String, MutableFolder>()
|
||||
|
||||
query(kind) { row ->
|
||||
if (row.relativePath.isEmpty()) return@query
|
||||
val folder = folders.getOrPut(row.relativePath.lowercase()) {
|
||||
MutableFolder(
|
||||
displayName = row.bucketDisplayName.ifBlank {
|
||||
row.relativePath.trimEnd('/').substringAfterLast('/')
|
||||
},
|
||||
relativePath = row.relativePath,
|
||||
)
|
||||
}
|
||||
folder.itemCount += 1
|
||||
}
|
||||
|
||||
return folders.values
|
||||
.map { PhoneMediaFolder(it.displayName, it.relativePath, it.itemCount) }
|
||||
.sortedWith(
|
||||
compareBy<PhoneMediaFolder> { it.displayName.lowercase() }
|
||||
.thenBy { it.relativePath.lowercase() }
|
||||
)
|
||||
}
|
||||
|
||||
fun getItems(source: AutomaticUploadMediaSource): List<PhoneMediaItem> = getItems(listOf(source))
|
||||
|
||||
fun getItems(sources: Collection<AutomaticUploadMediaSource>): List<PhoneMediaItem> {
|
||||
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 items = mutableListOf<PhoneMediaItem>()
|
||||
|
||||
query(kind) { row ->
|
||||
if (row.relativePath.lowercase() !in selectedPaths) return@query
|
||||
if (row.displayName.isBlank() || row.mimeType.isBlank()) return@query
|
||||
|
||||
items += PhoneMediaItem(
|
||||
uri = ContentUris.withAppendedId(collectionFor(kind), row.id),
|
||||
displayName = row.displayName,
|
||||
mimeType = row.mimeType,
|
||||
size = row.size,
|
||||
lastModified = row.lastModified,
|
||||
)
|
||||
}
|
||||
|
||||
return items.sortedBy { it.lastModified }
|
||||
}
|
||||
|
||||
private fun query(kind: AutomaticUploadMediaKind, 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.Images.Media.BUCKET_DISPLAY_NAME)
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
|
||||
add(MediaStore.MediaColumns.RELATIVE_PATH)
|
||||
} else {
|
||||
@Suppress("DEPRECATION")
|
||||
add(MediaStore.MediaColumns.DATA)
|
||||
}
|
||||
}.toTypedArray()
|
||||
|
||||
val selection = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
|
||||
"${MediaStore.MediaColumns.IS_PENDING}=0"
|
||||
} else {
|
||||
null
|
||||
}
|
||||
|
||||
val cursor = contentResolver.query(
|
||||
collectionFor(kind),
|
||||
projection,
|
||||
selection,
|
||||
null,
|
||||
"${MediaStore.MediaColumns.DATE_MODIFIED} DESC",
|
||||
) ?: throw IllegalStateException("MediaStore returned no cursor for $kind")
|
||||
|
||||
cursor.use {
|
||||
val idColumn = cursor.getColumnIndexOrThrow(MediaStore.MediaColumns._ID)
|
||||
val displayNameColumn = cursor.getColumnIndexOrThrow(MediaStore.MediaColumns.DISPLAY_NAME)
|
||||
val mimeTypeColumn = cursor.getColumnIndexOrThrow(MediaStore.MediaColumns.MIME_TYPE)
|
||||
val sizeColumn = cursor.getColumnIndexOrThrow(MediaStore.MediaColumns.SIZE)
|
||||
val modifiedColumn = cursor.getColumnIndexOrThrow(MediaStore.MediaColumns.DATE_MODIFIED)
|
||||
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)
|
||||
} else {
|
||||
@Suppress("DEPRECATION")
|
||||
cursor.getColumnIndexOrThrow(MediaStore.MediaColumns.DATA)
|
||||
}
|
||||
|
||||
while (cursor.moveToNext()) {
|
||||
val rawPath = cursor.getString(pathColumn).orEmpty()
|
||||
val relativePath = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
|
||||
AutomaticUploadMediaSource.normalizeRelativePath(rawPath)
|
||||
} else {
|
||||
relativePathFromLegacyData(rawPath)
|
||||
}
|
||||
|
||||
consume(
|
||||
MediaRow(
|
||||
id = cursor.getLong(idColumn),
|
||||
displayName = cursor.getString(displayNameColumn).orEmpty(),
|
||||
mimeType = cursor.getString(mimeTypeColumn).orEmpty(),
|
||||
size = cursor.getLong(sizeColumn),
|
||||
lastModified = cursor.getLong(modifiedColumn) * 1_000L,
|
||||
bucketDisplayName = cursor.getString(bucketColumn).orEmpty(),
|
||||
relativePath = relativePath,
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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 relativePathFromLegacyData(dataPath: String): String {
|
||||
val parentPath = File(dataPath).parent.orEmpty().replace('\\', '/')
|
||||
val externalRoot = Environment.getExternalStorageDirectory().absolutePath.replace('\\', '/').trimEnd('/')
|
||||
val relativePath = if (parentPath.startsWith(externalRoot, ignoreCase = true)) {
|
||||
parentPath.removePrefix(externalRoot)
|
||||
} else {
|
||||
parentPath.substringAfter("/storage/emulated/0/", parentPath)
|
||||
}
|
||||
return AutomaticUploadMediaSource.normalizeRelativePath(relativePath)
|
||||
}
|
||||
|
||||
private data class MutableFolder(
|
||||
val displayName: String,
|
||||
val relativePath: String,
|
||||
var itemCount: Int = 0,
|
||||
)
|
||||
|
||||
private data class MediaRow(
|
||||
val id: Long,
|
||||
val displayName: String,
|
||||
val mimeType: String,
|
||||
val size: Long,
|
||||
val lastModified: Long,
|
||||
val bucketDisplayName: String,
|
||||
val relativePath: String,
|
||||
)
|
||||
}
|
||||
+137
-66
@@ -25,12 +25,10 @@ package eu.qsfera.android.presentation.settings.automaticuploads
|
||||
import android.app.Activity
|
||||
import android.content.DialogInterface
|
||||
import android.content.Intent
|
||||
import android.os.Build
|
||||
import android.os.Bundle
|
||||
import android.provider.DocumentsContract
|
||||
import android.provider.Settings
|
||||
import android.view.View
|
||||
import androidx.activity.result.contract.ActivityResultContracts
|
||||
import androidx.core.net.toUri
|
||||
import androidx.lifecycle.Lifecycle
|
||||
import androidx.lifecycle.lifecycleScope
|
||||
import androidx.lifecycle.repeatOnLifecycle
|
||||
@@ -65,14 +63,17 @@ class SettingsPictureUploadsFragment : PreferenceFragmentCompat() {
|
||||
|
||||
private var prefEnablePictureUploads: SwitchPreferenceCompat? = null
|
||||
private var prefPictureUploadsPath: Preference? = null
|
||||
private var prefPictureUploadsOnWifi: CheckBoxPreference? = null
|
||||
private var prefPictureUploadsOnWifi: SwitchPreferenceCompat? = null
|
||||
private var prefPictureUploadsOnCharging: CheckBoxPreference? = null
|
||||
private var prefPictureUploadsSourcePath: Preference? = null
|
||||
private var prefPictureUploadsClearSourcePaths: Preference? = null
|
||||
private var prefPictureUploadsPermissions: Preference? = null
|
||||
private var prefPictureUploadsAccount: ListPreference? = null
|
||||
private var prefPictureUploadsLastSync: Preference? = null
|
||||
private var spaceId: String? = null
|
||||
private lateinit var selectedAccount: String
|
||||
private var pendingEnable = false
|
||||
private var pendingOpenFolders = false
|
||||
private var requestedMediaManagement = false
|
||||
|
||||
private val selectPictureUploadsPathLauncher =
|
||||
registerForActivityResult(ActivityResultContracts.StartActivityForResult()) { result ->
|
||||
@@ -83,12 +84,36 @@ class SettingsPictureUploadsFragment : PreferenceFragmentCompat() {
|
||||
private val selectPictureUploadsSourcePathLauncher =
|
||||
registerForActivityResult(ActivityResultContracts.StartActivityForResult()) { result ->
|
||||
if (result.resultCode != Activity.RESULT_OK) return@registerForActivityResult
|
||||
// here we ask the content resolver to persist the permission for us
|
||||
val takeFlags: Int = Intent.FLAG_GRANT_READ_URI_PERMISSION or Intent.FLAG_GRANT_WRITE_URI_PERMISSION
|
||||
val contentUriForTree = result.data!!.data!!
|
||||
val sourcePaths = result.data
|
||||
?.getStringArrayListExtra(AutomaticUploadFoldersActivity.EXTRA_SELECTED_SOURCES)
|
||||
.orEmpty()
|
||||
picturesViewModel.replacePictureUploadsSourcePaths(sourcePaths)
|
||||
}
|
||||
|
||||
requireContext().contentResolver.takePersistableUriPermission(contentUriForTree, takeFlags)
|
||||
picturesViewModel.handleSelectPictureUploadsSourcePath(contentUriForTree)
|
||||
private val readMediaPermissionLauncher =
|
||||
registerForActivityResult(ActivityResultContracts.RequestMultiplePermissions()) { grants ->
|
||||
if (grants[AutomaticUploadsPermissions.readPermission(AutomaticUploadMediaKind.IMAGE)] == true) {
|
||||
continuePendingPermissionAction()
|
||||
} else {
|
||||
pendingEnable = false
|
||||
pendingOpenFolders = false
|
||||
updatePermissionSummary()
|
||||
}
|
||||
}
|
||||
|
||||
private val deletePermissionLauncher =
|
||||
registerForActivityResult(ActivityResultContracts.StartActivityForResult()) {
|
||||
if (AutomaticUploadsPermissions.hasDeletePermission(requireContext())) {
|
||||
requestedMediaManagement = false
|
||||
continuePendingPermissionAction()
|
||||
} else if (!requestedMediaManagement) {
|
||||
requestRequiredPermissions()
|
||||
} else {
|
||||
requestedMediaManagement = false
|
||||
pendingEnable = false
|
||||
pendingOpenFolders = false
|
||||
updatePermissionSummary()
|
||||
}
|
||||
}
|
||||
|
||||
override fun onCreatePreferences(savedInstanceState: Bundle?, rootKey: String?) {
|
||||
@@ -99,14 +124,12 @@ class SettingsPictureUploadsFragment : PreferenceFragmentCompat() {
|
||||
prefPictureUploadsOnWifi = findPreference(PREF__CAMERA_PICTURE_UPLOADS_WIFI_ONLY)
|
||||
prefPictureUploadsOnCharging = findPreference(PREF__CAMERA_PICTURE_UPLOADS_CHARGING_ONLY)
|
||||
prefPictureUploadsSourcePath = findPreference(PREF__CAMERA_PICTURE_UPLOADS_SOURCE)
|
||||
prefPictureUploadsClearSourcePaths = findPreference(PREF_PICTURE_UPLOADS_CLEAR_SOURCE_PATHS)
|
||||
prefPictureUploadsPermissions = findPreference(PREF_PICTURE_UPLOADS_PERMISSIONS)
|
||||
prefPictureUploadsLastSync = findPreference(PREF__CAMERA_PICTURE_UPLOADS_LAST_SYNC)
|
||||
prefPictureUploadsAccount = findPreference(PREF__CAMERA_PICTURE_UPLOADS_ACCOUNT_NAME)
|
||||
|
||||
val comment = getString(R.string.prefs_camera_upload_source_path_title_required)
|
||||
prefPictureUploadsSourcePath?.title = String.format(prefPictureUploadsSourcePath?.title.toString(), comment)
|
||||
|
||||
initPreferenceListeners()
|
||||
updatePermissionSummary()
|
||||
}
|
||||
|
||||
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
|
||||
@@ -130,7 +153,7 @@ class SettingsPictureUploadsFragment : PreferenceFragmentCompat() {
|
||||
showMessageInSnackbar(getString(R.string.prefs_automatic_uploads_not_available_users_light))
|
||||
} else {
|
||||
val currentAccount = manageAccountsViewModel.getCurrentAccount()?.name
|
||||
currentAccount?.let {
|
||||
if (currentAccount != null) {
|
||||
selectedAccount = if (manageAccountsViewModel.checkUserLight(currentAccount)) {
|
||||
availableAccounts.first().accountName
|
||||
} else {
|
||||
@@ -145,8 +168,7 @@ class SettingsPictureUploadsFragment : PreferenceFragmentCompat() {
|
||||
prefPictureUploadsPath?.summary = picturesViewModel.getUploadPathString()
|
||||
val sourcePaths = picturesViewModel.getPictureUploadsSourcePaths()
|
||||
prefPictureUploadsSourcePath?.summary = getSourcePathsSummary(sourcePaths)
|
||||
prefPictureUploadsClearSourcePaths?.isEnabled = sourcePaths.isNotEmpty()
|
||||
prefPictureUploadsOnWifi?.isChecked = it.wifiOnly
|
||||
prefPictureUploadsOnWifi?.isChecked = !it.wifiOnly
|
||||
prefPictureUploadsOnCharging?.isChecked = it.chargingOnly
|
||||
prefPictureUploadsLastSync?.summary = DisplayUtils.unixTimeToHumanReadable(it.lastSyncTimestamp)
|
||||
spaceId = it.spaceId
|
||||
@@ -163,12 +185,9 @@ class SettingsPictureUploadsFragment : PreferenceFragmentCompat() {
|
||||
val value = newValue as Boolean
|
||||
|
||||
if (value) {
|
||||
picturesViewModel.enablePictureUploads(selectedAccount)
|
||||
showAlertDialog(
|
||||
title = getString(R.string.common_important),
|
||||
message = getString(R.string.proper_pics_folder_warning_camera_upload)
|
||||
)
|
||||
true
|
||||
pendingEnable = true
|
||||
requestRequiredPermissions()
|
||||
false
|
||||
} else {
|
||||
showAlertDialog(
|
||||
title = getString(R.string.confirmation_disable_camera_uploads_title),
|
||||
@@ -198,52 +217,34 @@ class SettingsPictureUploadsFragment : PreferenceFragmentCompat() {
|
||||
}
|
||||
|
||||
prefPictureUploadsSourcePath?.setOnPreferenceClickListener {
|
||||
val sourcePath = picturesViewModel.getPictureUploadsSourcePaths().lastOrNull()?.let { currentSourcePath ->
|
||||
currentSourcePath.takeUnless { it.endsWith(File.separator) } ?: currentSourcePath.plus(File.separator)
|
||||
}
|
||||
val intent = Intent(Intent.ACTION_OPEN_DOCUMENT_TREE).apply {
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
|
||||
putExtra(DocumentsContract.EXTRA_INITIAL_URI, sourcePath)
|
||||
}
|
||||
addFlags(
|
||||
Intent.FLAG_GRANT_READ_URI_PERMISSION
|
||||
or Intent.FLAG_GRANT_WRITE_URI_PERMISSION
|
||||
or Intent.FLAG_GRANT_PERSISTABLE_URI_PERMISSION
|
||||
or Intent.FLAG_GRANT_PREFIX_URI_PERMISSION
|
||||
)
|
||||
}
|
||||
selectPictureUploadsSourcePathLauncher.launch(intent)
|
||||
pendingOpenFolders = true
|
||||
requestRequiredPermissions(requireDeletePermission = false)
|
||||
true
|
||||
}
|
||||
|
||||
prefPictureUploadsClearSourcePaths?.setOnPreferenceClickListener {
|
||||
showAlertDialog(
|
||||
title = getString(R.string.confirmation_clear_camera_upload_sources_title),
|
||||
message = getString(R.string.confirmation_clear_camera_upload_sources_message),
|
||||
positiveButtonText = getString(R.string.common_yes),
|
||||
positiveButtonListener = { _: DialogInterface?, _: Int ->
|
||||
picturesViewModel.clearPictureUploadsSourcePaths()
|
||||
},
|
||||
negativeButtonText = getString(R.string.common_no)
|
||||
)
|
||||
prefPictureUploadsPermissions?.setOnPreferenceClickListener {
|
||||
requestRequiredPermissions()
|
||||
true
|
||||
}
|
||||
|
||||
prefPictureUploadsOnWifi?.setOnPreferenceChangeListener { _, newValue ->
|
||||
newValue as Boolean
|
||||
picturesViewModel.useWifiOnly(newValue)
|
||||
newValue
|
||||
val useMobileData = newValue as? Boolean
|
||||
?: return@setOnPreferenceChangeListener false
|
||||
picturesViewModel.useWifiOnly(!useMobileData)
|
||||
true
|
||||
}
|
||||
|
||||
prefPictureUploadsOnCharging?.setOnPreferenceChangeListener { _, newValue ->
|
||||
newValue as Boolean
|
||||
picturesViewModel.useChargingOnly(newValue)
|
||||
newValue
|
||||
val chargingOnly = newValue as? Boolean
|
||||
?: return@setOnPreferenceChangeListener false
|
||||
picturesViewModel.useChargingOnly(chargingOnly)
|
||||
true
|
||||
}
|
||||
|
||||
prefPictureUploadsAccount?.setOnPreferenceChangeListener { _, newValue ->
|
||||
newValue as String
|
||||
picturesViewModel.handleSelectAccount(newValue)
|
||||
val accountName = newValue as? String
|
||||
?: return@setOnPreferenceChangeListener false
|
||||
picturesViewModel.handleSelectAccount(accountName)
|
||||
true
|
||||
}
|
||||
|
||||
@@ -254,6 +255,11 @@ class SettingsPictureUploadsFragment : PreferenceFragmentCompat() {
|
||||
super.onDestroy()
|
||||
}
|
||||
|
||||
override fun onResume() {
|
||||
super.onResume()
|
||||
updatePermissionSummary()
|
||||
}
|
||||
|
||||
private fun enablePictureUploads(value: Boolean, isLightUser: Boolean) {
|
||||
prefEnablePictureUploads?.isChecked = value
|
||||
if (isLightUser) {
|
||||
@@ -263,7 +269,6 @@ class SettingsPictureUploadsFragment : PreferenceFragmentCompat() {
|
||||
prefPictureUploadsOnWifi?.isEnabled = value
|
||||
prefPictureUploadsOnCharging?.isEnabled = value
|
||||
prefPictureUploadsSourcePath?.isEnabled = value
|
||||
prefPictureUploadsClearSourcePaths?.isEnabled = value && picturesViewModel.getPictureUploadsSourcePaths().isNotEmpty()
|
||||
prefPictureUploadsAccount?.isEnabled = value
|
||||
prefPictureUploadsLastSync?.isEnabled = value
|
||||
}
|
||||
@@ -272,22 +277,88 @@ class SettingsPictureUploadsFragment : PreferenceFragmentCompat() {
|
||||
prefPictureUploadsAccount?.value = null
|
||||
prefPictureUploadsPath?.summary = null
|
||||
prefPictureUploadsSourcePath?.summary = getString(R.string.prefs_camera_upload_source_paths_empty)
|
||||
prefPictureUploadsClearSourcePaths?.isEnabled = false
|
||||
prefPictureUploadsOnWifi?.isChecked = false
|
||||
prefPictureUploadsOnWifi?.isChecked = true
|
||||
prefPictureUploadsOnCharging?.isChecked = false
|
||||
prefPictureUploadsLastSync?.summary = null
|
||||
}
|
||||
|
||||
private fun getSourcePathsSummary(sourcePaths: List<String>): String =
|
||||
if (sourcePaths.isEmpty()) {
|
||||
getString(R.string.prefs_camera_upload_source_paths_empty)
|
||||
private fun getSourcePathsSummary(sourcePaths: List<String>): String {
|
||||
val selectedCount = sourcePaths.count { sourcePath ->
|
||||
AutomaticUploadMediaSource.parse(sourcePath)?.isCamera != true
|
||||
}
|
||||
val suffix = if (selectedCount == 0) {
|
||||
""
|
||||
} else {
|
||||
sourcePaths.joinToString(separator = "\n") { sourcePath ->
|
||||
DisplayUtils.getPathWithoutLastSlash(sourcePath.toUri().path)
|
||||
}
|
||||
getString(R.string.automatic_upload_selected_folders_suffix, selectedCount)
|
||||
}
|
||||
return getString(R.string.automatic_upload_camera_and_folders_summary, suffix)
|
||||
}
|
||||
|
||||
private fun requestRequiredPermissions(requireDeletePermission: Boolean = true) {
|
||||
if (!AutomaticUploadsPermissions.hasReadPermission(requireContext(), AutomaticUploadMediaKind.IMAGE)) {
|
||||
readMediaPermissionLauncher.launch(AutomaticUploadsPermissions.readPermissions(AutomaticUploadMediaKind.IMAGE))
|
||||
updatePermissionSummary()
|
||||
return
|
||||
}
|
||||
|
||||
if (requireDeletePermission && !AutomaticUploadsPermissions.hasDeletePermission(requireContext())) {
|
||||
val permissionIntent = AutomaticUploadsPermissions.deletePermissionIntent(requireContext())
|
||||
requestedMediaManagement = permissionIntent.action == Settings.ACTION_REQUEST_MANAGE_MEDIA
|
||||
runCatching { deletePermissionLauncher.launch(permissionIntent) }
|
||||
.onFailure {
|
||||
startActivity(permissionIntent)
|
||||
}
|
||||
updatePermissionSummary()
|
||||
return
|
||||
}
|
||||
|
||||
continuePendingPermissionAction()
|
||||
}
|
||||
|
||||
private fun continuePendingPermissionAction() {
|
||||
updatePermissionSummary()
|
||||
if (!AutomaticUploadsPermissions.hasReadPermission(requireContext(), AutomaticUploadMediaKind.IMAGE)) return
|
||||
|
||||
if (pendingEnable && !AutomaticUploadsPermissions.hasDeletePermission(requireContext())) {
|
||||
requestRequiredPermissions()
|
||||
return
|
||||
}
|
||||
|
||||
if (pendingEnable) {
|
||||
pendingEnable = false
|
||||
picturesViewModel.enablePictureUploads(selectedAccount)
|
||||
showAlertDialog(
|
||||
title = getString(R.string.common_important),
|
||||
message = getString(R.string.automatic_upload_enable_message),
|
||||
)
|
||||
}
|
||||
|
||||
if (pendingOpenFolders) {
|
||||
pendingOpenFolders = false
|
||||
selectPictureUploadsSourcePathLauncher.launch(
|
||||
AutomaticUploadFoldersActivity.createIntent(
|
||||
requireContext(),
|
||||
AutomaticUploadMediaKind.IMAGE,
|
||||
picturesViewModel.getPictureUploadsSourcePaths(),
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private fun updatePermissionSummary() {
|
||||
val hasRead = AutomaticUploadsPermissions.hasReadPermission(requireContext(), AutomaticUploadMediaKind.IMAGE)
|
||||
val hasDelete = AutomaticUploadsPermissions.hasDeletePermission(requireContext())
|
||||
prefPictureUploadsPermissions?.summary = getString(
|
||||
when {
|
||||
hasRead && hasDelete -> R.string.automatic_upload_permission_ready
|
||||
!hasRead && !hasDelete -> R.string.automatic_upload_permission_both_missing
|
||||
!hasRead -> R.string.automatic_upload_permission_read_missing
|
||||
else -> R.string.automatic_upload_permission_delete_missing
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
companion object {
|
||||
private const val PREF_PICTURE_UPLOADS_CLEAR_SOURCE_PATHS = "picture_uploads_clear_source_paths"
|
||||
private const val PREF_PICTURE_UPLOADS_PERMISSIONS = "automatic_picture_uploads_permissions"
|
||||
}
|
||||
}
|
||||
|
||||
+5
-6
@@ -23,7 +23,6 @@
|
||||
package eu.qsfera.android.presentation.settings.automaticuploads
|
||||
|
||||
import android.content.Intent
|
||||
import android.net.Uri
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import androidx.work.ExistingWorkPolicy
|
||||
@@ -164,10 +163,9 @@ class SettingsPictureUploadsViewModel(
|
||||
}
|
||||
}
|
||||
|
||||
fun handleSelectPictureUploadsSourcePath(contentUriForTree: Uri) {
|
||||
fun replacePictureUploadsSourcePaths(sourcePaths: List<String>) {
|
||||
val previousSourcePaths = getPictureUploadsSourcePaths()
|
||||
val newSourcePath = contentUriForTree.toString()
|
||||
val updatedSourcePaths = (previousSourcePaths + newSourcePath).distinct()
|
||||
val updatedSourcePaths = FolderBackUpConfiguration.parseSourcePaths(encodeSourcePaths(sourcePaths))
|
||||
|
||||
viewModelScope.launch(coroutinesDispatcherProvider.io) {
|
||||
savePictureUploadsConfigurationUseCase(
|
||||
@@ -218,8 +216,8 @@ class SettingsPictureUploadsViewModel(
|
||||
behavior = UploadBehavior.MOVE,
|
||||
sourcePath = sourcePath.orEmpty(),
|
||||
uploadPath = uploadPath ?: PREF__CAMERA_UPLOADS_DEFAULT_PATH,
|
||||
wifiOnly = wifiOnly ?: false,
|
||||
chargingOnly = chargingOnly ?: false,
|
||||
wifiOnly = wifiOnly == true,
|
||||
chargingOnly = chargingOnly == true,
|
||||
lastSyncTimestamp = timestamp ?: System.currentTimeMillis(),
|
||||
name = _pictureUploads.value?.name ?: pictureUploadsName,
|
||||
spaceId = spaceId,
|
||||
@@ -237,6 +235,7 @@ class SettingsPictureUploadsViewModel(
|
||||
fun getUploadPathString(): String {
|
||||
|
||||
val spaceName = handleSpaceName(pictureUploadsSpace?.name)
|
||||
.orEmpty()
|
||||
val uploadPath = pictureUploads.value?.uploadPath
|
||||
val spaceId = pictureUploads.value?.spaceId
|
||||
|
||||
|
||||
+133
-69
@@ -25,12 +25,10 @@ package eu.qsfera.android.presentation.settings.automaticuploads
|
||||
import android.app.Activity
|
||||
import android.content.DialogInterface
|
||||
import android.content.Intent
|
||||
import android.os.Build
|
||||
import android.os.Bundle
|
||||
import android.provider.DocumentsContract
|
||||
import android.provider.Settings
|
||||
import android.view.View
|
||||
import androidx.activity.result.contract.ActivityResultContracts
|
||||
import androidx.core.net.toUri
|
||||
import androidx.lifecycle.Lifecycle
|
||||
import androidx.lifecycle.lifecycleScope
|
||||
import androidx.lifecycle.repeatOnLifecycle
|
||||
@@ -67,15 +65,19 @@ class SettingsVideoUploadsFragment : PreferenceFragmentCompat() {
|
||||
|
||||
private var prefEnableVideoUploads: SwitchPreferenceCompat? = null
|
||||
private var prefVideoUploadsPath: Preference? = null
|
||||
private var prefVideoUploadsOnWifi: CheckBoxPreference? = null
|
||||
private var prefVideoUploadsOnWifi: SwitchPreferenceCompat? = null
|
||||
private var prefVideoUploadsOnCharging: CheckBoxPreference? = null
|
||||
private var prefVideoUploadsSourcePath: Preference? = null
|
||||
private var prefVideoUploadsClearSourcePaths: Preference? = null
|
||||
private var prefVideoUploadsPermissions: Preference? = null
|
||||
private var prefVideoUploadsBehaviour: ListPreference? = null
|
||||
private var prefVideoUploadsAccount: ListPreference? = null
|
||||
private var prefVideoUploadsLastSync: Preference? = null
|
||||
private var spaceId: String? = null
|
||||
private lateinit var selectedAccount: String
|
||||
private var pendingEnable = false
|
||||
private var pendingOpenFolders = false
|
||||
private var pendingMoveBehavior = false
|
||||
private var requestedMediaManagement = false
|
||||
|
||||
private val selectVideoUploadsPathLauncher =
|
||||
registerForActivityResult(ActivityResultContracts.StartActivityForResult()) { result ->
|
||||
@@ -86,12 +88,37 @@ class SettingsVideoUploadsFragment : PreferenceFragmentCompat() {
|
||||
private val selectVideoUploadsSourcePathLauncher =
|
||||
registerForActivityResult(ActivityResultContracts.StartActivityForResult()) { result ->
|
||||
if (result.resultCode != Activity.RESULT_OK) return@registerForActivityResult
|
||||
// here we ask the content resolver to persist the permission for us
|
||||
val takeFlags: Int = Intent.FLAG_GRANT_READ_URI_PERMISSION or Intent.FLAG_GRANT_WRITE_URI_PERMISSION
|
||||
val contentUriForTree = result.data!!.data!!
|
||||
val sourcePaths = result.data
|
||||
?.getStringArrayListExtra(AutomaticUploadFoldersActivity.EXTRA_SELECTED_SOURCES)
|
||||
.orEmpty()
|
||||
videosViewModel.replaceVideoUploadsSourcePaths(sourcePaths)
|
||||
}
|
||||
|
||||
requireContext().contentResolver.takePersistableUriPermission(contentUriForTree, takeFlags)
|
||||
videosViewModel.handleSelectVideoUploadsSourcePath(contentUriForTree)
|
||||
private val readMediaPermissionLauncher =
|
||||
registerForActivityResult(ActivityResultContracts.RequestMultiplePermissions()) { grants ->
|
||||
if (grants[AutomaticUploadsPermissions.readPermission(AutomaticUploadMediaKind.VIDEO)] == true) {
|
||||
continuePendingPermissionAction()
|
||||
} else {
|
||||
pendingEnable = false
|
||||
pendingOpenFolders = false
|
||||
updatePermissionSummary()
|
||||
}
|
||||
}
|
||||
|
||||
private val deletePermissionLauncher =
|
||||
registerForActivityResult(ActivityResultContracts.StartActivityForResult()) {
|
||||
if (AutomaticUploadsPermissions.hasDeletePermission(requireContext()) && pendingMoveBehavior) {
|
||||
requestedMediaManagement = false
|
||||
pendingMoveBehavior = false
|
||||
videosViewModel.handleSelectBehaviour(UploadBehavior.MOVE.name)
|
||||
prefVideoUploadsBehaviour?.value = UploadBehavior.MOVE.name
|
||||
} else if (pendingMoveBehavior && !requestedMediaManagement) {
|
||||
requestDeletePermissionForMove()
|
||||
} else {
|
||||
requestedMediaManagement = false
|
||||
pendingMoveBehavior = false
|
||||
}
|
||||
updatePermissionSummary()
|
||||
}
|
||||
|
||||
override fun onCreatePreferences(savedInstanceState: Bundle?, rootKey: String?) {
|
||||
@@ -102,7 +129,7 @@ class SettingsVideoUploadsFragment : PreferenceFragmentCompat() {
|
||||
prefVideoUploadsOnWifi = findPreference(PREF__CAMERA_VIDEO_UPLOADS_WIFI_ONLY)
|
||||
prefVideoUploadsOnCharging = findPreference(PREF__CAMERA_VIDEO_UPLOADS_CHARGING_ONLY)
|
||||
prefVideoUploadsSourcePath = findPreference(PREF__CAMERA_VIDEO_UPLOADS_SOURCE)
|
||||
prefVideoUploadsClearSourcePaths = findPreference(PREF_VIDEO_UPLOADS_CLEAR_SOURCE_PATHS)
|
||||
prefVideoUploadsPermissions = findPreference(PREF_VIDEO_UPLOADS_PERMISSIONS)
|
||||
prefVideoUploadsLastSync = findPreference(PreferenceManager.PREF__CAMERA_VIDEO_UPLOADS_LAST_SYNC)
|
||||
prefVideoUploadsBehaviour = findPreference<ListPreference>(PREF__CAMERA_VIDEO_UPLOADS_BEHAVIOUR)?.apply {
|
||||
entries = listOf(getString(R.string.pref_behaviour_entries_keep_file),
|
||||
@@ -111,10 +138,8 @@ class SettingsVideoUploadsFragment : PreferenceFragmentCompat() {
|
||||
}
|
||||
prefVideoUploadsAccount = findPreference<ListPreference>(PREF__CAMERA_VIDEO_UPLOADS_ACCOUNT_NAME)
|
||||
|
||||
val comment = getString(R.string.prefs_camera_upload_source_path_title_required)
|
||||
prefVideoUploadsSourcePath?.title = String.format(prefVideoUploadsSourcePath?.title.toString(), comment)
|
||||
|
||||
initPreferenceListeners()
|
||||
updatePermissionSummary()
|
||||
}
|
||||
|
||||
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
|
||||
@@ -138,7 +163,7 @@ class SettingsVideoUploadsFragment : PreferenceFragmentCompat() {
|
||||
showMessageInSnackbar(getString(R.string.prefs_automatic_uploads_not_available_users_light))
|
||||
} else {
|
||||
val currentAccount = manageAccountsViewModel.getCurrentAccount()?.name
|
||||
currentAccount?.let {
|
||||
if (currentAccount != null) {
|
||||
selectedAccount = if (manageAccountsViewModel.checkUserLight(currentAccount)) {
|
||||
availableAccounts.first().accountName
|
||||
} else {
|
||||
@@ -153,8 +178,7 @@ class SettingsVideoUploadsFragment : PreferenceFragmentCompat() {
|
||||
prefVideoUploadsPath?.summary = videosViewModel.getUploadPathString()
|
||||
val sourcePaths = videosViewModel.getVideoUploadsSourcePaths()
|
||||
prefVideoUploadsSourcePath?.summary = getSourcePathsSummary(sourcePaths)
|
||||
prefVideoUploadsClearSourcePaths?.isEnabled = sourcePaths.isNotEmpty()
|
||||
prefVideoUploadsOnWifi?.isChecked = it.wifiOnly
|
||||
prefVideoUploadsOnWifi?.isChecked = !it.wifiOnly
|
||||
prefVideoUploadsOnCharging?.isChecked = it.chargingOnly
|
||||
prefVideoUploadsBehaviour?.value = it.behavior.name
|
||||
prefVideoUploadsLastSync?.summary = DisplayUtils.unixTimeToHumanReadable(it.lastSyncTimestamp)
|
||||
@@ -172,12 +196,9 @@ class SettingsVideoUploadsFragment : PreferenceFragmentCompat() {
|
||||
val value = newValue as Boolean
|
||||
|
||||
if (value) {
|
||||
videosViewModel.enableVideoUploads(selectedAccount)
|
||||
showAlertDialog(
|
||||
title = getString(R.string.common_important),
|
||||
message = getString(R.string.proper_videos_folder_warning_camera_upload)
|
||||
)
|
||||
true
|
||||
pendingEnable = true
|
||||
requestReadPermissionIfNeeded()
|
||||
false
|
||||
} else {
|
||||
showAlertDialog(
|
||||
title = getString(R.string.confirmation_disable_camera_uploads_title),
|
||||
@@ -207,59 +228,49 @@ class SettingsVideoUploadsFragment : PreferenceFragmentCompat() {
|
||||
}
|
||||
|
||||
prefVideoUploadsSourcePath?.setOnPreferenceClickListener {
|
||||
val sourcePath = videosViewModel.getVideoUploadsSourcePaths().lastOrNull()?.let { currentSourcePath ->
|
||||
currentSourcePath.takeUnless { it.endsWith(File.separator) } ?: currentSourcePath.plus(File.separator)
|
||||
}
|
||||
val intent = Intent(Intent.ACTION_OPEN_DOCUMENT_TREE).apply {
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
|
||||
putExtra(DocumentsContract.EXTRA_INITIAL_URI, sourcePath)
|
||||
}
|
||||
addFlags(
|
||||
Intent.FLAG_GRANT_READ_URI_PERMISSION
|
||||
or Intent.FLAG_GRANT_WRITE_URI_PERMISSION
|
||||
or Intent.FLAG_GRANT_PERSISTABLE_URI_PERMISSION
|
||||
or Intent.FLAG_GRANT_PREFIX_URI_PERMISSION
|
||||
)
|
||||
}
|
||||
selectVideoUploadsSourcePathLauncher.launch(intent)
|
||||
pendingOpenFolders = true
|
||||
requestReadPermissionIfNeeded()
|
||||
true
|
||||
}
|
||||
|
||||
prefVideoUploadsClearSourcePaths?.setOnPreferenceClickListener {
|
||||
showAlertDialog(
|
||||
title = getString(R.string.confirmation_clear_camera_upload_sources_title),
|
||||
message = getString(R.string.confirmation_clear_camera_upload_sources_message),
|
||||
positiveButtonText = getString(R.string.common_yes),
|
||||
positiveButtonListener = { _: DialogInterface?, _: Int ->
|
||||
videosViewModel.clearVideoUploadsSourcePaths()
|
||||
},
|
||||
negativeButtonText = getString(R.string.common_no)
|
||||
)
|
||||
prefVideoUploadsPermissions?.setOnPreferenceClickListener {
|
||||
requestReadPermissionIfNeeded()
|
||||
true
|
||||
}
|
||||
|
||||
prefVideoUploadsOnWifi?.setOnPreferenceChangeListener { _, newValue ->
|
||||
newValue as Boolean
|
||||
videosViewModel.useWifiOnly(newValue)
|
||||
newValue
|
||||
val useMobileData = newValue as? Boolean
|
||||
?: return@setOnPreferenceChangeListener false
|
||||
videosViewModel.useWifiOnly(!useMobileData)
|
||||
true
|
||||
}
|
||||
|
||||
prefVideoUploadsOnCharging?.setOnPreferenceChangeListener { _, newValue ->
|
||||
newValue as Boolean
|
||||
videosViewModel.useChargingOnly(newValue)
|
||||
newValue
|
||||
val chargingOnly = newValue as? Boolean
|
||||
?: return@setOnPreferenceChangeListener false
|
||||
videosViewModel.useChargingOnly(chargingOnly)
|
||||
true
|
||||
}
|
||||
|
||||
prefVideoUploadsAccount?.setOnPreferenceChangeListener { _, newValue ->
|
||||
newValue as String
|
||||
videosViewModel.handleSelectAccount(newValue)
|
||||
val accountName = newValue as? String
|
||||
?: return@setOnPreferenceChangeListener false
|
||||
videosViewModel.handleSelectAccount(accountName)
|
||||
true
|
||||
}
|
||||
|
||||
prefVideoUploadsBehaviour?.setOnPreferenceChangeListener { _, newValue ->
|
||||
newValue as String
|
||||
videosViewModel.handleSelectBehaviour(newValue)
|
||||
true
|
||||
val behaviorValue = newValue as? String
|
||||
?: return@setOnPreferenceChangeListener false
|
||||
val behavior = UploadBehavior.fromString(behaviorValue)
|
||||
if (behavior == UploadBehavior.MOVE && !AutomaticUploadsPermissions.hasDeletePermission(requireContext())) {
|
||||
pendingMoveBehavior = true
|
||||
requestDeletePermissionForMove()
|
||||
false
|
||||
} else {
|
||||
videosViewModel.handleSelectBehaviour(behaviorValue)
|
||||
true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -268,6 +279,11 @@ class SettingsVideoUploadsFragment : PreferenceFragmentCompat() {
|
||||
super.onDestroy()
|
||||
}
|
||||
|
||||
override fun onResume() {
|
||||
super.onResume()
|
||||
updatePermissionSummary()
|
||||
}
|
||||
|
||||
private fun enableVideoUploads(value: Boolean, isLightUser: Boolean) {
|
||||
prefEnableVideoUploads?.isChecked = value
|
||||
if (isLightUser) {
|
||||
@@ -277,7 +293,6 @@ class SettingsVideoUploadsFragment : PreferenceFragmentCompat() {
|
||||
prefVideoUploadsOnWifi?.isEnabled = value
|
||||
prefVideoUploadsOnCharging?.isEnabled = value
|
||||
prefVideoUploadsSourcePath?.isEnabled = value
|
||||
prefVideoUploadsClearSourcePaths?.isEnabled = value && videosViewModel.getVideoUploadsSourcePaths().isNotEmpty()
|
||||
prefVideoUploadsBehaviour?.isEnabled = value
|
||||
prefVideoUploadsAccount?.isEnabled = value
|
||||
prefVideoUploadsLastSync?.isEnabled = value
|
||||
@@ -287,23 +302,72 @@ class SettingsVideoUploadsFragment : PreferenceFragmentCompat() {
|
||||
prefVideoUploadsAccount?.value = null
|
||||
prefVideoUploadsPath?.summary = null
|
||||
prefVideoUploadsSourcePath?.summary = getString(R.string.prefs_camera_upload_source_paths_empty)
|
||||
prefVideoUploadsClearSourcePaths?.isEnabled = false
|
||||
prefVideoUploadsOnWifi?.isChecked = false
|
||||
prefVideoUploadsOnWifi?.isChecked = true
|
||||
prefVideoUploadsOnCharging?.isChecked = false
|
||||
prefVideoUploadsBehaviour?.value = UploadBehavior.COPY.name
|
||||
prefVideoUploadsLastSync?.summary = null
|
||||
}
|
||||
|
||||
private fun getSourcePathsSummary(sourcePaths: List<String>): String =
|
||||
if (sourcePaths.isEmpty()) {
|
||||
getString(R.string.prefs_camera_upload_source_paths_empty)
|
||||
private fun getSourcePathsSummary(sourcePaths: List<String>): String {
|
||||
val selectedCount = sourcePaths.count { sourcePath ->
|
||||
AutomaticUploadMediaSource.parse(sourcePath)?.isCamera != true
|
||||
}
|
||||
val suffix = if (selectedCount == 0) {
|
||||
""
|
||||
} else {
|
||||
sourcePaths.joinToString(separator = "\n") { sourcePath ->
|
||||
DisplayUtils.getPathWithoutLastSlash(sourcePath.toUri().path)
|
||||
}
|
||||
getString(R.string.automatic_upload_selected_folders_suffix, selectedCount)
|
||||
}
|
||||
return getString(R.string.automatic_upload_camera_and_folders_summary, suffix)
|
||||
}
|
||||
|
||||
private fun requestReadPermissionIfNeeded() {
|
||||
if (AutomaticUploadsPermissions.hasReadPermission(requireContext(), AutomaticUploadMediaKind.VIDEO)) {
|
||||
continuePendingPermissionAction()
|
||||
} else {
|
||||
readMediaPermissionLauncher.launch(AutomaticUploadsPermissions.readPermissions(AutomaticUploadMediaKind.VIDEO))
|
||||
}
|
||||
updatePermissionSummary()
|
||||
}
|
||||
|
||||
private fun requestDeletePermissionForMove() {
|
||||
val permissionIntent = AutomaticUploadsPermissions.deletePermissionIntent(requireContext())
|
||||
requestedMediaManagement = permissionIntent.action == Settings.ACTION_REQUEST_MANAGE_MEDIA
|
||||
deletePermissionLauncher.launch(permissionIntent)
|
||||
}
|
||||
|
||||
private fun continuePendingPermissionAction() {
|
||||
updatePermissionSummary()
|
||||
if (!AutomaticUploadsPermissions.hasReadPermission(requireContext(), AutomaticUploadMediaKind.VIDEO)) return
|
||||
|
||||
if (pendingEnable) {
|
||||
pendingEnable = false
|
||||
videosViewModel.enableVideoUploads(selectedAccount)
|
||||
showAlertDialog(
|
||||
title = getString(R.string.common_important),
|
||||
message = getString(R.string.automatic_upload_enable_message),
|
||||
)
|
||||
}
|
||||
|
||||
if (pendingOpenFolders) {
|
||||
pendingOpenFolders = false
|
||||
selectVideoUploadsSourcePathLauncher.launch(
|
||||
AutomaticUploadFoldersActivity.createIntent(
|
||||
requireContext(),
|
||||
AutomaticUploadMediaKind.VIDEO,
|
||||
videosViewModel.getVideoUploadsSourcePaths(),
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private fun updatePermissionSummary() {
|
||||
val hasRead = AutomaticUploadsPermissions.hasReadPermission(requireContext(), AutomaticUploadMediaKind.VIDEO)
|
||||
prefVideoUploadsPermissions?.summary = getString(
|
||||
if (hasRead) R.string.automatic_upload_permission_media_ready else R.string.automatic_upload_permission_read_missing
|
||||
)
|
||||
}
|
||||
|
||||
companion object {
|
||||
private const val PREF_VIDEO_UPLOADS_CLEAR_SOURCE_PATHS = "video_uploads_clear_source_paths"
|
||||
private const val PREF_VIDEO_UPLOADS_PERMISSIONS = "automatic_video_uploads_permissions"
|
||||
}
|
||||
}
|
||||
|
||||
+5
-6
@@ -23,7 +23,6 @@
|
||||
package eu.qsfera.android.presentation.settings.automaticuploads
|
||||
|
||||
import android.content.Intent
|
||||
import android.net.Uri
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import androidx.work.ExistingWorkPolicy
|
||||
@@ -174,10 +173,9 @@ class SettingsVideoUploadsViewModel(
|
||||
}
|
||||
}
|
||||
|
||||
fun handleSelectVideoUploadsSourcePath(contentUriForTree: Uri) {
|
||||
fun replaceVideoUploadsSourcePaths(sourcePaths: List<String>) {
|
||||
val previousSourcePaths = getVideoUploadsSourcePaths()
|
||||
val newSourcePath = contentUriForTree.toString()
|
||||
val updatedSourcePaths = (previousSourcePaths + newSourcePath).distinct()
|
||||
val updatedSourcePaths = FolderBackUpConfiguration.parseSourcePaths(encodeSourcePaths(sourcePaths))
|
||||
|
||||
viewModelScope.launch(coroutinesDispatcherProvider.io) {
|
||||
saveVideoUploadsConfigurationUseCase(
|
||||
@@ -230,8 +228,8 @@ class SettingsVideoUploadsViewModel(
|
||||
behavior = behavior ?: UploadBehavior.COPY,
|
||||
sourcePath = sourcePath.orEmpty(),
|
||||
uploadPath = uploadPath ?: PREF__CAMERA_UPLOADS_DEFAULT_PATH,
|
||||
wifiOnly = wifiOnly ?: false,
|
||||
chargingOnly = chargingOnly ?: false,
|
||||
wifiOnly = wifiOnly == true,
|
||||
chargingOnly = chargingOnly == true,
|
||||
lastSyncTimestamp = timestamp ?: System.currentTimeMillis(),
|
||||
name = _videoUploads.value?.name ?: videoUploadsName,
|
||||
spaceId = spaceId,
|
||||
@@ -249,6 +247,7 @@ class SettingsVideoUploadsViewModel(
|
||||
fun getUploadPathString(): String {
|
||||
|
||||
val spaceName = handleSpaceName(videoUploadsSpace?.name)
|
||||
.orEmpty()
|
||||
val uploadPath = videoUploads.value?.uploadPath
|
||||
val spaceId = videoUploads.value?.spaceId
|
||||
|
||||
|
||||
+32
-2
@@ -46,6 +46,7 @@ import okhttp3.Cache
|
||||
import okhttp3.Headers.Companion.toHeaders
|
||||
import okhttp3.Interceptor
|
||||
import okhttp3.Response
|
||||
import okhttp3.HttpUrl.Companion.toHttpUrlOrNull
|
||||
import org.koin.core.component.KoinComponent
|
||||
import org.koin.core.component.inject
|
||||
import timber.log.Timber
|
||||
@@ -104,6 +105,34 @@ object ThumbnailsRequester : KoinComponent {
|
||||
fun getPreviewUriForFile(fileWithSyncInfo: OCFileWithSyncInfo, account: Account, width: Int = 1024, height: Int = 1024): String =
|
||||
getPreviewUriForFile(fileWithSyncInfo.file, account, null, width, height)
|
||||
|
||||
fun getPreviewUriForWebDavHref(
|
||||
webDavHref: String,
|
||||
account: Account,
|
||||
etag: String? = null,
|
||||
width: Int = 1024,
|
||||
height: Int = 1024,
|
||||
): String {
|
||||
val baseUrl = accountBaseUrls.getOrPut(account.name) {
|
||||
val accountManager = AccountManager.get(appContext)
|
||||
accountManager.getUserData(account, eu.qsfera.android.lib.common.accounts.AccountUtils.Constants.KEY_OC_BASE_URL)
|
||||
?.trimEnd('/')
|
||||
.orEmpty()
|
||||
}
|
||||
val baseHttpUrl = baseUrl.toHttpUrlOrNull() ?: throw IllegalArgumentException("Invalid account base URL")
|
||||
val absoluteCandidate = webDavHref.toHttpUrlOrNull()
|
||||
if (absoluteCandidate != null) {
|
||||
require(
|
||||
absoluteCandidate.scheme == baseHttpUrl.scheme &&
|
||||
absoluteCandidate.host == baseHttpUrl.host &&
|
||||
absoluteCandidate.port == baseHttpUrl.port
|
||||
) { "WebDAV preview href must use the account origin" }
|
||||
}
|
||||
val absoluteHref = absoluteCandidate?.toString()
|
||||
?: "$baseUrl/${webDavHref.trimStart('/')}"
|
||||
val separator = if ('?' in absoluteHref) '&' else '?'
|
||||
return "$absoluteHref${separator}x=$width&y=$height&c=${etag.orEmpty()}&preview=1"
|
||||
}
|
||||
|
||||
fun getPreviewUriForSpaceSpecial(spaceSpecial: SpaceSpecial): String =
|
||||
String.format(Locale.US, SPACE_SPECIAL_PREVIEW_URI, spaceSpecial.webDavUrl, 1024, 1024, spaceSpecial.eTag)
|
||||
|
||||
@@ -114,7 +143,8 @@ object ThumbnailsRequester : KoinComponent {
|
||||
?.trimEnd('/')
|
||||
.orEmpty()
|
||||
}
|
||||
val path = if (remotePath?.startsWith("/") == true) remotePath else "/$remotePath"
|
||||
val normalizedRemotePath = remotePath.orEmpty()
|
||||
val path = if (normalizedRemotePath.startsWith("/")) normalizedRemotePath else "/$normalizedRemotePath"
|
||||
val encodedPath = Uri.encode(path, "/")
|
||||
|
||||
return String.format(Locale.US, FILE_PREVIEW_URI, baseUrl, encodedPath, width, height, etag.orEmpty())
|
||||
@@ -236,7 +266,7 @@ object ThumbnailsRequester : KoinComponent {
|
||||
override fun intercept(chain: Interceptor.Chain): Response {
|
||||
val response = chain.proceed(chain.request())
|
||||
|
||||
var builder = response.newBuilder()
|
||||
val builder = response.newBuilder()
|
||||
var changed = false
|
||||
|
||||
// The server sends no-cache (or no Cache-Control) for avatar responses.
|
||||
|
||||
@@ -125,8 +125,10 @@ class WorkManagerProvider(
|
||||
.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) {
|
||||
if (periodicRunning || immediateRunning || mediaTriggerRunning) {
|
||||
Timber.d("Automatic uploads worker already running, skipping immediate run")
|
||||
return
|
||||
}
|
||||
|
||||
+1
-1
@@ -16,7 +16,7 @@ fun FragmentActivity.enableEdgeToEdgePreSetContentView(
|
||||
isNavigationBackgroundPrimary: Boolean
|
||||
) {
|
||||
enableEdgeToEdge(
|
||||
statusBarStyle = SystemBarStyle.dark(Color.TRANSPARENT),
|
||||
statusBarStyle = SystemBarStyle.light(Color.TRANSPARENT, Color.TRANSPARENT),
|
||||
navigationBarStyle =
|
||||
if (isNavigationBackgroundPrimary)
|
||||
SystemBarStyle.dark(Color.TRANSPARENT)
|
||||
|
||||
@@ -44,38 +44,33 @@ import android.widget.ImageView
|
||||
import android.widget.ProgressBar
|
||||
import android.widget.TextView
|
||||
import androidx.appcompat.app.ActionBarDrawerToggle
|
||||
import androidx.appcompat.content.res.AppCompatResources
|
||||
import androidx.appcompat.widget.AppCompatImageView
|
||||
import androidx.constraintlayout.widget.ConstraintLayout
|
||||
import androidx.core.view.GravityCompat
|
||||
import androidx.core.view.get
|
||||
import androidx.core.view.isVisible
|
||||
import androidx.drawerlayout.widget.DrawerLayout
|
||||
import androidx.drawerlayout.widget.DrawerLayout.DrawerListener
|
||||
import com.google.android.material.bottomnavigation.BottomNavigationView
|
||||
import com.google.android.material.navigation.NavigationView
|
||||
import eu.qsfera.android.R
|
||||
import eu.qsfera.android.domain.capabilities.model.OCCapability
|
||||
import eu.qsfera.android.domain.files.model.FileListOption
|
||||
import eu.qsfera.android.domain.user.model.UserQuota
|
||||
import eu.qsfera.android.domain.user.model.UserQuotaState
|
||||
import eu.qsfera.android.domain.utils.Event
|
||||
import eu.qsfera.android.extensions.collectLatestLifecycleFlow
|
||||
import eu.qsfera.android.extensions.goToUrl
|
||||
import eu.qsfera.android.extensions.openPrivacyPolicy
|
||||
import eu.qsfera.android.extensions.sendEmailOrOpenFeedbackDialogAction
|
||||
import eu.qsfera.android.extensions.setAccessibilityRole
|
||||
import eu.qsfera.android.lib.common.QSferaAccount
|
||||
import eu.qsfera.android.presentation.authentication.AccountUtils
|
||||
import eu.qsfera.android.presentation.avatar.AvatarUtils
|
||||
import eu.qsfera.android.presentation.capabilities.CapabilityViewModel
|
||||
import eu.qsfera.android.presentation.cloud.CloudHomeActivity
|
||||
import eu.qsfera.android.presentation.cloud.CloudSection
|
||||
import eu.qsfera.android.presentation.common.DrawerViewModel
|
||||
import eu.qsfera.android.presentation.common.UIResult
|
||||
import eu.qsfera.android.presentation.settings.SettingsActivity
|
||||
import eu.qsfera.android.utils.DisplayUtils
|
||||
import eu.qsfera.android.utils.PreferenceUtils
|
||||
import org.koin.androidx.viewmodel.ext.android.viewModel
|
||||
import org.koin.core.parameter.parametersOf
|
||||
import timber.log.Timber
|
||||
|
||||
import androidx.lifecycle.lifecycleScope
|
||||
@@ -91,12 +86,6 @@ import eu.qsfera.android.presentation.thumbnails.ThumbnailsRequester
|
||||
abstract class DrawerActivity : ToolbarActivity() {
|
||||
|
||||
private val drawerViewModel by viewModel<DrawerViewModel>()
|
||||
private val capabilitiesViewModel by viewModel<CapabilityViewModel> {
|
||||
parametersOf(
|
||||
account?.name
|
||||
)
|
||||
}
|
||||
|
||||
private var currentAccountAvatarRadiusDimension = 0f
|
||||
|
||||
private var drawerToggle: ActionBarDrawerToggle? = null
|
||||
@@ -218,46 +207,25 @@ abstract class DrawerActivity : ToolbarActivity() {
|
||||
open fun setupNavigationBottomBar(menuItemId: Int) {
|
||||
// Allow or disallow touches with other visible windows
|
||||
getBottomNavigationView()?.filterTouchesWhenObscured = PreferenceUtils.shouldDisallowTouchesWithOtherVisibleWindows(this)
|
||||
if (account != null) {
|
||||
capabilitiesViewModel.capabilities.observe(this) { event: Event<UIResult<OCCapability>> ->
|
||||
setSpacesVisibilityBottomBar(event.peekContent())
|
||||
}
|
||||
}
|
||||
setCheckedItemAtBottomBar(menuItemId)
|
||||
getBottomNavigationView()?.setOnNavigationItemSelectedListener { menuItem: MenuItem ->
|
||||
bottomBarNavigationTo(menuItem.itemId, getBottomNavigationView()?.selectedItemId == menuItem.itemId)
|
||||
bottomBarNavigationTo(menuItem.itemId)
|
||||
true
|
||||
}
|
||||
}
|
||||
|
||||
private fun setSpacesVisibilityBottomBar(uiResult: UIResult<OCCapability>) {
|
||||
if (uiResult is UIResult.Success) {
|
||||
val capabilities = uiResult.data
|
||||
if (AccountUtils.isSpacesFeatureAllowedForAccount(baseContext, account, capabilities)) {
|
||||
getBottomNavigationView()?.menu?.get(0)?.title = getString(R.string.bottom_nav_personal)
|
||||
getBottomNavigationView()?.menu?.get(1)?.title = getString(R.string.bottom_nav_shares)
|
||||
getBottomNavigationView()?.menu?.get(1)?.icon = AppCompatResources.getDrawable(this, R.drawable.ic_server_shares)
|
||||
getBottomNavigationView()?.menu?.get(2)?.isVisible = capabilities?.isSpacesProjectsAllowed() == true
|
||||
} else {
|
||||
getBottomNavigationView()?.menu?.get(0)?.title = getString(R.string.bottom_nav_files)
|
||||
getBottomNavigationView()?.menu?.get(2)?.isVisible = false
|
||||
}
|
||||
private fun bottomBarNavigationTo(menuItemId: Int) {
|
||||
when (menuItemId) {
|
||||
R.id.nav_feed -> openCloudSection(CloudSection.FEED)
|
||||
R.id.nav_all_files -> navigateToOption(FileListOption.ALL_FILES)
|
||||
R.id.nav_photos -> openCloudSection(CloudSection.PHOTOS)
|
||||
R.id.nav_albums -> openCloudSection(CloudSection.ALBUMS)
|
||||
R.id.nav_more -> openCloudSection(CloudSection.MORE)
|
||||
}
|
||||
}
|
||||
|
||||
private fun bottomBarNavigationTo(menuItemId: Int, isCurrentOptionActive: Boolean) {
|
||||
when (menuItemId) {
|
||||
R.id.nav_all_files -> navigateToOption(FileListOption.ALL_FILES)
|
||||
R.id.nav_spaces -> navigateToOption(FileListOption.SPACES_LIST)
|
||||
R.id.nav_uploads -> if (!isCurrentOptionActive) {
|
||||
val uploadListIntent = Intent(applicationContext, UploadListActivity::class.java)
|
||||
uploadListIntent.addFlags(Intent.FLAG_ACTIVITY_CLEAR_TOP)
|
||||
startActivity(uploadListIntent)
|
||||
}
|
||||
|
||||
R.id.nav_available_offline_files -> navigateToOption(FileListOption.AV_OFFLINE)
|
||||
R.id.nav_shared_by_link_files -> navigateToOption(FileListOption.SHARED_BY_LINK)
|
||||
}
|
||||
private fun openCloudSection(section: CloudSection) {
|
||||
startActivity(CloudHomeActivity.createIntent(this, section))
|
||||
}
|
||||
|
||||
private fun openHelp() {
|
||||
|
||||
+2
-6
@@ -1839,12 +1839,8 @@ class FileDisplayActivity : FileActivity(),
|
||||
navigateTo(fileListOption)
|
||||
}
|
||||
|
||||
private fun getMenuItemForFileListOption(fileListOption: FileListOption?): Int = when (fileListOption) {
|
||||
FileListOption.SPACES_LIST -> R.id.nav_spaces
|
||||
FileListOption.SHARED_BY_LINK -> R.id.nav_shared_by_link_files
|
||||
FileListOption.AV_OFFLINE -> R.id.nav_available_offline_files
|
||||
else -> R.id.nav_all_files
|
||||
}
|
||||
private fun getMenuItemForFileListOption(fileListOption: FileListOption?): Int =
|
||||
if (fileListOption == FileListOption.ALL_FILES) R.id.nav_all_files else R.id.nav_more
|
||||
|
||||
override fun optionLockSelected(type: LockType) {
|
||||
manageOptionLockSelected(type)
|
||||
|
||||
@@ -20,7 +20,6 @@
|
||||
|
||||
package eu.qsfera.android.ui.activity
|
||||
|
||||
import android.content.Intent
|
||||
import android.os.Bundle
|
||||
import androidx.appcompat.app.AppCompatActivity
|
||||
import eu.qsfera.android.BuildConfig
|
||||
@@ -30,6 +29,8 @@ import eu.qsfera.android.data.providers.implementation.OCSharedPreferencesProvid
|
||||
import eu.qsfera.android.presentation.security.LockTimeout
|
||||
import eu.qsfera.android.presentation.security.PREFERENCE_LOCK_TIMEOUT
|
||||
import eu.qsfera.android.providers.MdmProvider
|
||||
import eu.qsfera.android.presentation.cloud.CloudHomeActivity
|
||||
import eu.qsfera.android.presentation.cloud.CloudSection
|
||||
import eu.qsfera.android.utils.CONFIGURATION_ALLOW_SCREENSHOTS
|
||||
import eu.qsfera.android.utils.CONFIGURATION_DEVICE_PROTECTION
|
||||
import eu.qsfera.android.utils.CONFIGURATION_LOCK_DELAY_TIME
|
||||
@@ -63,7 +64,7 @@ class SplashActivity : AppCompatActivity() {
|
||||
|
||||
checkLockDelayEnforced(mdmProvider)
|
||||
|
||||
startActivity(Intent(this, FileDisplayActivity::class.java))
|
||||
startActivity(CloudHomeActivity.createIntent(this, CloudSection.FEED))
|
||||
finish()
|
||||
}
|
||||
|
||||
|
||||
@@ -112,7 +112,7 @@ abstract class ToolbarActivity : BaseActivity() {
|
||||
val textSearchView = findViewById<EditText>(androidx.appcompat.R.id.search_src_text)
|
||||
val closeButton = findViewById<ImageView>(androidx.appcompat.R.id.search_close_btn)
|
||||
textSearchView.setHintTextColor(ContextCompat.getColor(applicationContext, R.color.search_view_hint_text))
|
||||
closeButton.setColorFilter(ContextCompat.getColor(applicationContext, R.color.white))
|
||||
closeButton.setColorFilter(ContextCompat.getColor(applicationContext, R.color.qsfera_text_primary))
|
||||
}
|
||||
|
||||
AccountUtils.getCurrentQSferaAccount(baseContext) ?: return
|
||||
@@ -171,7 +171,7 @@ abstract class ToolbarActivity : BaseActivity() {
|
||||
|
||||
searchButton.setBackgroundColor(getColor(R.color.actionbar_start_color))
|
||||
searchText.setHintTextColor(getColor(R.color.search_view_hint_text))
|
||||
closeButton.setColorFilter(getColor(R.color.white))
|
||||
closeButton.setColorFilter(getColor(R.color.qsfera_text_primary))
|
||||
background = getDrawable(R.drawable.rounded_search_view)
|
||||
isFocusable = false
|
||||
}
|
||||
|
||||
+1
-1
@@ -89,7 +89,7 @@ public class UploadListActivity extends FileActivity {
|
||||
setupDrawer();
|
||||
|
||||
// setup navigation bottom bar
|
||||
setupNavigationBottomBar(R.id.nav_uploads);
|
||||
setupNavigationBottomBar(R.id.nav_more);
|
||||
|
||||
// Add fragment with a transaction for setting a tag
|
||||
if (savedInstanceState == null) {
|
||||
|
||||
+140
-75
@@ -23,8 +23,8 @@ package eu.qsfera.android.workers
|
||||
|
||||
import android.content.Context
|
||||
import android.net.Uri
|
||||
import android.provider.DocumentsContract
|
||||
import androidx.core.net.toUri
|
||||
import androidx.documentfile.provider.DocumentFile
|
||||
import androidx.work.CoroutineWorker
|
||||
import androidx.work.ExistingWorkPolicy
|
||||
import androidx.work.WorkManager
|
||||
@@ -41,9 +41,11 @@ import eu.qsfera.android.domain.transfers.model.OCTransfer
|
||||
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.automaticuploads.AutomaticUploadMediaKind
|
||||
import eu.qsfera.android.presentation.settings.automaticuploads.AutomaticUploadMediaSource
|
||||
import eu.qsfera.android.presentation.settings.automaticuploads.PhoneMediaStore
|
||||
import eu.qsfera.android.providers.WorkManagerProvider
|
||||
import eu.qsfera.android.usecases.transfers.uploads.UploadFileFromContentUriUseCase
|
||||
import eu.qsfera.android.utils.MimetypeIconUtil
|
||||
import eu.qsfera.android.utils.NotificationUtils
|
||||
import eu.qsfera.android.utils.UPLOAD_NOTIFICATION_CHANNEL_ID
|
||||
import org.koin.core.component.KoinComponent
|
||||
@@ -90,20 +92,24 @@ class AutomaticUploadsWorker(
|
||||
configuredSourcePaths = cameraUploadsConfiguration.sourcePaths
|
||||
cameraUploadsConfiguration.pictureUploadsConfiguration?.let { pictureUploadsConfiguration ->
|
||||
try {
|
||||
checkSourcePathsAreValidUrisOrThrowException(pictureUploadsConfiguration.sourcePaths)
|
||||
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 {
|
||||
checkSourcePathsAreValidUrisOrThrowException(videoUploadsConfiguration.sourcePaths)
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -130,28 +136,21 @@ class AutomaticUploadsWorker(
|
||||
)
|
||||
}
|
||||
|
||||
@Throws(IllegalArgumentException::class)
|
||||
private fun checkSourcePathsAreValidUrisOrThrowException(sourcePaths: List<String>) {
|
||||
sourcePaths.forEach { sourcePath ->
|
||||
val sourceUri: Uri = sourcePath.toUri()
|
||||
DocumentFile.fromTreeUri(applicationContext, sourceUri)
|
||||
?: throw IllegalArgumentException("Source path is not a valid tree URI: $sourcePath")
|
||||
}
|
||||
}
|
||||
|
||||
private fun cancelWorker() {
|
||||
WorkManager.getInstance(appContext).cancelUniqueWork(AUTOMATIC_UPLOADS_WORKER)
|
||||
}
|
||||
|
||||
private fun syncFolder(folderBackUpConfiguration: FolderBackUpConfiguration?) {
|
||||
if (folderBackUpConfiguration == null) return
|
||||
|
||||
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
|
||||
}
|
||||
val effectiveBehavior = folderBackUpConfiguration.effectiveBehavior
|
||||
|
||||
val currentTimestamp = System.currentTimeMillis()
|
||||
@@ -159,50 +158,72 @@ class AutomaticUploadsWorker(
|
||||
SyncType.PICTURE_UPLOADS -> UploadEnqueuedBy.ENQUEUED_AS_AUTOMATIC_UPLOAD_PICTURE
|
||||
SyncType.VIDEO_UPLOADS -> UploadEnqueuedBy.ENQUEUED_AS_AUTOMATIC_UPLOAD_VIDEO
|
||||
}
|
||||
val completedUploadTimesBySourceUri = if (effectiveBehavior == UploadBehavior.MOVE) {
|
||||
transferRepository.getFinishedTransfers()
|
||||
.asSequence()
|
||||
.filter {
|
||||
it.createdBy == automaticUploadSourceType &&
|
||||
it.accountName == folderBackUpConfiguration.accountName
|
||||
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)
|
||||
}
|
||||
.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()!! }
|
||||
}
|
||||
}.distinct()
|
||||
val discoveryStartTimestamp = if (effectiveBehavior == UploadBehavior.MOVE) {
|
||||
0L
|
||||
} else {
|
||||
emptyMap()
|
||||
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 localPicturesDocumentFiles: List<DocumentFile> = folderBackUpConfiguration.sourcePaths.flatMap { sourcePath ->
|
||||
getFilesReadyToUpload(
|
||||
syncType = syncType,
|
||||
sourcePath = sourcePath,
|
||||
lastSyncTimestamp = folderBackUpConfiguration.lastSyncTimestamp,
|
||||
currentTimestamp = currentTimestamp,
|
||||
completedUploadTimesBySourceUri = completedUploadTimesBySourceUri,
|
||||
)
|
||||
}
|
||||
showNotification(syncType, uploadCandidates.size)
|
||||
|
||||
showNotification(syncType, localPicturesDocumentFiles.size)
|
||||
|
||||
for (documentFile in localPicturesDocumentFiles) {
|
||||
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
|
||||
// file whose lastModified changed (e.g. media scanner) would be re-discovered and
|
||||
// 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 = documentFile.uri.toString()
|
||||
val contentUri = candidate.uri.toString()
|
||||
if (transferRepository.existsNonFailedTransferForUri(contentUri)) {
|
||||
Timber.d("Skipping already-tracked file: %s", documentFile.name)
|
||||
Timber.d("Skipping already-tracked file: %s", candidate.name)
|
||||
continue
|
||||
}
|
||||
val uploadId = storeInUploadsDatabase(
|
||||
documentFile = documentFile,
|
||||
uploadPath = folderBackUpConfiguration.uploadPath.plus(File.separator).plus(documentFile.name),
|
||||
candidate = candidate,
|
||||
uploadPath = folderBackUpConfiguration.uploadPath.plus(File.separator).plus(candidate.name),
|
||||
accountName = folderBackUpConfiguration.accountName,
|
||||
behavior = effectiveBehavior,
|
||||
createdByWorker = when (syncType) {
|
||||
@@ -212,9 +233,9 @@ class AutomaticUploadsWorker(
|
||||
spaceId = folderBackUpConfiguration.spaceId
|
||||
)
|
||||
enqueueSingleUpload(
|
||||
contentUri = documentFile.uri,
|
||||
uploadPath = folderBackUpConfiguration.uploadPath.plus(File.separator).plus(documentFile.name),
|
||||
lastModified = documentFile.lastModified(),
|
||||
contentUri = candidate.uri,
|
||||
uploadPath = folderBackUpConfiguration.uploadPath.plus(File.separator).plus(candidate.name),
|
||||
lastModified = candidate.lastModified,
|
||||
behavior = effectiveBehavior.toString(),
|
||||
accountName = folderBackUpConfiguration.accountName,
|
||||
uploadId = uploadId,
|
||||
@@ -224,8 +245,29 @@ class AutomaticUploadsWorker(
|
||||
}
|
||||
// Save safeTimestamp (not currentTimestamp) so that files skipped by the
|
||||
// write-safety buffer are re-evaluated on the next run instead of being lost.
|
||||
val safeTimestamp = currentTimestamp - WRITE_SAFETY_BUFFER_MS
|
||||
updateTimestamp(folderBackUpConfiguration, syncType, safeTimestamp)
|
||||
if (allScansSuccessful) {
|
||||
val safeTimestamp = currentTimestamp - WRITE_SAFETY_BUFFER_MS
|
||||
updateTimestamp(folderBackUpConfiguration, syncType, safeTimestamp)
|
||||
}
|
||||
}
|
||||
|
||||
private fun completedUploadTimesBySourceUri(
|
||||
configuration: FolderBackUpConfiguration,
|
||||
behavior: UploadBehavior,
|
||||
sourceType: UploadEnqueuedBy,
|
||||
): 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()!! }
|
||||
}
|
||||
|
||||
private fun showNotification(
|
||||
@@ -296,48 +338,48 @@ class AutomaticUploadsWorker(
|
||||
}
|
||||
}
|
||||
|
||||
private fun getFilesReadyToUpload(
|
||||
private fun filterFilesReadyToUpload(
|
||||
syncType: SyncType,
|
||||
sourcePath: String,
|
||||
sourceLabel: String,
|
||||
allFiles: List<AutomaticUploadCandidate>,
|
||||
lastSyncTimestamp: Long,
|
||||
currentTimestamp: Long,
|
||||
completedUploadTimesBySourceUri: Map<String, Long>,
|
||||
): List<DocumentFile> {
|
||||
val sourceUri: Uri = sourcePath.toUri()
|
||||
val documentTree = DocumentFile.fromTreeUri(applicationContext, sourceUri)
|
||||
val arrayOfLocalFiles = documentTree?.listFiles() ?: arrayOf()
|
||||
|
||||
): 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
|
||||
// can result in uploading a truncated or 0-byte JPEG.
|
||||
val safeTimestamp = currentTimestamp - WRITE_SAFETY_BUFFER_MS
|
||||
|
||||
val mediaFiles = arrayOfLocalFiles
|
||||
.sortedBy { it.lastModified() }
|
||||
.filter { MimetypeIconUtil.getBestMimeTypeByFilename(it.name).startsWith(syncType.prefixForType) }
|
||||
val mediaFiles = allFiles
|
||||
.sortedBy { it.lastModified }
|
||||
.filter { it.mimeType.startsWith(syncType.prefixForType) }
|
||||
|
||||
val previouslyUploadedFiles = mediaFiles.filter { documentFile ->
|
||||
val previouslyUploadedFiles = mediaFiles.filter { candidate ->
|
||||
shouldRemovePreviouslyUploadedSource(
|
||||
sourceUri = documentFile.uri.toString(),
|
||||
lastModified = documentFile.lastModified(),
|
||||
sourceUri = candidate.uri.toString(),
|
||||
lastModified = candidate.lastModified,
|
||||
completedUploadTimesBySourceUri = completedUploadTimesBySourceUri,
|
||||
)
|
||||
}
|
||||
previouslyUploadedFiles.forEach { documentFile ->
|
||||
if (!removeSourceDocument(documentFile)) {
|
||||
Timber.w("Uploaded source file could not be removed yet: %s", documentFile.uri)
|
||||
previouslyUploadedFiles.forEach { candidate ->
|
||||
val removed = runCatching { removeSourceUri(applicationContext, candidate.uri) }
|
||||
.onFailure { Timber.w(it, "Uploaded source file could not be removed yet: %s", candidate.uri) }
|
||||
.getOrDefault(false)
|
||||
if (!removed) {
|
||||
Timber.w("Uploaded source file could not be removed yet: %s", candidate.uri)
|
||||
}
|
||||
}
|
||||
val previouslyUploadedUris = previouslyUploadedFiles.mapTo(mutableSetOf()) { it.uri }
|
||||
|
||||
val filteredList: List<DocumentFile> = mediaFiles
|
||||
val filteredList: List<AutomaticUploadCandidate> = mediaFiles
|
||||
.filterNot { it.uri in previouslyUploadedUris }
|
||||
.filter { it.lastModified() >= lastSyncTimestamp }
|
||||
.filter { it.lastModified() < safeTimestamp }
|
||||
.filter { it.lastModified >= lastSyncTimestamp }
|
||||
.filter { it.lastModified < safeTimestamp }
|
||||
|
||||
Timber.i("Last sync ${syncType.name}: ${Date(lastSyncTimestamp)}")
|
||||
Timber.i("CurrentTimestamp ${Date(currentTimestamp)}")
|
||||
Timber.i("${arrayOfLocalFiles.size} files found in folder: ${sourceUri.path}")
|
||||
Timber.i("${allFiles.size} files found in folder: $sourceLabel")
|
||||
Timber.i("${filteredList.size} files are ${syncType.name} and were taken after last sync")
|
||||
|
||||
return filteredList
|
||||
@@ -370,7 +412,7 @@ class AutomaticUploadsWorker(
|
||||
}
|
||||
|
||||
private fun storeInUploadsDatabase(
|
||||
documentFile: DocumentFile,
|
||||
candidate: AutomaticUploadCandidate,
|
||||
uploadPath: String,
|
||||
accountName: String,
|
||||
behavior: UploadBehavior,
|
||||
@@ -378,21 +420,36 @@ class AutomaticUploadsWorker(
|
||||
spaceId: String?,
|
||||
): Long {
|
||||
val ocTransfer = OCTransfer(
|
||||
localPath = documentFile.uri.toString(),
|
||||
localPath = candidate.uri.toString(),
|
||||
remotePath = uploadPath,
|
||||
accountName = accountName,
|
||||
fileSize = documentFile.length(),
|
||||
fileSize = candidate.size,
|
||||
status = TransferStatus.TRANSFER_QUEUED,
|
||||
localBehaviour = behavior,
|
||||
forceOverwrite = false,
|
||||
createdBy = createdByWorker,
|
||||
spaceId = spaceId,
|
||||
sourcePath = documentFile.uri.toString(),
|
||||
sourcePath = candidate.uri.toString(),
|
||||
)
|
||||
|
||||
return 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
|
||||
}
|
||||
|
||||
companion object {
|
||||
const val AUTOMATIC_UPLOADS_WORKER = "AUTOMATIC_UPLOADS_WORKER"
|
||||
const val IMMEDIATE_UPLOADS_WORKER = "IMMEDIATE_AUTOMATIC_UPLOADS_WORKER"
|
||||
@@ -407,6 +464,14 @@ class AutomaticUploadsWorker(
|
||||
}
|
||||
}
|
||||
|
||||
private data class AutomaticUploadCandidate(
|
||||
val uri: Uri,
|
||||
val name: String,
|
||||
val mimeType: String,
|
||||
val size: Long,
|
||||
val lastModified: Long,
|
||||
)
|
||||
|
||||
internal fun shouldRemovePreviouslyUploadedSource(
|
||||
sourceUri: String,
|
||||
lastModified: Long,
|
||||
|
||||
+54
-3
@@ -22,6 +22,8 @@ package eu.qsfera.android.workers
|
||||
|
||||
import android.content.Context
|
||||
import android.net.Uri
|
||||
import android.provider.MediaStore
|
||||
import android.os.Environment
|
||||
import androidx.core.net.toUri
|
||||
import androidx.documentfile.provider.DocumentFile
|
||||
import androidx.work.CoroutineWorker
|
||||
@@ -43,16 +45,18 @@ class RemoveSourceFileWorker(
|
||||
override suspend fun doWork(): Result {
|
||||
if (!areParametersValid()) return Result.failure()
|
||||
return try {
|
||||
val documentFile = DocumentFile.fromSingleUri(appContext, contentUri)
|
||||
if (removeSourceDocument(documentFile)) {
|
||||
if (removeSourceUri(appContext, contentUri)) {
|
||||
Result.success()
|
||||
} else {
|
||||
Timber.w("Source file could not be removed yet: %s", contentUri)
|
||||
Result.retry()
|
||||
}
|
||||
} catch (securityException: SecurityException) {
|
||||
Timber.e(securityException, "Media-management access is required to remove %s", contentUri)
|
||||
Result.failure()
|
||||
} catch (throwable: Throwable) {
|
||||
Timber.e(throwable)
|
||||
Result.retry()
|
||||
if (runAttemptCount >= MAX_RETRY_ATTEMPTS) Result.failure() else Result.retry()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -63,6 +67,53 @@ class RemoveSourceFileWorker(
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
companion object {
|
||||
private const val MAX_RETRY_ATTEMPTS = 4
|
||||
}
|
||||
}
|
||||
|
||||
internal fun removeSourceUri(context: Context, uri: Uri): Boolean {
|
||||
if (uri.scheme == "content" && uri.authority == MediaStore.AUTHORITY) {
|
||||
val removedRows = try {
|
||||
context.contentResolver.delete(uri, null, null)
|
||||
} catch (securityException: SecurityException) {
|
||||
if (android.os.Build.VERSION.SDK_INT < android.os.Build.VERSION_CODES.R || !Environment.isExternalStorageManager()) {
|
||||
throw securityException
|
||||
}
|
||||
if (!removeMediaStoreFileByPath(context, uri)) throw securityException
|
||||
1
|
||||
}
|
||||
if (removedRows > 0) return true
|
||||
|
||||
return context.contentResolver.query(
|
||||
uri,
|
||||
arrayOf(MediaStore.MediaColumns._ID),
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
)?.use { cursor -> !cursor.moveToFirst() } != false
|
||||
}
|
||||
|
||||
return removeSourceDocument(DocumentFile.fromSingleUri(context, uri))
|
||||
}
|
||||
|
||||
@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 file = java.io.File(path)
|
||||
val removed = !file.exists() || file.delete()
|
||||
if (removed) context.contentResolver.notifyChange(uri, null)
|
||||
return removed
|
||||
}
|
||||
|
||||
internal fun removeSourceDocument(documentFile: DocumentFile?): Boolean {
|
||||
|
||||
+37
-5
@@ -27,6 +27,8 @@ import android.app.Notification
|
||||
import android.content.Context
|
||||
import android.content.pm.ServiceInfo
|
||||
import android.net.Uri
|
||||
import android.provider.MediaStore
|
||||
import android.util.Log
|
||||
import androidx.core.net.toUri
|
||||
import androidx.documentfile.provider.DocumentFile
|
||||
import androidx.work.CoroutineWorker
|
||||
@@ -95,6 +97,10 @@ class UploadFileFromContentUriWorker(
|
||||
private lateinit var ocTransfer: OCTransfer
|
||||
private var spaceWebDavUrl: String? = null
|
||||
|
||||
private val transferRepository: TransferRepository by inject()
|
||||
private val getWebdavUrlForSpaceUseCase: GetWebDavUrlForSpaceUseCase by inject()
|
||||
private val getStoredCapabilitiesUseCase: GetStoredCapabilitiesUseCase by inject()
|
||||
|
||||
private lateinit var uploadFileOperation: UploadFileFromFileSystemOperation
|
||||
private val tusUploadHelper by lazy { TusUploadHelper(transferRepository) }
|
||||
|
||||
@@ -104,10 +110,6 @@ class UploadFileFromContentUriWorker(
|
||||
private var currentForegroundProgress = -1
|
||||
private val foregroundScope = CoroutineScope(Dispatchers.IO)
|
||||
|
||||
private val transferRepository: TransferRepository by inject()
|
||||
private val getWebdavUrlForSpaceUseCase: GetWebDavUrlForSpaceUseCase by inject()
|
||||
private val getStoredCapabilitiesUseCase: GetStoredCapabilitiesUseCase by inject()
|
||||
|
||||
override suspend fun doWork(): Result = try {
|
||||
prepareFile()
|
||||
startForeground()
|
||||
@@ -119,6 +121,10 @@ class UploadFileFromContentUriWorker(
|
||||
Result.success()
|
||||
}catch (throwable: Throwable) {
|
||||
Timber.e(throwable)
|
||||
Log.w(
|
||||
"QSferaUpload",
|
||||
"Content upload failed: ${throwable.javaClass.simpleName}; root=${throwable.rootCauseClassName()}",
|
||||
)
|
||||
|
||||
if (shouldRetry(throwable)) {
|
||||
Timber.i("Retrying upload %d after transient failure", uploadIdInStorageManager)
|
||||
@@ -190,6 +196,18 @@ class UploadFileFromContentUriWorker(
|
||||
}
|
||||
|
||||
private fun checkDocumentFileExists() {
|
||||
if (contentUri.authority == MediaStore.AUTHORITY) {
|
||||
val exists = appContext.contentResolver.query(
|
||||
contentUri,
|
||||
arrayOf(MediaStore.MediaColumns._ID),
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
)?.use { cursor -> cursor.moveToFirst() } == true
|
||||
if (!exists) throw LocalFileNotFoundException()
|
||||
return
|
||||
}
|
||||
|
||||
val documentFile = DocumentFile.fromSingleUri(appContext, contentUri)
|
||||
if (documentFile?.exists() != true && documentFile?.isFile != true) {
|
||||
// File does not exists anymore. Throw an exception to tell the user
|
||||
@@ -198,6 +216,14 @@ class UploadFileFromContentUriWorker(
|
||||
}
|
||||
|
||||
private fun checkPermissionsToReadDocumentAreGranted() {
|
||||
if (contentUri.authority == MediaStore.AUTHORITY) {
|
||||
val canRead = runCatching {
|
||||
appContext.contentResolver.openFileDescriptor(contentUri, "r")?.use { true } == true
|
||||
}.getOrDefault(false)
|
||||
if (!canRead) throw LocalFileNotFoundException()
|
||||
return
|
||||
}
|
||||
|
||||
val documentFile = DocumentFile.fromSingleUri(appContext, contentUri)
|
||||
if (documentFile?.canRead() != true) {
|
||||
// Permissions not granted. Throw an exception to ask for them.
|
||||
@@ -261,7 +287,7 @@ class UploadFileFromContentUriWorker(
|
||||
)
|
||||
|
||||
private fun checkParentFolderExistence(client: QSferaClient) {
|
||||
var pathToGrant: String = File(uploadPath).parent ?: ""
|
||||
var pathToGrant: String = File(uploadPath).parent.orEmpty()
|
||||
pathToGrant = if (pathToGrant.endsWith(File.separator)) pathToGrant else pathToGrant + File.separator
|
||||
|
||||
val checkPathExistenceOperation =
|
||||
@@ -579,3 +605,9 @@ class UploadFileFromContentUriWorker(
|
||||
const val KEY_PARAM_UPLOAD_ID = "KEY_PARAM_UPLOAD_ID"
|
||||
}
|
||||
}
|
||||
|
||||
private fun Throwable.rootCauseClassName(): String {
|
||||
var root = this
|
||||
while (root.cause != null && root.cause !== root) root = root.cause!!
|
||||
return root.javaClass.simpleName
|
||||
}
|
||||
|
||||
@@ -0,0 +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" />
|
||||
</selector>
|
||||
@@ -0,0 +1,6 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<selector xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<item android:color="#1F246BFD" android:state_pressed="true" />
|
||||
<item android:color="#14246BFD" android:state_focused="true" />
|
||||
<item android:color="#00000000" />
|
||||
</selector>
|
||||
@@ -0,0 +1,13 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<layer-list xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<item android:top="1dp">
|
||||
<shape android:shape="rectangle">
|
||||
<solid android:color="@color/white" />
|
||||
</shape>
|
||||
</item>
|
||||
<item android:height="1dp" android:gravity="top">
|
||||
<shape android:shape="rectangle">
|
||||
<solid android:color="#E6E8EC" />
|
||||
</shape>
|
||||
</item>
|
||||
</layer-list>
|
||||
@@ -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_muted" />
|
||||
<corners android:radius="20dp" />
|
||||
</shape>
|
||||
@@ -0,0 +1,8 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<shape xmlns:android="http://schemas.android.com/apk/res/android" android:shape="rectangle">
|
||||
<gradient
|
||||
android:angle="315"
|
||||
android:endColor="#E7ECF8"
|
||||
android:startColor="#F4F6FB" />
|
||||
<corners android:radius="4dp" />
|
||||
</shape>
|
||||
@@ -0,0 +1,17 @@
|
||||
<?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="M6,5L18,5A2,2 0,0 1,20 7L20,17A2,2 0,0 1,18 19L6,19A2,2 0,0 1,4 17L4,7A2,2 0,0 1,6 5ZM7,2.75L17,2.75M7,21.25L17,21.25M5,16L8.5,12.5A1.4,1.4 0,0 1,10.5 12.5L12.5,14.5L14.2,12.8A1.4,1.4 0,0 1,16.2 12.8L20,16.6"
|
||||
android:strokeColor="#FF000000"
|
||||
android:strokeLineCap="round"
|
||||
android:strokeLineJoin="round"
|
||||
android:strokeWidth="1.8" />
|
||||
<path
|
||||
android:fillColor="#FF000000"
|
||||
android:pathData="M8,8A1.25,1.25 0,1 0,8 10.5A1.25,1.25 0,1 0,8 8Z" />
|
||||
</vector>
|
||||
@@ -0,0 +1,17 @@
|
||||
<?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="M5,4.5L19,4.5A1.5,1.5 0,0 1,20.5 6L20.5,9A1.5,1.5 0,0 1,19 10.5L5,10.5A1.5,1.5 0,0 1,3.5 9L3.5,6A1.5,1.5 0,0 1,5 4.5ZM5,13.5L19,13.5A1.5,1.5 0,0 1,20.5 15L20.5,18A1.5,1.5 0,0 1,19 19.5L5,19.5A1.5,1.5 0,0 1,3.5 18L3.5,15A1.5,1.5 0,0 1,5 13.5Z"
|
||||
android:strokeColor="#FF000000"
|
||||
android:strokeLineCap="round"
|
||||
android:strokeLineJoin="round"
|
||||
android:strokeWidth="1.8" />
|
||||
<path
|
||||
android:fillColor="#FF000000"
|
||||
android:pathData="M7,6.5A1,1 0,1 0,7 8.5A1,1 0,1 0,7 6.5ZM7,15.5A1,1 0,1 0,7 17.5A1,1 0,1 0,7 15.5Z" />
|
||||
</vector>
|
||||
@@ -0,0 +1,14 @@
|
||||
<?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="M3.5,7.5L3.5,18A2,2 0,0 0,5.5 20L18.5,20A2,2 0,0 0,20.5 18L20.5,8.5A2,2 0,0 0,18.5 6.5L12,6.5L10.2,4.5L5.5,4.5A2,2 0,0 0,3.5 6.5Z"
|
||||
android:strokeColor="#FF000000"
|
||||
android:strokeLineCap="round"
|
||||
android:strokeLineJoin="round"
|
||||
android:strokeWidth="1.8" />
|
||||
</vector>
|
||||
@@ -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="#FF000000"
|
||||
android:pathData="M5,10.25A1.75,1.75 0,1 0,5 13.75A1.75,1.75 0,1 0,5 10.25ZM12,10.25A1.75,1.75 0,1 0,12 13.75A1.75,1.75 0,1 0,12 10.25ZM19,10.25A1.75,1.75 0,1 0,19 13.75A1.75,1.75 0,1 0,19 10.25Z" />
|
||||
</vector>
|
||||
@@ -0,0 +1,17 @@
|
||||
<?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="M5,4L19,4A2,2 0,0 1,21 6L21,18A2,2 0,0 1,19 20L5,20A2,2 0,0 1,3 18L3,6A2,2 0,0 1,5 4ZM4,17L8.4,12.6A1.5,1.5 0,0 1,10.5 12.6L12.8,14.9L15.2,12.5A1.5,1.5 0,0 1,17.3 12.5L21,16.2"
|
||||
android:strokeColor="#FF000000"
|
||||
android:strokeLineCap="round"
|
||||
android:strokeLineJoin="round"
|
||||
android:strokeWidth="1.8" />
|
||||
<path
|
||||
android:fillColor="#FF000000"
|
||||
android:pathData="M8,7A1.5,1.5 0,1 0,8 10A1.5,1.5 0,1 0,8 7Z" />
|
||||
</vector>
|
||||
@@ -0,0 +1,10 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="64dp"
|
||||
android:height="64dp"
|
||||
android:viewportWidth="24"
|
||||
android:viewportHeight="24">
|
||||
<path
|
||||
android:fillColor="@color/qsfera_folder"
|
||||
android:pathData="M3,5.25C3,4.56 3.56,4 4.25,4h5.1c0.4,0 0.77,0.19 1,0.5L11.5,6H19.75C20.44,6 21,6.56 21,7.25v10.5C21,18.44 20.44,19 19.75,19H4.25C3.56,19 3,18.44 3,17.75z" />
|
||||
</vector>
|
||||
@@ -0,0 +1,48 @@
|
||||
<?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="@android:color/black">
|
||||
|
||||
<androidx.appcompat.widget.Toolbar
|
||||
android:id="@+id/cloud_preview_toolbar"
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="?android:actionBarSize"
|
||||
android:background="#CC000000"
|
||||
android:theme="@style/ThemeOverlay.AppCompat.Dark.ActionBar"
|
||||
app:layout_constraintEnd_toEndOf="parent"
|
||||
app:layout_constraintStart_toStartOf="parent"
|
||||
app:layout_constraintTop_toTopOf="parent" />
|
||||
|
||||
<com.github.chrisbanes.photoview.PhotoView
|
||||
android:id="@+id/cloud_preview_photo"
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="0dp"
|
||||
android:contentDescription="@null"
|
||||
android:visibility="gone"
|
||||
app:layout_constraintBottom_toBottomOf="parent"
|
||||
app:layout_constraintEnd_toEndOf="parent"
|
||||
app:layout_constraintStart_toStartOf="parent"
|
||||
app:layout_constraintTop_toBottomOf="@id/cloud_preview_toolbar" />
|
||||
|
||||
<androidx.media3.ui.PlayerView
|
||||
android:id="@+id/cloud_preview_player"
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="0dp"
|
||||
android:visibility="gone"
|
||||
app:layout_constraintBottom_toBottomOf="parent"
|
||||
app:layout_constraintEnd_toEndOf="parent"
|
||||
app:layout_constraintStart_toStartOf="parent"
|
||||
app:layout_constraintTop_toBottomOf="@id/cloud_preview_toolbar" />
|
||||
|
||||
<ProgressBar
|
||||
android:id="@+id/cloud_preview_progress"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:indeterminateTint="@color/white"
|
||||
app:layout_constraintBottom_toBottomOf="parent"
|
||||
app:layout_constraintEnd_toEndOf="parent"
|
||||
app:layout_constraintStart_toStartOf="parent"
|
||||
app:layout_constraintTop_toTopOf="parent" />
|
||||
</androidx.constraintlayout.widget.ConstraintLayout>
|
||||
@@ -0,0 +1,17 @@
|
||||
<?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"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="match_parent"
|
||||
android:background="@color/qsfera_surface">
|
||||
|
||||
<androidx.recyclerview.widget.RecyclerView
|
||||
android:id="@+id/cloud_list"
|
||||
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>
|
||||
@@ -0,0 +1,50 @@
|
||||
<?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_marginHorizontal="4dp"
|
||||
android:layout_marginVertical="4dp"
|
||||
android:background="@drawable/cloud_card_background"
|
||||
android:foreground="?attr/selectableItemBackground"
|
||||
android:gravity="center_vertical"
|
||||
android:minHeight="76dp"
|
||||
android:orientation="horizontal"
|
||||
android:paddingHorizontal="16dp"
|
||||
android:paddingVertical="12dp">
|
||||
|
||||
<ImageView
|
||||
android:id="@+id/cloud_action_icon"
|
||||
android:layout_width="28dp"
|
||||
android:layout_height="28dp"
|
||||
android:contentDescription="@null" />
|
||||
|
||||
<LinearLayout
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginStart="16dp"
|
||||
android:layout_weight="1"
|
||||
android:orientation="vertical">
|
||||
|
||||
<TextView
|
||||
android:id="@+id/cloud_action_title"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:textColor="@color/qsfera_text_primary"
|
||||
android:textSize="17sp"
|
||||
android:textStyle="bold" />
|
||||
|
||||
<TextView
|
||||
android:id="@+id/cloud_action_summary"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginTop="3dp"
|
||||
android:textColor="@color/qsfera_text_secondary"
|
||||
android:textSize="14sp" />
|
||||
</LinearLayout>
|
||||
|
||||
<ImageView
|
||||
android:layout_width="22dp"
|
||||
android:layout_height="22dp"
|
||||
android:contentDescription="@null"
|
||||
android:src="@drawable/ic_arrow_forward" />
|
||||
</LinearLayout>
|
||||
@@ -0,0 +1,36 @@
|
||||
<?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_margin="6dp"
|
||||
android:background="@drawable/cloud_card_background"
|
||||
android:foreground="?attr/selectableItemBackground"
|
||||
android:orientation="vertical"
|
||||
android:padding="16dp">
|
||||
|
||||
<ImageView
|
||||
android:id="@+id/cloud_album_cover"
|
||||
android:layout_width="64dp"
|
||||
android:layout_height="64dp"
|
||||
android:contentDescription="@null"
|
||||
android:src="@drawable/ic_qsfera_folder" />
|
||||
|
||||
<TextView
|
||||
android:id="@+id/cloud_album_title"
|
||||
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" />
|
||||
|
||||
<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>
|
||||
@@ -0,0 +1,12 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<TextView xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:id="@+id/cloud_header_text"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:paddingStart="4dp"
|
||||
android:paddingTop="20dp"
|
||||
android:paddingEnd="4dp"
|
||||
android:paddingBottom="10dp"
|
||||
android:textColor="@color/qsfera_text_primary"
|
||||
android:textSize="20sp"
|
||||
android:textStyle="bold" />
|
||||
@@ -0,0 +1,27 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<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:background="@drawable/cloud_media_placeholder"
|
||||
android:clipToOutline="true"
|
||||
android:foreground="?attr/selectableItemBackgroundBorderless">
|
||||
|
||||
<ImageView
|
||||
android:id="@+id/cloud_media_image"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="match_parent"
|
||||
android:contentDescription="@null"
|
||||
android:scaleType="centerCrop" />
|
||||
|
||||
<ImageView
|
||||
android:id="@+id/cloud_media_video"
|
||||
android:layout_width="28dp"
|
||||
android:layout_height="28dp"
|
||||
android:layout_gravity="bottom|end"
|
||||
android:layout_margin="8dp"
|
||||
android:background="@drawable/cloud_card_background"
|
||||
android:padding="5dp"
|
||||
android:src="@drawable/ic_play_arrow"
|
||||
android:visibility="gone" />
|
||||
</eu.qsfera.android.presentation.security.passcode.SquareFrameLayout>
|
||||
@@ -0,0 +1,35 @@
|
||||
<?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:gravity="center"
|
||||
android:minHeight="260dp"
|
||||
android:orientation="vertical"
|
||||
android:padding="32dp">
|
||||
|
||||
<ImageView
|
||||
android:id="@+id/cloud_status_icon"
|
||||
android:layout_width="72dp"
|
||||
android:layout_height="72dp"
|
||||
android:contentDescription="@null"
|
||||
android:src="@drawable/ic_qsfera_folder" />
|
||||
|
||||
<TextView
|
||||
android:id="@+id/cloud_status_title"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginTop="20dp"
|
||||
android:gravity="center"
|
||||
android:textColor="@color/qsfera_text_primary"
|
||||
android:textSize="20sp"
|
||||
android:textStyle="bold" />
|
||||
|
||||
<TextView
|
||||
android:id="@+id/cloud_status_summary"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginTop="8dp"
|
||||
android:gravity="center"
|
||||
android:textColor="@color/qsfera_text_secondary"
|
||||
android:textSize="15sp" />
|
||||
</LinearLayout>
|
||||
@@ -46,22 +46,24 @@
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="@dimen/bottom_navigation_bar_height"
|
||||
android:layout_gravity="bottom"
|
||||
android:background="@color/actionbar_start_color"
|
||||
android:background="@drawable/bg_bottom_navigation"
|
||||
android:visibility="visible"
|
||||
app:itemIconTint="@color/primary_button_text_color"
|
||||
app:itemTextColor="@color/primary_button_text_color"
|
||||
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/bottom_nav_view_spacer"
|
||||
app:layout_constraintEnd_toEndOf="parent"
|
||||
app:layout_constraintStart_toStartOf="parent"
|
||||
app:elevation="0dp"
|
||||
app:elevation="8dp"
|
||||
app:menu="@menu/bottom_navbar_menu" />
|
||||
|
||||
<FrameLayout
|
||||
android:id="@+id/bottom_nav_view_spacer"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="0dp"
|
||||
android:background="@color/actionbar_start_color"
|
||||
android:background="@color/white"
|
||||
app:layout_constraintBottom_toBottomOf="parent"
|
||||
app:layout_constraintEnd_toEndOf="parent"
|
||||
app:layout_constraintStart_toStartOf="parent" />
|
||||
|
||||
@@ -18,6 +18,7 @@
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:theme="@style/qsfera.Appbar"
|
||||
android:background="@color/qsfera_surface"
|
||||
android:focusableInTouchMode="true">
|
||||
|
||||
<androidx.constraintlayout.widget.ConstraintLayout
|
||||
@@ -33,6 +34,7 @@
|
||||
android:padding="@dimen/standard_half_padding"
|
||||
android:src="@drawable/ic_drawer_icon"
|
||||
android:contentDescription="@string/content_description_menu"
|
||||
app:tint="@color/qsfera_text_primary"
|
||||
app:layout_constraintBottom_toBottomOf="parent"
|
||||
app:layout_constraintStart_toStartOf="parent"
|
||||
app:layout_constraintTop_toTopOf="parent" />
|
||||
@@ -62,11 +64,12 @@
|
||||
android:gravity="center_vertical"
|
||||
android:maxLines="1"
|
||||
android:paddingHorizontal="@dimen/standard_half_padding"
|
||||
android:textColor="@color/white"
|
||||
android:textColor="@color/qsfera_text_primary"
|
||||
android:textStyle="bold"
|
||||
android:textSize="@dimen/toolbar_title_text_size"
|
||||
android:contentDescription="@string/content_description_search"
|
||||
app:drawableEndCompat="@drawable/ic_search"
|
||||
app:drawableTint="@color/white"
|
||||
app:drawableTint="@color/qsfera_text_primary"
|
||||
app:layout_constraintBottom_toBottomOf="parent"
|
||||
app:layout_constraintEnd_toStartOf="@id/root_toolbar_avatar"
|
||||
app:layout_constraintStart_toEndOf="@id/root_toolbar_left_icon"
|
||||
@@ -90,7 +93,7 @@
|
||||
android:id="@+id/standard_toolbar"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="match_parent"
|
||||
android:background="?attr/colorPrimary"
|
||||
android:background="@color/qsfera_surface"
|
||||
android:theme="@style/qsfera.Appbar"
|
||||
android:visibility="gone"
|
||||
android:layout_marginEnd="@dimen/standard_margin"
|
||||
|
||||
@@ -17,26 +17,24 @@
|
||||
along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
-->
|
||||
<menu xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<item
|
||||
android:id="@+id/nav_feed"
|
||||
android:icon="@drawable/ic_bottom_nav_feed"
|
||||
android:title="@string/bottom_nav_feed" />
|
||||
<item
|
||||
android:id="@+id/nav_all_files"
|
||||
android:icon="@drawable/ic_folder"
|
||||
android:icon="@drawable/ic_bottom_nav_files"
|
||||
android:title="@string/bottom_nav_files" />
|
||||
<item
|
||||
android:id="@+id/nav_shared_by_link_files"
|
||||
android:icon="@drawable/ic_shared_by_link"
|
||||
android:title="@string/bottom_nav_links" />
|
||||
android:id="@+id/nav_photos"
|
||||
android:icon="@drawable/ic_bottom_nav_photos"
|
||||
android:title="@string/bottom_nav_photos" />
|
||||
<item
|
||||
android:id="@+id/nav_spaces"
|
||||
android:icon="@drawable/ic_spaces"
|
||||
android:title="@string/bottom_nav_spaces"
|
||||
android:visible="false"
|
||||
/>
|
||||
android:id="@+id/nav_albums"
|
||||
android:icon="@drawable/ic_bottom_nav_albums"
|
||||
android:title="@string/bottom_nav_albums" />
|
||||
<item
|
||||
android:id="@+id/nav_uploads"
|
||||
android:icon="@drawable/ic_uploads"
|
||||
android:title="@string/bottom_nav_uploads" />
|
||||
<item
|
||||
android:id="@+id/nav_available_offline_files"
|
||||
android:icon="@drawable/ic_available_offline"
|
||||
android:title="@string/bottom_nav_offline" />
|
||||
android:id="@+id/nav_more"
|
||||
android:icon="@drawable/ic_bottom_nav_more"
|
||||
android:title="@string/bottom_nav_more" />
|
||||
</menu>
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources>
|
||||
<string name="cloud_feed_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_photos_empty_title">Фотографий пока нет</string>
|
||||
<string name="cloud_photos_empty_summary">Загрузите фото или включите автозагрузку в настройках.</string>
|
||||
<string name="cloud_albums_empty_title">Альбомов пока нет</string>
|
||||
<string name="cloud_albums_empty_summary">Альбомы создаются из папок, в которых есть фото и видео.</string>
|
||||
<string name="cloud_media_loading">Загружаем ваше облако…</string>
|
||||
<string name="cloud_media_load_error">Не удалось загрузить медиафайлы</string>
|
||||
<string name="cloud_media_retry">Нажмите, чтобы повторить</string>
|
||||
<string name="cloud_recent_uploads">Недавние загрузки</string>
|
||||
<string name="cloud_all_photos">Все фото</string>
|
||||
<string name="cloud_album_items">Объектов: %1$d</string>
|
||||
<string name="cloud_more_transfers">Загрузки</string>
|
||||
<string name="cloud_more_transfers_summary">Текущие и завершённые передачи</string>
|
||||
<string name="cloud_more_offline">Офлайн</string>
|
||||
<string name="cloud_more_offline_summary">Файлы, доступные без сети</string>
|
||||
<string name="cloud_more_shares">Общие ссылки</string>
|
||||
<string name="cloud_more_shares_summary">Файлы, которыми вы поделились</string>
|
||||
<string name="cloud_more_spaces">Пространства</string>
|
||||
<string name="cloud_more_spaces_summary">Личное и проектное хранилище</string>
|
||||
<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>
|
||||
</resources>
|
||||
@@ -139,7 +139,11 @@
|
||||
<string name="pattern_configure_your_pattern_explanation">Графический ключ будет запрашиваться каждый раз при запуске приложения</string>
|
||||
<string name="document_provider_locked">Заблокировано</string>
|
||||
<string name="illegal_argument_exception_message">Допустимое ДЕЙСТВИЕ требуется для передачи в намерениях</string>
|
||||
<string name="bottom_nav_feed">Лента</string>
|
||||
<string name="bottom_nav_files">Файлы</string>
|
||||
<string name="bottom_nav_photos">Фото</string>
|
||||
<string name="bottom_nav_albums">Альбомы</string>
|
||||
<string name="bottom_nav_more">Ещё</string>
|
||||
<string name="bottom_nav_personal">Личный</string>
|
||||
<string name="bottom_nav_uploads">Загрузки</string>
|
||||
<string name="bottom_nav_offline">Оффлайн</string>
|
||||
@@ -555,10 +559,31 @@
|
||||
<string name="file_list__footer__files_and_folders">%1$d файлов, %2$d каталогов</string>
|
||||
<string name="prefs_picture_upload_account">Учётная запись для закачки изображений</string>
|
||||
<string name="prefs_video_upload_account">Учётная запись для закачки видео</string>
|
||||
<string name="prefs_camera_upload_source_path_title">Каталог камеры (%1$s)</string>
|
||||
<string name="prefs_camera_upload_source_path_title_required">обязательно</string>
|
||||
<string name="prefs_camera_upload_source_path_title">Папки</string>
|
||||
<string name="prefs_camera_upload_source_path_title_required">камера включена автоматически</string>
|
||||
<string name="prefs_camera_upload_source_paths_clear_title">Очистить выбранные папки</string>
|
||||
<string name="prefs_camera_upload_source_paths_empty">Папки не выбраны</string>
|
||||
<string name="prefs_automatic_uploads_source_permission_error">Выберите папку с разрешением на чтение и удаление файлов</string>
|
||||
<string name="automatic_upload_folders_title">Папки на телефоне</string>
|
||||
<string name="automatic_upload_folders_camera_description">Фото и видео из папки «Камера» загружаются автоматически. Загрузку из остальных папок можно настроить ниже.</string>
|
||||
<string name="automatic_upload_folders_other">Остальные папки</string>
|
||||
<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_folders_item_count">Медиафайлов: %1$d</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_both_missing">Нужны два разрешения: читать фото и удалять успешно загруженный оригинал.</string>
|
||||
<string name="automatic_upload_camera_and_folders_summary">Камера — автоматически%1$s</string>
|
||||
<string name="automatic_upload_selected_folders_suffix">; выбрано других папок: %1$d</string>
|
||||
<string name="automatic_upload_enable_message">Папка «Камера» отслеживается автоматически. Другие папки выбираются в разделе «Папки». Оригинал удаляется только после подтверждённой сервером успешной загрузки.</string>
|
||||
<string name="automatic_upload_mobile_data">Использовать мобильный интернет</string>
|
||||
<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="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>
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources>
|
||||
<string name="cloud_feed_title">Feed</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_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>
|
||||
<string name="cloud_albums_empty_summary">Albums are created from folders that contain photos and videos.</string>
|
||||
<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_recent_uploads">Recent uploads</string>
|
||||
<string name="cloud_all_photos">All photos</string>
|
||||
<string name="cloud_album_items">%1$d items</string>
|
||||
<string name="cloud_more_transfers">Transfers</string>
|
||||
<string name="cloud_more_transfers_summary">Current and completed uploads</string>
|
||||
<string name="cloud_more_offline">Offline</string>
|
||||
<string name="cloud_more_offline_summary">Files available without a network</string>
|
||||
<string name="cloud_more_shares">Shared links</string>
|
||||
<string name="cloud_more_shares_summary">Files shared by link</string>
|
||||
<string name="cloud_more_spaces">Spaces</string>
|
||||
<string name="cloud_more_spaces_summary">Personal and project storage</string>
|
||||
<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>
|
||||
</resources>
|
||||
@@ -20,14 +20,22 @@
|
||||
<resources>
|
||||
|
||||
<!-- standard material color definitions -->
|
||||
<color name="primary">@color/qsfera_petrol</color>
|
||||
<color name="primary_dark">#19353F</color>
|
||||
<color name="color_accent">@color/qsfera_petrol_accent</color>
|
||||
<color name="primary">@color/qsfera_blue</color>
|
||||
<color name="primary_dark">#2F61C7</color>
|
||||
<color name="color_accent">@color/qsfera_blue</color>
|
||||
|
||||
<!-- Colors -->
|
||||
<color name="qsfera_petrol">#21434F</color>
|
||||
<color name="qsfera_petrol_accent">#396676</color>
|
||||
<color name="qsfera_blue_bright">#00ddff</color>
|
||||
<color name="qsfera_blue">#4B7BEC</color>
|
||||
<color name="qsfera_blue_pressed">#3566D1</color>
|
||||
<color name="qsfera_surface">#FFFFFF</color>
|
||||
<color name="qsfera_surface_muted">#F3F4F6</color>
|
||||
<color name="qsfera_text_primary">#17181A</color>
|
||||
<color name="qsfera_text_secondary">#6D7178</color>
|
||||
<color name="qsfera_divider">#E8E9EC</color>
|
||||
<color name="qsfera_folder">#F4C95D</color>
|
||||
<color name="warning_grey_text">#525757</color>
|
||||
<color name="list_item_lastmod_and_filesize_text">#707575</color>
|
||||
<color name="search_view_hint_text">#BDBDBD</color>
|
||||
|
||||
@@ -38,15 +38,15 @@
|
||||
<color name="login_credentials_text_color">@color/white</color>
|
||||
<color name="login_button_background_color">@color/white</color>
|
||||
<color name="login_button_text_color">@color/color_accent</color>
|
||||
<color name="background_color">#FFFFFF</color>
|
||||
<color name="actionbar_start_color">@color/primary</color>
|
||||
<color name="background_color">@color/qsfera_surface</color>
|
||||
<color name="actionbar_start_color">@color/qsfera_surface</color>
|
||||
<color name="primary_button_background_color">@color/color_accent</color>
|
||||
<color name="primary_button_text_color">@color/white</color>
|
||||
<color name="secondary_button_background_color">#D6D7D7</color>
|
||||
<color name="secondary_button_text_color">@color/black</color>
|
||||
<color name="drawer_header_color">@color/qsfera_petrol_accent</color>
|
||||
<color name="spaces_card_background_color">#edf3fa</color>
|
||||
<color name="search_view_background_color">@color/actionbar_start_color</color>
|
||||
<color name="search_view_background_color">@color/qsfera_surface_muted</color>
|
||||
|
||||
<!-- Splash Screen Background -->
|
||||
<color name="splash_background">@color/qsfera_petrol</color>
|
||||
|
||||
@@ -142,7 +142,11 @@
|
||||
<string name="illegal_argument_exception_message">A valid ACTION is needed in the intent passed to</string>
|
||||
|
||||
<!-- Bottom navigation bar -->
|
||||
<string name="bottom_nav_feed">Feed</string>
|
||||
<string name="bottom_nav_files">Files</string>
|
||||
<string name="bottom_nav_photos">Photos</string>
|
||||
<string name="bottom_nav_albums">Albums</string>
|
||||
<string name="bottom_nav_more">More</string>
|
||||
<string name="bottom_nav_personal">Personal</string>
|
||||
<string name="bottom_nav_uploads">Uploads</string>
|
||||
<string name="bottom_nav_offline">Offline</string>
|
||||
@@ -593,10 +597,31 @@
|
||||
<string name="file_list__footer__files_and_folders">%1$d files, %2$d folders</string>
|
||||
<string name="prefs_picture_upload_account">Account to upload pictures</string>
|
||||
<string name="prefs_video_upload_account">Account to upload videos</string>
|
||||
<string name="prefs_camera_upload_source_path_title">Camera folder (%1$s)</string>
|
||||
<string name="prefs_camera_upload_source_path_title_required">required</string>
|
||||
<string name="prefs_camera_upload_source_path_title">Folders</string>
|
||||
<string name="prefs_camera_upload_source_path_title_required">Camera is included automatically</string>
|
||||
<string name="prefs_camera_upload_source_paths_clear_title">Clear selected folders</string>
|
||||
<string name="prefs_camera_upload_source_paths_empty">No folders selected</string>
|
||||
<string name="prefs_automatic_uploads_source_permission_error">Select a folder that grants both read and write access</string>
|
||||
<string name="automatic_upload_folders_title">Folders on phone</string>
|
||||
<string name="automatic_upload_folders_camera_description">Photos and videos from the Camera folder are uploaded automatically. Uploads from other folders can be enabled below.</string>
|
||||
<string name="automatic_upload_folders_other">Other folders</string>
|
||||
<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_folders_item_count">%1$d media files</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_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>
|
||||
<string name="automatic_upload_enable_message">The Camera folder is monitored automatically. Other folders can be selected under “Folders”. An original is removed only after the server confirms a successful upload.</string>
|
||||
<string name="automatic_upload_mobile_data">Use mobile data</string>
|
||||
<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="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>
|
||||
|
||||
@@ -107,13 +107,13 @@
|
||||
overrides colorPrimary to allow customization of the app bar color,
|
||||
independent of Material Design guidelines, if desired
|
||||
-->
|
||||
<style name="qsfera.Appbar" parent="ThemeOverlay.AppCompat.Dark.ActionBar">
|
||||
<style name="qsfera.Appbar" parent="ThemeOverlay.AppCompat.Light">
|
||||
<item name="colorPrimary">@color/actionbar_start_color</item>
|
||||
<item name="toolbarNavigationButtonStyle">@style/Toolbar.Button.Navigation.Tinted</item>
|
||||
</style>
|
||||
|
||||
<style name="Toolbar.Button.Navigation.Tinted" parent="Widget.AppCompat.Toolbar.Button.Navigation">
|
||||
<item name="tint">@color/white</item>
|
||||
<item name="tint">@color/qsfera_text_primary</item>
|
||||
</style>
|
||||
|
||||
<!-- Transparent style for the app bar -->
|
||||
|
||||
@@ -25,45 +25,54 @@
|
||||
app:key="enable_picture_uploads"
|
||||
app:summary="@string/prefs_camera_picture_upload_summary"
|
||||
app:title="@string/prefs_camera_picture_upload" />
|
||||
<ListPreference
|
||||
app:dialogTitle="@string/prefs_picture_upload_account"
|
||||
app:iconSpaceReserved="false"
|
||||
app:key="picture_uploads_account_name"
|
||||
app:negativeButtonText=""
|
||||
app:title="@string/prefs_picture_upload_account"
|
||||
app:useSimpleSummaryProvider="true" />
|
||||
<Preference
|
||||
app:iconSpaceReserved="false"
|
||||
app:key="picture_uploads_path"
|
||||
app:title="@string/prefs_camera_picture_upload_path_title" />
|
||||
app:key="automatic_picture_uploads_permissions"
|
||||
app:title="@string/automatic_upload_permission_title" />
|
||||
<Preference
|
||||
app:iconSpaceReserved="false"
|
||||
app:key="picture_uploads_source_path"
|
||||
app:title="@string/prefs_camera_upload_source_path_title" />
|
||||
<Preference
|
||||
app:iconSpaceReserved="false"
|
||||
app:key="picture_uploads_clear_source_paths"
|
||||
app:title="@string/prefs_camera_upload_source_paths_clear_title" />
|
||||
<Preference
|
||||
app:iconSpaceReserved="false"
|
||||
app:selectable="false"
|
||||
app:summary="@string/pref_behaviour_entries_remove_original_file"
|
||||
app:title="@string/prefs_camera_upload_behaviour_title" />
|
||||
<Preference
|
||||
app:iconSpaceReserved="false"
|
||||
app:key="picture_uploads_last_sync"
|
||||
app:title="@string/prefs_camera_upload_last_sync_title" />
|
||||
|
||||
<eu.qsfera.android.presentation.settings.LargePreferenceCategory
|
||||
android:title="@string/prefs_camera_picture_upload_conditions_title"
|
||||
android:title="@string/automatic_upload_destination_category"
|
||||
app:iconSpaceReserved="false">
|
||||
<ListPreference
|
||||
app:dialogTitle="@string/prefs_picture_upload_account"
|
||||
app:iconSpaceReserved="false"
|
||||
app:key="picture_uploads_account_name"
|
||||
app:negativeButtonText=""
|
||||
app:title="@string/prefs_picture_upload_account"
|
||||
app:useSimpleSummaryProvider="true" />
|
||||
<Preference
|
||||
app:iconSpaceReserved="false"
|
||||
app:key="picture_uploads_path"
|
||||
app:title="@string/prefs_camera_picture_upload_path_title" />
|
||||
</eu.qsfera.android.presentation.settings.LargePreferenceCategory>
|
||||
|
||||
<eu.qsfera.android.presentation.settings.LargePreferenceCategory
|
||||
android:title="@string/automatic_upload_options_category"
|
||||
app:iconSpaceReserved="false"
|
||||
app:summary="@string/prefs_camera_picture_upload_conditions_summary">
|
||||
<CheckBoxPreference
|
||||
<SwitchPreferenceCompat
|
||||
app:defaultValue="true"
|
||||
app:iconSpaceReserved="false"
|
||||
app:key="picture_uploads_on_wifi"
|
||||
app:title="@string/prefs_camera_picture_upload_on_wifi" />
|
||||
app:summary="@string/automatic_upload_mobile_data_summary"
|
||||
app:title="@string/automatic_upload_mobile_data" />
|
||||
<CheckBoxPreference
|
||||
app:iconSpaceReserved="false"
|
||||
app:key="picture_uploads_on_charging"
|
||||
app:title="@string/prefs_camera_picture_upload_on_charging" />
|
||||
</eu.qsfera.android.presentation.settings.LargePreferenceCategory>
|
||||
|
||||
<Preference
|
||||
app:iconSpaceReserved="false"
|
||||
app:key="picture_uploads_last_sync"
|
||||
app:title="@string/prefs_camera_upload_last_sync_title" />
|
||||
</PreferenceScreen>
|
||||
|
||||
@@ -23,25 +23,14 @@
|
||||
app:key="enable_video_uploads"
|
||||
app:summary="@string/prefs_camera_video_upload_summary"
|
||||
app:title="@string/prefs_camera_video_upload" />
|
||||
<ListPreference
|
||||
app:dialogTitle="@string/prefs_video_upload_account"
|
||||
app:iconSpaceReserved="false"
|
||||
app:key="video_uploads_account_name"
|
||||
app:negativeButtonText=""
|
||||
app:title="@string/prefs_video_upload_account"
|
||||
app:useSimpleSummaryProvider="true" />
|
||||
<Preference
|
||||
app:iconSpaceReserved="false"
|
||||
app:key="video_uploads_path"
|
||||
app:title="@string/prefs_camera_video_upload_path_title" />
|
||||
app:key="automatic_video_uploads_permissions"
|
||||
app:title="@string/automatic_upload_permission_title" />
|
||||
<Preference
|
||||
app:iconSpaceReserved="false"
|
||||
app:key="video_uploads_source_path"
|
||||
app:title="@string/prefs_camera_upload_source_path_title" />
|
||||
<Preference
|
||||
app:iconSpaceReserved="false"
|
||||
app:key="video_uploads_clear_source_paths"
|
||||
app:title="@string/prefs_camera_upload_source_paths_clear_title" />
|
||||
<ListPreference
|
||||
app:defaultValue="NOTHING"
|
||||
app:dialogTitle="@string/prefs_camera_upload_behaviour_dialog_title"
|
||||
@@ -50,21 +39,41 @@
|
||||
app:negativeButtonText=""
|
||||
app:title="@string/prefs_camera_upload_behaviour_title"
|
||||
app:useSimpleSummaryProvider="true" />
|
||||
<Preference
|
||||
app:iconSpaceReserved="false"
|
||||
app:key="video_uploads_last_sync"
|
||||
app:title="@string/prefs_camera_upload_last_sync_title" />
|
||||
|
||||
<eu.qsfera.android.presentation.settings.LargePreferenceCategory
|
||||
android:title="@string/prefs_camera_picture_upload_conditions_title"
|
||||
android:title="@string/automatic_upload_destination_category"
|
||||
app:iconSpaceReserved="false">
|
||||
<ListPreference
|
||||
app:dialogTitle="@string/prefs_video_upload_account"
|
||||
app:iconSpaceReserved="false"
|
||||
app:key="video_uploads_account_name"
|
||||
app:negativeButtonText=""
|
||||
app:title="@string/prefs_video_upload_account"
|
||||
app:useSimpleSummaryProvider="true" />
|
||||
<Preference
|
||||
app:iconSpaceReserved="false"
|
||||
app:key="video_uploads_path"
|
||||
app:title="@string/prefs_camera_video_upload_path_title" />
|
||||
</eu.qsfera.android.presentation.settings.LargePreferenceCategory>
|
||||
|
||||
<eu.qsfera.android.presentation.settings.LargePreferenceCategory
|
||||
android:title="@string/automatic_upload_options_category"
|
||||
app:iconSpaceReserved="false"
|
||||
app:summary="@string/prefs_camera_picture_upload_conditions_summary">
|
||||
<CheckBoxPreference
|
||||
<SwitchPreferenceCompat
|
||||
app:defaultValue="true"
|
||||
app:iconSpaceReserved="false"
|
||||
app:key="video_uploads_on_wifi"
|
||||
app:title="@string/prefs_camera_video_upload_on_wifi" />
|
||||
app:summary="@string/automatic_upload_mobile_data_summary"
|
||||
app:title="@string/automatic_upload_mobile_data" />
|
||||
<CheckBoxPreference
|
||||
app:iconSpaceReserved="false"
|
||||
app:key="video_uploads_on_charging"
|
||||
app:title="@string/prefs_camera_video_upload_on_charging" />
|
||||
</eu.qsfera.android.presentation.settings.LargePreferenceCategory>
|
||||
|
||||
<Preference
|
||||
app:iconSpaceReserved="false"
|
||||
app:key="video_uploads_last_sync"
|
||||
app:title="@string/prefs_camera_upload_last_sync_title" />
|
||||
</PreferenceScreen>
|
||||
|
||||
+40
@@ -0,0 +1,40 @@
|
||||
/**
|
||||
* 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.assertNull
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
|
||||
class AutomaticUploadMediaSourceTest {
|
||||
|
||||
@Test
|
||||
fun `source survives storage round trip`() {
|
||||
val source = AutomaticUploadMediaSource.create(
|
||||
AutomaticUploadMediaKind.IMAGE,
|
||||
"Pictures/Семья",
|
||||
)
|
||||
|
||||
assertEquals(source, AutomaticUploadMediaSource.parse(source.encodedValue))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `camera path is normalized and matched without case sensitivity`() {
|
||||
assertEquals(
|
||||
AutomaticUploadMediaSource.CAMERA_RELATIVE_PATH,
|
||||
AutomaticUploadMediaSource.normalizeRelativePath("/DCIM\\Camera"),
|
||||
)
|
||||
assertTrue(AutomaticUploadMediaSource.isCameraPath("dcim/camera"))
|
||||
assertFalse(AutomaticUploadMediaSource.isCameraPath("Pictures/Camera"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `unrelated configuration value is not parsed as media source`() {
|
||||
assertNull(AutomaticUploadMediaSource.parse("content://tree/primary"))
|
||||
}
|
||||
}
|
||||
+33
@@ -0,0 +1,33 @@
|
||||
/* qsfera Android Library is available under MIT license
|
||||
* Copyright (C) 2026 QSfera contributors.
|
||||
*/
|
||||
|
||||
package eu.qsfera.android.lib.common.http.methods.webdav
|
||||
|
||||
import eu.qsfera.android.lib.common.http.methods.nonwebdav.HttpMethod
|
||||
import okhttp3.MediaType.Companion.toMediaType
|
||||
import okhttp3.RequestBody.Companion.toRequestBody
|
||||
import java.net.URL
|
||||
|
||||
/**
|
||||
* OkHttp wrapper for WebDAV REPORT requests with an XML body.
|
||||
*/
|
||||
class ReportMethod(
|
||||
url: URL,
|
||||
reportBody: String,
|
||||
) : HttpMethod(url) {
|
||||
|
||||
init {
|
||||
request = request.newBuilder()
|
||||
.method(METHOD_REPORT, reportBody.toRequestBody(XML_MEDIA_TYPE))
|
||||
.header(HEADER_ACCEPT, XML_MEDIA_TYPE_VALUE)
|
||||
.build()
|
||||
}
|
||||
|
||||
private companion object {
|
||||
const val METHOD_REPORT = "REPORT"
|
||||
const val HEADER_ACCEPT = "Accept"
|
||||
const val XML_MEDIA_TYPE_VALUE = "application/xml; charset=utf-8"
|
||||
val XML_MEDIA_TYPE = XML_MEDIA_TYPE_VALUE.toMediaType()
|
||||
}
|
||||
}
|
||||
+27
@@ -0,0 +1,27 @@
|
||||
/* qsfera Android Library is available under MIT license
|
||||
* Copyright (C) 2026 QSfera contributors.
|
||||
*/
|
||||
|
||||
package eu.qsfera.android.lib.resources.files.search
|
||||
|
||||
/** Builds the XML body expected by QSfera's `search-files` REPORT endpoint. */
|
||||
object MediaSearchReportBody {
|
||||
|
||||
fun build(request: MediaSearchRequest): String = """
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<oc:search-files xmlns:d="DAV:" xmlns:oc="http://owncloud.org/ns">
|
||||
<oc:search>
|
||||
<oc:pattern>${request.toKqlPattern()}</oc:pattern>
|
||||
<oc:limit>${request.limit}</oc:limit>
|
||||
<oc:offset>${request.offset}</oc:offset>
|
||||
</oc:search>
|
||||
<d:prop>
|
||||
<oc:name/>
|
||||
<d:getcontenttype/>
|
||||
<d:getcontentlength/>
|
||||
<d:getlastmodified/>
|
||||
<d:getetag/>
|
||||
</d:prop>
|
||||
</oc:search-files>
|
||||
""".trimIndent()
|
||||
}
|
||||
+35
@@ -0,0 +1,35 @@
|
||||
/* qsfera Android Library is available under MIT license
|
||||
* Copyright (C) 2026 QSfera contributors.
|
||||
*/
|
||||
|
||||
package eu.qsfera.android.lib.resources.files.search
|
||||
|
||||
/**
|
||||
* Parameters for a server-side media search.
|
||||
*
|
||||
* A deterministic enum order is used when the KQL expression is generated, so
|
||||
* callers can pass any [Set] implementation without affecting the request body.
|
||||
*/
|
||||
data class MediaSearchRequest(
|
||||
val mediaTypes: Set<MediaSearchType> = MediaSearchType.values().toSet(),
|
||||
val limit: Int = DEFAULT_LIMIT,
|
||||
val offset: Int = 0,
|
||||
) {
|
||||
init {
|
||||
require(mediaTypes.isNotEmpty()) { "At least one media type is required" }
|
||||
require(limit in 1..MAX_LIMIT) { "Limit must be between 1 and $MAX_LIMIT" }
|
||||
require(offset >= 0) { "Offset must not be negative" }
|
||||
}
|
||||
|
||||
internal fun toKqlPattern(): String =
|
||||
MediaSearchType.values()
|
||||
.filter(mediaTypes::contains)
|
||||
.joinToString(separator = " OR ") { mediaType ->
|
||||
"mediatype:${mediaType.queryValue}"
|
||||
}
|
||||
|
||||
companion object {
|
||||
const val DEFAULT_LIMIT = 200
|
||||
const val MAX_LIMIT = 1_000
|
||||
}
|
||||
}
|
||||
+210
@@ -0,0 +1,210 @@
|
||||
/* qsfera Android Library is available under MIT license
|
||||
* Copyright (C) 2026 QSfera contributors.
|
||||
*/
|
||||
|
||||
package eu.qsfera.android.lib.resources.files.search
|
||||
|
||||
import org.xmlpull.v1.XmlPullParser
|
||||
import org.xmlpull.v1.XmlPullParserException
|
||||
import org.xmlpull.v1.XmlPullParserFactory
|
||||
import java.io.FilterInputStream
|
||||
import java.io.IOException
|
||||
import java.io.InputStream
|
||||
import java.net.URI
|
||||
import java.net.URLDecoder
|
||||
import java.nio.charset.StandardCharsets
|
||||
import java.time.ZonedDateTime
|
||||
import java.time.format.DateTimeFormatter
|
||||
|
||||
/**
|
||||
* Streaming parser for WebDAV Multi-Status media-search responses.
|
||||
*
|
||||
* DTD processing is disabled and rejected, the response byte count is capped,
|
||||
* and only successful propstats contribute metadata to a result.
|
||||
*/
|
||||
class MediaSearchResponseParser(
|
||||
private val maximumResponseBytes: Long = DEFAULT_MAXIMUM_RESPONSE_BYTES,
|
||||
private val maximumResults: Int = DEFAULT_MAXIMUM_RESULTS,
|
||||
) {
|
||||
|
||||
@Throws(IOException::class, XmlPullParserException::class)
|
||||
fun parse(inputStream: InputStream): List<RemoteMediaFile> {
|
||||
require(maximumResponseBytes > 0) { "Maximum response size must be positive" }
|
||||
require(maximumResults > 0) { "Maximum result count must be positive" }
|
||||
|
||||
val parser = XmlPullParserFactory.newInstance().newPullParser().apply {
|
||||
setFeature(XmlPullParser.FEATURE_PROCESS_NAMESPACES, true)
|
||||
try {
|
||||
setFeature(XmlPullParser.FEATURE_PROCESS_DOCDECL, false)
|
||||
} catch (_: XmlPullParserException) {
|
||||
// Some implementations do not expose this optional feature.
|
||||
// DOCDECL events are still rejected below.
|
||||
}
|
||||
setInput(LimitedInputStream(inputStream, maximumResponseBytes), null)
|
||||
}
|
||||
|
||||
val results = mutableListOf<RemoteMediaFile>()
|
||||
var response: ResponseDraft? = null
|
||||
var propStat: PropStatDraft? = null
|
||||
var insideProp = false
|
||||
|
||||
var eventType = parser.eventType
|
||||
while (eventType != XmlPullParser.END_DOCUMENT) {
|
||||
when (eventType) {
|
||||
XmlPullParser.DOCDECL -> throw XmlPullParserException("DTD declarations are not allowed")
|
||||
XmlPullParser.START_TAG -> when (parser.name) {
|
||||
TAG_RESPONSE -> response = ResponseDraft()
|
||||
TAG_PROPSTAT -> propStat = PropStatDraft()
|
||||
TAG_PROP -> insideProp = true
|
||||
TAG_HREF -> if (response != null && propStat == null) {
|
||||
response.href = parser.nextText().trim()
|
||||
}
|
||||
TAG_STATUS -> if (propStat != null) {
|
||||
propStat.status = parser.nextText().trim()
|
||||
}
|
||||
TAG_NAME -> if (insideProp && propStat != null && parser.namespace == NAMESPACE_OC) {
|
||||
propStat.name = parser.nextText()
|
||||
}
|
||||
TAG_CONTENT_TYPE -> if (insideProp && propStat != null) {
|
||||
propStat.mimeType = parser.nextText().trim().ifEmpty { null }
|
||||
}
|
||||
TAG_CONTENT_LENGTH -> if (insideProp && propStat != null) {
|
||||
propStat.size = parser.nextText().trim().toLongOrNull()?.takeIf { it >= 0 }
|
||||
}
|
||||
TAG_LAST_MODIFIED -> if (insideProp && propStat != null) {
|
||||
propStat.modifiedTimestamp = parseModifiedTimestamp(parser.nextText().trim())
|
||||
}
|
||||
TAG_ETAG -> if (insideProp && propStat != null) {
|
||||
propStat.etag = parser.nextText().trim().ifEmpty { null }
|
||||
}
|
||||
}
|
||||
XmlPullParser.END_TAG -> when (parser.name) {
|
||||
TAG_PROP -> {
|
||||
insideProp = false
|
||||
}
|
||||
TAG_PROPSTAT -> {
|
||||
response?.propStats?.add(propStat ?: PropStatDraft())
|
||||
propStat = null
|
||||
}
|
||||
TAG_RESPONSE -> {
|
||||
response?.toRemoteMediaFile()?.let { mediaFile ->
|
||||
if (results.size >= maximumResults) {
|
||||
throw IOException("Media search response exceeds $maximumResults results")
|
||||
}
|
||||
results.add(mediaFile)
|
||||
}
|
||||
response = null
|
||||
}
|
||||
}
|
||||
}
|
||||
eventType = parser.next()
|
||||
}
|
||||
return results
|
||||
}
|
||||
|
||||
private fun ResponseDraft.toRemoteMediaFile(): RemoteMediaFile? {
|
||||
val resultHref = href?.takeIf(String::isNotBlank) ?: return null
|
||||
val successfulPropStats = propStats.filter(PropStatDraft::isSuccessful)
|
||||
if (successfulPropStats.isEmpty()) return null
|
||||
|
||||
val decodedPath = decodeHrefPath(resultHref)
|
||||
val resultName = successfulPropStats.firstNotNullOfOrNull(PropStatDraft::name)
|
||||
?.takeIf(String::isNotBlank)
|
||||
?: decodedPath.trimEnd('/').substringAfterLast('/')
|
||||
|
||||
return RemoteMediaFile(
|
||||
href = resultHref,
|
||||
path = decodedPath,
|
||||
name = resultName,
|
||||
mimeType = successfulPropStats.firstNotNullOfOrNull(PropStatDraft::mimeType),
|
||||
size = successfulPropStats.firstNotNullOfOrNull(PropStatDraft::size),
|
||||
modifiedTimestamp = successfulPropStats.firstNotNullOfOrNull(PropStatDraft::modifiedTimestamp),
|
||||
etag = successfulPropStats.firstNotNullOfOrNull(PropStatDraft::etag),
|
||||
)
|
||||
}
|
||||
|
||||
private fun parseModifiedTimestamp(value: String): Long? =
|
||||
runCatching {
|
||||
ZonedDateTime.parse(value, DateTimeFormatter.RFC_1123_DATE_TIME)
|
||||
.toInstant()
|
||||
.toEpochMilli()
|
||||
}.getOrNull()
|
||||
|
||||
private fun decodeHrefPath(href: String): String {
|
||||
val rawPath = runCatching { URI(href).rawPath }
|
||||
.getOrNull()
|
||||
?.takeIf(String::isNotEmpty)
|
||||
?: href.substringBefore('?')
|
||||
|
||||
return runCatching {
|
||||
URLDecoder.decode(
|
||||
rawPath.replace("+", "%2B"),
|
||||
StandardCharsets.UTF_8.name(),
|
||||
)
|
||||
}.getOrDefault(rawPath)
|
||||
}
|
||||
|
||||
private data class ResponseDraft(
|
||||
var href: String? = null,
|
||||
val propStats: MutableList<PropStatDraft> = mutableListOf(),
|
||||
)
|
||||
|
||||
private data class PropStatDraft(
|
||||
var status: String? = null,
|
||||
var name: String? = null,
|
||||
var mimeType: String? = null,
|
||||
var size: Long? = null,
|
||||
var modifiedTimestamp: Long? = null,
|
||||
var etag: String? = null,
|
||||
) {
|
||||
fun isSuccessful(): Boolean = status
|
||||
?.substringAfter(' ', missingDelimiterValue = "")
|
||||
?.substringBefore(' ')
|
||||
?.toIntOrNull()
|
||||
?.let { it in 200..299 }
|
||||
?: false
|
||||
}
|
||||
|
||||
private class LimitedInputStream(
|
||||
inputStream: InputStream,
|
||||
private val maximumBytes: Long,
|
||||
) : FilterInputStream(inputStream) {
|
||||
private var bytesRead = 0L
|
||||
|
||||
override fun read(): Int {
|
||||
val value = super.read()
|
||||
if (value >= 0) incrementAndCheck(1)
|
||||
return value
|
||||
}
|
||||
|
||||
override fun read(buffer: ByteArray, offset: Int, length: Int): Int {
|
||||
val count = super.read(buffer, offset, length)
|
||||
if (count > 0) incrementAndCheck(count.toLong())
|
||||
return count
|
||||
}
|
||||
|
||||
private fun incrementAndCheck(count: Long) {
|
||||
bytesRead += count
|
||||
if (bytesRead > maximumBytes) {
|
||||
throw IOException("Media search response exceeds $maximumBytes bytes")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
companion object {
|
||||
const val DEFAULT_MAXIMUM_RESPONSE_BYTES = 8L * 1024L * 1024L
|
||||
const val DEFAULT_MAXIMUM_RESULTS = 10_000
|
||||
|
||||
private const val NAMESPACE_OC = "http://owncloud.org/ns"
|
||||
private const val TAG_RESPONSE = "response"
|
||||
private const val TAG_PROPSTAT = "propstat"
|
||||
private const val TAG_PROP = "prop"
|
||||
private const val TAG_HREF = "href"
|
||||
private const val TAG_STATUS = "status"
|
||||
private const val TAG_NAME = "name"
|
||||
private const val TAG_CONTENT_TYPE = "getcontenttype"
|
||||
private const val TAG_CONTENT_LENGTH = "getcontentlength"
|
||||
private const val TAG_LAST_MODIFIED = "getlastmodified"
|
||||
private const val TAG_ETAG = "getetag"
|
||||
}
|
||||
}
|
||||
+11
@@ -0,0 +1,11 @@
|
||||
/* qsfera Android Library is available under MIT license
|
||||
* Copyright (C) 2026 QSfera contributors.
|
||||
*/
|
||||
|
||||
package eu.qsfera.android.lib.resources.files.search
|
||||
|
||||
/** Media categories understood by QSfera's KQL `mediatype` search field. */
|
||||
enum class MediaSearchType(internal val queryValue: String) {
|
||||
IMAGE("image"),
|
||||
VIDEO("video"),
|
||||
}
|
||||
+21
@@ -0,0 +1,21 @@
|
||||
/* qsfera Android Library is available under MIT license
|
||||
* Copyright (C) 2026 QSfera contributors.
|
||||
*/
|
||||
|
||||
package eu.qsfera.android.lib.resources.files.search
|
||||
|
||||
/**
|
||||
* A media file returned by the WebDAV search endpoint.
|
||||
*
|
||||
* [href] is kept exactly as returned by the server for subsequent WebDAV calls.
|
||||
* [path] is the URL-decoded path component intended for display and grouping.
|
||||
*/
|
||||
data class RemoteMediaFile(
|
||||
val href: String,
|
||||
val path: String,
|
||||
val name: String,
|
||||
val mimeType: String?,
|
||||
val size: Long?,
|
||||
val modifiedTimestamp: Long?,
|
||||
val etag: String?,
|
||||
)
|
||||
+49
@@ -0,0 +1,49 @@
|
||||
/* qsfera Android Library is available under MIT license
|
||||
* Copyright (C) 2026 QSfera contributors.
|
||||
*/
|
||||
|
||||
package eu.qsfera.android.lib.resources.files.search
|
||||
|
||||
import eu.qsfera.android.lib.common.QSferaClient
|
||||
import eu.qsfera.android.lib.common.http.HttpConstants.HTTP_MULTI_STATUS
|
||||
import eu.qsfera.android.lib.common.http.HttpConstants.HTTP_OK
|
||||
import eu.qsfera.android.lib.common.http.methods.webdav.ReportMethod
|
||||
import eu.qsfera.android.lib.common.operations.RemoteOperation
|
||||
import eu.qsfera.android.lib.common.operations.RemoteOperationResult
|
||||
import eu.qsfera.android.lib.common.utils.isOneOf
|
||||
import timber.log.Timber
|
||||
import java.io.IOException
|
||||
import java.net.URL
|
||||
|
||||
/** Executes a media search against a user's or space's WebDAV endpoint. */
|
||||
class SearchRemoteMediaOperation(
|
||||
private val request: MediaSearchRequest = MediaSearchRequest(),
|
||||
private val webDavUrl: String? = null,
|
||||
private val responseParser: MediaSearchResponseParser = MediaSearchResponseParser(),
|
||||
) : RemoteOperation<List<RemoteMediaFile>>() {
|
||||
|
||||
override fun run(client: QSferaClient): RemoteOperationResult<List<RemoteMediaFile>> {
|
||||
val endpoint = webDavUrl ?: client.userFilesWebDavUri.toString()
|
||||
val reportMethod = ReportMethod(
|
||||
url = URL(endpoint),
|
||||
reportBody = MediaSearchReportBody.build(request),
|
||||
)
|
||||
|
||||
return try {
|
||||
val status = client.executeHttpMethod(reportMethod)
|
||||
if (status.isOneOf(HTTP_OK, HTTP_MULTI_STATUS)) {
|
||||
val responseStream = reportMethod.getResponseBodyAsStream()
|
||||
?: throw IOException("Media search response has no body")
|
||||
val mediaFiles = responseStream.use(responseParser::parse)
|
||||
RemoteOperationResult<List<RemoteMediaFile>>(RemoteOperationResult.ResultCode.OK).apply {
|
||||
data = mediaFiles
|
||||
}
|
||||
} else {
|
||||
RemoteOperationResult(reportMethod)
|
||||
}
|
||||
} catch (exception: Exception) {
|
||||
Timber.e(exception, "Media search REPORT failed")
|
||||
RemoteOperationResult(exception)
|
||||
}
|
||||
}
|
||||
}
|
||||
+17
@@ -0,0 +1,17 @@
|
||||
/* qsfera Android Library is available under MIT license
|
||||
* Copyright (C) 2026 QSfera contributors.
|
||||
*/
|
||||
|
||||
package eu.qsfera.android.lib.resources.files.search.services
|
||||
|
||||
import eu.qsfera.android.lib.common.operations.RemoteOperationResult
|
||||
import eu.qsfera.android.lib.resources.Service
|
||||
import eu.qsfera.android.lib.resources.files.search.MediaSearchRequest
|
||||
import eu.qsfera.android.lib.resources.files.search.RemoteMediaFile
|
||||
|
||||
interface MediaSearchService : Service {
|
||||
fun searchMedia(
|
||||
request: MediaSearchRequest = MediaSearchRequest(),
|
||||
webDavUrl: String? = null,
|
||||
): RemoteOperationResult<List<RemoteMediaFile>>
|
||||
}
|
||||
+23
@@ -0,0 +1,23 @@
|
||||
/* qsfera Android Library is available under MIT license
|
||||
* Copyright (C) 2026 QSfera contributors.
|
||||
*/
|
||||
|
||||
package eu.qsfera.android.lib.resources.files.search.services.implementation
|
||||
|
||||
import eu.qsfera.android.lib.common.QSferaClient
|
||||
import eu.qsfera.android.lib.common.operations.RemoteOperationResult
|
||||
import eu.qsfera.android.lib.resources.files.search.MediaSearchRequest
|
||||
import eu.qsfera.android.lib.resources.files.search.RemoteMediaFile
|
||||
import eu.qsfera.android.lib.resources.files.search.SearchRemoteMediaOperation
|
||||
import eu.qsfera.android.lib.resources.files.search.services.MediaSearchService
|
||||
|
||||
class OCMediaSearchService(override val client: QSferaClient) : MediaSearchService {
|
||||
override fun searchMedia(
|
||||
request: MediaSearchRequest,
|
||||
webDavUrl: String?,
|
||||
): RemoteOperationResult<List<RemoteMediaFile>> =
|
||||
SearchRemoteMediaOperation(
|
||||
request = request,
|
||||
webDavUrl = webDavUrl,
|
||||
).execute(client)
|
||||
}
|
||||
+27
@@ -0,0 +1,27 @@
|
||||
package eu.qsfera.android.lib.common.http.methods.webdav
|
||||
|
||||
import okio.Buffer
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Test
|
||||
import java.net.URL
|
||||
|
||||
class ReportMethodTest {
|
||||
|
||||
@Test
|
||||
fun `creates WebDAV report request with XML body`() {
|
||||
val reportBody = "<oc:search-files/>"
|
||||
|
||||
val method = ReportMethod(
|
||||
url = URL("https://cloud.example.test/remote.php/dav/files/alice"),
|
||||
reportBody = reportBody,
|
||||
)
|
||||
|
||||
val buffer = Buffer()
|
||||
method.request.body?.writeTo(buffer)
|
||||
|
||||
assertEquals("REPORT", method.request.method)
|
||||
assertEquals("application/xml; charset=utf-8", method.request.header("Accept"))
|
||||
assertEquals("application/xml; charset=utf-8", method.request.body?.contentType().toString())
|
||||
assertEquals(reportBody, buffer.readUtf8())
|
||||
}
|
||||
}
|
||||
+52
@@ -0,0 +1,52 @@
|
||||
package eu.qsfera.android.lib.resources.files.search
|
||||
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertFalse
|
||||
import org.junit.Assert.assertThrows
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
|
||||
class MediaSearchReportBodyTest {
|
||||
|
||||
@Test
|
||||
fun `build creates deterministic image and video report`() {
|
||||
val request = MediaSearchRequest(
|
||||
mediaTypes = linkedSetOf(MediaSearchType.VIDEO, MediaSearchType.IMAGE),
|
||||
limit = 75,
|
||||
offset = 150,
|
||||
)
|
||||
|
||||
val body = MediaSearchReportBody.build(request)
|
||||
|
||||
assertTrue(body.startsWith("<?xml version=\"1.0\" encoding=\"utf-8\"?>"))
|
||||
assertTrue(body.contains("<oc:pattern>mediatype:image OR mediatype:video</oc:pattern>"))
|
||||
assertTrue(body.contains("<oc:limit>75</oc:limit>"))
|
||||
assertTrue(body.contains("<oc:offset>150</oc:offset>"))
|
||||
assertTrue(body.contains("<oc:name/>"))
|
||||
assertTrue(body.contains("<d:getcontentlength/>"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `build creates a single type expression without boolean operator`() {
|
||||
val body = MediaSearchReportBody.build(
|
||||
MediaSearchRequest(mediaTypes = setOf(MediaSearchType.IMAGE)),
|
||||
)
|
||||
|
||||
assertTrue(body.contains("<oc:pattern>mediatype:image</oc:pattern>"))
|
||||
assertFalse(body.contains(" OR "))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `request rejects invalid paging and empty media types`() {
|
||||
assertEquals(200, MediaSearchRequest.DEFAULT_LIMIT)
|
||||
assertThrows(IllegalArgumentException::class.java) {
|
||||
MediaSearchRequest(mediaTypes = emptySet())
|
||||
}
|
||||
assertThrows(IllegalArgumentException::class.java) {
|
||||
MediaSearchRequest(limit = 0)
|
||||
}
|
||||
assertThrows(IllegalArgumentException::class.java) {
|
||||
MediaSearchRequest(offset = -1)
|
||||
}
|
||||
}
|
||||
}
|
||||
+130
@@ -0,0 +1,130 @@
|
||||
package eu.qsfera.android.lib.resources.files.search
|
||||
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertNull
|
||||
import org.junit.Assert.assertThrows
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
import org.junit.runner.RunWith
|
||||
import org.robolectric.RobolectricTestRunner
|
||||
import org.robolectric.annotation.Config
|
||||
import java.io.IOException
|
||||
import java.time.ZonedDateTime
|
||||
import java.time.format.DateTimeFormatter
|
||||
|
||||
@RunWith(RobolectricTestRunner::class)
|
||||
@Config(manifest = Config.NONE)
|
||||
class MediaSearchResponseParserTest {
|
||||
|
||||
private val parser = MediaSearchResponseParser()
|
||||
|
||||
@Test
|
||||
fun `parse reads successful WebDAV properties and decodes display path`() {
|
||||
val xml = """
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<d:multistatus xmlns:d="DAV:" xmlns:oc="http://owncloud.org/ns">
|
||||
<d:response>
|
||||
<d:href>/remote.php/dav/spaces/personal/DCIM/%D0%A4%D0%BE%D1%82%D0%BE+2025.jpg</d:href>
|
||||
<d:propstat>
|
||||
<d:prop>
|
||||
<oc:name>Фото+2025.jpg</oc:name>
|
||||
<d:getcontenttype>image/jpeg</d:getcontenttype>
|
||||
<d:getcontentlength>12582912</d:getcontentlength>
|
||||
<d:getlastmodified>Wed, 31 Dec 2025 23:59:59 GMT</d:getlastmodified>
|
||||
<d:getetag>"abc:123"</d:getetag>
|
||||
</d:prop>
|
||||
<d:status>HTTP/1.1 200 OK</d:status>
|
||||
</d:propstat>
|
||||
</d:response>
|
||||
</d:multistatus>
|
||||
""".trimIndent()
|
||||
|
||||
val result = parser.parse(xml.byteInputStream())
|
||||
|
||||
assertEquals(1, result.size)
|
||||
assertEquals(
|
||||
RemoteMediaFile(
|
||||
href = "/remote.php/dav/spaces/personal/DCIM/%D0%A4%D0%BE%D1%82%D0%BE+2025.jpg",
|
||||
path = "/remote.php/dav/spaces/personal/DCIM/Фото+2025.jpg",
|
||||
name = "Фото+2025.jpg",
|
||||
mimeType = "image/jpeg",
|
||||
size = 12_582_912,
|
||||
modifiedTimestamp = ZonedDateTime
|
||||
.parse("Wed, 31 Dec 2025 23:59:59 GMT", DateTimeFormatter.RFC_1123_DATE_TIME)
|
||||
.toInstant()
|
||||
.toEpochMilli(),
|
||||
etag = "\"abc:123\"",
|
||||
),
|
||||
result.single(),
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `parse ignores failed propstats and falls back to href file name`() {
|
||||
val xml = """
|
||||
<d:multistatus xmlns:d="DAV:" xmlns:oc="http://owncloud.org/ns">
|
||||
<d:response>
|
||||
<d:href>/remote.php/dav/spaces/personal/Videos/clip%2001.mp4</d:href>
|
||||
<d:propstat>
|
||||
<d:prop>
|
||||
<d:getcontenttype>application/octet-stream</d:getcontenttype>
|
||||
<d:getcontentlength>999</d:getcontentlength>
|
||||
</d:prop>
|
||||
<d:status>HTTP/1.1 404 Not Found</d:status>
|
||||
</d:propstat>
|
||||
<d:propstat>
|
||||
<d:prop>
|
||||
<d:getcontenttype>video/mp4</d:getcontenttype>
|
||||
<d:getcontentlength>4096</d:getcontentlength>
|
||||
<d:getlastmodified>not-a-date</d:getlastmodified>
|
||||
<d:getetag/>
|
||||
</d:prop>
|
||||
<d:status>HTTP/1.1 200 OK</d:status>
|
||||
</d:propstat>
|
||||
</d:response>
|
||||
<d:response>
|
||||
<d:href>/remote.php/dav/spaces/personal/missing.jpg</d:href>
|
||||
<d:propstat>
|
||||
<d:prop><d:getcontenttype>image/jpeg</d:getcontenttype></d:prop>
|
||||
<d:status>HTTP/1.1 403 Forbidden</d:status>
|
||||
</d:propstat>
|
||||
</d:response>
|
||||
</d:multistatus>
|
||||
""".trimIndent()
|
||||
|
||||
val result = parser.parse(xml.byteInputStream())
|
||||
|
||||
assertEquals(1, result.size)
|
||||
assertEquals("clip 01.mp4", result.single().name)
|
||||
assertEquals("video/mp4", result.single().mimeType)
|
||||
assertEquals(4_096L, result.single().size)
|
||||
assertNull(result.single().modifiedTimestamp)
|
||||
assertNull(result.single().etag)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `parse rejects DTD declarations`() {
|
||||
val xml = """
|
||||
<?xml version="1.0"?>
|
||||
<!DOCTYPE multistatus [<!ENTITY xxe SYSTEM "file:///etc/passwd">]>
|
||||
<d:multistatus xmlns:d="DAV:">
|
||||
<d:response><d:href>&xxe;</d:href></d:response>
|
||||
</d:multistatus>
|
||||
""".trimIndent()
|
||||
|
||||
assertThrows(Exception::class.java) {
|
||||
parser.parse(xml.byteInputStream())
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `parse enforces response byte limit`() {
|
||||
val limitedParser = MediaSearchResponseParser(maximumResponseBytes = 32)
|
||||
|
||||
val exception = assertThrows(Exception::class.java) {
|
||||
limitedParser.parse("<d:multistatus xmlns:d=\"DAV:\"></d:multistatus>".byteInputStream())
|
||||
}
|
||||
|
||||
assertTrue(exception is IOException || exception.cause is IOException)
|
||||
}
|
||||
}
|
||||
+92
@@ -0,0 +1,92 @@
|
||||
package eu.qsfera.android.lib.resources.files.search
|
||||
|
||||
import android.content.Context
|
||||
import android.net.Uri
|
||||
import android.os.Build
|
||||
import androidx.test.core.app.ApplicationProvider
|
||||
import eu.qsfera.android.lib.common.QSferaClient
|
||||
import eu.qsfera.android.lib.common.http.methods.HttpBaseMethod
|
||||
import eu.qsfera.android.lib.common.http.methods.webdav.ReportMethod
|
||||
import okhttp3.MediaType.Companion.toMediaType
|
||||
import okhttp3.Protocol
|
||||
import okhttp3.Response
|
||||
import okhttp3.ResponseBody.Companion.toResponseBody
|
||||
import okio.Buffer
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
import org.junit.runner.RunWith
|
||||
import org.robolectric.RobolectricTestRunner
|
||||
import org.robolectric.annotation.Config
|
||||
|
||||
@RunWith(RobolectricTestRunner::class)
|
||||
@Config(sdk = [Build.VERSION_CODES.O], manifest = Config.NONE)
|
||||
class SearchRemoteMediaOperationTest {
|
||||
|
||||
@Test
|
||||
fun `operation sends paged report and returns parsed media`() {
|
||||
val responseXml = """
|
||||
<d:multistatus xmlns:d="DAV:" xmlns:oc="http://owncloud.org/ns">
|
||||
<d:response>
|
||||
<d:href>/remote.php/dav/spaces/personal/Camera/photo.jpg</d:href>
|
||||
<d:propstat>
|
||||
<d:prop>
|
||||
<oc:name>photo.jpg</oc:name>
|
||||
<d:getcontenttype>image/jpeg</d:getcontenttype>
|
||||
<d:getcontentlength>42</d:getcontentlength>
|
||||
</d:prop>
|
||||
<d:status>HTTP/1.1 200 OK</d:status>
|
||||
</d:propstat>
|
||||
</d:response>
|
||||
</d:multistatus>
|
||||
""".trimIndent()
|
||||
val client = StubQSferaClient(
|
||||
context = ApplicationProvider.getApplicationContext(),
|
||||
responseXml = responseXml,
|
||||
)
|
||||
|
||||
val result = SearchRemoteMediaOperation(
|
||||
request = MediaSearchRequest(
|
||||
mediaTypes = setOf(MediaSearchType.IMAGE),
|
||||
limit = 50,
|
||||
offset = 100,
|
||||
),
|
||||
).execute(client)
|
||||
|
||||
val requestBody = Buffer().also { buffer ->
|
||||
client.capturedMethod.request.body?.writeTo(buffer)
|
||||
}.readUtf8()
|
||||
assertTrue(result.isSuccess)
|
||||
assertEquals(1, result.data?.size)
|
||||
assertEquals("photo.jpg", result.data?.single()?.name)
|
||||
assertEquals("REPORT", client.capturedMethod.request.method)
|
||||
assertEquals("https://cloud.example.test/remote.php/dav/files/", client.capturedMethod.request.url.toString())
|
||||
assertTrue(requestBody.contains("<oc:limit>50</oc:limit>"))
|
||||
assertTrue(requestBody.contains("<oc:offset>100</oc:offset>"))
|
||||
}
|
||||
|
||||
private class StubQSferaClient(
|
||||
context: Context,
|
||||
private val responseXml: String,
|
||||
) : QSferaClient(
|
||||
Uri.parse("https://cloud.example.test"),
|
||||
null,
|
||||
false,
|
||||
null,
|
||||
context,
|
||||
) {
|
||||
lateinit var capturedMethod: ReportMethod
|
||||
|
||||
override fun executeHttpMethod(method: HttpBaseMethod): Int {
|
||||
capturedMethod = method as ReportMethod
|
||||
capturedMethod.response = Response.Builder()
|
||||
.request(capturedMethod.request)
|
||||
.protocol(Protocol.HTTP_1_1)
|
||||
.code(207)
|
||||
.message("Multi-Status")
|
||||
.body(responseXml.toResponseBody("application/xml".toMediaType()))
|
||||
.build()
|
||||
return 207
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -34,6 +34,8 @@ import eu.qsfera.android.lib.resources.appregistry.services.AppRegistryService
|
||||
import eu.qsfera.android.lib.resources.appregistry.services.OCAppRegistryService
|
||||
import eu.qsfera.android.lib.resources.files.services.FileService
|
||||
import eu.qsfera.android.lib.resources.files.services.implementation.OCFileService
|
||||
import eu.qsfera.android.lib.resources.files.search.services.MediaSearchService
|
||||
import eu.qsfera.android.lib.resources.files.search.services.implementation.OCMediaSearchService
|
||||
import eu.qsfera.android.lib.resources.shares.services.ShareService
|
||||
import eu.qsfera.android.lib.resources.shares.services.ShareeService
|
||||
import eu.qsfera.android.lib.resources.shares.services.implementation.OCShareService
|
||||
@@ -138,6 +140,11 @@ class ClientManager(
|
||||
return OCFileService(client = qsferaClient)
|
||||
}
|
||||
|
||||
fun getMediaSearchService(accountName: String? = ""): MediaSearchService {
|
||||
val qsferaClient = getClientForAccount(accountName)
|
||||
return OCMediaSearchService(client = qsferaClient)
|
||||
}
|
||||
|
||||
fun getCapabilityService(accountName: String? = ""): CapabilityService {
|
||||
val qsferaClient = getClientForAccount(accountName)
|
||||
return OCCapabilityService(client = qsferaClient)
|
||||
|
||||
Reference in New Issue
Block a user