Skip to content
Open
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,8 @@ import eu.opencloud.android.data.authentication.datasources.LocalAuthenticationD
import eu.opencloud.android.data.authentication.datasources.implementation.OCLocalAuthenticationDataSource
import eu.opencloud.android.data.capabilities.datasources.LocalCapabilitiesDataSource
import eu.opencloud.android.data.capabilities.datasources.implementation.OCLocalCapabilitiesDataSource
import eu.opencloud.android.data.exportjobs.datasources.LocalExportJobDataSource
import eu.opencloud.android.data.exportjobs.datasources.implementation.OCLocalExportJobDataSource
import eu.opencloud.android.data.files.datasources.LocalFileDataSource
import eu.opencloud.android.data.files.datasources.implementation.OCLocalFileDataSource
import eu.opencloud.android.data.folderbackup.datasources.LocalFolderBackupDataSource
Expand Down Expand Up @@ -59,6 +61,7 @@ val localDataSourceModule = module {

single { OpencloudDatabase.getDatabase(androidContext()).appRegistryDao() }
single { OpencloudDatabase.getDatabase(androidContext()).capabilityDao() }
single { OpencloudDatabase.getDatabase(androidContext()).exportJobDao() }
single { OpencloudDatabase.getDatabase(androidContext()).fileDao() }
single { OpencloudDatabase.getDatabase(androidContext()).folderBackUpDao() }
single { OpencloudDatabase.getDatabase(androidContext()).shareDao() }
Expand All @@ -73,6 +76,7 @@ val localDataSourceModule = module {
factoryOf(::OCLocalFolderBackupDataSource) bind LocalFolderBackupDataSource::class
factoryOf(::OCLocalAppRegistryDataSource) bind LocalAppRegistryDataSource::class
factoryOf(::OCLocalCapabilitiesDataSource) bind LocalCapabilitiesDataSource::class
factoryOf(::OCLocalExportJobDataSource) bind LocalExportJobDataSource::class
factoryOf(::OCLocalFileDataSource) bind LocalFileDataSource::class
factoryOf(::OCLocalShareDataSource) bind LocalShareDataSource::class
factoryOf(::OCLocalSpacesDataSource) bind LocalSpacesDataSource::class
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ package eu.opencloud.android.dependecyinjection
import eu.opencloud.android.data.appregistry.repository.OCAppRegistryRepository
import eu.opencloud.android.data.authentication.repository.OCAuthenticationRepository
import eu.opencloud.android.data.capabilities.repository.OCCapabilityRepository
import eu.opencloud.android.data.exportjobs.repository.OCExportJobRepository
import eu.opencloud.android.data.files.repository.OCFileRepository
import eu.opencloud.android.data.folderbackup.repository.OCFolderBackupRepository
import eu.opencloud.android.data.oauth.repository.OCOAuthRepository
Expand All @@ -40,6 +41,7 @@ import eu.opencloud.android.domain.authentication.AuthenticationRepository
import eu.opencloud.android.domain.authentication.oauth.OAuthRepository
import eu.opencloud.android.domain.automaticuploads.FolderBackupRepository
import eu.opencloud.android.domain.capabilities.CapabilityRepository
import eu.opencloud.android.domain.exportjobs.ExportJobRepository
import eu.opencloud.android.domain.files.FileRepository
import eu.opencloud.android.domain.server.ServerInfoRepository
import eu.opencloud.android.domain.sharing.sharees.ShareeRepository
Expand All @@ -56,6 +58,7 @@ val repositoryModule = module {
factoryOf(::OCAppRegistryRepository) bind AppRegistryRepository::class
factoryOf(::OCAuthenticationRepository) bind AuthenticationRepository::class
factoryOf(::OCCapabilityRepository) bind CapabilityRepository::class
factoryOf(::OCExportJobRepository) bind ExportJobRepository::class
factoryOf(::OCFileRepository) bind FileRepository::class
factoryOf(::OCFolderBackupRepository) bind FolderBackupRepository::class
factoryOf(::OCOAuthRepository) bind OAuthRepository::class
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,7 @@ import eu.opencloud.android.domain.files.usecases.GetFolderContentAsStreamUseCas
import eu.opencloud.android.domain.files.usecases.GetFolderContentUseCase
import eu.opencloud.android.domain.files.usecases.GetFolderImagesUseCase
import eu.opencloud.android.domain.files.usecases.GetPersonalRootFolderForAccountUseCase
import eu.opencloud.android.domain.files.usecases.GetRemoteFolderContentUseCase
import eu.opencloud.android.domain.files.usecases.GetSearchFolderContentUseCase
import eu.opencloud.android.domain.files.usecases.GetSharedByLinkForAccountAsStreamUseCase
import eu.opencloud.android.domain.files.usecases.GetSharesRootFolderForAccount
Expand Down Expand Up @@ -112,6 +113,7 @@ import eu.opencloud.android.domain.user.usecases.RefreshUserQuotaFromServerAsync
import eu.opencloud.android.domain.webfinger.usecases.GetOpenCloudInstanceFromWebFingerUseCase
import eu.opencloud.android.domain.webfinger.usecases.GetOpenCloudInstancesFromAuthenticatedWebFingerUseCase
import eu.opencloud.android.usecases.accounts.RemoveAccountUseCase
import eu.opencloud.android.usecases.files.ExportFilesToDeviceUseCase
import eu.opencloud.android.usecases.files.FilterFileMenuOptionsUseCase
import eu.opencloud.android.usecases.files.RemoveLocalFilesForAccountUseCase
import eu.opencloud.android.usecases.files.RemoveLocallyFilesWithLastUsageOlderThanGivenTimeUseCase
Expand Down Expand Up @@ -165,6 +167,7 @@ val useCaseModule = module {
factoryOf(::CreateFolderAsyncUseCase)
factoryOf(::DisableThumbnailsForFileUseCase)
factoryOf(::FilterFileMenuOptionsUseCase)
factoryOf(::ExportFilesToDeviceUseCase)
factoryOf(::GetFileByIdAsStreamUseCase)
factoryOf(::GetFileByIdUseCase)
factoryOf(::GetFileByRemotePathUseCase)
Expand All @@ -174,6 +177,7 @@ val useCaseModule = module {
factoryOf(::GetFolderImagesUseCase)
factoryOf(::IsAnyFileAvailableLocallyAndNotAvailableOfflineUseCase)
factoryOf(::GetPersonalRootFolderForAccountUseCase)
factoryOf(::GetRemoteFolderContentUseCase)
factoryOf(::GetSearchFolderContentUseCase)
factoryOf(::GetSharedByLinkForAccountAsStreamUseCase)
factoryOf(::GetSharesRootFolderForAccount)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -91,7 +91,7 @@ val viewModelModule = module {
ShareViewModel(filePath, accountName, get(), get(), get(), get(), get(), get(), get(), get(), get(), get())
}
viewModel { (initialFolderToDisplay: OCFile, fileListOption: FileListOption) ->
MainFileListViewModel(get(), get(), get(), get(), get(), get(), get(), get(), get(), get(), get(), get(), get(), get(), get(),
MainFileListViewModel(get(), get(), get(), get(), get(), get(), get(), get(), get(), get(), get(), get(), get(), get(), get(), get(),
initialFolderToDisplay, fileListOption)
}
viewModel { (ocFile: OCFile) -> ConflictsResolveViewModel(get(), get(), get(), get(), get(), ocFile) }
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,7 @@ fun FileMenuOption.toResId() =
FileMenuOption.SHARE -> R.id.action_share_file
FileMenuOption.DETAILS -> R.id.action_see_details
FileMenuOption.SEND -> R.id.action_send_file
FileMenuOption.EXPORT -> R.id.action_export_file
FileMenuOption.SET_AV_OFFLINE -> R.id.action_set_available_offline
FileMenuOption.UNSET_AV_OFFLINE -> R.id.action_unset_available_offline
}
Expand All @@ -57,6 +58,7 @@ fun FileMenuOption.toStringResId() =
FileMenuOption.SHARE -> R.string.action_share
FileMenuOption.DETAILS -> R.string.actionbar_see_details
FileMenuOption.SEND -> R.string.actionbar_send_file
FileMenuOption.EXPORT -> R.string.actionbar_export_file
FileMenuOption.SET_AV_OFFLINE -> R.string.set_available_offline
FileMenuOption.UNSET_AV_OFFLINE -> R.string.unset_available_offline
}
Expand All @@ -76,6 +78,7 @@ fun FileMenuOption.toDrawableResId() =
FileMenuOption.SHARE -> R.drawable.ic_share_generic_white
FileMenuOption.DETAILS -> R.drawable.ic_info_white
FileMenuOption.SEND -> R.drawable.ic_send_white
FileMenuOption.EXPORT -> R.drawable.ic_action_save_to_device
FileMenuOption.SET_AV_OFFLINE -> R.drawable.ic_action_set_available_offline
FileMenuOption.UNSET_AV_OFFLINE -> R.drawable.ic_action_unset_available_offline
}
Original file line number Diff line number Diff line change
Expand Up @@ -192,6 +192,9 @@ class FileDetailsViewModel(
shareViaLinkAllowed = shareViaLinkAllowed,
shareWithUsersAllowed = shareWithUsersAllowed,
sendAllowed = sendAllowed,
// Exporting to a device folder is offered from the file list (single and
// multi-select), not from the details screen.
exportAllowed = false,
)
)
result.apply {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,7 @@ import android.view.WindowManager
import android.widget.ImageView
import android.widget.LinearLayout
import android.widget.TextView
import androidx.activity.result.contract.ActivityResultContracts
import androidx.appcompat.app.AlertDialog
import androidx.appcompat.app.AppCompatActivity
import androidx.appcompat.view.ActionMode
Expand All @@ -54,6 +55,7 @@ import androidx.core.view.isVisible
import androidx.core.widget.doOnTextChanged
import androidx.drawerlayout.widget.DrawerLayout
import androidx.fragment.app.Fragment
import androidx.lifecycle.lifecycleScope
import androidx.recyclerview.widget.RecyclerView
import androidx.recyclerview.widget.StaggeredGridLayoutManager
import coil.load
Expand Down Expand Up @@ -123,6 +125,7 @@ import eu.opencloud.android.utils.DisplayUtils
import eu.opencloud.android.utils.MimetypeIconUtil
import eu.opencloud.android.utils.PreferenceUtils
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import okio.Path.Companion.toPath
import org.koin.androidx.viewmodel.ext.android.sharedViewModel
Expand Down Expand Up @@ -184,6 +187,37 @@ class MainFileListFragment : Fragment(),

private var menu: Menu? = null
private var checkedFiles: List<OCFile> = emptyList()

// The unbounded selection itself lives in Room before the external picker is opened. Only its
// fixed-size job id is kept in Fragment state while the picker outlives this process.
private var pendingExportJobId: Long? = null
private var isPreparingExport = false

private val exportToDeviceFolderLauncher =
registerForActivityResult(ActivityResultContracts.OpenDocumentTree()) { treeUri ->
val exportJobId = pendingExportJobId
clearPendingExport()
if (exportJobId != null) {
if (treeUri == null) {
mainFileListViewModel.discardPendingExport(exportJobId)
} else {
val permissionTaken = runCatching {
requireContext().contentResolver.takePersistableUriPermission(
treeUri,
Intent.FLAG_GRANT_READ_URI_PERMISSION or Intent.FLAG_GRANT_WRITE_URI_PERMISSION
)
}.onFailure {
Timber.e(it, "Could not retain access to the selected export folder")
}.isSuccess

if (permissionTaken) {
mainFileListViewModel.exportFilesToDevice(exportJobId, treeUri.toString())
} else {
mainFileListViewModel.discardPendingExport(exportJobId)
}
}
}
}
private var filesToRemove: List<OCFile> = emptyList()
private var fileSingleFile: OCFile? = null
private var fileOptionsBottomSheetSingleFileLayout: LinearLayout? = null
Expand Down Expand Up @@ -317,6 +351,27 @@ class MainFileListFragment : Fragment(),
}
}

override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
savedInstanceState?.let { savedState ->
pendingExportJobId = if (savedState.containsKey(KEY_PENDING_EXPORT_JOB_ID)) {
savedState.getLong(KEY_PENDING_EXPORT_JOB_ID)
} else {
null
}
// The picker outlives this process, so the restored selection is claimed again before
// anything else of this account can collect it.
pendingExportJobId?.let { mainFileListViewModel.retainPendingExport(it) }
}
}

override fun onSaveInstanceState(outState: Bundle) {
super.onSaveInstanceState(outState)
// The SAF folder picker is another app, this fragment may be recreated while it is shown.
pendingExportJobId?.let { outState.putLong(KEY_PENDING_EXPORT_JOB_ID, it) }
?: outState.remove(KEY_PENDING_EXPORT_JOB_ID)
}

override fun onCreateView(
inflater: LayoutInflater, container: ViewGroup?,
savedInstanceState: Bundle?
Expand Down Expand Up @@ -345,6 +400,42 @@ class MainFileListFragment : Fragment(),
}
}

/**
* Persists what has to be exported and only then asks the user for the destination folder.
* The selection is consumed once the picker returns, see [exportToDeviceFolderLauncher].
*/
private fun startExportToDeviceFolder(files: List<OCFile>) {
val accountName = files.firstOrNull()?.owner
val fileIds = files.mapNotNull { it.id }
if (accountName == null || fileIds.isEmpty()) {
Timber.e("Nothing that could be exported was selected")
return
}
if (pendingExportJobId != null || isPreparingExport) {
Timber.w("An export selection is already waiting for a device folder")
return
}

isPreparingExport = true
viewLifecycleOwner.lifecycleScope.launch {
val exportJobId = try {
mainFileListViewModel.prepareExportToDevice(fileIds, accountName)
} finally {
isPreparingExport = false
}
if (exportJobId == null) {
Timber.e("The export selection could not be persisted")
} else {
pendingExportJobId = exportJobId
exportToDeviceFolderLauncher.launch(null)
}
}
}

private fun clearPendingExport() {
pendingExportJobId = null
}

override fun onCreateOptionsMenu(menu: Menu, inflater: MenuInflater) {
super.onCreateOptionsMenu(menu, inflater)
(menu.findItem(R.id.action_search).actionView as SearchView).run {
Expand Down Expand Up @@ -719,6 +810,10 @@ class MainFileListFragment : Fragment(),
}
}

FileMenuOption.EXPORT -> {
startExportToDeviceFolder(listOf(file))
}

FileMenuOption.SET_AV_OFFLINE -> {
fileOperationsViewModel.performOperation(FileOperation.SetFilesAsAvailableOffline(listOf(file)))
if (file.isFolder) {
Expand Down Expand Up @@ -1493,6 +1588,11 @@ class MainFileListFragment : Fragment(),
true
}

R.id.action_export_file -> {
startExportToDeviceFolder(checkedFiles)
true
}

R.id.action_move -> {
val action = Intent(activity, FolderPickerActivity::class.java)
action.putParcelableArrayListExtra(FolderPickerActivity.EXTRA_FILES, checkedFiles)
Expand Down Expand Up @@ -1636,6 +1736,8 @@ class MainFileListFragment : Fragment(),

private const val FILE_DOCXF_EXTENSION = "docxf"

private const val KEY_PENDING_EXPORT_JOB_ID = "KEY_PENDING_EXPORT_JOB_ID"

@JvmStatic
fun newInstance(
initialFolderToDisplay: OCFile,
Expand Down
Loading