Initial QSfera import

This commit is contained in:
Курнат Андрей
2026-06-07 10:20:04 +03:00
commit 2315f25754
16485 changed files with 4826827 additions and 0 deletions
@@ -0,0 +1,274 @@
<?xml version="1.0" encoding="utf-8"?><!--
qsfera Android client application
Copyright (C) 2012 Bartek Przybylski
Copyright (C) 2023 ownCloud GmbH.
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU General Public License version 2,
as published by the Free Software Foundation.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details.
You should have received a copy of the GNU General Public License
along with this program. If not, see <http://www.gnu.org/licenses/>.
-->
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools">
<!--
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" />
<!--
Notifications are off by default since API 33;
See note in https://developer.android.com/develop/ui/views/notifications/notification-permission
-->
<uses-permission android:name="android.permission.POST_NOTIFICATIONS"/>
<uses-permission android:name="android.permission.REQUEST_INSTALL_PACKAGES" />
<!--
Next permissions are always approved in installation time,
the apps needs to do nothing special in runtime
-->
<uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.READ_SYNC_STATS" />
<uses-permission android:name="android.permission.READ_SYNC_SETTINGS" />
<uses-permission android:name="android.permission.WRITE_SYNC_SETTINGS" />
<uses-permission android:name="android.permission.BROADCAST_STICKY" />
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
<uses-permission android:name="android.permission.RECEIVE_BOOT_COMPLETED" />
<uses-permission android:name="android.permission.WAKE_LOCK" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_MEDIA_PLAYBACK" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_DATA_SYNC" />
<uses-permission android:name="android.permission.REORDER_TASKS" />
<application
android:name=".MainApp"
android:allowBackup="false"
android:icon="@mipmap/icon"
android:label="@string/app_name"
android:localeConfig="@xml/locales_config"
android:networkSecurityConfig="@xml/network_security_config"
android:preserveLegacyExternalStorage="true"
android:requestLegacyExternalStorage="true"
android:resizeableActivity="true"
android:supportsPictureInPicture="false"
android:taskAffinity=""
android:theme="@style/Theme.qsfera.Toolbar">
<activity
android:name=".ui.preview.PreviewVideoActivity"
android:label="@string/video_preview_label"
android:configChanges="keyboard|keyboardHidden|orientation|screenSize|screenLayout|smallestScreenSize|uiMode"
android:theme="@style/Theme.qsfera.Video"
android:exported="false" />
<meta-data
android:name="android.content.APP_RESTRICTIONS"
android:resource="@xml/managed_configurations" />
<activity
android:name=".presentation.releasenotes.ReleaseNotesActivity"
android:label="@string/release_notes_label"
android:exported="false" />
<activity
android:name=".presentation.settings.privacypolicy.PrivacyPolicyActivity"
android:label="@string/actionbar_privacy_policy" />
<activity android:name=".presentation.settings.SettingsActivity" />
<activity android:name=".presentation.migration.StorageMigrationActivity"
android:theme="@style/Theme.qsfera.Toolbar.Fill" />
<activity
android:name=".ui.activity.SplashActivity"
android:exported="true"
android:theme="@style/Theme.qsfera.Splash">
<intent-filter>
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" />
</intent-filter>
</activity>
<activity
android:name=".ui.activity.FileDisplayActivity"
android:configChanges="orientation|screenSize"
android:theme="@style/Theme.qsfera.Toolbar.Drawer"
android:windowSoftInputMode="adjustPan" />
<activity
android:name=".ui.activity.ReceiveExternalFilesActivity"
android:configChanges="orientation|screenSize"
android:label="@string/receive_external_files_label"
android:excludeFromRecents="true"
android:exported="true"
android:taskAffinity="">
<intent-filter>
<action android:name="android.intent.action.SEND" />
<category android:name="android.intent.category.DEFAULT" />
<data android:mimeType="*/*" />
</intent-filter>
<intent-filter>
<action android:name="android.intent.action.SEND_MULTIPLE" />
<category android:name="android.intent.category.DEFAULT" />
<data android:mimeType="*/*" />
</intent-filter>
</activity>
<activity
android:name=".ui.preview.PreviewImageActivity"
android:label="@string/image_preview_label"
android:theme="@style/Theme.qsfera.Overlay" />
<service
android:name=".presentation.authentication.AccountAuthenticatorService"
android:exported="true">
<intent-filter android:priority="100">
<action android:name="android.accounts.AccountAuthenticator" />
</intent-filter>
<meta-data
android:name="android.accounts.AccountAuthenticator"
android:resource="@xml/authenticator" />
</service>
<service
android:name=".syncadapter.FileSyncService"
android:exported="true">
<intent-filter>
<action android:name="android.content.SyncAdapter" />
</intent-filter>
<meta-data
android:name="android.content.SyncAdapter"
android:resource="@xml/syncadapter_files" />
</service>
<provider
android:name=".providers.FileContentProvider"
android:authorities="@string/authority"
android:enabled="true"
android:exported="false"
android:label="@string/sync_string_files"
android:syncable="true" />
<provider
android:name=".presentation.sharing.sharees.UsersAndGroupsSearchProvider"
android:authorities="@string/search_suggest_authority"
android:enabled="true"
android:exported="false"
android:label="@string/search_users_and_groups_hint" />
<provider
android:name=".presentation.documentsprovider.DocumentsStorageProvider"
android:authorities="@string/document_provider_authority"
android:exported="true"
android:grantUriPermissions="true"
android:permission="android.permission.MANAGE_DOCUMENTS">
<intent-filter>
<action android:name="android.content.action.DOCUMENTS_PROVIDER" />
</intent-filter>
</provider>
<!-- new provider used to generate URIs without file:// scheme (forbidden from Android 7) -->
<provider
android:name="androidx.core.content.FileProvider"
android:authorities="@string/file_provider_authority"
android:exported="false"
android:grantUriPermissions="true"
tools:replace="android:authorities">
<meta-data
android:name="android.support.FILE_PROVIDER_PATHS"
android:resource="@xml/exposed_filepaths"
tools:replace="android:resource" />
</provider>
<service android:name=".services.OperationsService" />
<service
android:name="androidx.work.impl.foreground.SystemForegroundService"
android:foregroundServiceType="dataSync"
android:exported="false"
tools:replace="android:foregroundServiceType" />
<service
android:name=".media.MediaService"
android:foregroundServiceType="mediaPlayback"
android:exported="false">
</service>
<activity
android:name=".presentation.security.passcode.PassCodeActivity"
android:label="@string/passcode_label"
android:screenOrientation="portrait" />
<activity
android:name=".presentation.conflicts.ConflictsResolveActivity" />
<activity
android:name=".presentation.logging.LogsListActivity"
android:label="@string/prefs_log_open_logs_list_view"
android:configChanges="orientation|screenSize" />
<activity android:name=".ui.errorhandling.ErrorShowActivity" />
<activity
android:name=".ui.activity.UploadListActivity"
android:label="@string/bottom_nav_uploads"
android:theme="@style/Theme.qsfera.Toolbar.Drawer" />
<activity
android:name=".ui.activity.WhatsNewActivity"
android:label="@string/whats_new_label"
android:configChanges="keyboardHidden|orientation|screenSize" />
<activity
android:name=".ui.activity.CopyToClipboardActivity"
android:icon="@drawable/copy_link"
android:label="@string/copy_link" />
<activity android:name=".ui.activity.FolderPickerActivity"
android:label="@string/folder_picker_label"/>
<activity
android:name=".presentation.sharing.ShareActivity"
android:exported="false"
android:label="@string/share_dialog_title"
android:launchMode="singleTop"
android:theme="@style/Theme.qsfera.Toolbar"
android:windowSoftInputMode="adjustResize">
<intent-filter>
<action android:name="android.intent.action.SEARCH" />
</intent-filter>
<meta-data
android:name="android.app.searchable"
android:resource="@xml/users_and_groups_searchable" />
</activity>
<activity
android:name=".presentation.security.pattern.PatternActivity"
android:screenOrientation="portrait"
android:label="@string/pattern_label" />
<activity android:name=".presentation.security.biometric.BiometricActivity" />
<!-- Own taskAffinity + singleTask so LoginActivity always runs in its own task.
During re-auth, startActivityForResult overrides singleTask, so the first
instance still lands in the main task — but the OAuth redirect instance gets
its own task and finishes the orphaned one via a static reference.
autoRemoveFromRecents cleans the login task from recents once it finishes. -->
<activity
android:name=".presentation.authentication.LoginActivity"
android:autoRemoveFromRecents="true"
android:exported="true"
android:label="@string/login_label"
android:launchMode="singleTask"
android:taskAffinity="eu.qsfera.android.login"
android:theme="@style/Theme.qsfera.Toolbar">
<intent-filter>
<action android:name="android.intent.action.VIEW" />
<category android:name="android.intent.category.DEFAULT" />
<category android:name="android.intent.category.BROWSABLE" />
<data
android:host="@string/oauth2_redirect_uri_host"
android:scheme="@string/oauth2_redirect_uri_scheme" />
</intent-filter>
<intent-filter>
<action android:name="android.intent.action.VIEW" />
<category android:name="android.intent.category.DEFAULT" />
<category android:name="android.intent.category.BROWSABLE" />
<data android:scheme="@string/deep_link_uri_schemes" />
</intent-filter>
</activity>
</application>
</manifest>
Binary file not shown.

After

Width:  |  Height:  |  Size: 6.6 KiB

@@ -0,0 +1,97 @@
/**
* qsfera Android client application
* <p>
* Copyright (C) 2020 ownCloud GmbH.
* <p>
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License version 2,
* as published by the Free Software Foundation.
* <p>
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
* <p>
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
package eu.qsfera.android;
import android.content.Context;
import android.content.SharedPreferences;
import androidx.fragment.app.FragmentActivity;
import androidx.fragment.app.FragmentManager;
import androidx.fragment.app.FragmentTransaction;
import eu.qsfera.android.ui.dialog.RateMeDialog;
import timber.log.Timber;
public class AppRater {
private static final String DIALOG_RATE_ME_TAG = "DIALOG_RATE_ME";
private final static int DAYS_UNTIL_PROMPT = 2;
private final static int LAUNCHES_UNTIL_PROMPT = 2;
private final static int DAYS_UNTIL_NEUTRAL_CLICK = 1;
public static final String APP_RATER_PREF_TITLE = "app_rater";
public static final String APP_RATER_PREF_DONT_SHOW = "don't_show_again";
private static final String APP_RATER_PREF_LAUNCH_COUNT = "launch_count";
private static final String APP_RATER_PREF_DATE_FIRST_LAUNCH = "date_first_launch";
public static final String APP_RATER_PREF_DATE_NEUTRAL = "date_neutral";
public static void appLaunched(Context mContext, String packageName) {
SharedPreferences prefs = mContext.getSharedPreferences(APP_RATER_PREF_TITLE, 0);
if (prefs.getBoolean(APP_RATER_PREF_DONT_SHOW, false)) {
Timber.d("Do not show the rate dialog again as the user decided");
return;
}
SharedPreferences.Editor editor = prefs.edit();
/// Increment launch counter
long launchCount = prefs.getLong(APP_RATER_PREF_LAUNCH_COUNT, 0) + 1;
Timber.d("The app has been launched " + launchCount + " times");
editor.putLong(APP_RATER_PREF_LAUNCH_COUNT, launchCount);
/// Get date of first launch
long dateFirstLaunch = prefs.getLong(APP_RATER_PREF_DATE_FIRST_LAUNCH, 0);
if (dateFirstLaunch == 0) {
dateFirstLaunch = System.currentTimeMillis();
Timber.d("The app has been launched in " + dateFirstLaunch + " for the first time");
editor.putLong(APP_RATER_PREF_DATE_FIRST_LAUNCH, dateFirstLaunch);
}
/// Get date of neutral click
long dateNeutralClick = prefs.getLong(APP_RATER_PREF_DATE_NEUTRAL, 0);
/// Wait at least n days before opening
if (launchCount >= LAUNCHES_UNTIL_PROMPT) {
Timber.d("The number of launches already exceed " + LAUNCHES_UNTIL_PROMPT +
", the default number of launches, so let's check some dates");
Timber.d("Current moment is %s", System.currentTimeMillis());
Timber.d("The date of the first launch + days until prompt is " + dateFirstLaunch +
daysToMilliseconds(DAYS_UNTIL_PROMPT));
Timber.d("The date of the neutral click + days until neutral click is " + dateNeutralClick +
daysToMilliseconds(DAYS_UNTIL_NEUTRAL_CLICK));
if (System.currentTimeMillis() >= Math.max(dateFirstLaunch
+ daysToMilliseconds(DAYS_UNTIL_PROMPT), dateNeutralClick
+ daysToMilliseconds(DAYS_UNTIL_NEUTRAL_CLICK))) {
Timber.d("The current moment is later than any of the days set, so let's show the rate dialog");
showRateDialog(mContext, packageName);
}
}
editor.apply();
}
private static int daysToMilliseconds(int days) {
return days * 24 * 60 * 60 * 1000;
}
private static void showRateDialog(Context mContext, String packageName) {
RateMeDialog rateMeDialog = RateMeDialog.newInstance(packageName, false);
FragmentManager fm = ((FragmentActivity) mContext).getSupportFragmentManager();
FragmentTransaction ft = fm.beginTransaction();
rateMeDialog.show(ft, DIALOG_RATE_ME_TAG);
}
}
@@ -0,0 +1,437 @@
/**
* qsfera Android client application
*
* @author masensio
* @author David A. Velasco
* @author David González Verdugo
* @author Christian Schabesberger
* @author David Crespo Ríos
* @author Juan Carlos Garrote Gascón
* @author Aitor Ballesteros Pavón
*
* Copyright (C) 2024 ownCloud GmbH.
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License version 2,
* as published by the Free Software Foundation.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
package eu.qsfera.android
import android.app.Activity
import android.app.Application
import android.app.NotificationManager.IMPORTANCE_LOW
import android.content.Context
import android.content.pm.PackageInfo
import android.content.pm.PackageManager
import android.os.Build
import android.os.Bundle
import android.view.WindowManager
import android.widget.CheckBox
import androidx.appcompat.app.AlertDialog
import androidx.core.content.pm.PackageInfoCompat
import eu.qsfera.android.data.providers.implementation.OCSharedPreferencesProvider
import eu.qsfera.android.db.PreferenceManager
import eu.qsfera.android.dependecyinjection.commonModule
import eu.qsfera.android.dependecyinjection.localDataSourceModule
import eu.qsfera.android.dependecyinjection.remoteDataSourceModule
import eu.qsfera.android.dependecyinjection.repositoryModule
import eu.qsfera.android.dependecyinjection.useCaseModule
import eu.qsfera.android.dependecyinjection.viewModelModule
import eu.qsfera.android.domain.capabilities.usecases.GetStoredCapabilitiesUseCase
import eu.qsfera.android.domain.spaces.model.OCSpace
import eu.qsfera.android.domain.spaces.usecases.GetPersonalSpaceForAccountUseCase
import eu.qsfera.android.domain.user.usecases.GetStoredQuotaUseCase
import eu.qsfera.android.extensions.createNotificationChannel
import eu.qsfera.android.lib.common.SingleSessionManager
import eu.qsfera.android.presentation.authentication.AccountUtils
import eu.qsfera.android.presentation.migration.StorageMigrationActivity
import eu.qsfera.android.presentation.releasenotes.ReleaseNotesActivity
import eu.qsfera.android.presentation.security.biometric.BiometricActivity
import eu.qsfera.android.presentation.security.biometric.BiometricManager
import eu.qsfera.android.presentation.security.passcode.PassCodeActivity
import eu.qsfera.android.presentation.security.passcode.PassCodeManager
import eu.qsfera.android.presentation.security.pattern.PatternActivity
import eu.qsfera.android.presentation.security.pattern.PatternManager
import eu.qsfera.android.presentation.settings.logging.SettingsLogsFragment.Companion.PREFERENCE_ENABLE_LOGGING
import eu.qsfera.android.providers.CoroutinesDispatcherProvider
import eu.qsfera.android.providers.LogsProvider
import eu.qsfera.android.providers.MdmProvider
import eu.qsfera.android.providers.WorkManagerProvider
import eu.qsfera.android.ui.activity.FileDisplayActivity
import eu.qsfera.android.ui.activity.FileDisplayActivity.Companion.PREFERENCE_CLEAR_DATA_ALREADY_TRIGGERED
import eu.qsfera.android.ui.activity.WhatsNewActivity
import eu.qsfera.android.utils.CONFIGURATION_ALLOW_SCREENSHOTS
import eu.qsfera.android.utils.DOWNLOAD_NOTIFICATION_CHANNEL_ID
import eu.qsfera.android.utils.DebugInjector
import eu.qsfera.android.utils.FILE_SYNC_CONFLICT_NOTIFICATION_CHANNEL_ID
import eu.qsfera.android.utils.FILE_SYNC_NOTIFICATION_CHANNEL_ID
import eu.qsfera.android.utils.MEDIA_SERVICE_NOTIFICATION_CHANNEL_ID
import eu.qsfera.android.utils.UPLOAD_NOTIFICATION_CHANNEL_ID
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kotlinx.coroutines.runBlocking
import kotlinx.coroutines.withContext
import org.koin.android.ext.android.inject
import org.koin.android.ext.koin.androidContext
import org.koin.core.context.startKoin
import org.koin.core.context.stopKoin
import timber.log.Timber
/**
* Main Application of the project
*
*
* Contains methods to build the "static" strings. These strings were before constants in different
* classes
*/
class MainApp : Application() {
override fun onCreate() {
super.onCreate()
appContext = applicationContext
// Ensure Logcat shows Timber logs in debug builds
if (BuildConfig.DEBUG) {
try {
Timber.plant(Timber.DebugTree())
} catch (_: Throwable) {
// ignore if already planted
}
}
startLogsIfEnabled()
DebugInjector.injectDebugTools(appContext)
createNotificationChannels()
SingleSessionManager.setUserAgent(userAgent)
initDependencyInjection()
val workManagerProvider: WorkManagerProvider by inject()
var startedActivities = 0
// register global protection with pass code, pattern lock and biometric lock
registerActivityLifecycleCallbacks(object : ActivityLifecycleCallbacks {
override fun onActivityCreated(activity: Activity, savedInstanceState: Bundle?) {
Timber.d("${activity.javaClass.simpleName} onCreate(Bundle) starting")
// To prevent taking screenshots in MDM
if (!areScreenshotsAllowed()) {
activity.window.addFlags(WindowManager.LayoutParams.FLAG_SECURE)
}
// If there's any lock protection, don't show wizard at this point, show it when lock activities
// have finished
if (activity !is PassCodeActivity &&
activity !is PatternActivity &&
activity !is BiometricActivity
) {
StorageMigrationActivity.runIfNeeded(activity)
if (isFirstRun()) {
WhatsNewActivity.runIfNeeded(activity)
} else {
ReleaseNotesActivity.runIfNeeded(activity)
val pref = PreferenceManager.getDefaultSharedPreferences(appContext)
val clearDataAlreadyTriggered = pref.contains(PREFERENCE_CLEAR_DATA_ALREADY_TRIGGERED)
if (clearDataAlreadyTriggered || isNewVersionCode()) {
val dontShowAgainDialogPref = pref.getBoolean(PREFERENCE_KEY_DONT_SHOW_SERVER_ACCOUNT_WARNING_DIALOG, false)
if (!dontShowAgainDialogPref && shouldShowDialog(activity)) {
val checkboxDialog = activity.layoutInflater.inflate(R.layout.checkbox_dialog, null)
val checkbox = checkboxDialog.findViewById<CheckBox>(R.id.checkbox_dialog)
checkbox.setText(R.string.server_accounts_warning_checkbox_message)
val builder = AlertDialog.Builder(activity).apply {
setView(checkboxDialog)
setTitle(R.string.server_accounts_warning_title)
setMessage(R.string.server_accounts_warning_message)
setCancelable(false)
setPositiveButton(R.string.server_accounts_warning_button) { _, _ ->
if (checkbox.isChecked) {
pref.edit().putBoolean(PREFERENCE_KEY_DONT_SHOW_SERVER_ACCOUNT_WARNING_DIALOG, true).apply()
}
}
}
val alertDialog = builder.create()
alertDialog.show()
}
} else { // "Clear data" button is pressed from the app settings in the device settings.
AccountUtils.deleteAccounts(appContext)
WhatsNewActivity.runIfNeeded(activity)
}
}
}
PreferenceManager.migrateFingerprintToBiometricKey(applicationContext)
PreferenceManager.deleteOldSettingsPreferences(applicationContext)
}
private fun shouldShowDialog(activity: Activity) =
runBlocking(CoroutinesDispatcherProvider().io) {
if (activity !is FileDisplayActivity) return@runBlocking false
val account = AccountUtils.getCurrentQSferaAccount(appContext) ?: return@runBlocking false
val getStoredCapabilitiesUseCase: GetStoredCapabilitiesUseCase by inject()
val capabilities = withContext(CoroutineScope(CoroutinesDispatcherProvider().io).coroutineContext) {
getStoredCapabilitiesUseCase(
GetStoredCapabilitiesUseCase.Params(
accountName = account.name
)
)
}
val spacesAllowed = capabilities != null && capabilities.isSpacesAllowed()
var personalSpace: OCSpace? = null
if (spacesAllowed) {
val getPersonalSpaceForAccountUseCase: GetPersonalSpaceForAccountUseCase by inject()
personalSpace = withContext(CoroutineScope(CoroutinesDispatcherProvider().io).coroutineContext) {
getPersonalSpaceForAccountUseCase(
GetPersonalSpaceForAccountUseCase.Params(
accountName = account.name
)
)
}
}
val getStoredQuotaUseCase: GetStoredQuotaUseCase by inject()
val quota = withContext(CoroutineScope(CoroutinesDispatcherProvider().io).coroutineContext) {
getStoredQuotaUseCase(
GetStoredQuotaUseCase.Params(
accountName = account.name
)
)
}
val isLightUser = quota.getDataOrNull()?.available == -4L
spacesAllowed && personalSpace == null && !isLightUser
}
override fun onActivityStarted(activity: Activity) {
Timber.v("${activity.javaClass.simpleName} onStart() starting")
if (startedActivities == 0) {
// App entered foreground — ensure the periodic worker is registered
// (recovers if the chain was dropped) and trigger an immediate scan
// so the user doesn't have to wait up to 15 min.
CoroutineScope(Dispatchers.IO).launch {
workManagerProvider.enqueueAutomaticUploadsWorker()
workManagerProvider.enqueueMediaStoreAutomaticUploadsWorker()
workManagerProvider.enqueueImmediateAutomaticUploadsWorker()
}
}
startedActivities++
PassCodeManager.onActivityStarted(activity)
PatternManager.onActivityStarted(activity)
BiometricManager.onActivityStarted(activity)
}
override fun onActivityResumed(activity: Activity) {
Timber.v("${activity.javaClass.simpleName} onResume() starting")
}
override fun onActivityPaused(activity: Activity) {
Timber.v("${activity.javaClass.simpleName} onPause() ending")
}
override fun onActivityStopped(activity: Activity) {
startedActivities--
Timber.v("${activity.javaClass.simpleName} onStop() ending")
PassCodeManager.onActivityStopped(activity)
PatternManager.onActivityStopped(activity)
BiometricManager.onActivityStopped(activity)
}
override fun onActivitySaveInstanceState(activity: Activity, outState: Bundle) {
Timber.v("${activity.javaClass.simpleName} onSaveInstanceState(Bundle) starting")
}
override fun onActivityDestroyed(activity: Activity) {
Timber.v("${activity.javaClass.simpleName} onDestroy() ending")
}
})
}
private fun startLogsIfEnabled() {
val preferenceProvider = OCSharedPreferencesProvider(applicationContext)
if (BuildConfig.DEBUG) {
val alreadySet = preferenceProvider.containsPreference(PREFERENCE_ENABLE_LOGGING)
if (!alreadySet) {
preferenceProvider.putBoolean(PREFERENCE_ENABLE_LOGGING, true)
}
}
enabledLogging = preferenceProvider.getBoolean(PREFERENCE_ENABLE_LOGGING, false)
if (enabledLogging) {
val mdmProvider = MdmProvider(applicationContext)
LogsProvider(applicationContext, mdmProvider).startLogging()
}
}
/**
* Screenshots allowed in debug mode. Devs and tests <3
* Otherwise, depends on branding.
*/
private fun areScreenshotsAllowed(): Boolean {
if (BuildConfig.DEBUG) return true
val mdmProvider = MdmProvider(applicationContext)
return mdmProvider.getBrandingBoolean(CONFIGURATION_ALLOW_SCREENSHOTS, R.bool.allow_screenshots)
}
private fun createNotificationChannels() {
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) {
return
}
createNotificationChannel(
id = DOWNLOAD_NOTIFICATION_CHANNEL_ID,
name = getString(R.string.download_notification_channel_name),
description = getString(R.string.download_notification_channel_description),
importance = IMPORTANCE_LOW
)
createNotificationChannel(
id = UPLOAD_NOTIFICATION_CHANNEL_ID,
name = getString(R.string.upload_notification_channel_name),
description = getString(R.string.upload_notification_channel_description),
importance = IMPORTANCE_LOW
)
createNotificationChannel(
id = MEDIA_SERVICE_NOTIFICATION_CHANNEL_ID,
name = getString(R.string.media_service_notification_channel_name),
description = getString(R.string.media_service_notification_channel_description),
importance = IMPORTANCE_LOW
)
createNotificationChannel(
id = FILE_SYNC_CONFLICT_NOTIFICATION_CHANNEL_ID,
name = getString(R.string.file_sync_conflict_notification_channel_name),
description = getString(R.string.file_sync_conflict_notification_channel_description),
importance = IMPORTANCE_LOW
)
createNotificationChannel(
id = FILE_SYNC_NOTIFICATION_CHANNEL_ID,
name = getString(R.string.file_sync_notification_channel_name),
description = getString(R.string.file_sync_notification_channel_description),
importance = IMPORTANCE_LOW
)
}
private fun isFirstRun(): Boolean {
if (getLastSeenVersionCode() != 0) {
return false
}
return AccountUtils.getCurrentQSferaAccount(appContext) == null
}
companion object {
const val MDM_FLAVOR = "mdm"
lateinit var appContext: Context
private set
var enabledLogging: Boolean = false
private set
const val PREFERENCE_KEY_LAST_SEEN_VERSION_CODE = "lastSeenVersionCode"
const val PREFERENCE_KEY_DONT_SHOW_SERVER_ACCOUNT_WARNING_DIALOG = "PREFERENCE_KEY_DONT_SHOW_SERVER_ACCOUNT_WARNING_DIALOG"
/**
* Next methods give access in code to some constants that need to be defined in string resources to be referred
* in AndroidManifest.xml file or other xml resource files; or that need to be easy to modify in build time.
*/
val accountType: String
get() = appContext.resources.getString(R.string.account_type)
val versionCode: Int
get() =
try {
val pInfo: PackageInfo = appContext.packageManager.getPackageInfo(appContext.packageName, 0)
val longVersionCode: Long = PackageInfoCompat.getLongVersionCode(pInfo)
longVersionCode.toInt()
} catch (e: PackageManager.NameNotFoundException) {
Timber.w(e, "Version code not found, using 0 as fallback")
0
}
val authority: String
get() = appContext.resources.getString(R.string.authority)
val authTokenType: String
get() = appContext.resources.getString(R.string.authority)
val dataFolder: String
get() = appContext.resources.getString(R.string.data_folder)
// user agent
// Mozilla/5.0 (Android) qsfera-android/1.7.0
val userAgent: String
get() {
val appString = appContext.resources.getString(R.string.user_agent)
val packageName = appContext.packageName
var version: String? = ""
val pInfo: PackageInfo?
try {
pInfo = appContext.packageManager.getPackageInfo(packageName, 0)
if (pInfo != null) {
version = pInfo.versionName
}
} catch (e: PackageManager.NameNotFoundException) {
Timber.e(e, "Trying to get packageName")
}
return String.format(appString, version)
}
fun initDependencyInjection() {
stopKoin()
startKoin {
androidContext(appContext)
modules(
listOf(
commonModule,
viewModelModule,
useCaseModule,
repositoryModule,
localDataSourceModule,
remoteDataSourceModule
)
)
}
}
fun getLastSeenVersionCode(): Int {
val pref = PreferenceManager.getDefaultSharedPreferences(appContext)
return pref.getInt(PREFERENCE_KEY_LAST_SEEN_VERSION_CODE, 0)
}
private fun isNewVersionCode(): Boolean {
val lastSeenVersionCode = getLastSeenVersionCode()
if (lastSeenVersionCode == 0) { // The preferences have been deleted, so we can delete the accounts and navigate to login
return false
}
return lastSeenVersionCode != versionCode // The version has changed and the accounts must not be deleted
}
}
}
@@ -0,0 +1,123 @@
/**
* qsfera Android client application
*
* @author Bartek Przybylski
* @author Christian Schabesberger
* @author David González Verdugo
* @author Abel García de Prada
* @author Juan Carlos Garrote Gascón
*
* Copyright (C) 2012 Bartek Przybylski
* Copyright (C) 2023 ownCloud GmbH.
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License version 2,
* as published by the Free Software Foundation.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <http:></http:>//www.gnu.org/licenses/>.
*/
package eu.qsfera.android.datamodel
import android.accounts.Account
import eu.qsfera.android.domain.files.model.OCFile
import eu.qsfera.android.domain.files.model.OCFile.Companion.ROOT_PATH
import eu.qsfera.android.domain.files.usecases.GetFileByIdUseCase
import eu.qsfera.android.domain.files.usecases.GetFileByRemotePathUseCase
import eu.qsfera.android.domain.files.usecases.GetFolderContentUseCase
import eu.qsfera.android.domain.files.usecases.GetFolderImagesUseCase
import eu.qsfera.android.domain.files.usecases.GetPersonalRootFolderForAccountUseCase
import eu.qsfera.android.domain.files.usecases.GetSharesRootFolderForAccount
import eu.qsfera.android.providers.CoroutinesDispatcherProvider
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.runBlocking
import kotlinx.coroutines.withContext
import org.koin.core.component.KoinComponent
import org.koin.core.component.inject
class FileDataStorageManager(
val account: Account,
) : KoinComponent {
fun getFileByPath(remotePath: String, spaceId: String? = null): OCFile? =
if (remotePath == ROOT_PATH && spaceId == null) {
getRootPersonalFolder()
} else {
getFileByPathAndAccount(remotePath, account.name, spaceId)
}
private fun getFileByPathAndAccount(remotePath: String, accountName: String, spaceId: String? = null): OCFile? =
runBlocking(CoroutinesDispatcherProvider().io) {
val getFileByRemotePathUseCase: GetFileByRemotePathUseCase by inject()
val result = withContext(CoroutineScope(CoroutinesDispatcherProvider().io).coroutineContext) {
getFileByRemotePathUseCase(GetFileByRemotePathUseCase.Params(accountName, remotePath, spaceId))
}.getDataOrNull()
result
}
fun getRootPersonalFolder() = runBlocking(CoroutinesDispatcherProvider().io) {
val getPersonalRootFolderForAccountUseCase: GetPersonalRootFolderForAccountUseCase by inject()
val result = withContext(CoroutineScope(CoroutinesDispatcherProvider().io).coroutineContext) {
getPersonalRootFolderForAccountUseCase(GetPersonalRootFolderForAccountUseCase.Params(account.name))
}
result
}
fun getRootSharesFolder() = runBlocking(CoroutinesDispatcherProvider().io) {
val getSharesRootFolderForAccount: GetSharesRootFolderForAccount by inject()
val result = withContext(CoroutineScope(CoroutinesDispatcherProvider().io).coroutineContext) {
getSharesRootFolderForAccount(GetSharesRootFolderForAccount.Params(account.name))
}
result
}
// To do: New_arch. Remove this and call usecase inside FilesViewModel
fun getFileById(id: Long): OCFile? = runBlocking(CoroutinesDispatcherProvider().io) {
val getFileByIdUseCase: GetFileByIdUseCase by inject()
val result = withContext(CoroutineScope(CoroutinesDispatcherProvider().io).coroutineContext) {
getFileByIdUseCase(GetFileByIdUseCase.Params(id))
}.getDataOrNull()
result
}
fun fileExists(path: String): Boolean = getFileByPath(path) != null
fun getFolderContent(f: OCFile?): List<OCFile> =
if (f != null && f.isFolder && f.id != -1L) {
// To do: Remove !!
getFolderContent(f.id!!)
} else {
listOf()
}
// To do: New_arch. Remove this and call usecase inside FilesViewModel
fun getFolderImages(folder: OCFile?): List<OCFile> = runBlocking(CoroutinesDispatcherProvider().io) {
val getFolderImagesUseCase: GetFolderImagesUseCase by inject()
val result = withContext(CoroutineScope(CoroutinesDispatcherProvider().io).coroutineContext) {
// To do: Remove !!
getFolderImagesUseCase(GetFolderImagesUseCase.Params(folderId = folder!!.id!!))
}.getDataOrNull()
result ?: listOf()
}
// To do: New_arch. Remove this and call usecase inside FilesViewModel
private fun getFolderContent(parentId: Long): List<OCFile> = runBlocking(CoroutinesDispatcherProvider().io) {
val getFolderContentUseCase: GetFolderContentUseCase by inject()
val result = withContext(CoroutineScope(CoroutinesDispatcherProvider().io).coroutineContext) {
getFolderContentUseCase(GetFolderContentUseCase.Params(parentId))
}.getDataOrNull()
result ?: listOf()
}
}
@@ -0,0 +1,226 @@
/*
* qsfera Android client application
*
* @author David A. Velasco
* @author David González Verdugo
* @author Shashvat Kedia
* @author Juan Carlos Garrote Gascón
*
* Copyright (C) 2020 ownCloud GmbH.
* <p>
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License version 2,
* as published by the Free Software Foundation.
* <p>
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
* <p>
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
package eu.qsfera.android.db;
import android.content.Context;
import android.content.SharedPreferences;
import eu.qsfera.android.presentation.security.biometric.BiometricActivity;
import eu.qsfera.android.utils.FileStorageUtils;
/**
* Helper to simplify reading of Preferences all around the app
*/
public abstract class PreferenceManager {
// Legacy preferences - done in version 2.18
public static final String PREF__LEGACY_CLICK_DEV_MENU = "clickDeveloperMenu";
public static final String PREF__LEGACY_CAMERA_PICTURE_UPLOADS_ENABLED = "camera_picture_uploads";
public static final String PREF__LEGACY_CAMERA_VIDEO_UPLOADS_ENABLED = "camera_video_uploads";
public static final String PREF__LEGACY_CAMERA_PICTURE_UPLOADS_WIFI_ONLY = "camera_picture_uploads_on_wifi";
public static final String PREF__LEGACY_CAMERA_VIDEO_UPLOADS_WIFI_ONLY = "camera_video_uploads_on_wifi";
public static final String PREF__LEGACY_CAMERA_PICTURE_UPLOADS_PATH = "camera_picture_uploads_path";
public static final String PREF__LEGACY_CAMERA_VIDEO_UPLOADS_PATH = "camera_video_uploads_path";
public static final String PREF__LEGACY_CAMERA_UPLOADS_BEHAVIOUR = "camera_uploads_behaviour";
public static final String PREF__LEGACY_CAMERA_UPLOADS_SOURCE = "camera_uploads_source_path";
public static final String PREF__LEGACY_CAMERA_UPLOADS_ACCOUNT_NAME = "camera_uploads_account_name";
public static final String PREF__CAMERA_PICTURE_UPLOADS_ENABLED = "enable_picture_uploads";
public static final String PREF__CAMERA_VIDEO_UPLOADS_ENABLED = "enable_video_uploads";
public static final String PREF__CAMERA_PICTURE_UPLOADS_WIFI_ONLY = "picture_uploads_on_wifi";
public static final String PREF__CAMERA_PICTURE_UPLOADS_CHARGING_ONLY = "picture_uploads_on_charging";
public static final String PREF__CAMERA_VIDEO_UPLOADS_WIFI_ONLY = "video_uploads_on_wifi";
public static final String PREF__CAMERA_VIDEO_UPLOADS_CHARGING_ONLY = "video_uploads_on_charging";
public static final String PREF__CAMERA_PICTURE_UPLOADS_PATH = "picture_uploads_path";
public static final String PREF__CAMERA_VIDEO_UPLOADS_PATH = "video_uploads_path";
public static final String PREF__CAMERA_PICTURE_UPLOADS_BEHAVIOUR = "picture_uploads_behaviour";
public static final String PREF__CAMERA_PICTURE_UPLOADS_SOURCE = "picture_uploads_source_path";
public static final String PREF__CAMERA_VIDEO_UPLOADS_BEHAVIOUR = "video_uploads_behaviour";
public static final String PREF__CAMERA_VIDEO_UPLOADS_SOURCE = "video_uploads_source_path";
public static final String PREF__CAMERA_PICTURE_UPLOADS_ACCOUNT_NAME = "picture_uploads_account_name";
public static final String PREF__CAMERA_VIDEO_UPLOADS_ACCOUNT_NAME = "video_uploads_account_name";
public static final String PREF__CAMERA_PICTURE_UPLOADS_LAST_SYNC = "picture_uploads_last_sync";
public static final String PREF__CAMERA_VIDEO_UPLOADS_LAST_SYNC = "video_uploads_last_sync";
public static final String PREF__CAMERA_UPLOADS_DEFAULT_PATH = "/CameraUpload";
public static final String PREF__LEGACY_FINGERPRINT = "set_fingerprint";
/**
* Constant to access value of last path selected by the user to upload a file shared from other app.
* Value handled by the app without direct access in the UI.
*/
private static final String AUTO_PREF__LAST_UPLOAD_PATH = "last_upload_path";
private static final String AUTO_PREF__SORT_ORDER_FILE_DISP = "sortOrderFileDisp";
private static final String AUTO_PREF__SORT_ASCENDING_FILE_DISP = "sortAscendingFileDisp";
private static final String AUTO_PREF__SORT_ORDER_UPLOAD = "sortOrderUpload";
private static final String AUTO_PREF__SORT_ASCENDING_UPLOAD = "sortAscendingUpload";
public static void migrateFingerprintToBiometricKey(Context context) {
SharedPreferences sharedPref = getDefaultSharedPreferences(context);
// Check if legacy fingerprint key exists, delete it and migrate its value to the new key
if (sharedPref.contains(PREF__LEGACY_FINGERPRINT)) {
boolean currentFingerprintValue = sharedPref.getBoolean(PREF__LEGACY_FINGERPRINT, false);
SharedPreferences.Editor editor = sharedPref.edit();
editor.remove(PREF__LEGACY_FINGERPRINT);
editor.putBoolean(BiometricActivity.PREFERENCE_SET_BIOMETRIC, currentFingerprintValue);
editor.apply();
}
}
public static void deleteOldSettingsPreferences(Context context) {
SharedPreferences sharedPref = getDefaultSharedPreferences(context);
SharedPreferences.Editor editor = sharedPref.edit();
if (sharedPref.contains(PREF__LEGACY_CLICK_DEV_MENU)) {
editor.remove(PREF__LEGACY_CLICK_DEV_MENU);
}
if (sharedPref.contains(PREF__LEGACY_CAMERA_PICTURE_UPLOADS_ENABLED)) {
editor.remove(PREF__LEGACY_CAMERA_PICTURE_UPLOADS_ENABLED);
}
if (sharedPref.contains(PREF__LEGACY_CAMERA_VIDEO_UPLOADS_ENABLED)) {
editor.remove(PREF__LEGACY_CAMERA_VIDEO_UPLOADS_ENABLED);
}
if (sharedPref.contains(PREF__LEGACY_CAMERA_PICTURE_UPLOADS_WIFI_ONLY)) {
editor.remove(PREF__LEGACY_CAMERA_PICTURE_UPLOADS_WIFI_ONLY);
}
if (sharedPref.contains(PREF__LEGACY_CAMERA_VIDEO_UPLOADS_WIFI_ONLY)) {
editor.remove(PREF__LEGACY_CAMERA_VIDEO_UPLOADS_WIFI_ONLY);
}
if (sharedPref.contains(PREF__LEGACY_CAMERA_PICTURE_UPLOADS_PATH)) {
editor.remove(PREF__LEGACY_CAMERA_PICTURE_UPLOADS_PATH);
}
if (sharedPref.contains(PREF__LEGACY_CAMERA_VIDEO_UPLOADS_PATH)) {
editor.remove(PREF__LEGACY_CAMERA_VIDEO_UPLOADS_PATH);
}
if (sharedPref.contains(PREF__LEGACY_CAMERA_UPLOADS_BEHAVIOUR)) {
editor.remove(PREF__LEGACY_CAMERA_UPLOADS_BEHAVIOUR);
}
if (sharedPref.contains(PREF__LEGACY_CAMERA_UPLOADS_SOURCE)) {
editor.remove(PREF__LEGACY_CAMERA_UPLOADS_SOURCE);
}
if (sharedPref.contains(PREF__LEGACY_CAMERA_UPLOADS_ACCOUNT_NAME)) {
editor.remove(PREF__LEGACY_CAMERA_UPLOADS_ACCOUNT_NAME);
}
editor.apply();
}
/**
* Gets the path where the user selected to do the last upload of a file shared from other app.
*
* @param context Caller {@link Context}, used to access to shared preferences manager.
* @return path Absolute path to a folder, as previously stored by {@link #setLastUploadPath(String, Context)},
* or empty String if never saved before.
*/
public static String getLastUploadPath(Context context) {
return getDefaultSharedPreferences(context).getString(AUTO_PREF__LAST_UPLOAD_PATH, "");
}
/**
* Saves the path where the user selected to do the last upload of a file shared from other app.
*
* @param path Absolute path to a folder.
* @param context Caller {@link Context}, used to access to shared preferences manager.
*/
public static void setLastUploadPath(String path, Context context) {
saveStringPreference(AUTO_PREF__LAST_UPLOAD_PATH, path, context);
}
/**
* Gets the sort order which the user has set last.
*
* @param context Caller {@link Context}, used to access to shared preferences manager.
* @return sort order the sort order, default is {@link FileStorageUtils#SORT_NAME} (sort by name)
*/
public static int getSortOrder(Context context, int flag) {
if (flag == FileStorageUtils.FILE_DISPLAY_SORT) {
return getDefaultSharedPreferences(context)
.getInt(AUTO_PREF__SORT_ORDER_FILE_DISP, FileStorageUtils.SORT_NAME);
} else {
return getDefaultSharedPreferences(context)
.getInt(AUTO_PREF__SORT_ORDER_UPLOAD, FileStorageUtils.SORT_DATE);
}
}
/**
* Save the sort order which the user has set last.
*
* @param order the sort order
* @param context Caller {@link Context}, used to access to shared preferences manager.
*/
public static void setSortOrder(int order, Context context, int flag) {
if (flag == FileStorageUtils.FILE_DISPLAY_SORT) {
saveIntPreference(AUTO_PREF__SORT_ORDER_FILE_DISP, order, context);
} else {
saveIntPreference(AUTO_PREF__SORT_ORDER_UPLOAD, order, context);
}
}
/**
* Gets the ascending order flag which the user has set last.
*
* @param context Caller {@link Context}, used to access to shared preferences manager.
* @return ascending order the ascending order, default is true
*/
public static boolean getSortAscending(Context context, int flag) {
if (flag == FileStorageUtils.FILE_DISPLAY_SORT) {
return getDefaultSharedPreferences(context)
.getBoolean(AUTO_PREF__SORT_ASCENDING_FILE_DISP, true);
} else {
return getDefaultSharedPreferences(context)
.getBoolean(AUTO_PREF__SORT_ASCENDING_UPLOAD, true);
}
}
/**
* Saves the ascending order flag which the user has set last.
*
* @param ascending flag if sorting is ascending or descending
* @param context Caller {@link Context}, used to access to shared preferences manager.
*/
public static void setSortAscending(boolean ascending, Context context, int flag) {
if (flag == FileStorageUtils.FILE_DISPLAY_SORT) {
saveBooleanPreference(AUTO_PREF__SORT_ASCENDING_FILE_DISP, ascending, context);
} else {
saveBooleanPreference(AUTO_PREF__SORT_ASCENDING_UPLOAD, ascending, context);
}
}
private static void saveBooleanPreference(String key, boolean value, Context context) {
SharedPreferences.Editor appPreferences = getDefaultSharedPreferences(context.getApplicationContext()).edit();
appPreferences.putBoolean(key, value);
appPreferences.apply();
}
private static void saveStringPreference(String key, String value, Context context) {
SharedPreferences.Editor appPreferences = getDefaultSharedPreferences(context.getApplicationContext()).edit();
appPreferences.putString(key, value);
appPreferences.apply();
}
private static void saveIntPreference(String key, int value, Context context) {
SharedPreferences.Editor appPreferences = getDefaultSharedPreferences(context.getApplicationContext()).edit();
appPreferences.putInt(key, value);
appPreferences.apply();
}
public static SharedPreferences getDefaultSharedPreferences(Context context) {
return android.preference.PreferenceManager.getDefaultSharedPreferences(context.getApplicationContext());
}
}
@@ -0,0 +1,198 @@
/**
* qsfera Android client application
*
* @author Bartek Przybylski
* @author David A. Velasco
* @author masensio
* @author David González Verdugo
* @author Abel García de Prada
* Copyright (C) 2011 Bartek Przybylski
* Copyright (C) 2020 ownCloud GmbH.
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License version 2,
* as published by the Free Software Foundation.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
package eu.qsfera.android.db;
import android.net.Uri;
import android.provider.BaseColumns;
import eu.qsfera.android.MainApp;
/**
* Meta-Class that holds various static field information
* This is only used in FileContentProvider for legacy DB
*/
@Deprecated
public class ProviderMeta {
private ProviderMeta() {
}
static public class ProviderTableMeta implements BaseColumns {
public static final String FILE_TABLE_NAME = "filelist";
public static final String OCSHARES_TABLE_NAME = "ocshares";
public static final String CAPABILITIES_TABLE_NAME = "capabilities";
public static final String UPLOADS_TABLE_NAME = "list_of_uploads";
public static final String USER_AVATARS__TABLE_NAME = "user_avatars";
public static final String CAMERA_UPLOADS_SYNC_TABLE_NAME = "camera_uploads_sync";
public static final String USER_QUOTAS_TABLE_NAME = "user_quotas";
public static final Uri CONTENT_URI = Uri.parse("content://"
+ MainApp.Companion.getAuthority() + "/");
public static final Uri CONTENT_URI_FILE = Uri.parse("content://"
+ MainApp.Companion.getAuthority() + "/file");
public static final Uri CONTENT_URI_DIR = Uri.parse("content://"
+ MainApp.Companion.getAuthority() + "/dir");
public static final Uri CONTENT_URI_SHARE = Uri.parse("content://"
+ MainApp.Companion.getAuthority() + "/shares");
public static final Uri CONTENT_URI_CAPABILITIES = Uri.parse("content://"
+ MainApp.Companion.getAuthority() + "/capabilities");
public static final Uri CONTENT_URI_UPLOADS = Uri.parse("content://"
+ MainApp.Companion.getAuthority() + "/uploads");
public static final Uri CONTENT_URI_CAMERA_UPLOADS_SYNC = Uri.parse("content://"
+ MainApp.Companion.getAuthority() + "/cameraUploadsSync");
public static final Uri CONTENT_URI_QUOTAS = Uri.parse("content://"
+ MainApp.Companion.getAuthority() + "/quotas");
public static final String CONTENT_TYPE = "vnd.android.cursor.dir/vnd.qsfera.file";
public static final String CONTENT_TYPE_ITEM = "vnd.android.cursor.item/vnd.qsfera.file";
public static final String ID = "id";
// Columns of filelist table
public static final String FILE_PARENT = "parent";
public static final String FILE_NAME = "filename";
public static final String FILE_CREATION = "created";
public static final String FILE_MODIFIED = "modified";
public static final String FILE_MODIFIED_AT_LAST_SYNC_FOR_DATA = "modified_at_last_sync_for_data";
public static final String FILE_CONTENT_LENGTH = "content_length";
public static final String FILE_CONTENT_TYPE = "content_type";
public static final String FILE_STORAGE_PATH = "media_path";
public static final String FILE_PATH = "path";
public static final String FILE_ACCOUNT_OWNER = "file_owner";
public static final String FILE_LAST_SYNC_DATE = "last_sync_date";// _for_properties, but let's keep it as it is
public static final String FILE_LAST_SYNC_DATE_FOR_DATA = "last_sync_date_for_data";
public static final String FILE_KEEP_IN_SYNC = "keep_in_sync";
public static final String FILE_ETAG = "etag";
public static final String FILE_TREE_ETAG = "tree_etag";
public static final String FILE_SHARED_VIA_LINK = "share_by_link";
public static final String FILE_SHARED_WITH_SHAREE = "shared_via_users";
public static final String FILE_PERMISSIONS = "permissions";
public static final String FILE_REMOTE_ID = "remote_id";
public static final String FILE_UPDATE_THUMBNAIL = "update_thumbnail";
public static final String FILE_IS_DOWNLOADING = "is_downloading";
public static final String FILE_ETAG_IN_CONFLICT = "etag_in_conflict";
public static final String FILE_PRIVATE_LINK = "private_link";
public static final String FILE_DEFAULT_SORT_ORDER = FILE_NAME
+ " collate nocase asc";
// @deprecated
public static final String FILE_PUBLIC_LINK = "public_link";
// Columns of ocshares table
public static final String OCSHARES_SHARE_TYPE = "share_type";
public static final String OCSHARES_SHARE_WITH = "share_with";
public static final String OCSHARES_PATH = "path";
public static final String OCSHARES_PERMISSIONS = "permissions";
public static final String OCSHARES_SHARED_DATE = "shared_date";
public static final String OCSHARES_EXPIRATION_DATE = "expiration_date";
public static final String OCSHARES_TOKEN = "token";
public static final String OCSHARES_SHARE_WITH_DISPLAY_NAME = "shared_with_display_name";
public static final String OCSHARES_SHARE_WITH_ADDITIONAL_INFO = "share_with_additional_info";
public static final String OCSHARES_IS_DIRECTORY = "is_directory";
public static final String OCSHARES_ID_REMOTE_SHARED = "id_remote_shared";
public static final String OCSHARES_ACCOUNT_OWNER = "owner_share";
public static final String OCSHARES_NAME = "name";
public static final String OCSHARES_URL = "url";
public static final String OCSHARES_DEFAULT_SORT_ORDER = OCSHARES_ID_REMOTE_SHARED
+ " collate nocase asc";
// Columns of capabilities table
public static final String CAPABILITIES_ACCOUNT_NAME = "account";
public static final String CAPABILITIES_VERSION_MAYOR = "version_mayor";
public static final String CAPABILITIES_VERSION_MINOR = "version_minor";
public static final String CAPABILITIES_VERSION_MICRO = "version_micro";
public static final String CAPABILITIES_VERSION_STRING = "version_string";
public static final String CAPABILITIES_VERSION_EDITION = "version_edition";
public static final String CAPABILITIES_CORE_POLLINTERVAL = "core_pollinterval";
public static final String CAPABILITIES_DAV_CHUNKING_VERSION = "dav_chunking_version";
public static final String CAPABILITIES_SHARING_API_ENABLED = "sharing_api_enabled";
public static final String CAPABILITIES_SHARING_PUBLIC_ENABLED = "sharing_public_enabled";
public static final String CAPABILITIES_SHARING_PUBLIC_PASSWORD_ENFORCED = "sharing_public_password_enforced";
public static final String CAPABILITIES_SHARING_PUBLIC_PASSWORD_ENFORCED_READ_ONLY =
"sharing_public_password_enforced_read_only";
public static final String CAPABILITIES_SHARING_PUBLIC_PASSWORD_ENFORCED_READ_WRITE =
"sharing_public_password_enforced_read_write";
public static final String CAPABILITIES_SHARING_PUBLIC_PASSWORD_ENFORCED_UPLOAD_ONLY =
"sharing_public_password_enforced_public_only";
public static final String CAPABILITIES_SHARING_PUBLIC_EXPIRE_DATE_ENABLED =
"sharing_public_expire_date_enabled";
public static final String CAPABILITIES_SHARING_PUBLIC_EXPIRE_DATE_DAYS =
"sharing_public_expire_date_days";
public static final String CAPABILITIES_SHARING_PUBLIC_EXPIRE_DATE_ENFORCED =
"sharing_public_expire_date_enforced";
public static final String CAPABILITIES_SHARING_PUBLIC_UPLOAD = "sharing_public_upload";
public static final String CAPABILITIES_SHARING_PUBLIC_MULTIPLE = "sharing_public_multiple";
public static final String CAPABILITIES_SHARING_PUBLIC_SUPPORTS_UPLOAD_ONLY = "supports_upload_only";
public static final String CAPABILITIES_SHARING_RESHARING = "sharing_resharing";
public static final String CAPABILITIES_SHARING_FEDERATION_OUTGOING = "sharing_federation_outgoing";
public static final String CAPABILITIES_SHARING_FEDERATION_INCOMING = "sharing_federation_incoming";
public static final String CAPABILITIES_FILES_BIGFILECHUNKING = "files_bigfilechunking";
public static final String CAPABILITIES_FILES_UNDELETE = "files_undelete";
public static final String CAPABILITIES_FILES_VERSIONING = "files_versioning";
public static final String CAPABILITIES_DEFAULT_SORT_ORDER = CAPABILITIES_ACCOUNT_NAME
+ " collate nocase asc";
//Columns of Uploads table
public static final String UPLOADS_LOCAL_PATH = "local_path";
public static final String UPLOADS_REMOTE_PATH = "remote_path";
public static final String UPLOADS_ACCOUNT_NAME = "account_name";
public static final String UPLOADS_FILE_SIZE = "file_size";
public static final String UPLOADS_STATUS = "status";
public static final String UPLOADS_LOCAL_BEHAVIOUR = "local_behaviour";
public static final String UPLOADS_UPLOAD_TIME = "upload_time";
public static final String UPLOADS_FORCE_OVERWRITE = "force_overwrite";
public static final String UPLOADS_IS_CREATE_REMOTE_FOLDER = "is_create_remote_folder";
public static final String UPLOADS_UPLOAD_END_TIMESTAMP = "upload_end_timestamp";
public static final String UPLOADS_LAST_RESULT = "last_result";
public static final String UPLOADS_CREATED_BY = "created_by";
public static final String UPLOADS_TRANSFER_ID = "transfer_id";
public static final String UPLOADS_DEFAULT_SORT_ORDER =
ProviderTableMeta._ID + " collate nocase desc";
// Columns of user_avatars table
public static final String USER_AVATARS__ACCOUNT_NAME = "account_name";
public static final String USER_AVATARS__CACHE_KEY = "cache_key";
public static final String USER_AVATARS__ETAG = "etag";
public static final String USER_AVATARS__MIME_TYPE = "mime_type";
// Columns of camera upload synchronization table
public static final String PICTURES_LAST_SYNC_TIMESTAMP = "pictures_last_sync_date";
public static final String VIDEOS_LAST_SYNC_TIMESTAMP = "videos_last_sync_date";
public static final String CAMERA_UPLOADS_SYNC_DEFAULT_SORT_ORDER =
ProviderTableMeta._ID + " collate nocase asc";
// Columns of user_quotas table
public static final String USER_QUOTAS__ACCOUNT_NAME = "account_name";
public static final String USER_QUOTAS__FREE = "free";
public static final String USER_QUOTAS__RELATIVE = "relative";
public static final String USER_QUOTAS__TOTAL = "total";
public static final String USER_QUOTAS__USED = "used";
public static final String USER_QUOTAS_DEFAULT_SORT_ORDER =
ProviderTableMeta._ID + " collate nocase asc";
}
}
@@ -0,0 +1,46 @@
/**
* qsfera Android client application
*
* @author David González Verdugo
* @author Abel García de Prada
* Copyright (C) 2021 ownCloud GmbH.
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License version 2,
* as published by the Free Software Foundation.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
package eu.qsfera.android.dependecyinjection
import androidx.work.WorkManager
import eu.qsfera.android.providers.AccountProvider
import eu.qsfera.android.providers.ContextProvider
import eu.qsfera.android.providers.CoroutinesDispatcherProvider
import eu.qsfera.android.providers.LogsProvider
import eu.qsfera.android.providers.MdmProvider
import eu.qsfera.android.providers.WorkManagerProvider
import eu.qsfera.android.providers.implementation.OCContextProvider
import org.koin.android.ext.koin.androidApplication
import org.koin.android.ext.koin.androidContext
import org.koin.dsl.module
val commonModule = module {
single { CoroutinesDispatcherProvider() }
factory<ContextProvider> { OCContextProvider(androidContext()) }
single { LogsProvider(get(), get()) }
single { MdmProvider(androidContext()) }
single { WorkManagerProvider(androidContext()) }
single { AccountProvider(androidContext()) }
single { WorkManager.getInstance(androidApplication()) }
}
@@ -0,0 +1,81 @@
/**
* qsfera Android client application
*
* @author David González Verdugo
* @author Abel García de Prada
* @author Juan Carlos Garrote Gascón
*
* Copyright (C) 2023 ownCloud GmbH.
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License version 2,
* as published by the Free Software Foundation.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
package eu.qsfera.android.dependecyinjection
import android.accounts.AccountManager
import eu.qsfera.android.MainApp.Companion.accountType
import eu.qsfera.android.MainApp.Companion.dataFolder
import eu.qsfera.android.data.QSferaDatabase
import eu.qsfera.android.data.appregistry.datasources.LocalAppRegistryDataSource
import eu.qsfera.android.data.appregistry.datasources.implementation.OCLocalAppRegistryDataSource
import eu.qsfera.android.data.authentication.datasources.LocalAuthenticationDataSource
import eu.qsfera.android.data.authentication.datasources.implementation.OCLocalAuthenticationDataSource
import eu.qsfera.android.data.capabilities.datasources.LocalCapabilitiesDataSource
import eu.qsfera.android.data.capabilities.datasources.implementation.OCLocalCapabilitiesDataSource
import eu.qsfera.android.data.files.datasources.LocalFileDataSource
import eu.qsfera.android.data.files.datasources.implementation.OCLocalFileDataSource
import eu.qsfera.android.data.folderbackup.datasources.LocalFolderBackupDataSource
import eu.qsfera.android.data.folderbackup.datasources.implementation.OCLocalFolderBackupDataSource
import eu.qsfera.android.data.providers.SharedPreferencesProvider
import eu.qsfera.android.data.providers.implementation.OCSharedPreferencesProvider
import eu.qsfera.android.data.sharing.shares.datasources.LocalShareDataSource
import eu.qsfera.android.data.sharing.shares.datasources.implementation.OCLocalShareDataSource
import eu.qsfera.android.data.spaces.datasources.LocalSpacesDataSource
import eu.qsfera.android.data.spaces.datasources.implementation.OCLocalSpacesDataSource
import eu.qsfera.android.data.providers.LocalStorageProvider
import eu.qsfera.android.data.providers.ScopedStorageProvider
import eu.qsfera.android.data.transfers.datasources.LocalTransferDataSource
import eu.qsfera.android.data.transfers.datasources.implementation.OCLocalTransferDataSource
import eu.qsfera.android.data.user.datasources.LocalUserDataSource
import eu.qsfera.android.data.user.datasources.implementation.OCLocalUserDataSource
import org.koin.android.ext.koin.androidContext
import org.koin.core.module.dsl.factoryOf
import org.koin.core.module.dsl.singleOf
import org.koin.dsl.bind
import org.koin.dsl.module
val localDataSourceModule = module {
single { AccountManager.get(androidContext()) }
single { QSferaDatabase.getDatabase(androidContext()).appRegistryDao() }
single { QSferaDatabase.getDatabase(androidContext()).capabilityDao() }
single { QSferaDatabase.getDatabase(androidContext()).fileDao() }
single { QSferaDatabase.getDatabase(androidContext()).folderBackUpDao() }
single { QSferaDatabase.getDatabase(androidContext()).shareDao() }
single { QSferaDatabase.getDatabase(androidContext()).spacesDao() }
single { QSferaDatabase.getDatabase(androidContext()).transferDao() }
single { QSferaDatabase.getDatabase(androidContext()).userDao() }
singleOf(::OCSharedPreferencesProvider) bind SharedPreferencesProvider::class
single<LocalStorageProvider> { ScopedStorageProvider(dataFolder, androidContext()) }
factory<LocalAuthenticationDataSource> { OCLocalAuthenticationDataSource(androidContext(), get(), get(), accountType) }
factoryOf(::OCLocalFolderBackupDataSource) bind LocalFolderBackupDataSource::class
factoryOf(::OCLocalAppRegistryDataSource) bind LocalAppRegistryDataSource::class
factoryOf(::OCLocalCapabilitiesDataSource) bind LocalCapabilitiesDataSource::class
factoryOf(::OCLocalFileDataSource) bind LocalFileDataSource::class
factoryOf(::OCLocalShareDataSource) bind LocalShareDataSource::class
factoryOf(::OCLocalSpacesDataSource) bind LocalSpacesDataSource::class
factoryOf(::OCLocalTransferDataSource) bind LocalTransferDataSource::class
factoryOf(::OCLocalUserDataSource) bind LocalUserDataSource::class
}
@@ -0,0 +1,86 @@
/**
* qsfera Android client application
*
* @author David González Verdugo
* Copyright (C) 2020 ownCloud GmbH.
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License version 2,
* as published by the Free Software Foundation.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
package eu.qsfera.android.dependecyinjection
import eu.qsfera.android.MainApp
import eu.qsfera.android.R
import eu.qsfera.android.data.ClientManager
import eu.qsfera.android.data.appregistry.datasources.RemoteAppRegistryDataSource
import eu.qsfera.android.data.appregistry.datasources.implementation.OCRemoteAppRegistryDataSource
import eu.qsfera.android.data.authentication.datasources.RemoteAuthenticationDataSource
import eu.qsfera.android.data.authentication.datasources.implementation.OCRemoteAuthenticationDataSource
import eu.qsfera.android.data.capabilities.datasources.RemoteCapabilitiesDataSource
import eu.qsfera.android.data.capabilities.datasources.implementation.OCRemoteCapabilitiesDataSource
import eu.qsfera.android.data.capabilities.datasources.mapper.RemoteCapabilityMapper
import eu.qsfera.android.data.files.datasources.RemoteFileDataSource
import eu.qsfera.android.data.files.datasources.implementation.OCRemoteFileDataSource
import eu.qsfera.android.data.oauth.datasources.RemoteOAuthDataSource
import eu.qsfera.android.data.oauth.datasources.implementation.OCRemoteOAuthDataSource
import eu.qsfera.android.data.server.datasources.RemoteServerInfoDataSource
import eu.qsfera.android.data.server.datasources.implementation.OCRemoteServerInfoDataSource
import eu.qsfera.android.data.sharing.sharees.datasources.RemoteShareeDataSource
import eu.qsfera.android.data.sharing.sharees.datasources.implementation.OCRemoteShareeDataSource
import eu.qsfera.android.data.sharing.sharees.datasources.mapper.RemoteShareeMapper
import eu.qsfera.android.data.sharing.shares.datasources.RemoteShareDataSource
import eu.qsfera.android.data.sharing.shares.datasources.implementation.OCRemoteShareDataSource
import eu.qsfera.android.data.sharing.shares.datasources.mapper.RemoteShareMapper
import eu.qsfera.android.data.spaces.datasources.RemoteSpacesDataSource
import eu.qsfera.android.data.spaces.datasources.implementation.OCRemoteSpacesDataSource
import eu.qsfera.android.data.user.datasources.RemoteUserDataSource
import eu.qsfera.android.data.user.datasources.implementation.OCRemoteUserDataSource
import eu.qsfera.android.data.webfinger.datasources.RemoteWebFingerDataSource
import eu.qsfera.android.data.webfinger.datasources.implementation.OCRemoteWebFingerDataSource
import eu.qsfera.android.lib.common.ConnectionValidator
import eu.qsfera.android.lib.resources.oauth.services.OIDCService
import eu.qsfera.android.lib.resources.oauth.services.implementation.OCOIDCService
import eu.qsfera.android.lib.resources.status.services.ServerInfoService
import eu.qsfera.android.lib.resources.status.services.implementation.OCServerInfoService
import eu.qsfera.android.lib.resources.webfinger.services.WebFingerService
import eu.qsfera.android.lib.resources.webfinger.services.implementation.OCWebFingerService
import org.koin.android.ext.koin.androidContext
import org.koin.core.module.dsl.factoryOf
import org.koin.core.module.dsl.singleOf
import org.koin.dsl.bind
import org.koin.dsl.module
val remoteDataSourceModule = module {
single { ConnectionValidator(androidContext(), androidContext().resources.getBoolean(R.bool.clear_cookies_on_validation)) }
single { ClientManager(get(), get(), androidContext(), MainApp.accountType, get()) }
singleOf(::OCServerInfoService) bind ServerInfoService::class
singleOf(::OCOIDCService) bind OIDCService::class
singleOf(::OCWebFingerService) bind WebFingerService::class
singleOf(::OCRemoteAppRegistryDataSource) bind RemoteAppRegistryDataSource::class
singleOf(::OCRemoteAuthenticationDataSource) bind RemoteAuthenticationDataSource::class
singleOf(::OCRemoteCapabilitiesDataSource) bind RemoteCapabilitiesDataSource::class
singleOf(::OCRemoteFileDataSource) bind RemoteFileDataSource::class
singleOf(::OCRemoteOAuthDataSource) bind RemoteOAuthDataSource::class
singleOf(::OCRemoteServerInfoDataSource) bind RemoteServerInfoDataSource::class
singleOf(::OCRemoteShareDataSource) bind RemoteShareDataSource::class
singleOf(::OCRemoteShareeDataSource) bind RemoteShareeDataSource::class
singleOf(::OCRemoteSpacesDataSource) bind RemoteSpacesDataSource::class
singleOf(::OCRemoteWebFingerDataSource) bind RemoteWebFingerDataSource::class
single<RemoteUserDataSource> { OCRemoteUserDataSource(get(), androidContext().resources.getDimension(R.dimen.file_avatar_size).toInt()) }
factoryOf(::RemoteCapabilityMapper)
factoryOf(::RemoteShareMapper)
factoryOf(::RemoteShareeMapper)
}
@@ -0,0 +1,69 @@
/**
* qsfera Android client application
*
* @author David González Verdugo
* @author Abel García de Prada
* @author Juan Carlos Garrote Gascón
*
* Copyright (C) 2022 ownCloud GmbH.
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License version 2,
* as published by the Free Software Foundation.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
package eu.qsfera.android.dependecyinjection
import eu.qsfera.android.data.appregistry.repository.OCAppRegistryRepository
import eu.qsfera.android.data.authentication.repository.OCAuthenticationRepository
import eu.qsfera.android.data.capabilities.repository.OCCapabilityRepository
import eu.qsfera.android.data.files.repository.OCFileRepository
import eu.qsfera.android.data.folderbackup.repository.OCFolderBackupRepository
import eu.qsfera.android.data.oauth.repository.OCOAuthRepository
import eu.qsfera.android.data.server.repository.OCServerInfoRepository
import eu.qsfera.android.data.sharing.sharees.repository.OCShareeRepository
import eu.qsfera.android.data.sharing.shares.repository.OCShareRepository
import eu.qsfera.android.data.spaces.repository.OCSpacesRepository
import eu.qsfera.android.data.transfers.repository.OCTransferRepository
import eu.qsfera.android.data.user.repository.OCUserRepository
import eu.qsfera.android.data.webfinger.repository.OCWebFingerRepository
import eu.qsfera.android.domain.appregistry.AppRegistryRepository
import eu.qsfera.android.domain.authentication.AuthenticationRepository
import eu.qsfera.android.domain.authentication.oauth.OAuthRepository
import eu.qsfera.android.domain.automaticuploads.FolderBackupRepository
import eu.qsfera.android.domain.capabilities.CapabilityRepository
import eu.qsfera.android.domain.files.FileRepository
import eu.qsfera.android.domain.server.ServerInfoRepository
import eu.qsfera.android.domain.sharing.sharees.ShareeRepository
import eu.qsfera.android.domain.sharing.shares.ShareRepository
import eu.qsfera.android.domain.spaces.SpacesRepository
import eu.qsfera.android.domain.transfers.TransferRepository
import eu.qsfera.android.domain.user.UserRepository
import eu.qsfera.android.domain.webfinger.WebFingerRepository
import org.koin.core.module.dsl.factoryOf
import org.koin.dsl.bind
import org.koin.dsl.module
val repositoryModule = module {
factoryOf(::OCAppRegistryRepository) bind AppRegistryRepository::class
factoryOf(::OCAuthenticationRepository) bind AuthenticationRepository::class
factoryOf(::OCCapabilityRepository) bind CapabilityRepository::class
factoryOf(::OCFileRepository) bind FileRepository::class
factoryOf(::OCFolderBackupRepository) bind FolderBackupRepository::class
factoryOf(::OCOAuthRepository) bind OAuthRepository::class
factoryOf(::OCServerInfoRepository) bind ServerInfoRepository::class
factoryOf(::OCShareRepository) bind ShareRepository::class
factoryOf(::OCShareeRepository) bind ShareeRepository::class
factoryOf(::OCSpacesRepository) bind SpacesRepository::class
factoryOf(::OCTransferRepository) bind TransferRepository::class
factoryOf(::OCUserRepository) bind UserRepository::class
factoryOf(::OCWebFingerRepository) bind WebFingerRepository::class
}
@@ -0,0 +1,281 @@
/**
* qsfera Android client application
*
* @author David González Verdugo
* @author Abel García de Prada
* @author Juan Carlos Garrote Gascón
* @author Aitor Ballesteros Pavón
* @author Jorge Aguado Recio
*
* Copyright (C) 2024 ownCloud GmbH.
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License version 2,
* as published by the Free Software Foundation.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
package eu.qsfera.android.dependecyinjection
import eu.qsfera.android.domain.appregistry.usecases.CreateFileWithAppProviderUseCase
import eu.qsfera.android.domain.appregistry.usecases.GetAppRegistryForMimeTypeAsStreamUseCase
import eu.qsfera.android.domain.appregistry.usecases.GetAppRegistryWhichAllowCreationAsStreamUseCase
import eu.qsfera.android.domain.appregistry.usecases.GetUrlToOpenInWebUseCase
import eu.qsfera.android.domain.authentication.oauth.OIDCDiscoveryUseCase
import eu.qsfera.android.domain.authentication.oauth.RegisterClientUseCase
import eu.qsfera.android.domain.authentication.oauth.RequestTokenUseCase
import eu.qsfera.android.domain.authentication.usecases.GetBaseUrlUseCase
import eu.qsfera.android.domain.authentication.usecases.LoginBasicAsyncUseCase
import eu.qsfera.android.domain.authentication.usecases.LoginOAuthAsyncUseCase
import eu.qsfera.android.domain.authentication.usecases.SupportsOAuth2UseCase
import eu.qsfera.android.domain.availableoffline.usecases.GetFilesAvailableOfflineFromAccountAsStreamUseCase
import eu.qsfera.android.domain.availableoffline.usecases.GetFilesAvailableOfflineFromAccountUseCase
import eu.qsfera.android.domain.availableoffline.usecases.GetFilesAvailableOfflineFromEveryAccountUseCase
import eu.qsfera.android.domain.availableoffline.usecases.SetFilesAsAvailableOfflineUseCase
import eu.qsfera.android.domain.availableoffline.usecases.UnsetFilesAsAvailableOfflineUseCase
import eu.qsfera.android.domain.automaticuploads.usecases.GetAutomaticUploadsConfigurationUseCase
import eu.qsfera.android.domain.automaticuploads.usecases.GetPictureUploadsConfigurationStreamUseCase
import eu.qsfera.android.domain.automaticuploads.usecases.GetVideoUploadsConfigurationStreamUseCase
import eu.qsfera.android.domain.automaticuploads.usecases.ResetPictureUploadsUseCase
import eu.qsfera.android.domain.automaticuploads.usecases.ResetVideoUploadsUseCase
import eu.qsfera.android.domain.automaticuploads.usecases.SavePictureUploadsConfigurationUseCase
import eu.qsfera.android.domain.automaticuploads.usecases.SaveVideoUploadsConfigurationUseCase
import eu.qsfera.android.domain.capabilities.usecases.GetCapabilitiesAsLiveDataUseCase
import eu.qsfera.android.domain.capabilities.usecases.GetStoredCapabilitiesUseCase
import eu.qsfera.android.domain.capabilities.usecases.RefreshCapabilitiesFromServerAsyncUseCase
import eu.qsfera.android.domain.files.usecases.IsAnyFileAvailableLocallyAndNotAvailableOfflineUseCase
import eu.qsfera.android.domain.files.usecases.CleanConflictUseCase
import eu.qsfera.android.domain.files.usecases.CleanWorkersUUIDUseCase
import eu.qsfera.android.domain.files.usecases.CopyFileUseCase
import eu.qsfera.android.domain.files.usecases.CreateFolderAsyncUseCase
import eu.qsfera.android.domain.files.usecases.DisableThumbnailsForFileUseCase
import eu.qsfera.android.domain.files.usecases.GetFileByIdAsStreamUseCase
import eu.qsfera.android.domain.files.usecases.GetFileByIdUseCase
import eu.qsfera.android.domain.files.usecases.GetFileByRemotePathUseCase
import eu.qsfera.android.domain.files.usecases.GetFileWithSyncInfoByIdUseCase
import eu.qsfera.android.domain.files.usecases.GetFolderContentAsStreamUseCase
import eu.qsfera.android.domain.files.usecases.GetFolderContentUseCase
import eu.qsfera.android.domain.files.usecases.GetFolderImagesUseCase
import eu.qsfera.android.domain.files.usecases.GetPersonalRootFolderForAccountUseCase
import eu.qsfera.android.domain.files.usecases.GetSearchFolderContentUseCase
import eu.qsfera.android.domain.files.usecases.GetSharedByLinkForAccountAsStreamUseCase
import eu.qsfera.android.domain.files.usecases.GetSharesRootFolderForAccount
import eu.qsfera.android.domain.files.usecases.GetWebDavUrlForSpaceUseCase
import eu.qsfera.android.domain.files.usecases.ManageDeepLinkUseCase
import eu.qsfera.android.domain.files.usecases.MoveFileUseCase
import eu.qsfera.android.domain.files.usecases.RemoveFileUseCase
import eu.qsfera.android.domain.files.usecases.RenameFileUseCase
import eu.qsfera.android.domain.files.usecases.SaveConflictUseCase
import eu.qsfera.android.domain.files.usecases.SaveDownloadWorkerUUIDUseCase
import eu.qsfera.android.domain.files.usecases.SaveFileOrFolderUseCase
import eu.qsfera.android.domain.files.usecases.SetLastUsageFileUseCase
import eu.qsfera.android.domain.files.usecases.SortFilesUseCase
import eu.qsfera.android.domain.files.usecases.SortFilesWithSyncInfoUseCase
import eu.qsfera.android.domain.files.usecases.UpdateAlreadyDownloadedFilesPathUseCase
import eu.qsfera.android.domain.server.usecases.GetServerInfoAsyncUseCase
import eu.qsfera.android.domain.sharing.sharees.GetShareesAsyncUseCase
import eu.qsfera.android.domain.sharing.shares.usecases.CreatePrivateShareAsyncUseCase
import eu.qsfera.android.domain.sharing.shares.usecases.CreatePublicShareAsyncUseCase
import eu.qsfera.android.domain.sharing.shares.usecases.DeleteShareAsyncUseCase
import eu.qsfera.android.domain.sharing.shares.usecases.EditPrivateShareAsyncUseCase
import eu.qsfera.android.domain.sharing.shares.usecases.EditPublicShareAsyncUseCase
import eu.qsfera.android.domain.sharing.shares.usecases.GetShareAsLiveDataUseCase
import eu.qsfera.android.domain.sharing.shares.usecases.GetSharesAsLiveDataUseCase
import eu.qsfera.android.domain.sharing.shares.usecases.RefreshSharesFromServerAsyncUseCase
import eu.qsfera.android.domain.spaces.usecases.GetPersonalAndProjectSpacesForAccountUseCase
import eu.qsfera.android.domain.spaces.usecases.GetPersonalAndProjectSpacesWithSpecialsForAccountAsStreamUseCase
import eu.qsfera.android.domain.spaces.usecases.GetPersonalSpaceForAccountUseCase
import eu.qsfera.android.domain.spaces.usecases.GetPersonalSpacesWithSpecialsForAccountAsStreamUseCase
import eu.qsfera.android.domain.spaces.usecases.GetProjectSpacesWithSpecialsForAccountAsStreamUseCase
import eu.qsfera.android.domain.spaces.usecases.GetSpaceByIdForAccountUseCase
import eu.qsfera.android.domain.spaces.usecases.GetSpaceWithSpecialsByIdForAccountUseCase
import eu.qsfera.android.domain.spaces.usecases.GetSpacesFromEveryAccountUseCaseAsStream
import eu.qsfera.android.domain.spaces.usecases.RefreshSpacesFromServerAsyncUseCase
import eu.qsfera.android.domain.transfers.usecases.ClearSuccessfulTransfersUseCase
import eu.qsfera.android.domain.transfers.usecases.GetAllTransfersAsStreamUseCase
import eu.qsfera.android.domain.transfers.usecases.GetAllTransfersUseCase
import eu.qsfera.android.domain.transfers.usecases.UpdatePendingUploadsPathUseCase
import eu.qsfera.android.domain.user.usecases.GetStoredQuotaUseCase
import eu.qsfera.android.domain.user.usecases.GetStoredQuotaAsStreamUseCase
import eu.qsfera.android.domain.user.usecases.GetUserAvatarAsyncUseCase
import eu.qsfera.android.domain.user.usecases.GetUserInfoAsyncUseCase
import eu.qsfera.android.domain.user.usecases.GetUserQuotasUseCase
import eu.qsfera.android.domain.user.usecases.GetUserQuotasAsStreamUseCase
import eu.qsfera.android.domain.user.usecases.RefreshUserQuotaFromServerAsyncUseCase
import eu.qsfera.android.domain.webfinger.usecases.GetQSferaInstanceFromWebFingerUseCase
import eu.qsfera.android.domain.webfinger.usecases.GetQSferaInstancesFromAuthenticatedWebFingerUseCase
import eu.qsfera.android.usecases.accounts.RemoveAccountUseCase
import eu.qsfera.android.usecases.files.FilterFileMenuOptionsUseCase
import eu.qsfera.android.usecases.files.RemoveLocalFilesForAccountUseCase
import eu.qsfera.android.usecases.files.RemoveLocallyFilesWithLastUsageOlderThanGivenTimeUseCase
import eu.qsfera.android.usecases.synchronization.SynchronizeFileUseCase
import eu.qsfera.android.usecases.synchronization.SynchronizeFolderUseCase
import eu.qsfera.android.usecases.transfers.downloads.CancelDownloadForFileUseCase
import eu.qsfera.android.usecases.transfers.downloads.CancelDownloadsRecursivelyUseCase
import eu.qsfera.android.usecases.transfers.downloads.DownloadFileUseCase
import eu.qsfera.android.usecases.transfers.downloads.GetLiveDataForDownloadingFileUseCase
import eu.qsfera.android.usecases.transfers.downloads.GetLiveDataForFinishedDownloadsFromAccountUseCase
import eu.qsfera.android.usecases.transfers.uploads.CancelTransfersFromAccountUseCase
import eu.qsfera.android.usecases.transfers.uploads.CancelUploadForFileUseCase
import eu.qsfera.android.usecases.transfers.uploads.CancelUploadUseCase
import eu.qsfera.android.usecases.transfers.uploads.CancelUploadsRecursivelyUseCase
import eu.qsfera.android.usecases.transfers.uploads.ClearFailedTransfersUseCase
import eu.qsfera.android.usecases.transfers.uploads.RetryFailedUploadsForAccountUseCase
import eu.qsfera.android.usecases.transfers.uploads.RetryFailedUploadsUseCase
import eu.qsfera.android.usecases.transfers.uploads.RetryUploadFromContentUriUseCase
import eu.qsfera.android.usecases.transfers.uploads.RetryUploadFromSystemUseCase
import eu.qsfera.android.usecases.transfers.uploads.UploadFileFromContentUriUseCase
import eu.qsfera.android.usecases.transfers.uploads.UploadFileFromSystemUseCase
import eu.qsfera.android.usecases.transfers.uploads.UploadFileInConflictUseCase
import eu.qsfera.android.usecases.transfers.uploads.UploadFilesFromContentUriUseCase
import eu.qsfera.android.usecases.transfers.uploads.UploadFilesFromSystemUseCase
import org.koin.core.module.dsl.factoryOf
import org.koin.dsl.module
val useCaseModule = module {
// Authentication
factoryOf(::GetBaseUrlUseCase)
factoryOf(::GetQSferaInstanceFromWebFingerUseCase)
factoryOf(::GetQSferaInstancesFromAuthenticatedWebFingerUseCase)
factoryOf(::LoginBasicAsyncUseCase)
factoryOf(::LoginOAuthAsyncUseCase)
factoryOf(::SupportsOAuth2UseCase)
// OAuth
factoryOf(::OIDCDiscoveryUseCase)
factoryOf(::RegisterClientUseCase)
factoryOf(::RequestTokenUseCase)
// Capabilities
factoryOf(::GetCapabilitiesAsLiveDataUseCase)
factoryOf(::GetStoredCapabilitiesUseCase)
factoryOf(::RefreshCapabilitiesFromServerAsyncUseCase)
// Files
factoryOf(::CleanConflictUseCase)
factoryOf(::CleanWorkersUUIDUseCase)
factoryOf(::CopyFileUseCase)
factoryOf(::CreateFolderAsyncUseCase)
factoryOf(::DisableThumbnailsForFileUseCase)
factoryOf(::FilterFileMenuOptionsUseCase)
factoryOf(::GetFileByIdAsStreamUseCase)
factoryOf(::GetFileByIdUseCase)
factoryOf(::GetFileByRemotePathUseCase)
factoryOf(::GetFileWithSyncInfoByIdUseCase)
factoryOf(::GetFolderContentAsStreamUseCase)
factoryOf(::GetFolderContentUseCase)
factoryOf(::GetFolderImagesUseCase)
factoryOf(::IsAnyFileAvailableLocallyAndNotAvailableOfflineUseCase)
factoryOf(::GetPersonalRootFolderForAccountUseCase)
factoryOf(::GetSearchFolderContentUseCase)
factoryOf(::GetSharedByLinkForAccountAsStreamUseCase)
factoryOf(::GetSharesRootFolderForAccount)
factoryOf(::GetUrlToOpenInWebUseCase)
factoryOf(::ManageDeepLinkUseCase)
factoryOf(::MoveFileUseCase)
factoryOf(::RemoveFileUseCase)
factoryOf(::RemoveLocalFilesForAccountUseCase)
factoryOf(::RemoveLocallyFilesWithLastUsageOlderThanGivenTimeUseCase)
factoryOf(::RenameFileUseCase)
factoryOf(::SaveConflictUseCase)
factoryOf(::SaveDownloadWorkerUUIDUseCase)
factoryOf(::SaveFileOrFolderUseCase)
factoryOf(::SetLastUsageFileUseCase)
factoryOf(::SortFilesUseCase)
factoryOf(::SortFilesWithSyncInfoUseCase)
factoryOf(::SynchronizeFileUseCase)
factoryOf(::SynchronizeFolderUseCase)
// Open in web
factoryOf(::CreateFileWithAppProviderUseCase)
factoryOf(::GetAppRegistryForMimeTypeAsStreamUseCase)
factoryOf(::GetAppRegistryWhichAllowCreationAsStreamUseCase)
factoryOf(::GetUrlToOpenInWebUseCase)
// Av Offline
factoryOf(::GetFilesAvailableOfflineFromAccountAsStreamUseCase)
factoryOf(::GetFilesAvailableOfflineFromAccountUseCase)
factoryOf(::GetFilesAvailableOfflineFromEveryAccountUseCase)
factoryOf(::SetFilesAsAvailableOfflineUseCase)
factoryOf(::UnsetFilesAsAvailableOfflineUseCase)
// Sharing
factoryOf(::CreatePrivateShareAsyncUseCase)
factoryOf(::CreatePublicShareAsyncUseCase)
factoryOf(::DeleteShareAsyncUseCase)
factoryOf(::EditPrivateShareAsyncUseCase)
factoryOf(::EditPublicShareAsyncUseCase)
factoryOf(::GetShareAsLiveDataUseCase)
factoryOf(::GetShareesAsyncUseCase)
factoryOf(::GetSharesAsLiveDataUseCase)
factoryOf(::RefreshSharesFromServerAsyncUseCase)
// Spaces
factoryOf(::GetPersonalAndProjectSpacesForAccountUseCase)
factoryOf(::GetPersonalAndProjectSpacesWithSpecialsForAccountAsStreamUseCase)
factoryOf(::GetPersonalSpaceForAccountUseCase)
factoryOf(::GetPersonalSpacesWithSpecialsForAccountAsStreamUseCase)
factoryOf(::GetProjectSpacesWithSpecialsForAccountAsStreamUseCase)
factoryOf(::GetSpaceWithSpecialsByIdForAccountUseCase)
factoryOf(::GetSpacesFromEveryAccountUseCaseAsStream)
factoryOf(::GetWebDavUrlForSpaceUseCase)
factoryOf(::RefreshSpacesFromServerAsyncUseCase)
factoryOf(::GetSpaceByIdForAccountUseCase)
// Transfers
factoryOf(::CancelDownloadForFileUseCase)
factoryOf(::CancelDownloadsRecursivelyUseCase)
factoryOf(::CancelTransfersFromAccountUseCase)
factoryOf(::CancelUploadForFileUseCase)
factoryOf(::CancelUploadUseCase)
factoryOf(::CancelUploadsRecursivelyUseCase)
factoryOf(::ClearFailedTransfersUseCase)
factoryOf(::ClearSuccessfulTransfersUseCase)
factoryOf(::DownloadFileUseCase)
factoryOf(::GetAllTransfersAsStreamUseCase)
factoryOf(::GetAllTransfersUseCase)
factoryOf(::GetLiveDataForDownloadingFileUseCase)
factoryOf(::GetLiveDataForFinishedDownloadsFromAccountUseCase)
factoryOf(::RetryFailedUploadsForAccountUseCase)
factoryOf(::RetryFailedUploadsUseCase)
factoryOf(::RetryUploadFromContentUriUseCase)
factoryOf(::RetryUploadFromSystemUseCase)
factoryOf(::UpdateAlreadyDownloadedFilesPathUseCase)
factoryOf(::UpdatePendingUploadsPathUseCase)
factoryOf(::UploadFileFromContentUriUseCase)
factoryOf(::UploadFileFromSystemUseCase)
factoryOf(::UploadFileInConflictUseCase)
factoryOf(::UploadFilesFromContentUriUseCase)
factoryOf(::UploadFilesFromSystemUseCase)
// User
factoryOf(::GetStoredQuotaAsStreamUseCase)
factoryOf(::GetStoredQuotaUseCase)
factoryOf(::GetUserAvatarAsyncUseCase)
factoryOf(::GetUserInfoAsyncUseCase)
factoryOf(::GetUserQuotasAsStreamUseCase)
factoryOf(::GetUserQuotasUseCase)
factoryOf(::RefreshUserQuotaFromServerAsyncUseCase)
// Server
factoryOf(::GetServerInfoAsyncUseCase)
// Camera Uploads
factoryOf(::GetAutomaticUploadsConfigurationUseCase)
factoryOf(::GetPictureUploadsConfigurationStreamUseCase)
factoryOf(::GetVideoUploadsConfigurationStreamUseCase)
factoryOf(::ResetPictureUploadsUseCase)
factoryOf(::ResetVideoUploadsUseCase)
factoryOf(::SavePictureUploadsConfigurationUseCase)
factoryOf(::SaveVideoUploadsConfigurationUseCase)
// Accounts
factoryOf(::RemoveAccountUseCase)
}
@@ -0,0 +1,106 @@
/**
* qsfera Android client application
*
* @author David González Verdugo
* @author Abel García de Prada
* @author Juan Carlos Garrote Gascón
* @author David Crespo Ríos
*
* Copyright (C) 2024 ownCloud GmbH.
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License version 2,
* as published by the Free Software Foundation.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
package eu.qsfera.android.dependecyinjection
import eu.qsfera.android.MainApp
import eu.qsfera.android.domain.files.model.FileListOption
import eu.qsfera.android.domain.files.model.OCFile
import eu.qsfera.android.presentation.accounts.ManageAccountsViewModel
import eu.qsfera.android.presentation.authentication.AuthenticationViewModel
import eu.qsfera.android.presentation.authentication.oauth.OAuthViewModel
import eu.qsfera.android.presentation.capabilities.CapabilityViewModel
import eu.qsfera.android.presentation.common.DrawerViewModel
import eu.qsfera.android.presentation.conflicts.ConflictsResolveViewModel
import eu.qsfera.android.presentation.files.details.FileDetailsViewModel
import eu.qsfera.android.presentation.files.filelist.MainFileListViewModel
import eu.qsfera.android.presentation.files.operations.FileOperationsViewModel
import eu.qsfera.android.presentation.logging.LogListViewModel
import eu.qsfera.android.presentation.migration.MigrationViewModel
import eu.qsfera.android.presentation.previews.PreviewAudioViewModel
import eu.qsfera.android.presentation.previews.PreviewTextViewModel
import eu.qsfera.android.presentation.previews.PreviewVideoViewModel
import eu.qsfera.android.presentation.releasenotes.ReleaseNotesViewModel
import eu.qsfera.android.presentation.security.biometric.BiometricViewModel
import eu.qsfera.android.presentation.security.passcode.PassCodeViewModel
import eu.qsfera.android.presentation.security.passcode.PasscodeAction
import eu.qsfera.android.presentation.security.pattern.PatternViewModel
import eu.qsfera.android.presentation.settings.SettingsViewModel
import eu.qsfera.android.presentation.settings.advanced.SettingsAdvancedViewModel
import eu.qsfera.android.presentation.settings.automaticuploads.SettingsPictureUploadsViewModel
import eu.qsfera.android.presentation.settings.automaticuploads.SettingsVideoUploadsViewModel
import eu.qsfera.android.presentation.settings.logging.SettingsLogsViewModel
import eu.qsfera.android.presentation.settings.more.SettingsMoreViewModel
import eu.qsfera.android.presentation.settings.security.SettingsSecurityViewModel
import eu.qsfera.android.presentation.sharing.ShareViewModel
import eu.qsfera.android.presentation.spaces.SpacesListViewModel
import eu.qsfera.android.presentation.transfers.TransfersViewModel
import eu.qsfera.android.ui.ReceiveExternalFilesViewModel
import eu.qsfera.android.ui.preview.PreviewImageViewModel
import org.koin.androidx.viewmodel.dsl.viewModel
import org.koin.androidx.viewmodel.dsl.viewModelOf
import org.koin.dsl.module
val viewModelModule = module {
viewModelOf(::ManageAccountsViewModel)
viewModelOf(::BiometricViewModel)
viewModelOf(::DrawerViewModel)
viewModelOf(::FileDetailsViewModel)
viewModelOf(::FileOperationsViewModel)
viewModelOf(::LogListViewModel)
viewModelOf(::OAuthViewModel)
viewModelOf(::PatternViewModel)
viewModelOf(::PreviewAudioViewModel)
viewModelOf(::PreviewImageViewModel)
viewModelOf(::PreviewTextViewModel)
viewModelOf(::PreviewVideoViewModel)
viewModelOf(::ReceiveExternalFilesViewModel)
viewModelOf(::ReleaseNotesViewModel)
viewModelOf(::SettingsAdvancedViewModel)
viewModelOf(::SettingsLogsViewModel)
viewModelOf(::SettingsMoreViewModel)
viewModelOf(::SettingsPictureUploadsViewModel)
viewModelOf(::SettingsSecurityViewModel)
viewModelOf(::SettingsVideoUploadsViewModel)
viewModelOf(::SettingsViewModel)
viewModelOf(::FileOperationsViewModel)
viewModel { (accountName: String) -> CapabilityViewModel(accountName, get(), get(), get(), get()) }
viewModel { (action: PasscodeAction) -> PassCodeViewModel(get(), get(), action) }
viewModel { (filePath: String, accountName: String) ->
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(),
initialFolderToDisplay, fileListOption)
}
viewModel { (ocFile: OCFile) -> ConflictsResolveViewModel(get(), get(), get(), get(), get(), ocFile) }
viewModel { AuthenticationViewModel(get(), get(), get(), get(), get(), get(), get(), get(), get(), get(), get(), get(), get(), get(), get()) }
viewModel { MigrationViewModel(MainApp.dataFolder, get(), get(), get(), get(), get(), get(), get()) }
viewModel { TransfersViewModel(get(), get(), get(), get(), get(), get(), get(), get(), get(), get(), get(), get(), get(), get(), get(), get(),
get()) }
viewModel { ReceiveExternalFilesViewModel(get(), get(), get(), get()) }
viewModel { (accountName: String, showPersonalSpace: Boolean) ->
SpacesListViewModel(get(), get(), get(), get(), get(), get(), get(), accountName, showPersonalSpace)
}
}
@@ -0,0 +1,484 @@
/**
* qsfera Android client application
*
* @author David González Verdugo
* @author Aitor Ballesteros Pavón
*
* Copyright (C) 2024 ownCloud GmbH.
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License version 2,
* as published by the Free Software Foundation.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
package eu.qsfera.android.extensions
import android.app.Activity
import android.app.AlertDialog
import android.content.ActivityNotFoundException
import android.content.Context
import android.content.Intent
import android.content.Intent.FLAG_ACTIVITY_NO_HISTORY
import android.content.pm.PackageManager
import android.content.pm.ResolveInfo
import android.net.Uri
import android.text.method.LinkMovementMethod
import android.util.TypedValue
import android.view.inputmethod.InputMethodManager
import android.webkit.MimeTypeMap
import android.widget.LinearLayout
import android.widget.TextView
import android.widget.Toast
import androidx.appcompat.app.AppCompatActivity
import androidx.core.content.FileProvider
import androidx.core.text.HtmlCompat
import androidx.fragment.app.FragmentActivity
import androidx.lifecycle.Lifecycle
import androidx.lifecycle.lifecycleScope
import androidx.lifecycle.repeatOnLifecycle
import com.google.android.material.snackbar.Snackbar
import eu.qsfera.android.BuildConfig
import eu.qsfera.android.R
import eu.qsfera.android.data.providers.implementation.OCSharedPreferencesProvider
import eu.qsfera.android.domain.files.model.OCFile
import eu.qsfera.android.presentation.common.ShareSheetHelper
import eu.qsfera.android.presentation.security.LockEnforcedType
import eu.qsfera.android.presentation.security.LockEnforcedType.Companion.parseFromInteger
import eu.qsfera.android.presentation.security.LockType
import eu.qsfera.android.presentation.security.SecurityEnforced
import eu.qsfera.android.presentation.security.biometric.BiometricActivity
import eu.qsfera.android.presentation.security.biometric.BiometricStatus
import eu.qsfera.android.presentation.security.biometric.EnableBiometrics
import eu.qsfera.android.presentation.security.isDeviceSecure
import eu.qsfera.android.presentation.security.passcode.PassCodeActivity
import eu.qsfera.android.presentation.security.pattern.PatternActivity
import eu.qsfera.android.presentation.settings.privacypolicy.PrivacyPolicyActivity
import eu.qsfera.android.presentation.settings.security.SettingsSecurityFragment.Companion.EXTRAS_LOCK_ENFORCED
import eu.qsfera.android.providers.MdmProvider
import eu.qsfera.android.ui.activity.DrawerActivity
import eu.qsfera.android.ui.activity.FileDisplayActivity.Companion.ALL_FILES_SAF_REGEX
import eu.qsfera.android.utils.CONFIGURATION_DEVICE_PROTECTION
import eu.qsfera.android.utils.MimetypeIconUtil
import eu.qsfera.android.utils.UriUtilsKt.getExposedFileUriForOCFile
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.collectLatest
import kotlinx.coroutines.launch
import org.koin.android.ext.android.inject
import timber.log.Timber
import java.io.File
fun Activity.showErrorInSnackbar(genericErrorMessageId: Int, throwable: Throwable?) =
throwable?.let {
showMessageInSnackbar(
message = it.parseError(getString(genericErrorMessageId), resources)
)
}
fun Activity.showMessageInSnackbar(
layoutId: Int = android.R.id.content,
message: CharSequence,
duration: Int = Snackbar.LENGTH_LONG
) {
Snackbar.make(findViewById(layoutId), message, duration).show()
}
fun Activity.showErrorInToast(
genericErrorMessageId: Int,
throwable: Throwable?,
duration: Int = Toast.LENGTH_SHORT
) =
throwable?.let {
Toast.makeText(
this,
it.parseError(getString(genericErrorMessageId), resources),
duration
).show()
}
fun Activity.goToUrl(
url: String,
flags: Int? = null
) {
if (url.isNotEmpty()) {
val uriUrl = Uri.parse(url)
val intent = Intent(Intent.ACTION_VIEW, uriUrl)
if (flags != null) intent.addFlags(flags)
try {
startActivity(intent)
} catch (e: ActivityNotFoundException) {
showMessageInSnackbar(message = this.getString(R.string.file_list_no_app_for_perform_action))
Timber.e("No Activity found to handle Intent")
}
}
}
fun Activity.openPrivacyPolicy() {
val urlPrivacyPolicy = getString(R.string.url_privacy_policy)
val cantBeOpenedWithWebView = urlPrivacyPolicy.endsWith("pdf")
if (cantBeOpenedWithWebView) {
goToUrl(urlPrivacyPolicy)
} else {
val intent = Intent(this, PrivacyPolicyActivity::class.java)
startActivity(intent)
}
}
fun Activity.sendEmail(
email: String,
subject: String? = null,
text: String? = null
) {
val intent = Intent(Intent.ACTION_SENDTO).apply {
data = Uri.parse(email)
addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
putExtra(Intent.EXTRA_SUBJECT, subject)
if (text != null) putExtra(Intent.EXTRA_TEXT, text)
}
try {
startActivity(intent)
} catch (e: ActivityNotFoundException) {
showMessageInSnackbar(message = this.getString(R.string.file_list_no_app_for_perform_action))
Timber.e("No Activity found to handle Intent")
}
}
private fun getIntentForSavedMimeType(data: Uri, type: String): Intent {
val intentForSavedMimeType = Intent(Intent.ACTION_VIEW)
intentForSavedMimeType.setDataAndType(data, type)
intentForSavedMimeType.flags = Intent.FLAG_GRANT_READ_URI_PERMISSION or Intent.FLAG_GRANT_WRITE_URI_PERMISSION
return intentForSavedMimeType
}
private fun getIntentForGuessedMimeType(storagePath: String, type: String, data: Uri): Intent? {
var intentForGuessedMimeType: Intent? = null
if (storagePath.lastIndexOf('.') >= 0) {
val guessedMimeType = MimeTypeMap.getSingleton().getMimeTypeFromExtension(storagePath.substring(storagePath.lastIndexOf('.') + 1))
if (guessedMimeType != null && guessedMimeType != type) {
intentForGuessedMimeType = Intent(Intent.ACTION_VIEW)
intentForGuessedMimeType.setDataAndType(data, guessedMimeType)
intentForGuessedMimeType.flags = Intent.FLAG_GRANT_READ_URI_PERMISSION or Intent.FLAG_GRANT_WRITE_URI_PERMISSION
}
}
return intentForGuessedMimeType
}
fun Activity.openFile(file: File?) {
if (file != null) {
val intentForSavedMimeType = getIntentForSavedMimeType(
getExposedFileUri(this, file.path)!!,
MimetypeIconUtil.getBestMimeTypeByFilename(file.name)
)
val intentForGuessedMimeType = getIntentForGuessedMimeType(
file.path,
MimetypeIconUtil.getBestMimeTypeByFilename(file.name), getExposedFileUri(this, file.path)!!
)
openFileWithIntent(intentForSavedMimeType, intentForGuessedMimeType)
} else {
Timber.e("Trying to open a NULL file")
}
}
private fun getExposedFileUri(context: Context, localPath: String): Uri? {
var exposedFileUri: Uri? = null
if (localPath.isEmpty()) {
return null
}
// Use the FileProvider to get a content URI
try {
exposedFileUri = FileProvider.getUriForFile(
context,
context.getString(R.string.file_provider_authority),
File(localPath)
)
} catch (e: IllegalArgumentException) {
Timber.e(e, "File can't be exported")
}
return exposedFileUri
}
fun Activity.openFileWithIntent(intentForSavedMimeType: Intent, intentForGuessedMimeType: Intent?) {
val openFileWithIntent: Intent = intentForGuessedMimeType ?: intentForSavedMimeType
val launchables: List<ResolveInfo> =
this.packageManager.queryIntentActivities(openFileWithIntent, PackageManager.MATCH_DEFAULT_ONLY)
if (launchables.isNotEmpty()) {
try {
this.startActivity(
Intent.createChooser(
openFileWithIntent, this.getString(R.string.actionbar_open_with)
)
)
} catch (anfe: ActivityNotFoundException) {
Timber.i(anfe, "No app found for file type")
showMessageInSnackbar(
message = this.getString(
R.string.file_list_no_app_for_file_type
)
)
}
} else {
showMessageInSnackbar(
message = this.getString(
R.string.file_list_no_app_for_file_type
)
)
}
}
fun AppCompatActivity.sendFile(file: File?) {
if (file != null) {
val sendIntent: Intent = makeIntent(file, this)
val shareSheetIntent = ShareSheetHelper().getShareSheetIntent(
intent = sendIntent,
context = this,
title = R.string.activity_chooser_send_file_title,
packagesToExclude = arrayOf()
)
this.startActivity(shareSheetIntent)
} else {
Timber.e("Trying to send a NULL file")
}
}
private fun makeIntent(file: File?, context: Context): Intent {
val sendIntent = Intent(Intent.ACTION_SEND)
if (file != null) {
// set MimeType
sendIntent.type = MimetypeIconUtil.getBestMimeTypeByFilename(file.name)
sendIntent.putExtra(
Intent.EXTRA_STREAM,
getExposedFileUri(context, file.path)
)
}
sendIntent.putExtra(Intent.ACTION_SEND, true) // Send Action
return sendIntent
}
fun Activity.hideSoftKeyboard() {
val focusedView = currentFocus
focusedView?.let {
val inputMethodManager = getSystemService(Context.INPUT_METHOD_SERVICE) as InputMethodManager
inputMethodManager.hideSoftInputFromWindow(
focusedView.windowToken,
0
)
}
}
fun Activity.checkPasscodeEnforced(securityEnforced: SecurityEnforced) {
val sharedPreferencesProvider = OCSharedPreferencesProvider(this)
val mdmProvider by inject<MdmProvider>()
// If device protection is false, launch the previous behaviour (check the lockEnforced).
// If device protection is true, ask for security only if device is not secure.
val showDeviceProtectionForced: Boolean =
mdmProvider.getBrandingBoolean(CONFIGURATION_DEVICE_PROTECTION, R.bool.device_protection) && !isDeviceSecure()
val lockEnforced: Int = this.resources.getInteger(R.integer.lock_enforced)
val passcodeConfigured = sharedPreferencesProvider.getBoolean(PassCodeActivity.PREFERENCE_SET_PASSCODE, false)
val patternConfigured = sharedPreferencesProvider.getBoolean(PatternActivity.PREFERENCE_SET_PATTERN, false)
when (parseFromInteger(lockEnforced)) {
LockEnforcedType.DISABLED -> {
if (showDeviceProtectionForced) {
showSelectSecurityDialog(passcodeConfigured, patternConfigured, securityEnforced)
}
}
LockEnforcedType.EITHER_ENFORCED -> {
showSelectSecurityDialog(passcodeConfigured, patternConfigured, securityEnforced)
}
LockEnforcedType.PASSCODE_ENFORCED -> {
if (!passcodeConfigured) {
manageOptionLockSelected(LockType.PASSCODE)
}
}
LockEnforcedType.PATTERN_ENFORCED -> {
if (!patternConfigured) {
manageOptionLockSelected(LockType.PATTERN)
}
}
}
}
private fun Activity.showSelectSecurityDialog(
passcodeConfigured: Boolean,
patternConfigured: Boolean,
securityEnforced: SecurityEnforced
) {
if (!passcodeConfigured && !patternConfigured) {
val options = arrayOf(getString(R.string.security_enforced_first_option), getString(R.string.security_enforced_second_option))
var optionSelected = 0
AlertDialog.Builder(this)
.setCancelable(false)
.setTitle(getString(R.string.security_enforced_title))
.setSingleChoiceItems(options, LockType.PASSCODE.ordinal) { _, which -> optionSelected = which }
.setPositiveButton(android.R.string.ok) { dialog, _ ->
when (LockType.parseFromInteger(optionSelected)) {
LockType.PASSCODE -> securityEnforced.optionLockSelected(LockType.PASSCODE)
LockType.PATTERN -> securityEnforced.optionLockSelected(LockType.PATTERN)
}
dialog.dismiss()
}
.show()
}
}
fun Activity.sendEmailOrOpenFeedbackDialogAction(feedbackMail: String) {
if (feedbackMail.isNotEmpty()) {
val feedback = "Android v" + BuildConfig.VERSION_NAME + " - " + getString(R.string.prefs_feedback)
sendEmail(email = feedbackMail, subject = feedback)
} else {
openFeedbackDialog()
}
}
fun Activity.openFeedbackDialog() {
val getInContactDescription =
getString(
R.string.feedback_dialog_get_in_contact_description,
DrawerActivity.GITHUB_URL
).trimIndent()
val spannableString = HtmlCompat.fromHtml(getInContactDescription, HtmlCompat.FROM_HTML_MODE_LEGACY)
val getInContactDescriptionTextView = TextView(this).apply {
text = spannableString
setTextColor(getColor(android.R.color.black))
setTextSize(TypedValue.COMPLEX_UNIT_SP, 16f)
movementMethod = LinkMovementMethod.getInstance()
}
val layout = LinearLayout(this).apply {
orientation = LinearLayout.VERTICAL
setPadding(64, 16, 64, 16)
addView(getInContactDescriptionTextView)
}
val builder = AlertDialog.Builder(this)
builder.apply {
setTitle(getString(R.string.drawer_feedback))
setView(layout)
setNegativeButton(R.string.drawer_close) { dialog, _ ->
dialog.dismiss()
}
setCancelable(false)
}
val alertDialog = builder.create()
alertDialog.show()
}
fun Activity.manageOptionLockSelected(type: LockType) {
OCSharedPreferencesProvider(this).let {
// Remove passcode
it.removePreference(PassCodeActivity.PREFERENCE_PASSCODE)
it.putBoolean(PassCodeActivity.PREFERENCE_SET_PASSCODE, false)
// Remove pattern
it.removePreference(PatternActivity.PREFERENCE_PATTERN)
it.putBoolean(PatternActivity.PREFERENCE_SET_PATTERN, false)
// Remove biometric
it.putBoolean(BiometricActivity.PREFERENCE_SET_BIOMETRIC, false)
}
when (type) {
LockType.PASSCODE -> startActivity(Intent(this, PassCodeActivity::class.java).apply {
action = PassCodeActivity.ACTION_CREATE
flags = FLAG_ACTIVITY_NO_HISTORY
putExtra(EXTRAS_LOCK_ENFORCED, true)
})
LockType.PATTERN -> startActivity(Intent(this, PatternActivity::class.java).apply {
action = PatternActivity.ACTION_REQUEST_WITH_RESULT
flags = FLAG_ACTIVITY_NO_HISTORY
putExtra(EXTRAS_LOCK_ENFORCED, true)
})
}
}
fun Activity.showBiometricDialog(iEnableBiometrics: EnableBiometrics) {
AlertDialog.Builder(this)
.setCancelable(false)
.setTitle(getString(R.string.biometric_dialog_title))
.setPositiveButton(R.string.common_yes) { dialog, _ ->
iEnableBiometrics.onOptionSelected(BiometricStatus.ENABLED_BY_USER)
dialog.dismiss()
}
.setNegativeButton(R.string.common_no) { dialog, _ ->
iEnableBiometrics.onOptionSelected(BiometricStatus.DISABLED_BY_USER)
dialog.dismiss()
}
.show()
}
fun FragmentActivity.sendDownloadedFilesByShareSheet(ocFiles: List<OCFile>) {
if (ocFiles.isEmpty()) throw IllegalArgumentException("Can't share anything")
val sendIntent = if (ocFiles.size == 1) {
Intent(Intent.ACTION_SEND).apply {
type = ocFiles.first().mimeType
putExtra(Intent.EXTRA_STREAM, getExposedFileUriForOCFile(this@sendDownloadedFilesByShareSheet, ocFiles.first()))
}
} else {
val fileUris = ocFiles.map { getExposedFileUriForOCFile(this@sendDownloadedFilesByShareSheet, it) }
Intent(Intent.ACTION_SEND_MULTIPLE).apply {
type = ALL_FILES_SAF_REGEX
putParcelableArrayListExtra(Intent.EXTRA_STREAM, ArrayList(fileUris))
}
}
val packagesToExclude = arrayOf<String>(this@sendDownloadedFilesByShareSheet.packageName)
val shareSheetIntent = ShareSheetHelper().getShareSheetIntent(
sendIntent,
this@sendDownloadedFilesByShareSheet,
R.string.activity_chooser_send_file_title,
packagesToExclude
)
startActivity(shareSheetIntent)
}
fun Activity.openOCFile(ocFile: OCFile) {
val intentForSavedMimeType = Intent(Intent.ACTION_VIEW).apply {
setDataAndType(getExposedFileUriForOCFile(this@openOCFile, ocFile), ocFile.mimeType)
flags = Intent.FLAG_GRANT_READ_URI_PERMISSION
if (ocFile.hasWritePermission) {
flags = flags or Intent.FLAG_GRANT_WRITE_URI_PERMISSION
}
}
try {
startActivity(Intent.createChooser(intentForSavedMimeType, getString(R.string.actionbar_open_with)))
} catch (anfe: ActivityNotFoundException) {
showErrorInSnackbar(genericErrorMessageId = R.string.file_list_no_app_for_file_type, anfe)
}
}
fun <T> FragmentActivity.collectLatestLifecycleFlow(
flow: Flow<T>,
lifecycleState: Lifecycle.State = Lifecycle.State.STARTED,
collect: suspend (T) -> Unit
) {
lifecycleScope.launch {
repeatOnLifecycle(lifecycleState) {
flow.collectLatest(collect)
}
}
}
@@ -0,0 +1,41 @@
/**
* qsfera Android client application
*
* @author Abel García de Prada
* Copyright (C) 2020 ownCloud GmbH.
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License version 2,
* as published by the Free Software Foundation.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
package eu.qsfera.android.extensions
import android.app.NotificationChannel
import android.app.NotificationManager
import android.content.Context
import android.os.Build
import androidx.annotation.RequiresApi
@RequiresApi(Build.VERSION_CODES.O)
fun Context.createNotificationChannel(
id: String,
name: String,
description: String,
importance: Int
) {
val notificationManager = getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
val notificationChannel = NotificationChannel(id, name, importance).apply {
setDescription(description)
}
notificationManager.createNotificationChannel(notificationChannel)
}
@@ -0,0 +1,38 @@
/**
* qsfera Android client application
*
* @author Abel García de Prada
* Copyright (C) 2020 ownCloud GmbH.
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License version 2,
* as published by the Free Software Foundation.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
package eu.qsfera.android.extensions
import android.database.Cursor
fun Cursor.getStringFromColumnOrThrow(
columnName: String
): String? = getString(getColumnIndexOrThrow(columnName))
fun Cursor.getStringFromColumnOrEmpty(
columnName: String
): String = getColumnIndex(columnName).takeUnless { it < 0 }?.let { getString(it) }.orEmpty()
fun Cursor.getIntFromColumnOrThrow(
columnName: String
): Int = getInt(getColumnIndexOrThrow(columnName))
fun Cursor.getLongFromColumnOrThrow(
columnName: String
): Long = getLong(getColumnIndexOrThrow(columnName))
@@ -0,0 +1,31 @@
/**
* qsfera Android client application
*
* @author David Crespo Ríos
* Copyright (C) 2022 ownCloud GmbH.
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License version 2,
* as published by the Free Software Foundation.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
package eu.qsfera.android.extensions
import android.app.Dialog
import android.view.WindowManager
import eu.qsfera.android.BuildConfig
import eu.qsfera.android.R
fun Dialog.avoidScreenshotsIfNeeded() {
if (!BuildConfig.DEBUG && context.resources?.getBoolean(R.bool.allow_screenshots) == false) {
window?.addFlags(WindowManager.LayoutParams.FLAG_SECURE)
}
}
@@ -0,0 +1,30 @@
/*
* qsfera Android client application
*
* @author Fernando Sanz Velasco
* Copyright (C) 2021 ownCloud GmbH.
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License version 2,
* as published by the Free Software Foundation.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*
*/
package eu.qsfera.android.extensions
import android.content.Context
import eu.qsfera.android.utils.DisplayUtils
import java.io.File
fun File.toLegibleStringSize(context: Context): String {
val bytes = if (!exists()) 0L else length()
return DisplayUtils.bytesToHumanReadable(bytes, context, true)
}
@@ -0,0 +1,48 @@
/**
* qsfera Android client application
*
* Copyright (C) 2022 ownCloud GmbH.
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License version 2,
* as published by the Free Software Foundation.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*
*/
package eu.qsfera.android.extensions
import androidx.annotation.DrawableRes
import androidx.annotation.StringRes
import eu.qsfera.android.R
import eu.qsfera.android.domain.files.model.FileListOption
@StringRes
fun FileListOption.toTitleStringRes(): Int = when (this) {
FileListOption.ALL_FILES -> R.string.file_list_empty_title_all_files
FileListOption.SPACES_LIST -> R.string.spaces_list_empty_title
FileListOption.SHARED_BY_LINK -> R.string.file_list_empty_title_shared_by_links
FileListOption.AV_OFFLINE -> R.string.file_list_empty_title_available_offline
}
@StringRes
fun FileListOption.toSubtitleStringRes(): Int = when (this) {
FileListOption.ALL_FILES -> R.string.file_list_empty_subtitle_all_files
FileListOption.SPACES_LIST -> R.string.spaces_list_empty_subtitle
FileListOption.SHARED_BY_LINK -> R.string.file_list_empty_subtitle_shared_by_links
FileListOption.AV_OFFLINE -> R.string.file_list_empty_subtitle_available_offline
}
@DrawableRes
fun FileListOption.toDrawableRes(): Int = when (this) {
FileListOption.ALL_FILES -> R.drawable.ic_folder
FileListOption.SPACES_LIST -> R.drawable.ic_spaces
FileListOption.SHARED_BY_LINK -> R.drawable.ic_shared_by_link
FileListOption.AV_OFFLINE -> R.drawable.ic_available_offline
}
@@ -0,0 +1,81 @@
/**
* qsfera Android client application
*
* @author Juan Carlos Garrote Gascón
*
* Copyright (C) 2023 ownCloud GmbH.
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License version 2,
* as published by the Free Software Foundation.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
package eu.qsfera.android.extensions
import eu.qsfera.android.R
import eu.qsfera.android.domain.files.model.FileMenuOption
fun FileMenuOption.toResId() =
when (this) {
FileMenuOption.SELECT_ALL -> R.id.file_action_select_all
FileMenuOption.SELECT_INVERSE -> R.id.action_select_inverse
FileMenuOption.DOWNLOAD -> R.id.action_download_file
FileMenuOption.RENAME -> R.id.action_rename_file
FileMenuOption.MOVE -> R.id.action_move
FileMenuOption.COPY -> R.id.action_copy
FileMenuOption.REMOVE -> R.id.action_remove_file
FileMenuOption.OPEN_WITH -> R.id.action_open_file_with
FileMenuOption.SYNC -> R.id.action_sync_file
FileMenuOption.CANCEL_SYNC -> R.id.action_cancel_sync
FileMenuOption.SHARE -> R.id.action_share_file
FileMenuOption.DETAILS -> R.id.action_see_details
FileMenuOption.SEND -> R.id.action_send_file
FileMenuOption.SET_AV_OFFLINE -> R.id.action_set_available_offline
FileMenuOption.UNSET_AV_OFFLINE -> R.id.action_unset_available_offline
}
fun FileMenuOption.toStringResId() =
when (this) {
FileMenuOption.SELECT_ALL -> R.string.actionbar_select_all
FileMenuOption.SELECT_INVERSE -> R.string.actionbar_select_inverse
FileMenuOption.DOWNLOAD -> R.string.filedetails_download
FileMenuOption.RENAME -> R.string.common_rename
FileMenuOption.MOVE -> R.string.actionbar_move
FileMenuOption.COPY -> android.R.string.copy
FileMenuOption.REMOVE -> R.string.common_remove
FileMenuOption.OPEN_WITH -> R.string.actionbar_open_with
FileMenuOption.SYNC -> R.string.filedetails_sync_file
FileMenuOption.CANCEL_SYNC -> R.string.common_cancel_sync
FileMenuOption.SHARE -> R.string.action_share
FileMenuOption.DETAILS -> R.string.actionbar_see_details
FileMenuOption.SEND -> R.string.actionbar_send_file
FileMenuOption.SET_AV_OFFLINE -> R.string.set_available_offline
FileMenuOption.UNSET_AV_OFFLINE -> R.string.unset_available_offline
}
fun FileMenuOption.toDrawableResId() =
when (this) {
FileMenuOption.SELECT_ALL -> R.drawable.ic_select_all
FileMenuOption.SELECT_INVERSE -> R.drawable.ic_select_inverse
FileMenuOption.DOWNLOAD -> R.drawable.ic_action_download
FileMenuOption.RENAME -> R.drawable.ic_pencil
FileMenuOption.MOVE -> R.drawable.ic_action_move
FileMenuOption.COPY -> R.drawable.ic_action_copy
FileMenuOption.REMOVE -> R.drawable.ic_action_delete_white
FileMenuOption.OPEN_WITH -> R.drawable.ic_open_in_app
FileMenuOption.SYNC -> R.drawable.ic_action_refresh
FileMenuOption.CANCEL_SYNC -> R.drawable.ic_action_cancel_white
FileMenuOption.SHARE -> R.drawable.ic_share_generic_white
FileMenuOption.DETAILS -> R.drawable.ic_info_white
FileMenuOption.SEND -> R.drawable.ic_send_white
FileMenuOption.SET_AV_OFFLINE -> R.drawable.ic_action_set_available_offline
FileMenuOption.UNSET_AV_OFFLINE -> R.drawable.ic_action_unset_available_offline
}
@@ -0,0 +1,38 @@
/**
* qsfera Android client application
*
* @author David González Verdugo
* Copyright (C) 2020 ownCloud GmbH.
*
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License version 2,
* as published by the Free Software Foundation.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <http:></http:>//www.gnu.org/licenses/>.
*/
package eu.qsfera.android.extensions
import androidx.fragment.app.DialogFragment
import androidx.fragment.app.FragmentActivity
fun FragmentActivity.showDialogFragment(
newFragment: DialogFragment, fragmentTag: String
) {
val ft = supportFragmentManager.beginTransaction()
val prev = supportFragmentManager.findFragmentByTag(fragmentTag)
if (prev != null) {
ft.remove(prev)
}
ft.addToBackStack(null)
newFragment.show(ft, fragmentTag)
}
@@ -0,0 +1,112 @@
/**
* qsfera Android client application
*
* @author David González Verdugo
* @author Juan Carlos Garrote Gascón
*
* Copyright (C) 2023 ownCloud GmbH.
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License version 2,
* as published by the Free Software Foundation.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
package eu.qsfera.android.extensions
import android.app.AlertDialog
import android.content.Context
import android.content.DialogInterface
import android.view.Menu
import android.view.MenuItem.SHOW_AS_ACTION_NEVER
import android.view.inputmethod.InputMethodManager
import androidx.fragment.app.Fragment
import androidx.lifecycle.Lifecycle
import androidx.lifecycle.lifecycleScope
import androidx.lifecycle.repeatOnLifecycle
import com.google.android.material.snackbar.Snackbar
import eu.qsfera.android.R
import eu.qsfera.android.domain.appregistry.model.AppRegistryProvider
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.collectLatest
import kotlinx.coroutines.launch
fun Fragment.showErrorInSnackbar(genericErrorMessageId: Int, throwable: Throwable?) =
throwable?.let {
showMessageInSnackbar(it.parseError(getString(genericErrorMessageId), resources))
}
fun Fragment.showMessageInSnackbar(
message: CharSequence,
duration: Int = Snackbar.LENGTH_LONG
) {
val requiredView = view ?: return
Snackbar.make(requiredView, message, duration).show()
}
fun Fragment.showAlertDialog(
title: String,
message: String,
positiveButtonText: String = getString(android.R.string.ok),
positiveButtonListener: ((DialogInterface, Int) -> Unit)? = null,
negativeButtonText: String = "",
negativeButtonListener: ((DialogInterface, Int) -> Unit)? = null
) {
val requiredActivity = activity ?: return
AlertDialog.Builder(requiredActivity)
.setTitle(title)
.setMessage(message)
.setPositiveButton(positiveButtonText, positiveButtonListener)
.setNegativeButton(negativeButtonText, negativeButtonListener)
.show()
.avoidScreenshotsIfNeeded()
}
fun Fragment.hideSoftKeyboard() {
val focusedView = requireActivity().currentFocus
focusedView?.let {
val inputMethodManager = requireActivity().getSystemService(Context.INPUT_METHOD_SERVICE) as InputMethodManager
inputMethodManager.hideSoftInputFromWindow(
focusedView.windowToken,
0
)
}
}
fun <T> Fragment.collectLatestLifecycleFlow(
flow: Flow<T>,
lifecycleState: Lifecycle.State = Lifecycle.State.STARTED,
collect: suspend (T) -> Unit
) {
lifecycleScope.launch {
repeatOnLifecycle(lifecycleState) {
flow.collectLatest(collect)
}
}
}
fun Fragment.addOpenInWebMenuOptions(
menu: Menu,
openInWebProviders: Map<String, Int> = emptyMap(),
appRegistryProviders: List<AppRegistryProvider>? = emptyList(),
): Map<String, Int> {
val newOpenInWebProviders = emptyMap<String, Int>().toMutableMap()
// Remove "open in web" dynamic menu items and add them again to avoid duplications
openInWebProviders.forEach { (_, menuItemId) ->
menu.removeItem(menuItemId)
}
appRegistryProviders?.forEachIndexed { index, appRegistryProvider ->
menu.add(Menu.NONE, index, 0, getString(R.string.ic_action_open_with_web, appRegistryProvider.name)).also {
it.setShowAsAction(SHOW_AS_ACTION_NEVER)
newOpenInWebProviders[appRegistryProvider.name] = it.itemId
}
}
return newOpenInWebProviders
}
@@ -0,0 +1,34 @@
/**
* qsfera Android client application
*
* @author Fernando Sanz Velasco
* Copyright (C) 2021 ownCloud GmbH.
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License version 2,
* as published by the Free Software Foundation.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*
*/
package eu.qsfera.android.extensions
import android.annotation.SuppressLint
import android.widget.ImageView
import com.bumptech.glide.Glide
import com.bumptech.glide.load.engine.DiskCacheStrategy
@SuppressLint("CheckResult")
fun ImageView.setPicture(imageToLoad: Int) {
Glide.with(this)
.load(imageToLoad)
.diskCacheStrategy(DiskCacheStrategy.RESOURCE)
.into(this)
}
@@ -0,0 +1,59 @@
/**
* qsfera Android client application
*
* @author Abel García de Prada
* Copyright (C) 2021 ownCloud GmbH.
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License version 2,
* as published by the Free Software Foundation.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
package eu.qsfera.android.extensions
import androidx.lifecycle.LifecycleOwner
import androidx.lifecycle.LiveData
import androidx.lifecycle.Observer
import androidx.work.WorkInfo
import eu.qsfera.android.workers.DownloadFileWorker.Companion.WORKER_KEY_PROGRESS
fun LiveData<WorkInfo?>.observeWorkerTillItFinishes(
owner: LifecycleOwner,
onWorkEnqueued: () -> Unit = {},
onWorkRunning: (progress: Int) -> Unit,
onWorkSucceeded: () -> Unit,
onWorkFailed: () -> Unit,
onWorkBlocked: () -> Unit = {},
onWorkCancelled: () -> Unit = {},
removeObserverAfterNull: Boolean = true,
) {
observe(owner, object : Observer<WorkInfo?> {
override fun onChanged(value: WorkInfo?) {
if (value == null) {
if (removeObserverAfterNull) {
removeObserver(this)
}
return
}
if (value.state.isFinished) {
removeObserver(this)
}
when (value.state) {
WorkInfo.State.ENQUEUED -> onWorkEnqueued()
WorkInfo.State.RUNNING -> onWorkRunning(value.progress.getInt(WORKER_KEY_PROGRESS, -1))
WorkInfo.State.SUCCEEDED -> onWorkSucceeded()
WorkInfo.State.FAILED -> onWorkFailed()
WorkInfo.State.BLOCKED -> onWorkBlocked()
WorkInfo.State.CANCELLED -> onWorkCancelled()
}
}
})
}
@@ -0,0 +1,51 @@
/**
* qsfera Android client application
*
* @author Juan Carlos Garrote Gascón
*
* Copyright (C) 2023 ownCloud GmbH.
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License version 2,
* as published by the Free Software Foundation.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
package eu.qsfera.android.extensions
import android.view.Menu
import eu.qsfera.android.R
import eu.qsfera.android.domain.files.model.FileMenuOption
fun Menu.filterMenuOptions(
optionsToShow: List<FileMenuOption>,
hasWritePermission: Boolean,
) {
FileMenuOption.values().forEach { fileMenuOption ->
val item = this.findItem(fileMenuOption.toResId())
item?.let {
if (optionsToShow.contains(fileMenuOption)) {
it.isVisible = true
it.isEnabled = true
if (fileMenuOption.toResId() == R.id.action_open_file_with) {
if (!hasWritePermission) {
item.setTitle(R.string.actionbar_open_with_read_only)
} else {
item.setTitle(R.string.actionbar_open_with)
}
}
} else {
it.isVisible = false
it.isEnabled = false
}
}
}
}
@@ -0,0 +1,68 @@
/**
* qsfera Android client application
*
* @author Juan Carlos Garrote Gascón
*
* Copyright (C) 2022 ownCloud GmbH.
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License version 2,
* as published by the Free Software Foundation.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
package eu.qsfera.android.extensions
import android.content.Context
import android.net.Uri
import androidx.annotation.StringRes
import androidx.documentfile.provider.DocumentFile
import eu.qsfera.android.R
import eu.qsfera.android.domain.transfers.model.OCTransfer
import eu.qsfera.android.domain.transfers.model.TransferResult
import eu.qsfera.android.domain.transfers.model.TransferStatus
@StringRes
fun OCTransfer.statusToStringRes(): Int =
when (status) {
TransferStatus.TRANSFER_IN_PROGRESS -> R.string.uploader_upload_in_progress_ticker
TransferStatus.TRANSFER_SUCCEEDED -> R.string.uploads_view_upload_status_succeeded
TransferStatus.TRANSFER_QUEUED -> R.string.uploads_view_upload_status_queued
TransferStatus.TRANSFER_FAILED -> when (lastResult) {
TransferResult.CREDENTIAL_ERROR -> R.string.uploads_view_upload_status_failed_credentials_error
TransferResult.FOLDER_ERROR -> R.string.uploads_view_upload_status_failed_folder_error
TransferResult.FILE_NOT_FOUND -> R.string.uploads_view_upload_status_failed_localfile_error
TransferResult.FILE_ERROR -> R.string.uploads_view_upload_status_failed_file_error
TransferResult.PRIVILEGES_ERROR -> R.string.uploads_view_upload_status_failed_permission_error
TransferResult.NETWORK_CONNECTION -> R.string.uploads_view_upload_status_failed_connection_error
TransferResult.DELAYED_FOR_WIFI -> R.string.uploads_view_upload_status_waiting_for_wifi
TransferResult.CONFLICT_ERROR -> R.string.uploads_view_upload_status_conflict
TransferResult.SERVICE_INTERRUPTED -> R.string.uploads_view_upload_status_service_interrupted
TransferResult.SERVICE_UNAVAILABLE -> R.string.service_unavailable
TransferResult.QUOTA_EXCEEDED -> R.string.failed_upload_quota_exceeded_text
TransferResult.SSL_RECOVERABLE_PEER_UNVERIFIED -> R.string.ssl_certificate_not_trusted
TransferResult.UNKNOWN -> R.string.uploads_view_upload_status_unknown_fail
// Should not get here; cancelled uploads should be wiped out
TransferResult.CANCELLED -> R.string.uploads_view_upload_status_cancelled
// Should not get here; status should be UPLOAD_SUCCESS
TransferResult.UPLOADED -> R.string.uploads_view_upload_status_succeeded
// We don't know the specific forbidden error message because it is not being saved in transfers storage
TransferResult.SPECIFIC_FORBIDDEN -> R.string.uploads_view_upload_status_failed_permission_error
// We don't know the specific unavailable service error message because it is not being saved in transfers storage
TransferResult.SPECIFIC_SERVICE_UNAVAILABLE -> R.string.service_unavailable
// We don't know the specific unsupported media type error message because it is not being saved in transfers storage
TransferResult.SPECIFIC_UNSUPPORTED_MEDIA_TYPE -> R.string.uploads_view_unsupported_media_type
// Should not get here; status should be not null
null -> R.string.uploads_view_upload_status_unknown_fail
}
}
fun OCTransfer.isContentUri(context: Context): Boolean =
DocumentFile.isDocumentUri(context, Uri.parse(localPath))
@@ -0,0 +1,112 @@
/**
* qsfera Android client application
*
* @author David González Verdugo
* Copyright (C) 2020 ownCloud GmbH.
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License version 2,
* as published by the Free Software Foundation.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
package eu.qsfera.android.extensions
import android.content.res.Resources
import eu.qsfera.android.R
import eu.qsfera.android.domain.exceptions.AccountNotNewException
import eu.qsfera.android.domain.exceptions.AccountNotTheSameException
import eu.qsfera.android.domain.exceptions.BadOcVersionException
import eu.qsfera.android.domain.exceptions.ConflictException
import eu.qsfera.android.domain.exceptions.CopyIntoDescendantException
import eu.qsfera.android.domain.exceptions.CopyIntoSameFolderException
import eu.qsfera.android.domain.exceptions.FileAlreadyExistsException
import eu.qsfera.android.domain.exceptions.FileNotFoundException
import eu.qsfera.android.domain.exceptions.ForbiddenException
import eu.qsfera.android.domain.exceptions.IncorrectAddressException
import eu.qsfera.android.domain.exceptions.InstanceNotConfiguredException
import eu.qsfera.android.domain.exceptions.InvalidOverwriteException
import eu.qsfera.android.domain.exceptions.LocalFileNotFoundException
import eu.qsfera.android.domain.exceptions.MoveIntoDescendantException
import eu.qsfera.android.domain.exceptions.MoveIntoSameFolderException
import eu.qsfera.android.domain.exceptions.NetworkErrorException
import eu.qsfera.android.domain.exceptions.NoConnectionWithServerException
import eu.qsfera.android.domain.exceptions.NoNetworkConnectionException
import eu.qsfera.android.domain.exceptions.OAuth2ErrorAccessDeniedException
import eu.qsfera.android.domain.exceptions.OAuth2ErrorException
import eu.qsfera.android.domain.exceptions.QuotaExceededException
import eu.qsfera.android.domain.exceptions.RedirectToNonSecureException
import eu.qsfera.android.domain.exceptions.ResourceLockedException
import eu.qsfera.android.domain.exceptions.SSLErrorException
import eu.qsfera.android.domain.exceptions.SSLRecoverablePeerUnverifiedException
import eu.qsfera.android.domain.exceptions.ServerConnectionTimeoutException
import eu.qsfera.android.domain.exceptions.ServerNotReachableException
import eu.qsfera.android.domain.exceptions.ServerResponseTimeoutException
import eu.qsfera.android.domain.exceptions.ServiceUnavailableException
import eu.qsfera.android.domain.exceptions.SpecificForbiddenException
import eu.qsfera.android.domain.exceptions.UnauthorizedException
import eu.qsfera.android.domain.exceptions.validation.FileNameException
import java.util.Locale
fun Throwable.parseError(
genericErrorMessage: String,
resources: Resources,
showJustReason: Boolean = false
): CharSequence {
if (!this.message.isNullOrEmpty()) { // If there's an specific error message from layers below use it
return this.message as String
} else { // Build the error message otherwise
val reason = when (this) {
is AccountNotNewException -> resources.getString(R.string.auth_account_not_new)
is AccountNotTheSameException -> resources.getString(R.string.auth_account_not_the_same)
is BadOcVersionException -> resources.getString(R.string.auth_bad_oc_version_title)
is ConflictException -> resources.getString(R.string.error_conflict)
is CopyIntoDescendantException -> resources.getString(R.string.copy_file_invalid_into_descendent)
is CopyIntoSameFolderException -> resources.getString(R.string.copy_file_invalid_overwrite)
is FileAlreadyExistsException -> resources.getString(R.string.file_already_exists)
is FileNameException -> resources.getString(when (this.type) {
FileNameException.FileNameExceptionType.FILE_NAME_EMPTY -> R.string.filename_empty
FileNameException.FileNameExceptionType.FILE_NAME_FORBIDDEN_CHARACTERS -> R.string.filename_forbidden_characters_from_server
FileNameException.FileNameExceptionType.FILE_NAME_TOO_LONG -> R.string.filename_too_long
})
is FileNotFoundException -> resources.getString(R.string.common_not_found)
is ForbiddenException -> resources.getString(R.string.uploads_view_upload_status_failed_permission_error)
is IncorrectAddressException -> resources.getString(R.string.auth_incorrect_address_title)
is InstanceNotConfiguredException -> resources.getString(R.string.auth_not_configured_title)
is InvalidOverwriteException -> resources.getString(R.string.file_already_exists)
is LocalFileNotFoundException -> resources.getString(R.string.local_file_not_found_toast)
is MoveIntoDescendantException -> resources.getString(R.string.move_file_invalid_into_descendent)
is MoveIntoSameFolderException -> resources.getString(R.string.move_file_invalid_overwrite)
is NoConnectionWithServerException -> resources.getString(R.string.network_error_socket_exception)
is NoNetworkConnectionException -> resources.getString(R.string.error_no_network_connection)
is OAuth2ErrorAccessDeniedException -> resources.getString(R.string.auth_oauth_error_access_denied)
is OAuth2ErrorException -> resources.getString(R.string.auth_oauth_error)
is QuotaExceededException -> resources.getString(R.string.failed_upload_quota_exceeded_text)
is RedirectToNonSecureException -> resources.getString(R.string.auth_redirect_non_secure_connection_title)
is SSLErrorException -> resources.getString(R.string.auth_ssl_general_error_title)
is SSLRecoverablePeerUnverifiedException -> resources.getString(R.string.ssl_certificate_not_trusted)
is ServerConnectionTimeoutException -> resources.getString(R.string.network_error_connect_timeout_exception)
is ServerNotReachableException -> resources.getString(R.string.network_host_not_available)
is ServerResponseTimeoutException -> resources.getString(R.string.network_error_socket_timeout_exception)
is ServiceUnavailableException -> resources.getString(R.string.service_unavailable)
is SpecificForbiddenException -> resources.getString(R.string.uploads_view_upload_status_failed_permission_error)
is UnauthorizedException -> resources.getString(R.string.auth_unauthorized)
is NetworkErrorException -> resources.getString(R.string.network_error_message)
is ResourceLockedException -> resources.getString(R.string.resource_locked_error_message)
else -> resources.getString(R.string.common_error_unknown)
}
return if (showJustReason) {
reason
} else {
"$genericErrorMessage ${resources.getString(R.string.error_reason)} ${reason.lowercase(Locale.getDefault())}"
}
}
}
@@ -0,0 +1,54 @@
/**
* qsfera Android client application
*
* @author John Kalimeris
* Copyright (C) 2020 ownCloud GmbH.
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License version 2,
* as published by the Free Software Foundation.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
package eu.qsfera.android.extensions
import eu.qsfera.android.domain.files.model.OCFile
import java.util.ArrayList
import java.util.Locale
import java.util.Vector
fun Vector<OCFile>.filterByQuery(query: String): List<OCFile> {
val lowerCaseQuery = query.lowercase(Locale.ROOT)
val filteredList: MutableList<OCFile> = ArrayList()
for (fileToAdd in this) {
val nameOfTheFileToAdd: String = fileToAdd.fileName.lowercase(Locale.ROOT)
if (nameOfTheFileToAdd.contains(lowerCaseQuery)) {
filteredList.add(fileToAdd)
}
}
// Remove not matching files from this filelist
for (i in this.indices.reversed()) {
if (!filteredList.contains(this[i])) {
removeAt(i)
}
}
// Add matching files to this filelist
for (i in filteredList.indices) {
if (!contains(filteredList[i])) {
add(i, filteredList[i])
}
}
return this
}
@@ -0,0 +1,36 @@
/**
* qsfera Android client application
*
* @author Aitor Ballesteros Pavón
*
* Copyright (C) 2024 ownCloud GmbH.
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License version 2,
* as published by the Free Software Foundation.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
package eu.qsfera.android.extensions
import android.view.View
import androidx.core.view.AccessibilityDelegateCompat
import androidx.core.view.ViewCompat
import androidx.core.view.accessibility.AccessibilityNodeInfoCompat
fun View.setAccessibilityRole(className: Class<*>? = null, roleDescription: String? = null) {
ViewCompat.setAccessibilityDelegate(this, object : AccessibilityDelegateCompat() {
override fun onInitializeAccessibilityNodeInfo(v: View, info: AccessibilityNodeInfoCompat) {
super.onInitializeAccessibilityNodeInfo(v, info)
className?.let { info.className = it.name }
roleDescription?.let { info.roleDescription = it }
}
})
}
@@ -0,0 +1,185 @@
/**
* qsfera Android client application
*
* @author David González Verdugo
* @author Juan Carlos Garrote Gascón
*
* Copyright (C) 2023 ownCloud GmbH.
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License version 2,
* as published by the Free Software Foundation.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
package eu.qsfera.android.extensions
import androidx.lifecycle.MediatorLiveData
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import eu.qsfera.android.domain.BaseUseCaseWithResult
import eu.qsfera.android.domain.exceptions.NoNetworkConnectionException
import eu.qsfera.android.domain.utils.Event
import eu.qsfera.android.presentation.common.UIResult
import eu.qsfera.android.providers.ContextProvider
import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
import org.koin.core.component.KoinComponent
import org.koin.core.component.inject
import timber.log.Timber
object ViewModelExt : KoinComponent {
private val contextProvider: ContextProvider by inject()
fun <T, Params> ViewModel.runUseCaseWithResult(
coroutineDispatcher: CoroutineDispatcher,
requiresConnection: Boolean = true,
showLoading: Boolean = false,
liveData: MediatorLiveData<Event<UIResult<T>>>,
useCase: BaseUseCaseWithResult<T, Params>,
useCaseParams: Params,
postSuccess: Boolean = true,
postSuccessWithData: Boolean = true
) {
viewModelScope.launch(coroutineDispatcher) {
if (showLoading) {
liveData.postValue(Event(UIResult.Loading()))
}
// If use case requires connection and is not connected, it is not needed to execute use case.
if (requiresConnection and !contextProvider.isConnected()) {
liveData.postValue(Event(UIResult.Error(error = NoNetworkConnectionException())))
Timber.w("${useCase.javaClass.simpleName} will not be executed due to lack of network connection")
return@launch
}
val useCaseResult = useCase(useCaseParams)
Timber.d("Use case executed: ${useCase.javaClass.simpleName} with result: $useCaseResult")
if (useCaseResult.isSuccess && postSuccess) {
if (postSuccessWithData) {
liveData.postValue(Event(UIResult.Success(useCaseResult.getDataOrNull())))
} else {
liveData.postValue(Event(UIResult.Success()))
}
} else if (useCaseResult.isError) {
liveData.postValue(Event(UIResult.Error(error = useCaseResult.getThrowableOrNull())))
}
}
}
fun <T, Params> ViewModel.runUseCaseWithResult(
coroutineDispatcher: CoroutineDispatcher,
requiresConnection: Boolean = true,
showLoading: Boolean = false,
flow: MutableStateFlow<Event<UIResult<T>>?>,
useCase: BaseUseCaseWithResult<T, Params>,
useCaseParams: Params,
postSuccess: Boolean = true,
postSuccessWithData: Boolean = true
) {
viewModelScope.launch(coroutineDispatcher) {
if (showLoading) {
flow.update { Event(UIResult.Loading()) }
}
// If use case requires connection and is not connected, it is not needed to execute use case
if (requiresConnection and !contextProvider.isConnected()) {
flow.update { Event(UIResult.Error(error = NoNetworkConnectionException())) }
Timber.w("${useCase.javaClass.simpleName} will not be executed due to lack of network connection")
return@launch
}
val useCaseResult = useCase(useCaseParams)
Timber.d("Use case executed: ${useCase.javaClass.simpleName} with result: $useCaseResult")
if (useCaseResult.isSuccess && postSuccess) {
if (postSuccessWithData) {
flow.update { Event(UIResult.Success(useCaseResult.getDataOrNull())) }
} else {
flow.update { Event(UIResult.Success()) }
}
} else if (useCaseResult.isError) {
flow.update { Event(UIResult.Error(error = useCaseResult.getThrowableOrNull())) }
}
}
}
fun <T, Params> ViewModel.runUseCaseWithResult(
coroutineDispatcher: CoroutineDispatcher,
requiresConnection: Boolean = true,
showLoading: Boolean = false,
sharedFlow: MutableSharedFlow<UIResult<T>>,
useCase: BaseUseCaseWithResult<T, Params>,
useCaseParams: Params,
postSuccess: Boolean = true,
postSuccessWithData: Boolean = true
) {
viewModelScope.launch(coroutineDispatcher) {
if (showLoading) {
sharedFlow.emit(UIResult.Loading())
}
// If use case requires connection and is not connected, it is not needed to execute use case
if (requiresConnection and !contextProvider.isConnected()) {
sharedFlow.emit(UIResult.Error(error = NoNetworkConnectionException()))
Timber.w("${useCase.javaClass.simpleName} will not be executed due to lack of network connection")
return@launch
}
val useCaseResult = useCase(useCaseParams)
Timber.d("Use case executed: ${useCase.javaClass.simpleName} with result: $useCaseResult")
if (useCaseResult.isSuccess && postSuccess) {
if (postSuccessWithData) {
sharedFlow.emit(UIResult.Success(useCaseResult.getDataOrNull()))
} else {
sharedFlow.emit(UIResult.Success())
}
} else if (useCaseResult.isError) {
sharedFlow.emit(UIResult.Error(error = useCaseResult.getThrowableOrNull()))
}
}
}
fun <T, U, Params> ViewModel.runUseCaseWithResultAndUseCachedData(
coroutineDispatcher: CoroutineDispatcher,
requiresConnection: Boolean = true,
cachedData: T?,
liveData: MediatorLiveData<Event<UIResult<T>>>,
useCase: BaseUseCaseWithResult<U, Params>,
useCaseParams: Params
) {
viewModelScope.launch(coroutineDispatcher) {
liveData.postValue(Event(UIResult.Loading(cachedData)))
// If use case requires connection and is not connected, it is not needed to execute use case
if (requiresConnection && !contextProvider.isConnected()) {
liveData.postValue(Event(UIResult.Error(error = NoNetworkConnectionException(), data = cachedData)))
Timber.w("${useCase.javaClass.simpleName} will not be executed due to lack of network connection")
return@launch
}
val useCaseResult = useCase(useCaseParams)
Timber.d("Use case executed: ${useCase.javaClass.simpleName} with result: $useCaseResult")
if (useCaseResult.isError) {
liveData.postValue(Event(UIResult.Error(error = useCaseResult.getThrowableOrNull(), data = cachedData)))
}
}
}
}
@@ -0,0 +1,32 @@
/**
* qsfera Android client application
*
* @author Abel García de Prada
* Copyright (C) 2022 ownCloud GmbH.
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License version 2,
* as published by the Free Software Foundation.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
package eu.qsfera.android.extensions
import androidx.work.WorkInfo
import eu.qsfera.android.domain.extensions.isOneOf
import eu.qsfera.android.workers.DownloadFileWorker
import eu.qsfera.android.workers.UploadFileFromContentUriWorker
import eu.qsfera.android.workers.UploadFileFromFileSystemWorker
fun WorkInfo.isUpload() =
tags.any { it.isOneOf(UploadFileFromContentUriWorker::class.java.name, UploadFileFromFileSystemWorker::class.java.name) }
fun WorkInfo.isDownload() =
tags.any { it.isOneOf(DownloadFileWorker::class.java.name) }
@@ -0,0 +1,78 @@
/**
* qsfera Android client application
*
* @author Abel García de Prada
* @author Juan Carlos Garrote Gascón
*
* Copyright (C) 2022 ownCloud GmbH.
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License version 2,
* as published by the Free Software Foundation.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
package eu.qsfera.android.extensions
import android.accounts.Account
import androidx.lifecycle.LiveData
import androidx.work.WorkInfo
import androidx.work.WorkManager
import androidx.work.WorkQuery
import eu.qsfera.android.domain.files.model.OCFile
import eu.qsfera.android.usecases.transfers.TRANSFER_TAG_DOWNLOAD
val PENDING_WORK_STATUS = listOf(WorkInfo.State.ENQUEUED, WorkInfo.State.RUNNING, WorkInfo.State.BLOCKED)
val FINISHED_WORK_STATUS = listOf(WorkInfo.State.SUCCEEDED, WorkInfo.State.FAILED, WorkInfo.State.CANCELLED)
/**
* Get a list of WorkInfo that matches EVERY tag.
*/
fun WorkManager.getWorkInfoByTags(tags: List<String>): List<WorkInfo> =
this.getWorkInfos(buildWorkQuery(tags = tags)).get().filter { it.tags.containsAll(tags) }
/**
* Get a list of WorkInfo of running workers that matches EVERY tag.
*/
fun WorkManager.getRunningWorkInfosByTags(tags: List<String>): List<WorkInfo> =
getWorkInfos(buildWorkQuery(tags = tags, states = listOf(WorkInfo.State.RUNNING))).get().filter { it.tags.containsAll(tags) }
/**
* Get a list of WorkInfo of running workers as LiveData that matches at least one of the tags.
*/
fun WorkManager.getRunningWorkInfosLiveData(tags: List<String>): LiveData<List<WorkInfo>> =
getWorkInfosLiveData(buildWorkQuery(tags = tags, states = listOf(WorkInfo.State.RUNNING)))
/**
* Check if a download is pending. It could be enqueued, downloading or blocked.
* @param account - Owner of the file
* @param file - File to check whether it is pending.
*
* @return true if the download is pending.
*/
fun WorkManager.isDownloadPending(account: Account, file: OCFile): Boolean =
this.getWorkInfoByTags(getTagsForDownload(file, account.name)).any { !it.state.isFinished }
fun getTagsForDownload(file: OCFile, accountName: String) =
listOf(TRANSFER_TAG_DOWNLOAD, file.id.toString(), accountName)
/**
* Take care with WorkQueries. It will return workers that match at least ONE of the tags.
* If we perform a query with tags {"account@server", "2"}, [WorkManager.getWorkInfos] will return workers that
* contains at least ONE of the tags, but not both of them. If we want workers that match every tag,
* @see getWorkInfoByTags
*/
fun buildWorkQuery(
tags: List<String>,
states: List<WorkInfo.State> = listOf(),
): WorkQuery = WorkQuery.Builder
.fromTags(tags)
.addStates(states)
.build()
@@ -0,0 +1,376 @@
/**
* qsfera Android client application
*
* @author David A. Velasco
* @author Christian Schabesberger
* @author David González Verdugo
* Copyright (C) 2020 ownCloud GmbH.
* <p>
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License version 2,
* as published by the Free Software Foundation.
* <p>
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
* <p>
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
package eu.qsfera.android.media;
import android.content.Context;
import android.media.MediaPlayer;
import android.os.Handler;
import android.os.Message;
import android.util.AttributeSet;
import android.view.KeyEvent;
import android.view.LayoutInflater;
import android.view.View;
import android.view.View.OnClickListener;
import android.view.ViewGroup;
import android.view.accessibility.AccessibilityEvent;
import android.view.accessibility.AccessibilityNodeInfo;
import android.widget.FrameLayout;
import android.widget.ImageButton;
import android.widget.MediaController.MediaPlayerControl;
import android.widget.ProgressBar;
import android.widget.SeekBar;
import android.widget.SeekBar.OnSeekBarChangeListener;
import android.widget.TextView;
import eu.qsfera.android.R;
import eu.qsfera.android.utils.PreferenceUtils;
import java.util.Formatter;
import java.util.Locale;
/**
* View containing controls for a {@link MediaPlayer}.
*
* Holds buttons "play / pause", "rewind", "fast forward"
* and a progress slider.
*
* It synchronizes itself with the state of the
* {@link MediaPlayer}.
*/
public class MediaControlView extends FrameLayout implements OnClickListener, OnSeekBarChangeListener {
private MediaPlayerControl mPlayer;
private Context mContext;
private View mRoot;
private ProgressBar mProgress;
private TextView mEndTime, mCurrentTime;
private boolean mDragging;
private static final int SHOW_PROGRESS = 1;
StringBuilder mFormatBuilder;
Formatter mFormatter;
private ImageButton mPauseButton;
private ImageButton mFfwdButton;
private ImageButton mRewButton;
public MediaControlView(Context context, AttributeSet attrs) {
super(context, attrs);
mContext = context;
FrameLayout.LayoutParams frameParams = new FrameLayout.LayoutParams(
ViewGroup.LayoutParams.MATCH_PARENT,
ViewGroup.LayoutParams.MATCH_PARENT
);
LayoutInflater inflate = (LayoutInflater) mContext.getSystemService(Context.LAYOUT_INFLATER_SERVICE);
mRoot = inflate.inflate(R.layout.media_control, null);
// Allow or disallow touches with other visible windows
mRoot.setFilterTouchesWhenObscured(
PreferenceUtils.shouldDisallowTouchesWithOtherVisibleWindows(context)
);
initControllerView(mRoot);
addView(mRoot, frameParams);
setFocusable(true);
setFocusableInTouchMode(true);
setDescendantFocusability(ViewGroup.FOCUS_AFTER_DESCENDANTS);
requestFocus();
}
public void setMediaPlayer(MediaPlayerControl player) {
mPlayer = player;
mHandler.sendEmptyMessage(SHOW_PROGRESS);
updatePausePlay();
}
private void initControllerView(View v) {
mPauseButton = v.findViewById(R.id.playBtn);
if (mPauseButton != null) {
mPauseButton.requestFocus();
mPauseButton.setOnClickListener(this);
}
mFfwdButton = v.findViewById(R.id.forwardBtn);
if (mFfwdButton != null) {
mFfwdButton.setOnClickListener(this);
}
mRewButton = v.findViewById(R.id.rewindBtn);
if (mRewButton != null) {
mRewButton.setOnClickListener(this);
}
mProgress = v.findViewById(R.id.progressBar);
if (mProgress != null) {
if (mProgress instanceof SeekBar) {
SeekBar seeker = (SeekBar) mProgress;
seeker.setOnSeekBarChangeListener(this);
}
mProgress.setMax(1000);
}
mEndTime = v.findViewById(R.id.totalTimeText);
mCurrentTime = v.findViewById(R.id.currentTimeText);
mFormatBuilder = new StringBuilder();
mFormatter = new Formatter(mFormatBuilder, Locale.getDefault());
}
/**
* Disable pause or seek buttons if the stream cannot be paused or seeked.
* This requires the control interface to be a MediaPlayerControlExt
*/
private void disableUnsupportedButtons() {
try {
if (mPauseButton != null && !mPlayer.canPause()) {
mPauseButton.setEnabled(false);
}
if (mRewButton != null && !mPlayer.canSeekBackward()) {
mRewButton.setEnabled(false);
}
if (mFfwdButton != null && !mPlayer.canSeekForward()) {
mFfwdButton.setEnabled(false);
}
} catch (IncompatibleClassChangeError ex) {
// We were given an old version of the interface, that doesn't have
// the canPause/canSeekXYZ methods. This is OK, it just means we
// assume the media can be paused and seeked, and so we don't disable
// the buttons.
}
}
private Handler mHandler = new Handler() {
@Override
public void handleMessage(Message msg) {
int pos;
switch (msg.what) {
case SHOW_PROGRESS:
pos = setProgress();
if (!mDragging) {
msg = obtainMessage(SHOW_PROGRESS);
sendMessageDelayed(msg, 1000 - (pos % 1000));
}
break;
}
}
};
private String stringForTime(int timeMs) {
int totalSeconds = timeMs / 1000;
int seconds = totalSeconds % 60;
int minutes = (totalSeconds / 60) % 60;
int hours = totalSeconds / 3600;
mFormatBuilder.setLength(0);
if (hours > 0) {
return mFormatter.format("%d:%02d:%02d", hours, minutes, seconds).toString();
} else {
return mFormatter.format("%02d:%02d", minutes, seconds).toString();
}
}
private int setProgress() {
if (mPlayer == null || mDragging) {
return 0;
}
int position = mPlayer.getCurrentPosition();
int duration = mPlayer.getDuration();
if (mProgress != null) {
if (duration > 0) {
// use long to avoid overflow
long pos = 1000L * position / duration;
mProgress.setProgress((int) pos);
}
int percent = mPlayer.getBufferPercentage();
mProgress.setSecondaryProgress(percent * 10);
}
if (mEndTime != null) {
mEndTime.setText(stringForTime(duration));
}
if (mCurrentTime != null) {
mCurrentTime.setText(stringForTime(position));
}
return position;
}
@Override
public boolean dispatchKeyEvent(KeyEvent event) {
int keyCode = event.getKeyCode();
final boolean uniqueDown = event.getRepeatCount() == 0
&& event.getAction() == KeyEvent.ACTION_DOWN;
if (keyCode == KeyEvent.KEYCODE_HEADSETHOOK
|| keyCode == KeyEvent.KEYCODE_MEDIA_PLAY_PAUSE
|| keyCode == KeyEvent.KEYCODE_SPACE) {
if (uniqueDown) {
doPauseResume();
//show(sDefaultTimeout);
if (mPauseButton != null) {
mPauseButton.requestFocus();
}
}
return true;
} else if (keyCode == KeyEvent.KEYCODE_MEDIA_PLAY) {
if (uniqueDown && !mPlayer.isPlaying()) {
mPlayer.start();
updatePausePlay();
}
return true;
} else if (keyCode == KeyEvent.KEYCODE_MEDIA_STOP
|| keyCode == KeyEvent.KEYCODE_MEDIA_PAUSE) {
if (uniqueDown && mPlayer.isPlaying()) {
mPlayer.pause();
updatePausePlay();
}
return true;
}
return super.dispatchKeyEvent(event);
}
public void updatePausePlay() {
if (mRoot == null || mPauseButton == null) {
return;
}
if (mPlayer.isPlaying()) {
mPauseButton.setImageResource(android.R.drawable.ic_media_pause);
} else {
mPauseButton.setImageResource(android.R.drawable.ic_media_play);
}
}
private void doPauseResume() {
if (mPlayer.isPlaying()) {
mPlayer.pause();
} else {
mPlayer.start();
}
updatePausePlay();
}
@Override
public void setEnabled(boolean enabled) {
if (mPauseButton != null) {
mPauseButton.setEnabled(enabled);
}
if (mFfwdButton != null) {
mFfwdButton.setEnabled(enabled);
}
if (mRewButton != null) {
mRewButton.setEnabled(enabled);
}
if (mProgress != null) {
mProgress.setEnabled(enabled);
}
disableUnsupportedButtons();
super.setEnabled(enabled);
}
@Override
public void onClick(View v) {
int pos;
boolean playing = mPlayer.isPlaying();
switch (v.getId()) {
case R.id.playBtn:
doPauseResume();
break;
case R.id.rewindBtn:
pos = mPlayer.getCurrentPosition();
pos -= 5000;
mPlayer.seekTo(pos);
if (!playing) {
mPlayer.pause(); // necessary in some 2.3.x devices
}
setProgress();
break;
case R.id.forwardBtn:
pos = mPlayer.getCurrentPosition();
pos += 15000;
mPlayer.seekTo(pos);
if (!playing) {
mPlayer.pause(); // necessary in some 2.3.x devices
}
setProgress();
break;
}
}
@Override
public void onProgressChanged(SeekBar seekBar, int progress, boolean fromUser) {
if (!fromUser) {
// We're not interested in programmatically generated changes to
// the progress bar's position.
return;
}
long duration = mPlayer.getDuration();
long newposition = (duration * progress) / 1000L;
mPlayer.seekTo((int) newposition);
if (mCurrentTime != null) {
mCurrentTime.setText(stringForTime((int) newposition));
}
}
/**
* Called in devices with touchpad when the user starts to adjust the
* position of the seekbar's thumb.
*
* Will be followed by several onProgressChanged notifications.
*/
@Override
public void onStartTrackingTouch(SeekBar seekBar) {
mDragging = true; // monitors the duration of dragging
mHandler.removeMessages(SHOW_PROGRESS); // grants no more updates with media player progress while dragging
}
/**
* Called in devices with touchpad when the user finishes the
* adjusting of the seekbar.
*/
@Override
public void onStopTrackingTouch(SeekBar seekBar) {
mDragging = false;
setProgress();
updatePausePlay();
mHandler.sendEmptyMessage(SHOW_PROGRESS); // grants future updates with media player progress
}
@Override
public void onInitializeAccessibilityEvent(AccessibilityEvent event) {
super.onInitializeAccessibilityEvent(event);
event.setClassName(MediaControlView.class.getName());
}
@Override
public void onInitializeAccessibilityNodeInfo(AccessibilityNodeInfo info) {
super.onInitializeAccessibilityNodeInfo(info);
info.setClassName(MediaControlView.class.getName());
}
}
@@ -0,0 +1,763 @@
/**
* qsfera Android client application
*
* @author David A. Velasco
* @author Christian Schabesberger
* Copyright (C) 2020 ownCloud GmbH.
* <p>
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License version 2,
* as published by the Free Software Foundation.
* <p>
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
* <p>
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
package eu.qsfera.android.media;
import android.accounts.Account;
import android.accounts.AccountManager;
import android.accounts.OnAccountsUpdateListener;
import android.app.NotificationManager;
import android.app.PendingIntent;
import android.app.Service;
import android.content.Context;
import android.content.Intent;
import android.media.AudioManager;
import android.media.MediaPlayer;
import android.media.MediaPlayer.OnCompletionListener;
import android.media.MediaPlayer.OnErrorListener;
import android.media.MediaPlayer.OnPreparedListener;
import android.net.wifi.WifiManager;
import android.net.wifi.WifiManager.WifiLock;
import android.os.FileObserver;
import android.os.IBinder;
import android.os.PowerManager;
import android.widget.Toast;
import androidx.core.app.NotificationCompat;
import eu.qsfera.android.R;
import eu.qsfera.android.presentation.authentication.AccountUtils;
import eu.qsfera.android.domain.files.model.OCFile;
import eu.qsfera.android.ui.activity.FileActivity;
import eu.qsfera.android.ui.activity.FileDisplayActivity;
import eu.qsfera.android.utils.NotificationUtils;
import timber.log.Timber;
import java.io.File;
import java.io.IOException;
import static eu.qsfera.android.utils.NotificationConstantsKt.MEDIA_SERVICE_NOTIFICATION_CHANNEL_ID;
/**
* Service that handles media playback, both audio and video.
*
* Waits for Intents which signal the service to perform specific operations: Play, Pause,
* Rewind, etc.
*/
public class MediaService extends Service implements OnCompletionListener, OnPreparedListener,
OnErrorListener, AudioManager.OnAudioFocusChangeListener {
private static final String MY_PACKAGE = MediaService.class.getPackage() != null ?
MediaService.class.getPackage().getName() : "eu.qsfera.android.media";
/// Intent actions that we are prepared to handle
public static final String ACTION_PLAY_FILE = MY_PACKAGE + ".action.PLAY_FILE";
public static final String ACTION_STOP_ALL = MY_PACKAGE + ".action.STOP_ALL";
public static final String ACTION_STOP_FILE = MY_PACKAGE + ".action.STOP_FILE";
/// Keys to add extras to the action
public static final String EXTRA_FILE = MY_PACKAGE + ".extra.FILE";
public static final String EXTRA_ACCOUNT = MY_PACKAGE + ".extra.ACCOUNT";
public static String EXTRA_START_POSITION = MY_PACKAGE + ".extra.START_POSITION";
public static final String EXTRA_PLAY_ON_LOAD = MY_PACKAGE + ".extra.PLAY_ON_LOAD";
/** Error code for specific messages - see regular error codes at {@link MediaPlayer} */
public static final int OC_MEDIA_ERROR = 0;
/** Time To keep the control panel visible when the user does not use it */
public static final int MEDIA_CONTROL_SHORT_LIFE = 4000;
/** Time To keep the control panel visible when the user does not use it */
public static final int MEDIA_CONTROL_PERMANENT = 0;
/** Volume to set when audio focus is lost and ducking is allowed */
private static final float DUCK_VOLUME = 0.1f;
/** Media player instance */
private MediaPlayer mPlayer = null;
/** Reference to the system AudioManager */
private AudioManager mAudioManager = null;
/** Reference to the system AccountManager */
private AccountManager mAccountManager;
/** Values to indicate the state of the service */
enum State {
STOPPED,
PREPARING,
PLAYING,
PAUSED
}
/** Current state */
private State mState = State.STOPPED;
/** Possible focus values */
enum AudioFocus {
NO_FOCUS,
NO_FOCUS_CAN_DUCK,
FOCUS
}
/** Current focus state */
private AudioFocus mAudioFocus = AudioFocus.NO_FOCUS;
/** 'True' when the current song is streaming from the network */
private boolean mIsStreaming = false;
/** Wifi lock kept to prevents the device from shutting off the radio when streaming a file. */
private WifiLock mWifiLock;
private static final String MEDIA_WIFI_LOCK_TAG = MY_PACKAGE + ".WIFI_LOCK";
/** Notification to keep in the notification bar while a song is playing */
private NotificationManager mNotificationManager;
/** File being played */
private OCFile mFile;
/** Observer being notified if played file is deleted */
private MediaFileObserver mFileObserver = null;
/** Account holding the file being played */
private Account mAccount;
/** Flag signaling if the audio should be played immediately when the file is prepared */
protected boolean mPlayOnPrepared;
/** Position, in milliseconds, where the audio should be started */
private int mStartPosition;
/** Interface to access the service through binding */
private IBinder mBinder;
/** Control panel shown to the user to control the playback, to register through binding */
private MediaControlView mMediaController;
/** Notification builder to create notifications, new reuse way since Android 6 */
private NotificationCompat.Builder mNotificationBuilder;
/**
* Helper method to get an error message suitable to show to users for errors occurred in media playback,
*
* @param context A context to access string resources.
* @param what See {@link MediaPlayer.OnErrorListener#onError(MediaPlayer, int, int)
* @param extra See {@link MediaPlayer.OnErrorListener#onError(MediaPlayer, int, int)
* @return Message suitable to users.
*/
public static String getMessageForMediaError(Context context, int what, int extra) {
int messageId;
if (what == OC_MEDIA_ERROR) {
messageId = extra;
} else if (extra == MediaPlayer.MEDIA_ERROR_UNSUPPORTED) {
/* Added in API level 17
Bitstream is conforming to the related coding standard or file spec,
but the media framework does not support the feature.
Constant Value: -1010 (0xfffffc0e)
*/
messageId = R.string.media_err_unsupported;
} else if (extra == MediaPlayer.MEDIA_ERROR_IO) {
/* Added in API level 17
File or network related operation errors.
Constant Value: -1004 (0xfffffc14)
*/
messageId = R.string.media_err_io;
} else if (extra == MediaPlayer.MEDIA_ERROR_MALFORMED) {
/* Added in API level 17
Bitstream is not conforming to the related coding standard or file spec.
Constant Value: -1007 (0xfffffc11)
*/
messageId = R.string.media_err_malformed;
} else if (extra == MediaPlayer.MEDIA_ERROR_TIMED_OUT) {
/* Added in API level 17
Some operation takes too long to complete, usually more than 3-5 seconds.
Constant Value: -110 (0xffffff92)
*/
messageId = R.string.media_err_timeout;
} else if (what == MediaPlayer.MEDIA_ERROR_NOT_VALID_FOR_PROGRESSIVE_PLAYBACK) {
/* Added in API level 3
The video is streamed and its container is not valid for progressive playback i.e the video's index
(e.g moov atom) is not at the start of the file.
Constant Value: 200 (0x000000c8)
*/
messageId = R.string.media_err_invalid_progressive_playback;
} else {
/* MediaPlayer.MEDIA_ERROR_UNKNOWN
Added in API level 1
Unspecified media player error.
Constant Value: 1 (0x00000001)
*/
/* MediaPlayer.MEDIA_ERROR_SERVER_DIED)
Added in API level 1
Media server died. In this case, the application must release the MediaPlayer
object and instantiate a new one.
Constant Value: 100 (0x00000064)
*/
messageId = R.string.media_err_unknown;
}
return context.getString(messageId);
}
/**
* Initialize a service instance
*
* {@inheritDoc}
*/
@Override
public void onCreate() {
super.onCreate();
Timber.d("Creating qsfera media service");
mWifiLock = ((WifiManager) getApplicationContext().getSystemService(Context.WIFI_SERVICE)).
createWifiLock(WifiManager.WIFI_MODE_FULL, MEDIA_WIFI_LOCK_TAG);
mNotificationManager = (NotificationManager) getSystemService(NOTIFICATION_SERVICE);
mNotificationBuilder = new NotificationCompat.Builder(this);
mNotificationBuilder.setColor(this.getResources().getColor(R.color.primary));
mAudioManager = (AudioManager) getSystemService(AUDIO_SERVICE);
mBinder = new MediaServiceBinder(this);
// add AccountsUpdatedListener
mAccountManager = AccountManager.get(this);
mAccountManager.addOnAccountsUpdatedListener(new OnAccountsUpdateListener() {
@Override
public void onAccountsUpdated(Account[] accounts) {
// stop playback if account of the played media files was removed
if (mAccount != null && !AccountUtils.exists(mAccount.name, MediaService.this)) {
processStopRequest(false);
}
}
}, null, false);
}
/**
* Entry point for Intents requesting actions, sent here via startService.
*
* {@inheritDoc}
*/
@Override
public int onStartCommand(Intent intent, int flags, int startId) {
String action = intent.getAction();
if (action.equals(ACTION_PLAY_FILE)) {
processPlayFileRequest(intent);
} else if (action.equals(ACTION_STOP_ALL)) {
processStopRequest(true);
} else if (action.equals(ACTION_STOP_FILE)) {
processStopFileRequest(intent);
}
return START_NOT_STICKY; // don't want it to restart in case it's killed.
}
private void processStopFileRequest(Intent intent) {
OCFile file = intent.getExtras().getParcelable(EXTRA_FILE);
if (file != null && file.equals(mFile)) {
processStopRequest(true);
}
}
/**
* Processes a request to play a media file received as a parameter
*
* TODO If a new request is received when a file is being prepared, it is ignored. Is this what we want?
*
* @param intent Intent received in the request with the data to identify the file to play.
*/
private void processPlayFileRequest(Intent intent) {
if (mState != State.PREPARING) {
mFile = intent.getExtras().getParcelable(EXTRA_FILE);
mAccount = intent.getExtras().getParcelable(EXTRA_ACCOUNT);
mPlayOnPrepared = intent.getExtras().getBoolean(EXTRA_PLAY_ON_LOAD, false);
mStartPosition = intent.getExtras().getInt(EXTRA_START_POSITION, 0);
tryToGetAudioFocus();
playMedia();
}
}
/**
* Processes a request to play a media file.
*/
protected void processPlayRequest() {
// request audio focus
tryToGetAudioFocus();
// actually play the song
if (mState == State.STOPPED) {
// (re)start playback
playMedia();
} else if (mState == State.PAUSED) {
// continue playback
mState = State.PLAYING;
setUpAsForeground(String.format(getString(R.string.media_state_playing), mFile.getFileName()));
configAndStartMediaPlayer();
}
}
/**
* Makes sure the media player exists and has been reset. This will create the media player
* if needed. reset the existing media player if one already exists.
*/
protected void createMediaPlayerIfNeeded() {
if (mPlayer == null) {
mPlayer = new MediaPlayer();
// make sure the CPU won't go to sleep while media is playing
mPlayer.setWakeMode(getApplicationContext(), PowerManager.PARTIAL_WAKE_LOCK);
// the media player will notify the service when it's ready preparing, and when it's done playing
mPlayer.setOnPreparedListener(this);
mPlayer.setOnCompletionListener(this);
mPlayer.setOnErrorListener(this);
} else {
mPlayer.reset();
}
}
/**
* Processes a request to pause the current playback
*/
protected void processPauseRequest() {
if (mState == State.PLAYING) {
mState = State.PAUSED;
mPlayer.pause();
releaseResources(false); // retain media player in pause
// TODO polite audio focus, instead of keep it owned; or not?
}
}
/**
* Processes a request to stop the playback.
*
* @param force When 'true', the playback is stopped no matter the value of mState
*/
protected void processStopRequest(boolean force) {
if (mState != State.PREPARING || force) {
mState = State.STOPPED;
mFile = null;
stopFileObserver();
mAccount = null;
releaseResources(true);
giveUpAudioFocus();
stopSelf(); // service is no longer necessary
}
}
/**
* Releases resources used by the service for playback. This includes the "foreground service"
* status and notification, the wake locks and possibly the MediaPlayer.
*
* @param releaseMediaPlayer Indicates whether the Media Player should also be released or not
*/
protected void releaseResources(boolean releaseMediaPlayer) {
// stop being a foreground service
stopForeground(true);
// stop and release the Media Player, if it's available
if (releaseMediaPlayer && mPlayer != null) {
mPlayer.reset();
mPlayer.release();
mPlayer = null;
}
// release the Wifi lock, if holding it
if (mWifiLock.isHeld()) {
mWifiLock.release();
}
}
/**
* Fully releases the audio focus.
*/
private void giveUpAudioFocus() {
if (mAudioFocus == AudioFocus.FOCUS
&& mAudioManager != null
&& AudioManager.AUDIOFOCUS_REQUEST_GRANTED == mAudioManager.abandonAudioFocus(this)) {
mAudioFocus = AudioFocus.NO_FOCUS;
}
}
/**
* Reconfigures MediaPlayer according to audio focus settings and starts/restarts it.
*/
protected void configAndStartMediaPlayer() {
if (mPlayer == null) {
throw new IllegalStateException("mPlayer is NULL");
}
if (mAudioFocus == AudioFocus.NO_FOCUS) {
if (mPlayer.isPlaying()) {
mPlayer.pause(); // have to be polite; but mState is not changed, to resume when focus is
// received again
}
} else {
if (mAudioFocus == AudioFocus.NO_FOCUS_CAN_DUCK) {
mPlayer.setVolume(DUCK_VOLUME, DUCK_VOLUME);
} else {
mPlayer.setVolume(1.0f, 1.0f); // full volume
}
if (!mPlayer.isPlaying()) {
mPlayer.start();
}
}
}
/**
* Requests the audio focus to the Audio Manager
*/
private void tryToGetAudioFocus() {
if (mAudioFocus != AudioFocus.FOCUS
&& mAudioManager != null
&& (AudioManager.AUDIOFOCUS_REQUEST_GRANTED == mAudioManager.requestAudioFocus(this,
AudioManager.STREAM_MUSIC,
AudioManager.AUDIOFOCUS_GAIN))
) {
mAudioFocus = AudioFocus.FOCUS;
}
}
/**
* Starts playing the current media file.
*/
protected void playMedia() {
mState = State.STOPPED;
releaseResources(false); // release everything except MediaPlayer
try {
if (mFile == null) {
Toast.makeText(this, R.string.media_err_nothing_to_play, Toast.LENGTH_LONG).show();
processStopRequest(true);
return;
} else if (mAccount == null) {
Toast.makeText(this, R.string.media_err_not_in_qsfera, Toast.LENGTH_LONG).show();
processStopRequest(true);
return;
}
createMediaPlayerIfNeeded();
mPlayer.setAudioStreamType(AudioManager.STREAM_MUSIC);
String url = mFile.getStoragePath();
updateFileObserver(url);
/* Streaming is not possible right now
if (url == null || url.length() <= 0) {
url = AccountUtils.constructFullURLForAccount(this, mAccount) + mFile.getRemotePath();
}
mIsStreaming = url.startsWith("http:") || url.startsWith("https:");
*/
mIsStreaming = false;
mPlayer.setDataSource(url);
mState = State.PREPARING;
setUpAsForeground(String.format(getString(R.string.media_state_loading), mFile.getFileName()));
// starts preparing the media player in background
mPlayer.prepareAsync();
// prevent the Wifi from going to sleep when streaming
if (mIsStreaming) {
mWifiLock.acquire();
} else if (mWifiLock.isHeld()) {
mWifiLock.release();
}
} catch (SecurityException e) {
Timber.e(e, "SecurityException playing " + mAccount.name + mFile.getRemotePath());
Toast.makeText(this, String.format(getString(R.string.media_err_security_ex), mFile.getFileName()),
Toast.LENGTH_LONG).show();
processStopRequest(true);
} catch (IOException e) {
Timber.e(e, "IOException playing " + mAccount.name + mFile.getRemotePath());
Toast.makeText(this, String.format(getString(R.string.media_err_io_ex), mFile.getFileName()),
Toast.LENGTH_LONG).show();
processStopRequest(true);
} catch (IllegalStateException e) {
Timber.e(e, "IllegalStateException " + mAccount.name + mFile.getRemotePath());
Toast.makeText(this, String.format(getString(R.string.media_err_unexpected), mFile.getFileName()),
Toast.LENGTH_LONG).show();
processStopRequest(true);
} catch (IllegalArgumentException e) {
Timber.e(e, "IllegalArgumentException " + mAccount.name + mFile.getRemotePath());
Toast.makeText(this, String.format(getString(R.string.media_err_unexpected), mFile.getFileName()),
Toast.LENGTH_LONG).show();
processStopRequest(true);
}
}
private void updateFileObserver(String url) {
stopFileObserver();
mFileObserver = new MediaFileObserver(url);
mFileObserver.startWatching();
}
private void stopFileObserver() {
if (mFileObserver != null) {
mFileObserver.stopWatching();
}
}
/** Called when media player is done playing current song. */
public void onCompletion(MediaPlayer player) {
Toast.makeText(this, String.format(getString(R.string.media_event_done), mFile.getFileName()),
Toast.LENGTH_LONG).show();
if (mMediaController != null) {
// somebody is still bound to the service
player.seekTo(0);
processPauseRequest();
mMediaController.updatePausePlay();
} else {
// nobody is bound
processStopRequest(true);
}
}
/**
* Called when media player is done preparing.
*
* Time to start.
*/
public void onPrepared(MediaPlayer player) {
mState = State.PLAYING;
updateNotification(String.format(getString(R.string.media_state_playing), mFile.getFileName()));
if (mMediaController != null) {
mMediaController.setEnabled(true);
}
player.seekTo(mStartPosition);
configAndStartMediaPlayer();
if (!mPlayOnPrepared) {
processPauseRequest();
}
if (mMediaController != null) {
mMediaController.updatePausePlay();
}
}
/**
* Updates the status notification
*/
private void updateNotification(String content) {
String ticker = String.format(getString(R.string.media_notif_ticker), getString(R.string.app_name));
// TODO check if updating the Intent is really necessary
Intent showDetailsIntent = new Intent(this, FileDisplayActivity.class);
showDetailsIntent.putExtra(FileActivity.EXTRA_FILE, mFile);
showDetailsIntent.putExtra(FileActivity.EXTRA_ACCOUNT, mAccount);
showDetailsIntent.setFlags(Intent.FLAG_ACTIVITY_CLEAR_TOP);
mNotificationBuilder.setContentIntent(PendingIntent.getActivity(getApplicationContext(),
(int) System.currentTimeMillis(),
showDetailsIntent,
NotificationUtils.pendingIntentFlags));
mNotificationBuilder.setWhen(System.currentTimeMillis());
mNotificationBuilder.setTicker(ticker);
mNotificationBuilder.setContentTitle(ticker);
mNotificationBuilder.setContentText(content);
mNotificationBuilder.setChannelId(MEDIA_SERVICE_NOTIFICATION_CHANNEL_ID);
mNotificationManager.notify(R.string.media_notif_ticker, mNotificationBuilder.build());
}
/**
* Configures the service as a foreground service.
*
* The system will avoid finishing the service as much as possible when resources as low.
*
* A notification must be created to keep the user aware of the existence of the service.
*/
private void setUpAsForeground(String content) {
String ticker = String.format(getString(R.string.media_notif_ticker), getString(R.string.app_name));
/// creates status notification
// TODO put a progress bar to follow the playback progress
mNotificationBuilder.setSmallIcon(R.drawable.ic_play_arrow);
//mNotification.tickerText = text;
mNotificationBuilder.setWhen(System.currentTimeMillis());
mNotificationBuilder.setOngoing(true);
/// includes a pending intent in the notification showing the details view of the file
Intent showDetailsIntent = new Intent(this, FileDisplayActivity.class);
showDetailsIntent.putExtra(FileActivity.EXTRA_FILE, mFile);
showDetailsIntent.putExtra(FileActivity.EXTRA_ACCOUNT, mAccount);
showDetailsIntent.setFlags(Intent.FLAG_ACTIVITY_CLEAR_TOP);
mNotificationBuilder.setContentIntent(PendingIntent.getActivity(getApplicationContext(),
(int) System.currentTimeMillis(),
showDetailsIntent,
NotificationUtils.pendingIntentFlags));
mNotificationBuilder.setContentTitle(ticker);
mNotificationBuilder.setContentText(content);
mNotificationBuilder.setChannelId(MEDIA_SERVICE_NOTIFICATION_CHANNEL_ID);
startForeground(R.string.media_notif_ticker, mNotificationBuilder.build());
}
/**
* Called when there's an error playing media.
*
* Warns the user about the error and resets the media player.
*/
public boolean onError(MediaPlayer mp, int what, int extra) {
Timber.e("Error in audio playback, what = " + what + ", extra = " + extra);
String message = getMessageForMediaError(this, what, extra);
Toast.makeText(getApplicationContext(), message, Toast.LENGTH_SHORT).show();
processStopRequest(true);
return true;
}
/**
* Called by the system when another app tries to play some sound.
*
* {@inheritDoc}
*/
@Override
public void onAudioFocusChange(int focusChange) {
if (focusChange > 0) {
// focus gain; check AudioManager.AUDIOFOCUS_* values
mAudioFocus = AudioFocus.FOCUS;
// restart media player with new focus settings
if (mState == State.PLAYING) {
configAndStartMediaPlayer();
}
} else if (focusChange < 0) {
// focus loss; check AudioManager.AUDIOFOCUS_* values
boolean canDuck = AudioManager.AUDIOFOCUS_LOSS_TRANSIENT_CAN_DUCK == focusChange;
mAudioFocus = canDuck ? AudioFocus.NO_FOCUS_CAN_DUCK : AudioFocus.NO_FOCUS;
// start/restart/pause media player with new focus settings
if (mPlayer != null && mPlayer.isPlaying()) {
configAndStartMediaPlayer();
}
}
}
/**
* Called when the service is finished for final clean-up.
*
* {@inheritDoc}
*/
@Override
public void onDestroy() {
mState = State.STOPPED;
releaseResources(true);
giveUpAudioFocus();
stopForeground(true);
super.onDestroy();
}
/**
* Provides a binder object that clients can use to perform operations on the MediaPlayer managed by the
* MediaService.
*/
@Override
public IBinder onBind(Intent arg) {
return mBinder;
}
/**
* Called when ALL the bound clients were onbound.
*
* The service is destroyed if playback stopped or paused
*/
@Override
public boolean onUnbind(Intent intent) {
if (mState == State.PAUSED || mState == State.STOPPED) {
processStopRequest(false);
}
return false; // not accepting rebinding (default behaviour)
}
/**
* Accesses the current MediaPlayer instance in the service.
*
* To be handled carefully. Visibility is protected to be accessed only
*
* @return Current MediaPlayer instance handled by MediaService.
*/
protected MediaPlayer getPlayer() {
return mPlayer;
}
/**
* Accesses the current OCFile loaded in the service.
*
* @return The current OCFile loaded in the service.
*/
protected OCFile getCurrentFile() {
return mFile;
}
/**
* Accesses the current {@link State} of the MediaService.
*
* @return The current {@link State} of the MediaService.
*/
protected State getState() {
return mState;
}
protected void setMediaController(MediaControlView mediaController) {
mMediaController = mediaController;
}
protected MediaControlView getMediaController() {
return mMediaController;
}
/**
* Observer monitoring the media file currently played and stopping the playback in case
* that it's deleted or moved away from its storage location.
*/
private class MediaFileObserver extends FileObserver {
public MediaFileObserver(String path) {
super((new File(path)).getParent(), FileObserver.DELETE | FileObserver.MOVED_FROM);
}
@Override
public void onEvent(int event, String path) {
if (path != null && path.equals(mFile.getFileName())) {
Timber.d("Media file deleted or moved out of sight, stopping playback");
processStopRequest(true);
}
}
}
}
@@ -0,0 +1,176 @@
/**
* qsfera Android client application
*
* @author David A. Velasco
* Copyright (C) 2016 ownCloud GmbH.
* <p>
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License version 2,
* as published by the Free Software Foundation.
* <p>
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
* <p>
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
package eu.qsfera.android.media;
import android.accounts.Account;
import android.content.Intent;
import android.media.MediaPlayer;
import android.os.Binder;
import android.widget.MediaController;
import eu.qsfera.android.domain.files.model.OCFile;
import eu.qsfera.android.media.MediaService.State;
import timber.log.Timber;
/**
* Binder allowing client components to perform operations on on the MediaPlayer managed by a MediaService instance.
*
* Provides the operations of {@link MediaController.MediaPlayerControl}, and an extra method to check if
* an {@link OCFile} instance is handled by the MediaService.
*/
public class MediaServiceBinder extends Binder implements MediaController.MediaPlayerControl {
/**
* {@link MediaService} instance to access with the binder
*/
private MediaService mService = null;
/**
* Public constructor
*
* @param service A {@link MediaService} instance to access with the binder
*/
public MediaServiceBinder(MediaService service) {
if (service == null) {
throw new IllegalArgumentException("Argument 'service' can not be null");
}
mService = service;
}
public boolean isPlaying(OCFile mFile) {
return (mFile != null && mFile.equals(mService.getCurrentFile()));
}
@Override
public boolean canPause() {
return true;
}
@Override
public boolean canSeekBackward() {
return true;
}
@Override
public boolean canSeekForward() {
return true;
}
@Override
public int getBufferPercentage() {
MediaPlayer currentPlayer = mService.getPlayer();
if (currentPlayer != null) {
return 100;
// TODO update for streamed playback; add OnBufferUpdateListener in MediaService
} else {
return 0;
}
}
@Override
public int getCurrentPosition() {
MediaPlayer currentPlayer = mService.getPlayer();
if (currentPlayer != null) {
return currentPlayer.getCurrentPosition();
} else {
return 0;
}
}
@Override
public int getDuration() {
MediaPlayer currentPlayer = mService.getPlayer();
if (currentPlayer != null) {
return currentPlayer.getDuration();
} else {
return 0;
}
}
/**
* Reports if the MediaService is playing a file or not.
*
* Considers that the file is being played when it is in preparation because the expected
* client of this method is a {@link MediaController} , and we do not want that the 'play'
* button is shown when the file is being prepared by the MediaService.
*/
@Override
public boolean isPlaying() {
MediaService.State currentState = mService.getState();
return (currentState == State.PLAYING || (currentState == State.PREPARING && mService.mPlayOnPrepared));
}
@Override
public void pause() {
Timber.d("Pausing through binder...");
mService.processPauseRequest();
}
@Override
public void seekTo(int pos) {
Timber.d("Seeking " + pos + " through binder...");
MediaPlayer currentPlayer = mService.getPlayer();
MediaService.State currentState = mService.getState();
if (currentPlayer != null && currentState != State.PREPARING && currentState != State.STOPPED) {
currentPlayer.seekTo(pos);
}
}
@Override
public void start() {
Timber.d("Starting through binder...");
mService.processPlayRequest(); // this will finish the service if there is no file preloaded to play
}
public void start(Account account, OCFile file, boolean playImmediately, int position) {
Timber.d("Loading and starting through binder...");
Intent i = new Intent(mService, MediaService.class);
i.putExtra(MediaService.EXTRA_ACCOUNT, account);
i.putExtra(MediaService.EXTRA_FILE, file);
i.putExtra(MediaService.EXTRA_PLAY_ON_LOAD, playImmediately);
i.putExtra(MediaService.EXTRA_START_POSITION, position);
i.setAction(MediaService.ACTION_PLAY_FILE);
mService.startService(i);
}
public void registerMediaController(MediaControlView mediaController) {
mService.setMediaController(mediaController);
}
public void unregisterMediaController(MediaControlView mediaController) {
if (mediaController != null && mediaController == mService.getMediaController()) {
mService.setMediaController(null);
}
}
public boolean isInPlaybackState() {
MediaService.State currentState = mService.getState();
return (currentState == MediaService.State.PLAYING || currentState == MediaService.State.PAUSED);
}
@Override
public int getAudioSessionId() {
return 1; // not really used
}
}
@@ -0,0 +1,61 @@
/**
* qsfera Android client application
*
* @author David A. Velasco
* @author Christian Schabesberger
* @author David González Verdugo
* Copyright (C) 2020 ownCloud GmbH.
* <p>
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License version 2,
* as published by the Free Software Foundation.
* <p>
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
* <p>
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
package eu.qsfera.android.operations;
import android.accounts.Account;
import eu.qsfera.android.domain.files.model.OCFile;
import eu.qsfera.android.lib.common.QSferaClient;
import eu.qsfera.android.lib.common.operations.RemoteOperation;
import eu.qsfera.android.lib.common.operations.RemoteOperationResult;
import eu.qsfera.android.lib.resources.files.CheckPathExistenceRemoteOperation;
import eu.qsfera.android.operations.common.SyncOperation;
/**
* Checks validity of currently stored credentials for a given OC account
*/
public class CheckCurrentCredentialsOperation extends SyncOperation<Account> {
private Account mAccount;
public CheckCurrentCredentialsOperation(Account account) {
if (account == null) {
throw new IllegalArgumentException("NULL account");
}
mAccount = account;
}
@Override
protected RemoteOperationResult<Account> run(QSferaClient client) {
if (!getStorageManager().getAccount().name.equals(mAccount.name)) {
return new RemoteOperationResult<>(new IllegalStateException(
"Account to validate is not the account connected to!"));
} else {
RemoteOperation checkPathExistenceOperation = new CheckPathExistenceRemoteOperation(OCFile.ROOT_PATH, false, null);
final RemoteOperationResult existenceCheckResult = checkPathExistenceOperation.execute(client);
final RemoteOperationResult<Account> result
= new RemoteOperationResult<>(existenceCheckResult.getCode());
result.setData(mAccount);
return result;
}
}
}
@@ -0,0 +1,85 @@
/**
* qsfera Android client application
*
* @author David A. Velasco
* @author David González Verdugo
* Copyright (C) 2020 ownCloud GmbH.
* <p>
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License version 2,
* as published by the Free Software Foundation.
* <p>
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
* <p>
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
package eu.qsfera.android.operations
import android.accounts.Account
import android.accounts.AccountManager
import eu.qsfera.android.MainApp.Companion.appContext
import eu.qsfera.android.domain.capabilities.usecases.GetStoredCapabilitiesUseCase
import eu.qsfera.android.domain.user.usecases.GetUserInfoAsyncUseCase
import eu.qsfera.android.domain.user.usecases.RefreshUserQuotaFromServerAsyncUseCase
import eu.qsfera.android.lib.common.accounts.AccountUtils
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import org.koin.core.component.KoinComponent
import org.koin.core.component.inject
import timber.log.Timber
/**
* Performs the Profile synchronization for account step by step.
*
* First: Synchronize user info
* Second: Synchronize user quota
*
* If one step fails, next one is not performed since it may fail too.
*/
class SyncProfileOperation(
private val account: Account
) : KoinComponent {
fun syncUserProfile() {
try {
CoroutineScope(Dispatchers.IO).launch {
val getUserInfoAsyncUseCase: GetUserInfoAsyncUseCase by inject()
val userInfoResult = getUserInfoAsyncUseCase(GetUserInfoAsyncUseCase.Params(account.name))
userInfoResult.getDataOrNull()?.let { userInfo ->
Timber.d("User info synchronized for account ${account.name}")
AccountManager.get(appContext).run {
setUserData(account, AccountUtils.Constants.KEY_DISPLAY_NAME, userInfo.displayName)
setUserData(account, AccountUtils.Constants.KEY_ID, userInfo.id)
}
val getStoredCapabilitiesUseCase: GetStoredCapabilitiesUseCase by inject()
val storedCapabilities = getStoredCapabilitiesUseCase(GetStoredCapabilitiesUseCase.Params(account.name))
storedCapabilities?.let {
if (!it.isSpacesAllowed()) {
val refreshUserQuotaFromServerAsyncUseCase: RefreshUserQuotaFromServerAsyncUseCase by inject()
val userQuotaResult =
refreshUserQuotaFromServerAsyncUseCase(
RefreshUserQuotaFromServerAsyncUseCase.Params(
account.name
)
)
userQuotaResult.getDataOrNull()?.let {
Timber.d("User quota synchronized for oC10 account ${account.name}")
}
}
}
} ?: Timber.d("User profile was not synchronized")
}
} catch (e: Exception) {
Timber.e(e, "Exception while getting user profile")
}
}
}
@@ -0,0 +1,137 @@
/**
* qsfera Android client application
*
* @author David A. Velasco
* @author Christian Schabesberger
* Copyright (C) 2020 ownCloud GmbH.
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License version 2,
* as published by the Free Software Foundation.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
package eu.qsfera.android.operations.common;
import android.content.Context;
import android.os.Handler;
import eu.qsfera.android.datamodel.FileDataStorageManager;
import eu.qsfera.android.lib.common.QSferaClient;
import eu.qsfera.android.lib.common.operations.OnRemoteOperationListener;
import eu.qsfera.android.lib.common.operations.RemoteOperation;
import eu.qsfera.android.lib.common.operations.RemoteOperationResult;
/**
* Operation which execution involves both interactions with an qsfera server and
* with local data in the device.
*
* Provides methods to execute the operation both synchronously or asynchronously.
*/
public abstract class SyncOperation<T> extends RemoteOperation<T> {
private FileDataStorageManager mStorageManager;
public FileDataStorageManager getStorageManager() {
return mStorageManager;
}
/**
* Synchronously executes the operation on the received qsfera account.
*
* Do not call this method from the main thread.
*
* This method should be used whenever an qsfera account is available, instead of
* {@link #execute(QSferaClient, eu.qsfera.android.datamodel.FileDataStorageManager)}.
*
* @param storageManager
* @param context Android context for the component calling the method.
* @return Result of the operation.
*/
public RemoteOperationResult<T> execute(FileDataStorageManager storageManager, Context context) {
if (storageManager == null) {
throw new IllegalArgumentException("Trying to execute a sync operation with a " +
"NULL storage manager");
}
if (storageManager.getAccount() == null) {
throw new IllegalArgumentException("Trying to execute a sync operation with a " +
"storage manager for a NULL account");
}
mStorageManager = storageManager;
return super.execute(mStorageManager.getAccount(), context);
}
/**
* Synchronously executes the remote operation
*
* Do not call this method from the main thread.
*
* @param client Client object to reach an qsfera server during the execution of the operation.
* @param storageManager Instance of local repository to sync with remote.
* @return Result of the operation.
*/
public RemoteOperationResult<T> execute(QSferaClient client,
FileDataStorageManager storageManager) {
if (storageManager == null) {
throw new IllegalArgumentException("Trying to execute a sync operation with a " +
"NULL storage manager");
}
mStorageManager = storageManager;
return super.execute(client);
}
/**
* Asynchronously executes the remote operation
*
* This method should be used whenever an qsfera account is available,
* instead of {@link #execute(QSferaClient, OnRemoteOperationListener, Handler))}.
*
* @param storageManager Instance of local repository to sync with remote.
* @param context Android context for the component calling the method.
* @param listener Listener to be notified about the execution of the operation.
* @param listenerHandler Handler associated to the thread where the methods of the listener
* objects must be called.
* @return Thread were the remote operation is executed.
*/
public Thread execute(FileDataStorageManager storageManager, Context context,
OnRemoteOperationListener listener, Handler listenerHandler) {
if (storageManager == null) {
throw new IllegalArgumentException("Trying to execute a sync operation " +
"with a NULL storage manager");
}
if (storageManager.getAccount() == null) {
throw new IllegalArgumentException("Trying to execute a sync operation with a" +
" storage manager for a NULL account");
}
mStorageManager = storageManager;
return super.execute(mStorageManager.getAccount(), context, listener, listenerHandler);
}
/**
* Asynchronously executes the remote operation
*
* @param client Client object to reach an qsfera server during the
* execution of the operation.
* @param storageManager Instance of local repository to sync with remote.
* @param listener Listener to be notified about the execution of the operation.
* @param listenerHandler Handler associated to the thread where the methods of
* the listener objects must be called.
* @return Thread were the remote operation is executed.
*/
public Thread execute(QSferaClient client, FileDataStorageManager storageManager,
OnRemoteOperationListener listener, Handler listenerHandler) {
if (storageManager == null) {
throw new IllegalArgumentException("Trying to execute a sync operation " +
"with a NULL storage manager");
}
mStorageManager = storageManager;
return super.execute(client, listener, listenerHandler);
}
}
@@ -0,0 +1,245 @@
/**
* qsfera Android client application
*
* @author Javier Rodríguez Pérez
* @author Aitor Ballesteros Pavón
* @author Jorge Aguado Recio
*
* Copyright (C) 2024 ownCloud GmbH.
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License version 2,
* as published by the Free Software Foundation.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
package eu.qsfera.android.presentation.accounts
import android.accounts.Account
import android.content.Context
import android.content.res.ColorStateList
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import android.widget.Button
import android.widget.ProgressBar
import android.widget.TextView
import androidx.recyclerview.widget.RecyclerView
import eu.qsfera.android.R
import eu.qsfera.android.databinding.AccountActionBinding
import eu.qsfera.android.databinding.AccountItemBinding
import eu.qsfera.android.domain.user.model.UserQuotaState
import eu.qsfera.android.domain.user.model.UserQuota
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.utils.DisplayUtils
import eu.qsfera.android.utils.PreferenceUtils
import timber.log.Timber
import androidx.lifecycle.findViewTreeLifecycleOwner
import androidx.lifecycle.lifecycleScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
class ManageAccountsAdapter(
private val accountListener: AccountAdapterListener,
) : RecyclerView.Adapter<RecyclerView.ViewHolder>() {
private var accountItemsList = listOf<AccountRecyclerItem>()
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): RecyclerView.ViewHolder {
val inflater = LayoutInflater.from(parent.context)
return if (viewType == AccountManagementRecyclerItemViewType.ITEM_VIEW_ACCOUNT.ordinal) {
val view = inflater.inflate(R.layout.account_item, parent, false)
view.filterTouchesWhenObscured = PreferenceUtils.shouldDisallowTouchesWithOtherVisibleWindows(parent.context)
view.setAccessibilityRole(className = Button::class.java)
AccountManagementViewHolder(view)
} else {
val view = inflater.inflate(R.layout.account_action, parent, false)
view.filterTouchesWhenObscured = PreferenceUtils.shouldDisallowTouchesWithOtherVisibleWindows(parent.context)
NewAccountViewHolder(view)
}
}
fun submitAccountList(accountList: List<AccountRecyclerItem>) {
accountItemsList = accountList
notifyDataSetChanged()
}
override fun onBindViewHolder(holder: RecyclerView.ViewHolder, position: Int) {
when (holder) {
is AccountManagementViewHolder -> {
val accountItem = getItem(position) as AccountRecyclerItem.AccountItem
val account: Account = accountItem.account
val accountAvatarRadiusDimension = holder.itemView.context.resources.getDimension(R.dimen.list_item_avatar_icon_radius)
try {
val oca = QSferaAccount(account, holder.itemView.context)
holder.binding.name.text = oca.displayName
} catch (e: Exception) {
Timber.w(
e, "Account not found right after being read :\\ ; using account name instead of display name"
)
holder.binding.name.text = AccountUtils.getUsernameOfAccount(account.name)
}
holder.binding.name.tag = account.name
holder.binding.account.text = DisplayUtils.convertIdn(account.name, false)
updateQuota(
quotaText = holder.binding.manageAccountsQuotaText,
quotaBar = holder.binding.manageAccountsQuotaBar,
userQuota = accountItem.userQuota,
context = holder.itemView.context
)
try {
val avatarUtils = AvatarUtils()
holder.itemView.findViewTreeLifecycleOwner()?.lifecycleScope?.launch(Dispatchers.IO) {
val loader = eu.qsfera.android.presentation.thumbnails.ThumbnailsRequester.getRevalidatingImageLoader(account)
withContext(Dispatchers.Main) {
avatarUtils.loadAvatarForAccount(
holder.binding.icon,
account,
accountAvatarRadiusDimension,
loader
)
}
}
} catch (e: java.lang.Exception) {
Timber.e(e, "Error calculating RGB value for account list item.")
// use user icon as a fallback
holder.binding.icon.setImageResource(R.drawable.ic_user)
}
if (AccountUtils.getCurrentQSferaAccount(holder.itemView.context).name == account.name) {
holder.binding.ticker.visibility = View.VISIBLE
} else {
holder.binding.ticker.visibility = View.INVISIBLE
}
/// bind listener to clean local storage from account
holder.binding.cleanAccountLocalStorageButton.apply {
setImageResource(R.drawable.ic_clean_account)
setOnClickListener { accountListener.cleanAccountLocalStorage(account) }
}
/// bind listener to remove account
holder.binding.removeButton.apply {
setImageResource(R.drawable.ic_action_delete_grey)
setOnClickListener { accountListener.removeAccount(account) }
}
///bind listener to switchAccount
holder.itemView.apply {
setOnClickListener { accountListener.switchAccount(position) }
}
}
is NewAccountViewHolder -> {
holder.binding.icon.setImageResource(R.drawable.ic_account_plus)
holder.binding.name.setText(R.string.prefs_add_account)
holder.binding.name.setAccessibilityRole(className = Button::class.java)
// bind action listener
holder.binding.constraintLayoutAction.setOnClickListener {
accountListener.createAccount()
}
}
}
}
override fun getItemCount(): Int = accountItemsList.size
fun getItem(position: Int) = accountItemsList[position]
private fun updateQuota(quotaText: TextView, quotaBar: ProgressBar, userQuota: UserQuota, context: Context) {
when {
userQuota.available == -4L -> { // Light users
quotaBar.visibility = View.GONE
quotaText.text = context.getString(R.string.drawer_unavailable_used_storage)
}
userQuota.available < 0 -> { // Pending, unknown or unlimited free storage. The progress bar is hid
quotaBar.visibility = View.GONE
quotaText.text = DisplayUtils.bytesToHumanReadable(userQuota.used, context, false)
}
userQuota.available == 0L -> { // Exceeded storage. Value over 100%
quotaBar.apply {
progress = 100
progressTintList = ColorStateList.valueOf(resources.getColor(R.color.quota_exceeded))
}
if (userQuota.state == UserQuotaState.EXCEEDED) {
quotaText.text = String.format(
context.getString(R.string.manage_accounts_quota),
DisplayUtils.bytesToHumanReadable(userQuota.used, context, false),
DisplayUtils.bytesToHumanReadable(userQuota.getTotal(), context, false)
)
} else { // oC10
quotaText.text = context.getString(R.string.drawer_exceeded_quota)
}
}
else -> { // Limited storage. Value under 100%
if (userQuota.state == UserQuotaState.CRITICAL || userQuota.state == UserQuotaState.EXCEEDED ||
userQuota.state == UserQuotaState.NEARING) { // Value over 75%
quotaBar.apply {
progressTintList = ColorStateList.valueOf(resources.getColor(R.color.quota_exceeded))
}
}
quotaBar.progress = userQuota.getRelative().toInt()
quotaText.text = String.format(
context.getString(R.string.manage_accounts_quota),
DisplayUtils.bytesToHumanReadable(userQuota.used, context, false),
DisplayUtils.bytesToHumanReadable(userQuota.getTotal(), context, false)
)
}
}
}
sealed class AccountRecyclerItem {
data class AccountItem(val account: Account, val userQuota: UserQuota) : AccountRecyclerItem()
object NewAccount : AccountRecyclerItem()
}
class AccountManagementViewHolder(itemView: View) : RecyclerView.ViewHolder(itemView) {
val binding = AccountItemBinding.bind(itemView)
}
class NewAccountViewHolder(itemView: View) : RecyclerView.ViewHolder(itemView) {
val binding = AccountActionBinding.bind(itemView)
}
override fun getItemViewType(position: Int): Int =
when (getItem(position)) {
is AccountRecyclerItem.AccountItem -> AccountManagementRecyclerItemViewType.ITEM_VIEW_ACCOUNT.ordinal
is AccountRecyclerItem.NewAccount -> AccountManagementRecyclerItemViewType.ITEM_VIEW_ADD.ordinal
}
enum class AccountManagementRecyclerItemViewType {
ITEM_VIEW_ACCOUNT, ITEM_VIEW_ADD
}
/**
* Listener interface for Activities using the [ManageAccountsAdapter]
*/
interface AccountAdapterListener {
fun removeAccount(account: Account)
fun cleanAccountLocalStorage(account: Account)
fun createAccount()
fun switchAccount(position: Int)
}
}
@@ -0,0 +1,288 @@
/**
* qsfera Android client application
*
* @author Jorge Aguado Recio
* @author Juan Carlos Garrote Gascón
*
* Copyright (C) 2024 ownCloud GmbH.
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License version 2,
* as published by the Free Software Foundation.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
package eu.qsfera.android.presentation.accounts
import android.accounts.Account
import android.accounts.AccountManager
import android.accounts.AccountManagerFuture
import android.accounts.OperationCanceledException
import android.app.AlertDialog
import android.app.Dialog
import android.content.Intent
import android.os.Bundle
import android.view.ContextThemeWrapper
import android.view.View
import android.widget.ImageView
import android.widget.LinearLayout
import android.widget.ProgressBar
import androidx.core.view.isVisible
import androidx.fragment.app.DialogFragment
import androidx.recyclerview.widget.LinearLayoutManager
import androidx.recyclerview.widget.RecyclerView
import eu.qsfera.android.MainApp
import eu.qsfera.android.R
import eu.qsfera.android.domain.user.model.UserQuota
import eu.qsfera.android.extensions.avoidScreenshotsIfNeeded
import eu.qsfera.android.extensions.collectLatestLifecycleFlow
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.ui.activity.ToolbarActivity
import eu.qsfera.android.utils.PreferenceUtils
import org.koin.androidx.viewmodel.ext.android.viewModel
import timber.log.Timber
class ManageAccountsDialogFragment : DialogFragment(), ManageAccountsAdapter.AccountAdapterListener {
private lateinit var accountListAdapter: ManageAccountsAdapter
private var currentAccount: Account? = null
private lateinit var dialogView: View
private lateinit var parentActivity: ToolbarActivity
private lateinit var recyclerView: RecyclerView
private val manageAccountsViewModel: ManageAccountsViewModel by viewModel()
override fun onStart() {
super.onStart()
parentActivity = requireActivity() as ToolbarActivity
currentAccount = requireArguments().getParcelable(KEY_CURRENT_ACCOUNT)
subscribeToViewModels()
}
override fun onCreateDialog(savedInstanceState: Bundle?): Dialog {
val builder = AlertDialog.Builder(ContextThemeWrapper(requireContext(), R.style.Theme_AppCompat_Dialog_Alert))
val inflater = this.layoutInflater
dialogView = inflater.inflate(R.layout.manage_accounts_dialog, null)
builder.setView(dialogView)
recyclerView = dialogView.findViewById(R.id.account_list_recycler_view)
recyclerView.apply {
filterTouchesWhenObscured = PreferenceUtils.shouldDisallowTouchesWithOtherVisibleWindows(requireContext())
layoutManager = LinearLayoutManager(requireContext())
}
accountListAdapter = ManageAccountsAdapter(this)
val closeButton = dialogView.findViewById<ImageView>(R.id.cross)
closeButton.setOnClickListener {
dismiss()
}
val dialog = builder.create()
dialog.window?.setBackgroundDrawableResource(R.color.transparent)
return dialog
}
override fun removeAccount(account: Account) {
dialogView.isVisible = false
val hasAccountAttachedCameraUploads = manageAccountsViewModel.hasAutomaticUploadsAttached(account.name)
val dialog = AlertDialog.Builder(requireContext())
.setMessage(getString(
if (hasAccountAttachedCameraUploads) R.string.confirmation_remove_account_alert_camera_uploads
else R.string.confirmation_remove_account_alert, account.name)
)
.setPositiveButton(getString(R.string.common_yes)) { _, _ ->
val accountManager = AccountManager.get(MainApp.appContext)
accountManager.removeAccount(account, null, null)
if (manageAccountsViewModel.getLoggedAccounts().size > 1) {
dialogView.isVisible = true
}
}
.setNegativeButton(getString(R.string.common_no)) { _, _ ->
dialogView.isVisible = true
}
.setOnDismissListener {
dialogView.isVisible = true
}
.create()
dialog.avoidScreenshotsIfNeeded()
dialog.show()
}
override fun cleanAccountLocalStorage(account: Account) {
dialogView.isVisible = false
val dialog = AlertDialog.Builder(requireContext())
.setTitle(getString(R.string.clean_data_account_title))
.setIcon(R.drawable.ic_warning)
.setMessage(getString(R.string.clean_data_account_message, account.name))
.setPositiveButton(getString(R.string.clean_data_account_button_yes)) { _, _ ->
dialogView.isVisible = true
manageAccountsViewModel.cleanAccountLocalStorage(account.name)
}
.setNegativeButton(R.string.drawer_close) { _, _ ->
dialogView.isVisible = true
}
.setOnDismissListener {
dialogView.isVisible = true
}
.create()
dialog.avoidScreenshotsIfNeeded()
dialog.show()
}
override fun createAccount() {
val accountManager = AccountManager.get(MainApp.appContext)
accountManager.addAccount(
MainApp.accountType,
null,
null,
null,
parentActivity,
{ future: AccountManagerFuture<Bundle>? ->
if (future != null) {
try {
val result = future.result
val name = result.getString(AccountManager.KEY_ACCOUNT_NAME)
val newAccount = AccountUtils.getQSferaAccountByName(parentActivity.applicationContext, name)
changeToAccountContext(newAccount)
} catch (e: OperationCanceledException) {
Timber.e(e, "Account creation canceled")
} catch (e: Exception) {
Timber.e(e, "Account creation finished in exception")
}
}
},
null
)
}
/**
* Switch current account to that contained in the received position of the list adapter.
*
* @param position A position of the account adapter containing an account.
*/
override fun switchAccount(position: Int) {
val clickedAccount: Account = (accountListAdapter.getItem(position) as ManageAccountsAdapter.AccountRecyclerItem.AccountItem).account
if (currentAccount?.name == clickedAccount.name) {
// current account selected, just go back
dismiss()
} else {
// restart list of files with new account
parentActivity.showLoadingDialog(R.string.common_loading)
dismiss()
changeToAccountContext(clickedAccount)
}
}
private fun changeToAccountContext(account: Account) {
AccountUtils.setCurrentQSferaAccount(
parentActivity.applicationContext,
account.name
)
parentActivity.account = account
// Refresh dependencies to be used in selected account
MainApp.initDependencyInjection()
val i = Intent(
parentActivity.applicationContext,
FileDisplayActivity::class.java
)
i.putExtra(FileActivity.EXTRA_ACCOUNT, account)
i.addFlags(Intent.FLAG_ACTIVITY_CLEAR_TOP)
parentActivity.startActivity(i)
}
private fun subscribeToViewModels() {
collectLatestLifecycleFlow(manageAccountsViewModel.cleanAccountLocalStorageFlow) { event ->
event?.peekContent()?.let { uiResult ->
when (uiResult) {
is UIResult.Loading -> {
parentActivity.showLoadingDialog(R.string.common_loading)
dialogView.isVisible = false
}
is UIResult.Success -> {
parentActivity.dismissLoadingDialog()
dialogView.isVisible = true
}
is UIResult.Error -> {
parentActivity.dismissLoadingDialog()
showErrorInSnackbar(R.string.common_error_unknown, uiResult.error)
Timber.e(uiResult.error)
}
}
}
}
collectLatestLifecycleFlow(manageAccountsViewModel.userQuotas) { listUserQuotas ->
if (listUserQuotas.isNotEmpty()) {
manageAccountsViewModel.getCurrentAccount()?.let {
if (currentAccount != it) {
parentActivity.showLoadingDialog(R.string.common_loading)
dismiss()
changeToAccountContext(it)
}
}
// hide the progress bar and show manage accounts dialog
val indeterminateProgressBar = dialogView.findViewById<ProgressBar>(R.id.indeterminate_progress_bar)
indeterminateProgressBar.visibility = View.GONE
val manageAccountsLayout = dialogView.findViewById<LinearLayout>(R.id.manage_accounts_layout)
manageAccountsLayout.visibility = View.VISIBLE
accountListAdapter.submitAccountList(accountList = getAccountListItems(listUserQuotas))
recyclerView.adapter = accountListAdapter
} else {
createAccount()
}
}
}
/**
* creates the account list items list including the add-account action in case multiaccount_support is enabled.
*
* @return list of account list items
*/
private fun getAccountListItems(userQuotasList: List<UserQuota>): List<ManageAccountsAdapter.AccountRecyclerItem> {
val accountList = manageAccountsViewModel.getLoggedAccounts()
val provisionalAccountList = mutableListOf<ManageAccountsAdapter.AccountRecyclerItem>()
accountList.forEach { account ->
val userQuota = userQuotasList.firstOrNull { userQuota -> userQuota.accountName == account.name }
if (userQuota != null) {
provisionalAccountList.add(ManageAccountsAdapter.AccountRecyclerItem.AccountItem(account, userQuota))
}
}
// Add Create Account item at the end of account list if multi-account is enabled
if (resources.getBoolean(R.bool.multiaccount_support) || accountList.isEmpty()) {
provisionalAccountList.add(ManageAccountsAdapter.AccountRecyclerItem.NewAccount)
}
return provisionalAccountList
}
companion object {
const val MANAGE_ACCOUNTS_DIALOG = "MANAGE_ACCOUNTS_DIALOG"
const val KEY_CURRENT_ACCOUNT = "KEY_CURRENT_ACCOUNT"
fun newInstance(currentAccount: Account?): ManageAccountsDialogFragment {
val args = Bundle().apply {
putParcelable(KEY_CURRENT_ACCOUNT, currentAccount)
}
return ManageAccountsDialogFragment().apply { arguments = args }
}
}
}
@@ -0,0 +1,95 @@
/**
* qsfera Android client application
*
* @author Javier Rodríguez Pérez
* @author Aitor Ballesteros Pavón
* @author Juan Carlos Garrote Gascón
* @author Jorge Aguado Recio
*
* Copyright (C) 2024 ownCloud GmbH.
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License version 2,
* as published by the Free Software Foundation.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
package eu.qsfera.android.presentation.accounts
import android.accounts.Account
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import eu.qsfera.android.domain.user.model.UserQuota
import eu.qsfera.android.domain.automaticuploads.model.AutomaticUploadsConfiguration
import eu.qsfera.android.domain.automaticuploads.usecases.GetAutomaticUploadsConfigurationUseCase
import eu.qsfera.android.domain.user.usecases.GetStoredQuotaUseCase
import eu.qsfera.android.domain.user.usecases.GetUserQuotasAsStreamUseCase
import eu.qsfera.android.domain.utils.Event
import eu.qsfera.android.extensions.ViewModelExt.runUseCaseWithResult
import eu.qsfera.android.presentation.common.UIResult
import eu.qsfera.android.providers.AccountProvider
import eu.qsfera.android.providers.CoroutinesDispatcherProvider
import eu.qsfera.android.usecases.files.RemoveLocalFilesForAccountUseCase
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.launch
import kotlinx.coroutines.runBlocking
import kotlinx.coroutines.withContext
class ManageAccountsViewModel(
private val accountProvider: AccountProvider,
private val removeLocalFilesForAccountUseCase: RemoveLocalFilesForAccountUseCase,
private val getAutomaticUploadsConfigurationUseCase: GetAutomaticUploadsConfigurationUseCase,
private val getStoredQuotaUseCase: GetStoredQuotaUseCase,
getUserQuotasAsStreamUseCase: GetUserQuotasAsStreamUseCase,
private val coroutinesDispatcherProvider: CoroutinesDispatcherProvider,
) : ViewModel() {
private val _cleanAccountLocalStorageFlow = MutableStateFlow<Event<UIResult<Unit>>?>(null)
val cleanAccountLocalStorageFlow: StateFlow<Event<UIResult<Unit>>?> = _cleanAccountLocalStorageFlow
val userQuotas: Flow<List<UserQuota>> = getUserQuotasAsStreamUseCase(Unit)
private var automaticUploadsConfiguration: AutomaticUploadsConfiguration? = null
init {
viewModelScope.launch(coroutinesDispatcherProvider.io) {
automaticUploadsConfiguration = getAutomaticUploadsConfigurationUseCase(Unit).getDataOrNull()
}
}
fun getLoggedAccounts(): Array<Account> =
accountProvider.getLoggedAccounts()
fun getCurrentAccount(): Account? =
accountProvider.getCurrentQSferaAccount()
fun cleanAccountLocalStorage(accountName: String) {
runUseCaseWithResult(
coroutineDispatcher = coroutinesDispatcherProvider.io,
showLoading = true,
flow = _cleanAccountLocalStorageFlow,
useCase = removeLocalFilesForAccountUseCase,
useCaseParams = RemoveLocalFilesForAccountUseCase.Params(accountName),
)
}
fun hasAutomaticUploadsAttached(accountName: String): Boolean =
accountName == automaticUploadsConfiguration?.pictureUploadsConfiguration?.accountName ||
accountName == automaticUploadsConfiguration?.videoUploadsConfiguration?.accountName
fun checkUserLight(accountName: String): Boolean = runBlocking(CoroutinesDispatcherProvider().io) {
val quota = withContext(CoroutinesDispatcherProvider().io) {
getStoredQuotaUseCase(GetStoredQuotaUseCase.Params(accountName))
}
quota.getDataOrNull()?.available == -4L
}
}
@@ -0,0 +1,472 @@
/*
* qsfera Android client application
*
* @author David A. Velasco
* @author Christian Schabesberger
* @author David González Verdugo
* @author Juan Carlos Garrote Gascón
*
* Copyright (C) 2012 Bartek Przybylski
* Copyright (C) 2024 ownCloud GmbH.
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License version 2,
* as published by the Free Software Foundation.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
package eu.qsfera.android.presentation.authentication;
import android.accounts.AbstractAccountAuthenticator;
import android.accounts.Account;
import android.accounts.AccountAuthenticatorResponse;
import android.accounts.AccountManager;
import android.accounts.NetworkErrorException;
import android.content.Context;
import android.content.Intent;
import android.os.Bundle;
import eu.qsfera.android.MainApp;
import eu.qsfera.android.R;
import eu.qsfera.android.presentation.authentication.oauth.OAuthUtils;
import eu.qsfera.android.domain.UseCaseResult;
import eu.qsfera.android.domain.authentication.oauth.OIDCDiscoveryUseCase;
import eu.qsfera.android.domain.authentication.oauth.RequestTokenUseCase;
import eu.qsfera.android.domain.authentication.oauth.model.OIDCServerConfiguration;
import eu.qsfera.android.domain.authentication.oauth.model.TokenRequest;
import eu.qsfera.android.domain.authentication.oauth.model.TokenResponse;
import eu.qsfera.android.lib.common.accounts.AccountTypeUtils;
import eu.qsfera.android.lib.common.accounts.AccountUtils;
import kotlin.Lazy;
import org.jetbrains.annotations.NotNull;
import timber.log.Timber;
import java.io.File;
import static eu.qsfera.android.data.authentication.AuthenticationConstantsKt.KEY_CLIENT_REGISTRATION_CLIENT_EXPIRATION_DATE;
import static eu.qsfera.android.data.authentication.AuthenticationConstantsKt.KEY_CLIENT_REGISTRATION_CLIENT_ID;
import static eu.qsfera.android.data.authentication.AuthenticationConstantsKt.KEY_CLIENT_REGISTRATION_CLIENT_SECRET;
import static eu.qsfera.android.data.authentication.AuthenticationConstantsKt.KEY_OAUTH2_REFRESH_TOKEN;
import static eu.qsfera.android.data.authentication.AuthenticationConstantsKt.KEY_OAUTH2_SCOPE;
import static eu.qsfera.android.data.authentication.AuthenticationConstantsKt.KEY_OIDC_ISSUER;
import static eu.qsfera.android.presentation.authentication.AuthenticatorConstants.KEY_AUTH_TOKEN_TYPE;
import static org.koin.java.KoinJavaComponent.inject;
/**
* Authenticator for qsfera accounts.
*
* Controller class accessed from the system AccountManager, providing integration of qsfera accounts with the
* Android system.
*/
public class AccountAuthenticator extends AbstractAccountAuthenticator {
/**
* Is used by android system to assign accounts to authenticators. Should be
* used by application and all extensions.
*/
private static final String KEY_REQUIRED_FEATURES = "requiredFeatures";
public static final String KEY_ACCOUNT = "account";
private Context mContext;
AccountAuthenticator(Context context) {
super(context);
mContext = context;
}
/**
* {@inheritDoc}
*/
@Override
public Bundle addAccount(AccountAuthenticatorResponse response,
String accountType, String authTokenType,
String[] requiredFeatures, Bundle options) {
Timber.i("Adding account with type " + accountType + " and auth token " + authTokenType);
final Bundle bundle = new Bundle();
AccountManager accountManager = AccountManager.get(mContext);
Account[] accounts = accountManager.getAccountsByType(MainApp.Companion.getAccountType());
if (mContext.getResources().getBoolean(R.bool.multiaccount_support) || accounts.length < 1) {
try {
validateAccountType(accountType);
} catch (AuthenticatorException e) {
Timber.e(e, "Failed to validate account type %s", accountType);
return e.getFailureBundle();
}
final Intent intent = new Intent(mContext, LoginActivity.class);
intent.putExtra(AccountManager.KEY_ACCOUNT_AUTHENTICATOR_RESPONSE, response);
intent.putExtra(KEY_AUTH_TOKEN_TYPE, authTokenType);
intent.putExtra(KEY_REQUIRED_FEATURES, requiredFeatures);
intent.putExtra(AuthenticatorConstants.EXTRA_ACTION, AuthenticatorConstants.ACTION_CREATE);
setIntentFlags(intent);
bundle.putParcelable(AccountManager.KEY_INTENT, intent);
}
return bundle;
}
/**
* {@inheritDoc}
*/
@Override
public Bundle confirmCredentials(AccountAuthenticatorResponse response,
Account account, Bundle options) {
try {
validateAccountType(account.type);
} catch (AuthenticatorException e) {
Timber.e(e, "Failed to validate account type %s", account.type);
return e.getFailureBundle();
}
Intent intent = new Intent(mContext, LoginActivity.class);
intent.putExtra(AccountManager.KEY_ACCOUNT_AUTHENTICATOR_RESPONSE,
response);
intent.putExtra(KEY_ACCOUNT, account);
setIntentFlags(intent);
Bundle resultBundle = new Bundle();
resultBundle.putParcelable(AccountManager.KEY_INTENT, intent);
return resultBundle;
}
@Override
public Bundle editProperties(AccountAuthenticatorResponse response,
String accountType) {
return null;
}
/**
* {@inheritDoc}
*/
@Override
public Bundle getAuthToken(AccountAuthenticatorResponse accountAuthenticatorResponse,
Account account, String authTokenType, Bundle options) {
/// validate parameters
try {
validateAccountType(account.type);
validateAuthTokenType(authTokenType);
} catch (AuthenticatorException e) {
Timber.e(e, "Failed to validate account type %s", account.type);
return e.getFailureBundle();
}
String accessToken;
/// check if required token is stored
final AccountManager accountManager = AccountManager.get(mContext);
if (authTokenType.equals(AccountTypeUtils.getAuthTokenTypePass(MainApp.Companion.getAccountType()))) {
// Basic
accessToken = accountManager.getPassword(account);
} else {
// OAuth, gets an auth token from the AccountManager's cache. If no auth token is cached for
// this account, null will be returned
accessToken = accountManager.peekAuthToken(account, authTokenType);
if (accessToken == null && canBeRefreshed(authTokenType) && clientSecretIsValid(accountManager, account)) {
accessToken = refreshToken(account, authTokenType, accountManager);
}
}
if (accessToken != null) {
final Bundle result = new Bundle();
result.putString(AccountManager.KEY_ACCOUNT_NAME, account.name);
result.putString(AccountManager.KEY_ACCOUNT_TYPE, MainApp.Companion.getAccountType());
result.putString(AccountManager.KEY_AUTHTOKEN, accessToken);
return result;
}
/// if not stored, return Intent to access the LoginActivity and UPDATE the token for the account
return prepareBundleToAccessLoginActivity(accountAuthenticatorResponse, account, authTokenType, options);
}
/**
* Check if the client has expired or not.
* If the client has expired, we can not refresh the token and user needs to re-authenticate.
*
* @return true if the client is still valid
*/
private boolean clientSecretIsValid(AccountManager accountManager, Account account) {
String clientSecretExpiration = accountManager.getUserData(account,
KEY_CLIENT_REGISTRATION_CLIENT_EXPIRATION_DATE);
Timber.d("Client secret expiration [" + clientSecretExpiration + "]");
if (clientSecretExpiration == null) {
return true;
}
long currentTimeStamp = System.currentTimeMillis() / 1000L;
int clientSecretExpirationInt = Integer.parseInt(clientSecretExpiration);
boolean clientSecretIsValid = clientSecretExpirationInt == 0 || clientSecretExpirationInt > currentTimeStamp;
Timber.d("Current time [" + currentTimeStamp + "]");
Timber.d("Client is valid [" + clientSecretIsValid + "]");
return clientSecretIsValid;
}
@Override
public String getAuthTokenLabel(String authTokenType) {
return null;
}
@Override
public Bundle hasFeatures(AccountAuthenticatorResponse response,
Account account, String[] features) {
final Bundle result = new Bundle();
result.putBoolean(AccountManager.KEY_BOOLEAN_RESULT, true);
return result;
}
@Override
public Bundle updateCredentials(AccountAuthenticatorResponse response,
Account account, String authTokenType, Bundle options) {
final Intent intent = new Intent(mContext, LoginActivity.class);
intent.putExtra(AccountManager.KEY_ACCOUNT_AUTHENTICATOR_RESPONSE,
response);
intent.putExtra(KEY_ACCOUNT, account);
intent.putExtra(KEY_AUTH_TOKEN_TYPE, authTokenType);
setIntentFlags(intent);
final Bundle bundle = new Bundle();
bundle.putParcelable(AccountManager.KEY_INTENT, intent);
return bundle;
}
@Override
public Bundle getAccountRemovalAllowed(
AccountAuthenticatorResponse response, Account account)
throws NetworkErrorException {
return super.getAccountRemovalAllowed(response, account);
}
private void setIntentFlags(Intent intent) {
intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK);
intent.addFlags(Intent.FLAG_ACTIVITY_EXCLUDE_FROM_RECENTS);
intent.addFlags(Intent.FLAG_FROM_BACKGROUND);
}
private void validateAccountType(String type)
throws UnsupportedAccountTypeException {
if (!type.equals(MainApp.Companion.getAccountType())) {
throw new UnsupportedAccountTypeException();
}
}
private void validateAuthTokenType(String authTokenType)
throws UnsupportedAuthTokenTypeException {
if (!authTokenType.equals(MainApp.Companion.getAuthTokenType()) &&
!authTokenType.equals(AccountTypeUtils.getAuthTokenTypePass(MainApp.Companion.getAccountType())) &&
!authTokenType.equals(AccountTypeUtils.getAuthTokenTypeAccessToken(MainApp.Companion.getAccountType())) &&
!authTokenType.equals(AccountTypeUtils.getAuthTokenTypeRefreshToken(MainApp.Companion.getAccountType()))
) {
throw new UnsupportedAuthTokenTypeException();
}
}
public static class AuthenticatorException extends Exception {
private static final long serialVersionUID = 1L;
private Bundle mFailureBundle;
AuthenticatorException(int code, String errorMsg) {
mFailureBundle = new Bundle();
mFailureBundle.putInt(AccountManager.KEY_ERROR_CODE, code);
mFailureBundle
.putString(AccountManager.KEY_ERROR_MESSAGE, errorMsg);
}
Bundle getFailureBundle() {
return mFailureBundle;
}
}
public static class UnsupportedAccountTypeException extends
AuthenticatorException {
private static final long serialVersionUID = 1L;
UnsupportedAccountTypeException() {
super(AccountManager.ERROR_CODE_UNSUPPORTED_OPERATION,
"Unsupported account type");
}
}
public static class UnsupportedAuthTokenTypeException extends
AuthenticatorException {
private static final long serialVersionUID = 1L;
UnsupportedAuthTokenTypeException() {
super(AccountManager.ERROR_CODE_UNSUPPORTED_OPERATION,
"Unsupported auth token type");
}
}
private boolean canBeRefreshed(String authTokenType) {
return (authTokenType.equals(AccountTypeUtils.getAuthTokenTypeAccessToken(MainApp.Companion.
getAccountType())));
}
private String refreshToken(
Account account,
String authTokenType,
AccountManager accountManager
) {
// Prepare everything to perform the token request
String refreshToken = accountManager.getUserData(account, KEY_OAUTH2_REFRESH_TOKEN);
if (refreshToken == null || refreshToken.isEmpty()) {
Timber.w("No refresh token stored for silent renewal of access token");
return null;
}
Timber.d("Ready to exchange for new tokens. Account: [ %s ], Refresh token: [ %s ]", account.name,
refreshToken);
String baseUrl = accountManager.getUserData(account, AccountUtils.Constants.KEY_OC_BASE_URL);
// OIDC Discovery: prefer the stored webfinger issuer (points to the real IDP),
// fall back to baseUrl for accounts created before webfinger support.
String oidcIssuer = accountManager.getUserData(account, KEY_OIDC_ISSUER);
String discoveryUrl = (oidcIssuer != null) ? oidcIssuer : baseUrl;
@NotNull Lazy<OIDCDiscoveryUseCase> oidcDiscoveryUseCase = inject(OIDCDiscoveryUseCase.class);
OIDCDiscoveryUseCase.Params oidcDiscoveryUseCaseParams = new OIDCDiscoveryUseCase.Params(discoveryUrl);
UseCaseResult<OIDCServerConfiguration> oidcServerConfigurationUseCaseResult =
oidcDiscoveryUseCase.getValue().invoke(oidcDiscoveryUseCaseParams);
String tokenEndpoint;
String clientId = accountManager.getUserData(account, KEY_CLIENT_REGISTRATION_CLIENT_ID);
String clientSecret = accountManager.getUserData(account, KEY_CLIENT_REGISTRATION_CLIENT_SECRET);
String clientIdForRequest = null;
String clientSecretForRequest = null;
String clientAuth;
if (clientId == null) {
Timber.d("Client Id not stored. Let's use the hardcoded one");
clientId = mContext.getString(R.string.oauth2_client_id);
}
if (clientSecret == null) {
Timber.d("Client Secret not stored. Let's use the hardcoded one");
clientSecret = mContext.getString(R.string.oauth2_client_secret);
}
if (oidcServerConfigurationUseCaseResult.isSuccess()) {
Timber.d("OIDC Discovery success. Server discovery info: [ %s ]",
oidcServerConfigurationUseCaseResult.getDataOrNull());
// Use token endpoint retrieved from oidc discovery
tokenEndpoint = oidcServerConfigurationUseCaseResult.getDataOrNull().getTokenEndpoint();
// RFC 7636: Public clients (token_endpoint_auth_method: none) must not send Authorization header
if (oidcServerConfigurationUseCaseResult.getDataOrNull() != null &&
oidcServerConfigurationUseCaseResult.getDataOrNull().isTokenEndpointAuthMethodNone()) {
clientAuth = "";
clientIdForRequest = clientId;
} else if (oidcServerConfigurationUseCaseResult.getDataOrNull() != null &&
oidcServerConfigurationUseCaseResult.getDataOrNull().isTokenEndpointAuthMethodSupportedClientSecretPost()) {
// For client_secret_post, credentials go in body, not Authorization header
clientAuth = "";
clientIdForRequest = clientId;
clientSecretForRequest = clientSecret;
} else {
// For other methods (e.g., client_secret_basic), use Basic auth header
clientAuth = OAuthUtils.Companion.getClientAuth(clientSecret, clientId);
}
} else {
Timber.d("OIDC Discovery failed. Server discovery info: [ %s ]",
oidcServerConfigurationUseCaseResult.getThrowableOrNull().toString());
tokenEndpoint = baseUrl + File.separator + mContext.getString(R.string.oauth2_url_endpoint_access);
clientAuth = OAuthUtils.Companion.getClientAuth(clientSecret, clientId);
}
String scope = accountManager.getUserData(account, KEY_OAUTH2_SCOPE);
if (scope == null) {
scope = mContext.getResources().getString(R.string.oauth2_openid_scope);
}
TokenRequest oauthTokenRequest = new TokenRequest.RefreshToken(
baseUrl,
tokenEndpoint,
clientAuth,
scope,
clientIdForRequest,
clientSecretForRequest,
refreshToken
);
// Token exchange
@NotNull Lazy<RequestTokenUseCase> requestTokenUseCase = inject(RequestTokenUseCase.class);
RequestTokenUseCase.Params requestTokenParams = new RequestTokenUseCase.Params(oauthTokenRequest);
UseCaseResult<TokenResponse> tokenResponseResult = requestTokenUseCase.getValue().invoke(requestTokenParams);
TokenResponse safeTokenResponse = tokenResponseResult.getDataOrNull();
if (safeTokenResponse != null) {
return handleSuccessfulRefreshToken(safeTokenResponse,
account, authTokenType, accountManager, refreshToken);
} else {
Timber.e(tokenResponseResult.getThrowableOrNull(), "OAuth request to refresh access token failed. Preparing to access Login Activity");
return null;
}
}
private String handleSuccessfulRefreshToken(
TokenResponse tokenResponse,
Account account,
String authTokenType,
AccountManager accountManager,
String oldRefreshToken
) {
String newAccessToken = tokenResponse.getAccessToken();
accountManager.setAuthToken(account, authTokenType, newAccessToken);
String refreshTokenToUseFromNowOn;
if (tokenResponse.getRefreshToken() != null) {
refreshTokenToUseFromNowOn = tokenResponse.getRefreshToken();
} else {
refreshTokenToUseFromNowOn = oldRefreshToken;
}
accountManager.setUserData(account, KEY_OAUTH2_REFRESH_TOKEN, refreshTokenToUseFromNowOn);
Timber.d("Token refreshed successfully. New access token: [ %s ]. New refresh token: [ %s ]",
newAccessToken, refreshTokenToUseFromNowOn);
return newAccessToken;
}
/**
* Return bundle with intent to access LoginActivity and UPDATE the token for the account
*/
private Bundle prepareBundleToAccessLoginActivity(
AccountAuthenticatorResponse accountAuthenticatorResponse,
Account account,
String authTokenType,
Bundle options
) {
final Intent intent = new Intent(mContext, LoginActivity.class);
intent.putExtra(AccountManager.KEY_ACCOUNT_AUTHENTICATOR_RESPONSE,
accountAuthenticatorResponse);
intent.putExtra(KEY_AUTH_TOKEN_TYPE, authTokenType);
intent.putExtra(AuthenticatorConstants.EXTRA_ACCOUNT, account);
intent.putExtra(
AuthenticatorConstants.EXTRA_ACTION,
AuthenticatorConstants.ACTION_UPDATE_EXPIRED_TOKEN
);
final Bundle bundle = new Bundle();
bundle.putParcelable(AccountManager.KEY_INTENT, intent);
return bundle;
}
}
@@ -0,0 +1,41 @@
/**
* qsfera Android client application
* <p>
* Copyright (C) 2011 Bartek Przybylski
* Copyright (C) 2016 ownCloud GmbH.
* <p>
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License version 2,
* as published by the Free Software Foundation.
* <p>
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
* <p>
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
package eu.qsfera.android.presentation.authentication;
import android.app.Service;
import android.content.Intent;
import android.os.IBinder;
public class AccountAuthenticatorService extends Service {
private AccountAuthenticator mAuthenticator;
@Override
public void onCreate() {
super.onCreate();
mAuthenticator = new AccountAuthenticator(this);
}
@Override
public IBinder onBind(Intent intent) {
return mAuthenticator.getIBinder();
}
}
@@ -0,0 +1,249 @@
/**
* qsfera Android client application
* <p>
* @author Aitor Ballesteros Pavón
* <p>
* Copyright (C) 2012 Bartek Przybylski
* Copyright (C) 2024 ownCloud GmbH.
* <p>
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License version 2,
* as published by the Free Software Foundation.
* <p>
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
* <p>
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
package eu.qsfera.android.presentation.authentication;
import android.accounts.Account;
import android.accounts.AccountManager;
import android.content.Context;
import android.content.SharedPreferences;
import android.net.Uri;
import android.preference.PreferenceManager;
import eu.qsfera.android.MainApp;
import eu.qsfera.android.domain.capabilities.model.OCCapability;
import eu.qsfera.android.lib.common.accounts.AccountUtils.Constants;
import timber.log.Timber;
import java.util.Locale;
import static eu.qsfera.android.data.authentication.AuthenticationConstantsKt.KEY_FEATURE_ALLOWED;
import static eu.qsfera.android.data.authentication.AuthenticationConstantsKt.KEY_FEATURE_SPACES;
import static eu.qsfera.android.data.authentication.AuthenticationConstantsKt.SELECTED_ACCOUNT;
import static eu.qsfera.android.lib.common.accounts.AccountUtils.Constants.OAUTH_SUPPORTED_TRUE;
public class AccountUtils {
private static final int ACCOUNT_VERSION = 1;
/**
* Can be used to get the currently selected qsfera {@link Account} in the
* application preferences.
*
* @param context The current application {@link Context}
* @return The qsfera {@link Account} currently saved in preferences, or the first
* {@link Account} available, if valid (still registered in the system as qsfera
* account). If none is available and valid, returns null.
*/
public static Account getCurrentQSferaAccount(Context context) {
Account[] ocAccounts = getAccounts(context);
Account defaultAccount = null;
SharedPreferences appPreferences = PreferenceManager.getDefaultSharedPreferences(context);
String accountName = appPreferences.getString(SELECTED_ACCOUNT, null);
// account validation: the saved account MUST be in the list of qsfera Accounts known by the AccountManager
if (accountName != null) {
for (Account account : ocAccounts) {
if (account.name.equals(accountName)) {
defaultAccount = account;
break;
}
}
}
if (defaultAccount == null && ocAccounts.length != 0) {
// take first account as fallback
defaultAccount = ocAccounts[0];
}
return defaultAccount;
}
public static Account[] getAccounts(Context context) {
AccountManager accountManager = AccountManager.get(context);
return accountManager.getAccountsByType(MainApp.Companion.getAccountType());
}
public static void deleteAccounts(Context context) {
AccountManager accountManager = AccountManager.get(context);
Account[] accounts = getAccounts(context);
for (Account account : accounts) {
accountManager.removeAccount(account, null, null, null);
}
}
public static boolean exists(String accountName, Context context) {
Account[] ocAccounts = getAccounts(context);
if (accountName != null) {
int lastAtPos = accountName.lastIndexOf("@");
String hostAndPort = accountName.substring(lastAtPos + 1);
String username = accountName.substring(0, lastAtPos);
String otherHostAndPort, otherUsername;
Locale currentLocale = context.getResources().getConfiguration().locale;
for (Account otherAccount : ocAccounts) {
lastAtPos = otherAccount.name.lastIndexOf("@");
otherHostAndPort = otherAccount.name.substring(lastAtPos + 1);
otherUsername = otherAccount.name.substring(0, lastAtPos);
if (otherHostAndPort.equals(hostAndPort) &&
otherUsername.toLowerCase(currentLocale).
equals(username.toLowerCase(currentLocale))) {
return true;
}
}
}
return false;
}
/**
* returns the user's name based on the account name.
*
* @param accountName the account name
* @return the user's name
*/
public static String getUsernameOfAccount(String accountName) {
if (accountName != null) {
return accountName.substring(0, accountName.lastIndexOf("@"));
} else {
return null;
}
}
/**
* Returns qsfera account identified by accountName or null if it does not exist.
*
* @param context
* @param accountName name of account to be returned
* @return qsfera account named accountName
*/
public static Account getQSferaAccountByName(Context context, String accountName) {
Account[] ocAccounts = AccountManager.get(context).getAccountsByType(
MainApp.Companion.getAccountType());
for (Account account : ocAccounts) {
if (account.name.equals(accountName)) {
return account;
}
}
return null;
}
public static boolean isSpacesFeatureAllowedForAccount(Context context, Account account, OCCapability capability) {
if (capability == null || !capability.isSpacesAllowed()) {
return false;
}
AccountManager accountManager = AccountManager.get(context);
String spacesFeatureValue = accountManager.getUserData(account, KEY_FEATURE_SPACES);
return KEY_FEATURE_ALLOWED.equals(spacesFeatureValue);
}
public static boolean setCurrentQSferaAccount(Context context, String accountName) {
boolean result = false;
if (accountName != null) {
boolean found;
for (Account account : getAccounts(context)) {
found = (account.name.equals(accountName));
if (found) {
SharedPreferences.Editor appPrefs = PreferenceManager
.getDefaultSharedPreferences(context).edit();
appPrefs.putString(SELECTED_ACCOUNT, accountName);
appPrefs.apply();
result = true;
break;
}
}
}
return result;
}
/**
* Update the accounts in AccountManager to meet the current version of accounts expected by the app, if needed.
* <p>
* Introduced to handle a change in the structure of stored account names needed to allow different OC servers
* in the same domain, but not in the same path.
*
* @param context Used to access the AccountManager.
*/
public static void updateAccountVersion(Context context) {
Account currentAccount = AccountUtils.getCurrentQSferaAccount(context);
AccountManager accountMgr = AccountManager.get(context);
if (currentAccount != null) {
String currentAccountVersion = accountMgr.getUserData(currentAccount, Constants.KEY_OC_ACCOUNT_VERSION);
if (currentAccountVersion == null) {
Timber.i("Upgrading accounts to account version #%s", ACCOUNT_VERSION);
Account[] ocAccounts = accountMgr.getAccountsByType(MainApp.Companion.getAccountType());
String serverUrl, username, newAccountName, password;
Account newAccount;
for (Account account : ocAccounts) {
// build new account name
serverUrl = accountMgr.getUserData(account, Constants.KEY_OC_BASE_URL);
username = eu.qsfera.android.lib.common.accounts.AccountUtils.
getUsernameForAccount(account);
newAccountName = eu.qsfera.android.lib.common.accounts.AccountUtils.
buildAccountName(Uri.parse(serverUrl), username);
// migrate to a new account, if needed
if (!newAccountName.equals(account.name)) {
Timber.d("Upgrading " + account.name + " to " + newAccountName);
// create the new account
newAccount = new Account(newAccountName, MainApp.Companion.getAccountType());
password = accountMgr.getPassword(account);
accountMgr.addAccountExplicitly(newAccount, (password != null) ? password : "", null);
// copy base URL
accountMgr.setUserData(newAccount, Constants.KEY_OC_BASE_URL, serverUrl);
String isOauthStr = accountMgr.getUserData(account, Constants.KEY_SUPPORTS_OAUTH2);
boolean isOAuth = OAUTH_SUPPORTED_TRUE.equals(isOauthStr);
if (isOAuth) {
accountMgr.setUserData(newAccount, Constants.KEY_SUPPORTS_OAUTH2, OAUTH_SUPPORTED_TRUE);
}
// don't forget the account saved in preferences as the current one
if (currentAccount.name.equals(account.name)) {
AccountUtils.setCurrentQSferaAccount(context, newAccountName);
}
// remove the old account
accountMgr.removeAccount(account, null, null);
// will assume it succeeds, not a big deal otherwise
} else {
// servers which base URL is in the root of their domain need no change
Timber.d("%s needs no upgrade ", account.name);
newAccount = account;
}
// at least, upgrade account version
Timber.d("Setting version " + ACCOUNT_VERSION + " to " + newAccountName);
accountMgr.setUserData(
newAccount, Constants.KEY_OC_ACCOUNT_VERSION, Integer.toString(ACCOUNT_VERSION)
);
}
}
}
}
}
@@ -0,0 +1,282 @@
/**
* qsfera Android client application
*
* @author David González Verdugo
* @author Abel García de Prada
* @author Juan Carlos Garrote Gascón
*
* Copyright (C) 2024 ownCloud GmbH.
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License version 2,
* as published by the Free Software Foundation.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
package eu.qsfera.android.presentation.authentication
import androidx.lifecycle.LiveData
import androidx.lifecycle.MediatorLiveData
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import eu.qsfera.android.MainApp
import eu.qsfera.android.R
import eu.qsfera.android.domain.authentication.oauth.RegisterClientUseCase
import eu.qsfera.android.domain.authentication.oauth.RequestTokenUseCase
import eu.qsfera.android.domain.authentication.oauth.model.ClientRegistrationInfo
import eu.qsfera.android.domain.authentication.oauth.model.TokenRequest
import eu.qsfera.android.domain.authentication.oauth.model.TokenResponse
import eu.qsfera.android.domain.authentication.usecases.GetBaseUrlUseCase
import eu.qsfera.android.domain.authentication.usecases.LoginBasicAsyncUseCase
import eu.qsfera.android.domain.authentication.usecases.LoginOAuthAsyncUseCase
import eu.qsfera.android.domain.authentication.usecases.SupportsOAuth2UseCase
import eu.qsfera.android.domain.capabilities.usecases.GetStoredCapabilitiesUseCase
import eu.qsfera.android.domain.capabilities.usecases.RefreshCapabilitiesFromServerAsyncUseCase
import eu.qsfera.android.domain.server.model.ServerInfo
import eu.qsfera.android.domain.server.usecases.GetServerInfoAsyncUseCase
import eu.qsfera.android.domain.spaces.usecases.RefreshSpacesFromServerAsyncUseCase
import eu.qsfera.android.domain.utils.Event
import eu.qsfera.android.domain.webfinger.usecases.GetQSferaInstanceFromWebFingerUseCase
import eu.qsfera.android.domain.webfinger.usecases.GetQSferaInstancesFromAuthenticatedWebFingerUseCase
import eu.qsfera.android.extensions.ViewModelExt.runUseCaseWithResult
import eu.qsfera.android.presentation.authentication.oauth.OAuthUtils
import eu.qsfera.android.presentation.common.UIResult
import eu.qsfera.android.providers.ContextProvider
import eu.qsfera.android.providers.CoroutinesDispatcherProvider
import eu.qsfera.android.providers.WorkManagerProvider
import kotlinx.coroutines.launch
import timber.log.Timber
class AuthenticationViewModel(
private val loginBasicAsyncUseCase: LoginBasicAsyncUseCase,
private val loginOAuthAsyncUseCase: LoginOAuthAsyncUseCase,
private val getServerInfoAsyncUseCase: GetServerInfoAsyncUseCase,
private val supportsOAuth2UseCase: SupportsOAuth2UseCase,
private val getBaseUrlUseCase: GetBaseUrlUseCase,
private val getQSferaInstancesFromAuthenticatedWebFingerUseCase: GetQSferaInstancesFromAuthenticatedWebFingerUseCase,
private val getQSferaInstanceFromWebFingerUseCase: GetQSferaInstanceFromWebFingerUseCase,
private val refreshCapabilitiesFromServerAsyncUseCase: RefreshCapabilitiesFromServerAsyncUseCase,
private val getStoredCapabilitiesUseCase: GetStoredCapabilitiesUseCase,
private val refreshSpacesFromServerAsyncUseCase: RefreshSpacesFromServerAsyncUseCase,
private val workManagerProvider: WorkManagerProvider,
private val requestTokenUseCase: RequestTokenUseCase,
private val registerClientUseCase: RegisterClientUseCase,
private val coroutinesDispatcherProvider: CoroutinesDispatcherProvider,
private val contextProvider: ContextProvider,
) : ViewModel() {
var codeVerifier: String = OAuthUtils().generateRandomCodeVerifier()
var codeChallenge: String = OAuthUtils().generateCodeChallenge(codeVerifier)
var oidcState: String = OAuthUtils().generateRandomState()
private val _legacyWebfingerHost = MediatorLiveData<Event<UIResult<String>>>()
val legacyWebfingerHost: LiveData<Event<UIResult<String>>> = _legacyWebfingerHost
private val _serverInfo = MediatorLiveData<Event<UIResult<ServerInfo>>>()
val serverInfo: LiveData<Event<UIResult<ServerInfo>>> = _serverInfo
private val _loginResult = MediatorLiveData<Event<UIResult<String>>>()
val loginResult: LiveData<Event<UIResult<String>>> = _loginResult
private val _supportsOAuth2 = MediatorLiveData<Event<UIResult<Boolean>>>()
val supportsOAuth2: LiveData<Event<UIResult<Boolean>>> = _supportsOAuth2
private val _baseUrl = MediatorLiveData<Event<UIResult<String>>>()
val baseUrl: LiveData<Event<UIResult<String>>> = _baseUrl
private val _registerClient = MediatorLiveData<Event<UIResult<ClientRegistrationInfo>>>()
val registerClient: LiveData<Event<UIResult<ClientRegistrationInfo>>> = _registerClient
private val _requestToken = MediatorLiveData<Event<UIResult<TokenResponse>>>()
val requestToken: LiveData<Event<UIResult<TokenResponse>>> = _requestToken
private val _accountDiscovery = MediatorLiveData<Event<UIResult<Unit>>>()
val accountDiscovery: LiveData<Event<UIResult<Unit>>> = _accountDiscovery
var launchedFromDeepLink = false
fun getLegacyWebfingerHost(
webfingerLookupServer: String,
webfingerUsername: String,
) {
runUseCaseWithResult(
coroutineDispatcher = coroutinesDispatcherProvider.io,
showLoading = true,
liveData = _legacyWebfingerHost,
useCase = getQSferaInstanceFromWebFingerUseCase,
useCaseParams = GetQSferaInstanceFromWebFingerUseCase.Params(server = webfingerLookupServer, resource = webfingerUsername)
)
}
fun getServerInfo(
serverUrl: String,
creatingAccount: Boolean = false,
) {
runUseCaseWithResult(
coroutineDispatcher = coroutinesDispatcherProvider.io,
showLoading = true,
liveData = _serverInfo,
useCase = getServerInfoAsyncUseCase,
useCaseParams = GetServerInfoAsyncUseCase.Params(
serverPath = serverUrl,
creatingAccount = creatingAccount,
enforceOIDC = contextProvider.getBoolean(R.bool.enforce_oidc),
secureConnectionEnforced = contextProvider.getBoolean(R.bool.enforce_secure_connection),
)
)
}
fun loginBasic(
username: String,
password: String,
updateAccountWithUsername: String?
) = runUseCaseWithResult(
coroutineDispatcher = coroutinesDispatcherProvider.io,
liveData = _loginResult,
showLoading = true,
useCase = loginBasicAsyncUseCase,
useCaseParams = LoginBasicAsyncUseCase.Params(
serverInfo = serverInfo.value?.peekContent()?.getStoredData(),
username = username,
password = password,
updateAccountWithUsername = updateAccountWithUsername
)
)
fun loginOAuth(
serverBaseUrl: String,
username: String,
authTokenType: String,
accessToken: String,
refreshToken: String,
scope: String?,
updateAccountWithUsername: String? = null,
clientRegistrationInfo: ClientRegistrationInfo?
) {
viewModelScope.launch(coroutinesDispatcherProvider.io) {
_loginResult.postValue(Event(UIResult.Loading()))
val serverInfo = serverInfo.value?.peekContent()?.getStoredData() ?: throw java.lang.IllegalArgumentException("Server info value cannot" +
" be null")
// Authenticated WebFinger needed only for account creations. Logged accounts already know their instances.
if (updateAccountWithUsername == null) {
val qsferaInstancesAvailable = getQSferaInstancesFromAuthenticatedWebFingerUseCase(
GetQSferaInstancesFromAuthenticatedWebFingerUseCase.Params(
server = serverBaseUrl,
username = username,
accessToken = accessToken,
)
)
Timber.d("Instances retrieved from authenticated webfinger: $qsferaInstancesAvailable")
// Multiple instances are not supported yet. Let's use the first instance we receive for the moment.
qsferaInstancesAvailable.getDataOrNull()?.let {
if (it.isNotEmpty()) {
serverInfo.baseUrl = it.first()
}
}
}
val useCaseResult = loginOAuthAsyncUseCase(
LoginOAuthAsyncUseCase.Params(
serverInfo = serverInfo,
username = username,
authTokenType = authTokenType,
accessToken = accessToken,
refreshToken = refreshToken,
scope = scope,
updateAccountWithUsername = updateAccountWithUsername,
clientRegistrationInfo = clientRegistrationInfo,
)
)
if (useCaseResult.isSuccess) {
_loginResult.postValue(Event(UIResult.Success(useCaseResult.getDataOrNull())))
} else if (useCaseResult.isError) {
_loginResult.postValue(Event(UIResult.Error(error = useCaseResult.getThrowableOrNull())))
}
}
}
fun supportsOAuth2(
accountName: String
) = runUseCaseWithResult(
coroutineDispatcher = coroutinesDispatcherProvider.io,
requiresConnection = false,
liveData = _supportsOAuth2,
useCase = supportsOAuth2UseCase,
useCaseParams = SupportsOAuth2UseCase.Params(
accountName = accountName
)
)
fun getBaseUrl(
accountName: String
) = runUseCaseWithResult(
coroutineDispatcher = coroutinesDispatcherProvider.io,
requiresConnection = false,
liveData = _baseUrl,
useCase = getBaseUrlUseCase,
useCaseParams = GetBaseUrlUseCase.Params(
accountName = accountName
)
)
fun registerClient(
registrationEndpoint: String
) {
val registrationRequest = OAuthUtils.buildClientRegistrationRequest(
registrationEndpoint = registrationEndpoint,
MainApp.appContext
)
runUseCaseWithResult(
coroutineDispatcher = coroutinesDispatcherProvider.io,
showLoading = false,
liveData = _registerClient,
useCase = registerClientUseCase,
useCaseParams = RegisterClientUseCase.Params(registrationRequest)
)
}
fun requestToken(
tokenRequest: TokenRequest
) = runUseCaseWithResult(
coroutineDispatcher = coroutinesDispatcherProvider.io,
showLoading = false,
liveData = _requestToken,
useCase = requestTokenUseCase,
useCaseParams = RequestTokenUseCase.Params(tokenRequest = tokenRequest)
)
fun discoverAccount(accountName: String, discoveryNeeded: Boolean = false) {
Timber.d("Account Discovery for account: $accountName needed: $discoveryNeeded")
if (!discoveryNeeded) {
_accountDiscovery.postValue(Event(UIResult.Success()))
return
}
_accountDiscovery.postValue(Event(UIResult.Loading()))
viewModelScope.launch(coroutinesDispatcherProvider.io) {
// 1. Refresh capabilities for account
refreshCapabilitiesFromServerAsyncUseCase(RefreshCapabilitiesFromServerAsyncUseCase.Params(accountName))
val capabilities = getStoredCapabilitiesUseCase(GetStoredCapabilitiesUseCase.Params(accountName))
val spacesAvailableForAccount = capabilities?.isSpacesAllowed() == true
// 2 If Account does not support spaces we can skip this
if (spacesAvailableForAccount) {
refreshSpacesFromServerAsyncUseCase(RefreshSpacesFromServerAsyncUseCase.Params(accountName))
}
_accountDiscovery.postValue(Event(UIResult.Success()))
}
workManagerProvider.enqueueAccountDiscovery(accountName)
}
}
@@ -0,0 +1,45 @@
/**
* qsfera Android client application
*
* @author Abel García de Prada
* Copyright (C) 2012 Bartek Przybylski
* Copyright (C) 2020 ownCloud GmbH.
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License version 2,
* as published by the Free Software Foundation.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
@file:JvmName("AuthenticatorConstants")
package eu.qsfera.android.presentation.authentication
import eu.qsfera.android.MainApp.Companion.accountType
import eu.qsfera.android.lib.common.accounts.AccountTypeUtils
const val EXTRA_ACTION = "ACTION"
const val EXTRA_ACCOUNT = "ACCOUNT"
const val ACTION_CREATE: Byte = 0
const val ACTION_UPDATE_TOKEN: Byte = 1 // requested by the user
const val ACTION_UPDATE_EXPIRED_TOKEN: Byte = 2 // detected by the app
const val KEY_AUTH_TOKEN_TYPE = "authTokenType"
val BASIC_TOKEN_TYPE: String = AccountTypeUtils.getAuthTokenTypePass(
accountType
)
val OAUTH_TOKEN_TYPE: String = AccountTypeUtils.getAuthTokenTypeAccessToken(
accountType
)
const val UNTRUSTED_CERT_DIALOG_TAG = "UNTRUSTED_CERT_DIALOG"
const val WAIT_DIALOG_TAG = "WAIT_DIALOG"
@@ -0,0 +1,133 @@
/**
* qsfera Android client application
*
* @author David González Verdugo
* @author Juan Carlos Garrote Gascón
*
* Copyright (C) 2024 ownCloud GmbH
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License version 2,
* as published by the Free Software Foundation.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
package eu.qsfera.android.presentation.authentication.oauth
import android.content.Context
import android.net.Uri
import android.util.Base64
import eu.qsfera.android.MainApp
import eu.qsfera.android.R
import eu.qsfera.android.data.authentication.QUERY_PARAMETER_CLIENT_ID
import eu.qsfera.android.data.authentication.QUERY_PARAMETER_CODE_CHALLENGE
import eu.qsfera.android.data.authentication.QUERY_PARAMETER_CODE_CHALLENGE_METHOD
import eu.qsfera.android.data.authentication.QUERY_PARAMETER_LOGIN_HINT
import eu.qsfera.android.data.authentication.QUERY_PARAMETER_PROMPT
import eu.qsfera.android.data.authentication.QUERY_PARAMETER_REDIRECT_URI
import eu.qsfera.android.data.authentication.QUERY_PARAMETER_RESPONSE_TYPE
import eu.qsfera.android.data.authentication.QUERY_PARAMETER_SCOPE
import eu.qsfera.android.data.authentication.QUERY_PARAMETER_STATE
import eu.qsfera.android.data.authentication.QUERY_PARAMETER_USER
import eu.qsfera.android.domain.authentication.oauth.model.ClientRegistrationRequest
import java.net.URLEncoder
import java.security.MessageDigest
import java.security.SecureRandom
class OAuthUtils {
fun generateRandomState(): String {
val secureRandom = SecureRandom()
val randomBytes = ByteArray(DEFAULT_STATE_ENTROPY)
secureRandom.nextBytes(randomBytes)
val encoding = Base64.NO_WRAP or Base64.NO_PADDING or Base64.URL_SAFE
return Base64.encodeToString(randomBytes, encoding)
}
fun generateRandomCodeVerifier(): String {
val secureRandom = SecureRandom()
val randomBytes = ByteArray(DEFAULT_CODE_VERIFIER_ENTROPY)
secureRandom.nextBytes(randomBytes)
val encoding = Base64.NO_WRAP or Base64.NO_PADDING or Base64.URL_SAFE
return Base64.encodeToString(randomBytes, encoding)
}
fun generateCodeChallenge(codeVerifier: String): String {
val bytes = codeVerifier.toByteArray()
val messageDigest = MessageDigest.getInstance(ALGORITHM_SHA_256)
messageDigest.update(bytes)
val digest = messageDigest.digest()
val encoding = Base64.NO_WRAP or Base64.NO_PADDING or Base64.URL_SAFE
return Base64.encodeToString(digest, encoding)
}
companion object {
private const val ALGORITHM_SHA_256 = "SHA-256"
private const val CODE_CHALLENGE_METHOD = "S256"
private const val DEFAULT_CODE_VERIFIER_ENTROPY = 64
private const val DEFAULT_STATE_ENTROPY = 15
fun buildClientRegistrationRequest(
registrationEndpoint: String,
context: Context
): ClientRegistrationRequest =
ClientRegistrationRequest(
registrationEndpoint = registrationEndpoint,
clientName = MainApp.userAgent,
redirectUris = listOf(buildRedirectUri(context).toString())
)
fun getClientAuth(
clientSecret: String,
clientId: String
): String {
// From the OAuth2 RFC, client ID and secret should be encoded prior to concatenation and
// conversion to Base64: https://tools.ietf.org/html/rfc6749#section-2.3.1
val encodedClientId = URLEncoder.encode(clientId, "utf-8")
val encodedClientSecret = URLEncoder.encode(clientSecret, "utf-8")
val credentials = "$encodedClientId:$encodedClientSecret"
return "Basic " + Base64.encodeToString(credentials.toByteArray(), Base64.NO_WRAP)
}
fun buildAuthorizationRequest(
authorizationEndpoint: Uri,
redirectUri: String,
clientId: String,
responseType: String,
scope: String,
prompt: String,
codeChallenge: String,
state: String,
username: String?,
sendLoginHintAndUser: Boolean,
): Uri =
authorizationEndpoint.buildUpon().apply {
appendQueryParameter(QUERY_PARAMETER_REDIRECT_URI, redirectUri)
appendQueryParameter(QUERY_PARAMETER_CLIENT_ID, clientId)
appendQueryParameter(QUERY_PARAMETER_RESPONSE_TYPE, responseType)
appendQueryParameter(QUERY_PARAMETER_SCOPE, scope)
appendQueryParameter(QUERY_PARAMETER_PROMPT, prompt)
appendQueryParameter(QUERY_PARAMETER_CODE_CHALLENGE, codeChallenge)
appendQueryParameter(QUERY_PARAMETER_CODE_CHALLENGE_METHOD, CODE_CHALLENGE_METHOD)
appendQueryParameter(QUERY_PARAMETER_STATE, state)
if (sendLoginHintAndUser && !username.isNullOrEmpty()) {
appendQueryParameter(QUERY_PARAMETER_USER, username)
appendQueryParameter(QUERY_PARAMETER_LOGIN_HINT, username)
}
}.build()
fun buildRedirectUri(context: Context): Uri =
Uri.Builder()
.scheme(context.getString(R.string.oauth2_redirect_uri_scheme))
.authority(context.getString(R.string.oauth2_redirect_uri_host))
.path(context.getString(R.string.oauth2_redirect_uri_path))
.build()
}
}
@@ -0,0 +1,94 @@
/**
* qsfera Android client application
*
* @author Abel García de Prada
* Copyright (C) 2020 ownCloud GmbH.
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License version 2,
* as published by the Free Software Foundation.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
package eu.qsfera.android.presentation.authentication.oauth
import androidx.lifecycle.LiveData
import androidx.lifecycle.MediatorLiveData
import androidx.lifecycle.ViewModel
import eu.qsfera.android.MainApp
import eu.qsfera.android.domain.authentication.oauth.OIDCDiscoveryUseCase
import eu.qsfera.android.domain.authentication.oauth.RegisterClientUseCase
import eu.qsfera.android.domain.authentication.oauth.RequestTokenUseCase
import eu.qsfera.android.domain.authentication.oauth.model.ClientRegistrationInfo
import eu.qsfera.android.domain.authentication.oauth.model.OIDCServerConfiguration
import eu.qsfera.android.domain.authentication.oauth.model.TokenRequest
import eu.qsfera.android.domain.authentication.oauth.model.TokenResponse
import eu.qsfera.android.domain.utils.Event
import eu.qsfera.android.extensions.ViewModelExt.runUseCaseWithResult
import eu.qsfera.android.presentation.common.UIResult
import eu.qsfera.android.providers.CoroutinesDispatcherProvider
class OAuthViewModel(
private val getOIDCDiscoveryUseCase: OIDCDiscoveryUseCase,
private val requestTokenUseCase: RequestTokenUseCase,
private val registerClientUseCase: RegisterClientUseCase,
private val coroutinesDispatcherProvider: CoroutinesDispatcherProvider
) : ViewModel() {
val codeVerifier: String = OAuthUtils().generateRandomCodeVerifier()
val codeChallenge: String = OAuthUtils().generateCodeChallenge(codeVerifier)
val oidcState: String = OAuthUtils().generateRandomState()
private val _oidcDiscovery = MediatorLiveData<Event<UIResult<OIDCServerConfiguration>>>()
val oidcDiscovery: LiveData<Event<UIResult<OIDCServerConfiguration>>> = _oidcDiscovery
private val _registerClient = MediatorLiveData<Event<UIResult<ClientRegistrationInfo>>>()
val registerClient: LiveData<Event<UIResult<ClientRegistrationInfo>>> = _registerClient
private val _requestToken = MediatorLiveData<Event<UIResult<TokenResponse>>>()
val requestToken: LiveData<Event<UIResult<TokenResponse>>> = _requestToken
fun getOIDCServerConfiguration(
serverUrl: String
) = runUseCaseWithResult(
coroutineDispatcher = coroutinesDispatcherProvider.io,
showLoading = false,
liveData = _oidcDiscovery,
useCase = getOIDCDiscoveryUseCase,
useCaseParams = OIDCDiscoveryUseCase.Params(baseUrl = serverUrl)
)
fun registerClient(
registrationEndpoint: String
) {
val registrationRequest = OAuthUtils.buildClientRegistrationRequest(
registrationEndpoint = registrationEndpoint,
MainApp.appContext
)
runUseCaseWithResult(
coroutineDispatcher = coroutinesDispatcherProvider.io,
showLoading = false,
liveData = _registerClient,
useCase = registerClientUseCase,
useCaseParams = RegisterClientUseCase.Params(registrationRequest)
)
}
fun requestToken(
tokenRequest: TokenRequest
) = runUseCaseWithResult(
coroutineDispatcher = coroutinesDispatcherProvider.io,
showLoading = false,
liveData = _requestToken,
useCase = requestTokenUseCase,
useCaseParams = RequestTokenUseCase.Params(tokenRequest = tokenRequest)
)
}
@@ -0,0 +1,98 @@
/**
* qsfera Android client application
*
* @author Abel García de Prada
* Copyright (C) 2020 ownCloud GmbH.
* <p>
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License version 2,
* as published by the Free Software Foundation.
* <p>
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
* <p>
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
package eu.qsfera.android.presentation.avatar
import android.accounts.Account
import android.view.MenuItem
import android.widget.ImageView
import eu.qsfera.android.R
import coil.request.ErrorResult
import coil.request.ImageRequest
import eu.qsfera.android.MainApp.Companion.appContext
import eu.qsfera.android.presentation.thumbnails.ThumbnailsRequester
import kotlinx.coroutines.delay
import org.koin.core.component.KoinComponent
import timber.log.Timber
class AvatarUtils : KoinComponent {
/**
* Show the avatar corresponding to the received account in an {@ImageView}.
* <p>
* The avatar is shown if available locally. The avatar is not
* fetched from the server if not available.
* <p>
* If there is no avatar stored, a colored icon is generated with the first letter of the account username.
* <p>
* If this is not possible either, a predefined user icon is shown instead.
*
* @param account OC account which avatar will be shown.
* @param displayRadius The radius of the circle where the avatar will be clipped into.
* @param fetchIfNotCached When 'true', if there is no avatar stored in the cache, it's fetched from
* the server. When 'false', server is not accessed, the fallback avatar is
* generated instead.
*/
suspend fun loadAvatarForAccount(
imageView: ImageView,
account: Account,
@Suppress("UnusedParameter") displayRadius: Float,
imageLoader: coil.ImageLoader? = null
) {
val uri = ThumbnailsRequester.getAvatarUri(account)
val loader = imageLoader ?: ThumbnailsRequester.getRevalidatingImageLoader(account)
// No .target(imageView) here — using execute() with a ViewTarget can hang
// due to Coil's internal lifecycle checks. We set the drawable manually instead.
val request = ImageRequest.Builder(appContext)
.data(uri)
.transformations(coil.transform.CircleCropTransformation())
.build()
Timber.d("Avatar load for $uri")
var result = loader.execute(request)
if (result is ErrorResult) {
// On failure, give ConnectionValidator time to refresh the token on another
// thread, then retry once.
Timber.d("Avatar load failed for $uri, retrying in 5s")
delay(5_000L)
Timber.d("Retrying avatar load for $uri")
result = loader.execute(request)
}
(result as? coil.request.SuccessResult)?.let { imageView.setImageDrawable(it.drawable) }
?: imageView.setImageResource(R.drawable.ic_account_circle)
}
fun loadAvatarForAccount(
menuItem: MenuItem,
account: Account,
@Suppress("UnusedParameter") displayRadius: Float
) {
val uri = ThumbnailsRequester.getAvatarUri(account)
val imageLoader = ThumbnailsRequester.getRevalidatingImageLoader(account)
val request = coil.request.ImageRequest.Builder(appContext)
.data(uri)
.target(
onStart = { menuItem.setIcon(R.drawable.ic_account_circle) },
onSuccess = { result -> menuItem.icon = result },
onError = { menuItem.setIcon(R.drawable.ic_account_circle) }
)
.transformations(coil.transform.CircleCropTransformation())
.build()
imageLoader.enqueue(request)
}
}
@@ -0,0 +1,83 @@
/**
* qsfera Android client application
*
* @author David González Verdugo
* @author Abel García de Prada
* @author Juan Carlos Garrote Gascón
*
* Copyright (C) 2024 ownCloud GmbH.
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License version 2,
* as published by the Free Software Foundation.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
package eu.qsfera.android.presentation.capabilities
import androidx.lifecycle.LiveData
import androidx.lifecycle.MediatorLiveData
import androidx.lifecycle.ViewModel
import eu.qsfera.android.domain.capabilities.model.OCCapability
import eu.qsfera.android.domain.capabilities.usecases.GetCapabilitiesAsLiveDataUseCase
import eu.qsfera.android.domain.capabilities.usecases.GetStoredCapabilitiesUseCase
import eu.qsfera.android.domain.capabilities.usecases.RefreshCapabilitiesFromServerAsyncUseCase
import eu.qsfera.android.domain.utils.Event
import eu.qsfera.android.extensions.ViewModelExt.runUseCaseWithResultAndUseCachedData
import eu.qsfera.android.presentation.common.UIResult
import eu.qsfera.android.providers.CoroutinesDispatcherProvider
import kotlinx.coroutines.runBlocking
import kotlinx.coroutines.withContext
/**
* View Model to keep a reference to the capability repository and an up-to-date capability
*/
class CapabilityViewModel(
private val accountName: String,
getCapabilitiesAsLiveDataUseCase: GetCapabilitiesAsLiveDataUseCase,
private val refreshCapabilitiesFromServerAsyncUseCase: RefreshCapabilitiesFromServerAsyncUseCase,
private val getStoredCapabilitiesUseCase: GetStoredCapabilitiesUseCase,
private val coroutineDispatcherProvider: CoroutinesDispatcherProvider
) : ViewModel() {
private val _capabilities = MediatorLiveData<Event<UIResult<OCCapability>>>()
val capabilities: LiveData<Event<UIResult<OCCapability>>> = _capabilities
private var capabilitiesLiveData: LiveData<OCCapability?> = getCapabilitiesAsLiveDataUseCase(
GetCapabilitiesAsLiveDataUseCase.Params(
accountName = accountName
)
)
init {
_capabilities.addSource(capabilitiesLiveData) { capabilities ->
_capabilities.postValue(Event(UIResult.Success(capabilities)))
}
refreshCapabilitiesFromNetwork()
}
fun refreshCapabilitiesFromNetwork() = runUseCaseWithResultAndUseCachedData(
coroutineDispatcher = coroutineDispatcherProvider.io,
cachedData = capabilitiesLiveData.value,
liveData = _capabilities,
useCase = refreshCapabilitiesFromServerAsyncUseCase,
useCaseParams = RefreshCapabilitiesFromServerAsyncUseCase.Params(
accountName = accountName
)
)
fun checkMultiPersonal(): Boolean = runBlocking(CoroutinesDispatcherProvider().io) {
val capabilities = withContext(CoroutinesDispatcherProvider().io) {
getStoredCapabilitiesUseCase(GetStoredCapabilitiesUseCase.Params(accountName))
}
capabilities?.spaces?.hasMultiplePersonalSpaces == true
}
}
@@ -0,0 +1,91 @@
/**
* qsfera Android client application
*
* @author Abel García de Prada
* @author Juan Carlos Garrote Gascón
* @author Aitor Ballesteros Pavón
*
* Copyright (C) 2024 ownCloud GmbH.
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License version 2,
* as published by the Free Software Foundation.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
package eu.qsfera.android.presentation.common
import android.content.Context
import android.content.res.ColorStateList
import android.graphics.drawable.Drawable
import android.util.AttributeSet
import android.view.LayoutInflater
import androidx.constraintlayout.widget.ConstraintLayout
import androidx.core.content.ContextCompat
import eu.qsfera.android.R
import eu.qsfera.android.databinding.BottomSheetFragmentItemBinding
class BottomSheetFragmentItemView @JvmOverloads constructor(
context: Context,
attrs: AttributeSet? = null,
defStyle: Int = 0
) : ConstraintLayout(context, attrs, defStyle) {
private var _binding: BottomSheetFragmentItemBinding? = null
private val binding get() = _binding!!
var itemIcon: Drawable?
get() = binding.itemIcon.drawable
set(value) {
binding.itemIcon.setImageDrawable(value)
}
var title: CharSequence?
get() = binding.itemTitle.text
set(value) {
binding.itemTitle.text = value
}
var itemAdditionalIcon: Drawable?
get() = binding.itemAdditionalIcon.drawable
set(value) {
binding.itemAdditionalIcon.setImageDrawable(value)
}
init {
_binding = BottomSheetFragmentItemBinding.inflate(LayoutInflater.from(context), this, true)
val a = context.theme.obtainStyledAttributes(attrs, R.styleable.BottomSheetFragmentItemView, 0, 0)
try {
itemIcon = a.getDrawable(R.styleable.BottomSheetFragmentItemView_itemIcon)
title = a.getString(R.styleable.BottomSheetFragmentItemView_title)
} finally {
a.recycle()
}
}
fun setSelected(iconAdditional: Int) {
itemAdditionalIcon = ContextCompat.getDrawable(context, iconAdditional)
val selectedColor = ContextCompat.getColor(context, R.color.primary)
binding.itemIcon.setColorFilter(selectedColor)
binding.itemTitle.setTextColor(selectedColor)
binding.itemAdditionalIcon.setColorFilter(selectedColor)
}
fun removeDefaultTint() {
binding.itemIcon.imageTintList = null
}
fun addDefaultTint(tintColor: Int) {
val itemColor = ContextCompat.getColor(context, tintColor)
val itemColorStateList = ColorStateList.valueOf(itemColor)
binding.itemIcon.imageTintList = itemColorStateList
}
}
@@ -0,0 +1,102 @@
/**
* qsfera Android client application
*
* @author David González Verdugo
* @author Abel García de Prada
* @author Aitor Ballesteros Pavón
* @author Jorge Aguado Recio
*
* Copyright (C) 2024 ownCloud GmbH.
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License version 2,
* as published by the Free Software Foundation.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
package eu.qsfera.android.presentation.common
import android.accounts.Account
import android.content.Context
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import eu.qsfera.android.R
import eu.qsfera.android.data.providers.LocalStorageProvider
import eu.qsfera.android.domain.user.model.UserQuota
import eu.qsfera.android.domain.user.usecases.GetStoredQuotaAsStreamUseCase
import eu.qsfera.android.domain.user.usecases.GetUserQuotasUseCase
import eu.qsfera.android.domain.utils.Event
import eu.qsfera.android.extensions.ViewModelExt.runUseCaseWithResult
import eu.qsfera.android.presentation.authentication.AccountUtils
import eu.qsfera.android.providers.ContextProvider
import eu.qsfera.android.providers.CoroutinesDispatcherProvider
import eu.qsfera.android.usecases.accounts.RemoveAccountUseCase
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.launch
import timber.log.Timber
class DrawerViewModel(
private val getStoredQuotaAsStreamUseCase: GetStoredQuotaAsStreamUseCase,
private val removeAccountUseCase: RemoveAccountUseCase,
private val getUserQuotasUseCase: GetUserQuotasUseCase,
private val localStorageProvider: LocalStorageProvider,
private val coroutinesDispatcherProvider: CoroutinesDispatcherProvider,
private val contextProvider: ContextProvider,
) : ViewModel() {
private val _userQuota = MutableStateFlow<Event<UIResult<Flow<UserQuota?>>>?>(null)
val userQuota: StateFlow<Event<UIResult<Flow<UserQuota?>>>?> = _userQuota
fun getUserQuota(accountName: String) {
runUseCaseWithResult(
coroutineDispatcher = coroutinesDispatcherProvider.io,
requiresConnection = false,
showLoading = true,
flow = _userQuota,
useCase = getStoredQuotaAsStreamUseCase,
useCaseParams = GetStoredQuotaAsStreamUseCase.Params(accountName = accountName),
)
}
fun getAccounts(context: Context): List<Account> =
AccountUtils.getAccounts(context).asList()
fun getUsernameOfAccount(accountName: String): String =
AccountUtils.getUsernameOfAccount(accountName)
fun getFeedbackMail() = contextProvider.getString(R.string.mail_feedback)
fun removeAccount(context: Context) {
viewModelScope.launch(coroutinesDispatcherProvider.io) {
val loggedAccounts = AccountUtils.getAccounts(context)
localStorageProvider.deleteUnusedUserDirs(loggedAccounts)
val userQuotas = getUserQuotasUseCase(Unit)
val loggedAccountsNames = loggedAccounts.map { it.name }
val totalAccountsNames = userQuotas.map { it.accountName }
val removedAccountsNames = mutableListOf<String>()
for (accountName in totalAccountsNames) {
if (!loggedAccountsNames.contains(accountName)) {
removedAccountsNames.add(accountName)
}
}
removedAccountsNames.forEach { removedAccountName ->
Timber.d("$removedAccountName is being removed")
removeAccountUseCase(
RemoveAccountUseCase.Params(accountName = removedAccountName)
)
localStorageProvider.removeLocalStorageForAccount(removedAccountName)
}
}
}
}
@@ -0,0 +1,60 @@
/**
* qsfera Android client application
*
* @author Abel García de Prada
* Copyright (C) 2020 ownCloud GmbH.
* <p>
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License version 2,
* as published by the Free Software Foundation.
* <p>
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
* <p>
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
package eu.qsfera.android.presentation.common
import android.content.ComponentName
import android.content.Context
import android.content.Intent
import android.content.pm.PackageManager
import android.content.pm.ResolveInfo
import android.os.Parcelable
import androidx.annotation.StringRes
class ShareSheetHelper {
fun getShareSheetIntent(
intent: Intent,
context: Context,
@StringRes title: Int,
packagesToExclude: Array<String>
): Intent {
// Get excluding specific targets by component. We want to hide oC targets.
val resInfo: List<ResolveInfo> =
context.packageManager.queryIntentActivities(intent, PackageManager.MATCH_DEFAULT_ONLY)
val excludeLists = ArrayList<ComponentName>()
if (resInfo.isNotEmpty()) {
for (info in resInfo) {
val activityInfo = info.activityInfo
for (packageToExclude in packagesToExclude) {
if (activityInfo != null && activityInfo.packageName == packageToExclude) {
excludeLists.add(ComponentName(activityInfo.packageName, activityInfo.name))
}
}
}
}
// Return a new ShareSheet intent
return Intent.createChooser(intent, "").apply {
putExtra(Intent.EXTRA_EXCLUDE_COMPONENTS, excludeLists.toArray(arrayOf<Parcelable>()))
putExtra(Intent.EXTRA_TITLE, context.getString(title))
}
}
}
@@ -0,0 +1,72 @@
/**
* qsfera Android client application
*
* @author David González Verdugo
* Copyright (C) 2020 ownCloud GmbH.
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License version 2,
* as published by the Free Software Foundation.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
package eu.qsfera.android.presentation.common
sealed class UIResult<out T> {
data class Loading<out T>(val data: T? = null) : UIResult<T>()
data class Success<out T>(val data: T? = null) : UIResult<T>()
data class Error<out T>(val error: Throwable? = null, val data: T? = null) : UIResult<T>()
val isLoading get() = this is Loading
val isSuccess get() = this is Success
val isError get() = this is Error
@Deprecated(message = "Start to use new extensions")
fun getStoredData(): T? =
when (this) {
is Loading -> data
is Success -> data
is Error -> data // Even when there's an error we still want to show database data
}
fun getThrowableOrNull(): Throwable? =
if (this is Error) {
error
} else {
null
}
}
fun <T> UIResult<T>.onLoading(action: (data: T?) -> Unit): UIResult<T> {
if (this is UIResult.Loading) action(data)
return this
}
fun <T> UIResult<T>.onSuccess(action: (data: T?) -> Unit): UIResult<T> {
if (this is UIResult.Success) action(data)
return this
}
fun <T> UIResult<T>.onError(action: (error: Throwable?) -> Unit): UIResult<T> {
if (this is UIResult.Error) action(error)
return this
}
fun <T> UIResult<T>.fold(
onLoading: (data: T?) -> Unit,
onSuccess: (data: T?) -> Unit,
onFailure: (error: Throwable?) -> Unit
) {
when (this) {
is UIResult.Loading -> onLoading(data)
is UIResult.Success -> onSuccess(data)
is UIResult.Error -> onFailure(error)
}
}
@@ -0,0 +1,106 @@
/**
* qsfera Android client application
*
* @author Bartek Przybylski
* @author David A. Velasco
* @author Juan Carlos Garrote Gascón
*
* Copyright (C) 2012 Bartek Przybylski
* Copyright (C) 2022 ownCloud GmbH.
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License version 2,
* as published by the Free Software Foundation.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
package eu.qsfera.android.presentation.conflicts
import android.os.Bundle
import androidx.appcompat.app.AppCompatActivity
import androidx.core.view.updatePadding
import androidx.lifecycle.Lifecycle
import androidx.lifecycle.lifecycleScope
import androidx.lifecycle.repeatOnLifecycle
import eu.qsfera.android.databinding.ActivityConflictsResolveBinding
import eu.qsfera.android.ui.activity.enableEdgeToEdgePostSetContentView
import eu.qsfera.android.ui.activity.enableEdgeToEdgePreSetContentView
import kotlinx.coroutines.flow.collectLatest
import kotlinx.coroutines.launch
import org.koin.androidx.viewmodel.ext.android.viewModel
import org.koin.core.parameter.parametersOf
import timber.log.Timber
class ConflictsResolveActivity : AppCompatActivity(), ConflictsResolveDialogFragment.OnConflictDecisionMadeListener {
private var _binding: ActivityConflictsResolveBinding? = null
val binding get() = _binding!!
private val conflictsResolveViewModel by viewModel<ConflictsResolveViewModel> {
parametersOf(
intent.getParcelableExtra(
EXTRA_FILE
)
)
}
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
// edge-to-edge
enableEdgeToEdgePreSetContentView(true)
_binding = ActivityConflictsResolveBinding.inflate(layoutInflater)
setContentView(binding.root)
setSupportActionBar(binding.toolbar)
// edge-to-edge
enableEdgeToEdgePostSetContentView { insets ->
binding.toolbar.updatePadding(top = insets.top)
binding.root.updatePadding(bottom = insets.bottom)
}
lifecycleScope.launch {
repeatOnLifecycle(Lifecycle.State.STARTED) {
conflictsResolveViewModel.currentFile.collectLatest { updatedOCFile ->
Timber.d("File ${updatedOCFile?.remotePath} from ${updatedOCFile?.owner} needs to fix a conflict with etag" +
" in conflict ${updatedOCFile?.etagInConflict}")
// Finish if the file does not exists or if the file is not in conflict anymore.
updatedOCFile?.etagInConflict ?: finish()
}
}
}
ConflictsResolveDialogFragment.newInstance(onConflictDecisionMadeListener = this).showDialog(this)
}
override fun conflictDecisionMade(decision: ConflictsResolveDialogFragment.Decision) {
when (decision) {
ConflictsResolveDialogFragment.Decision.CANCEL -> {}
ConflictsResolveDialogFragment.Decision.KEEP_LOCAL -> {
conflictsResolveViewModel.uploadFileInConflict()
}
ConflictsResolveDialogFragment.Decision.KEEP_BOTH -> {
conflictsResolveViewModel.uploadFileFromSystem()
}
ConflictsResolveDialogFragment.Decision.KEEP_SERVER -> {
conflictsResolveViewModel.downloadFile()
}
}
Timber.d("Decision to fix conflict on file ${conflictsResolveViewModel.currentFile.value?.remotePath} is ${decision.name}")
finish()
}
companion object {
const val EXTRA_FILE = "EXTRA_FILE"
}
}
@@ -0,0 +1,92 @@
/**
* qsfera Android client application
*
* @author Bartek Przybylski
* @author Christian Schabesberger
* @author Juan Carlos Garrote Gascón
*
* Copyright (C) 2012 Bartek Przybylski
* Copyright (C) 2022 ownCloud GmbH.
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License version 2,
* as published by the Free Software Foundation.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
package eu.qsfera.android.presentation.conflicts
import android.app.AlertDialog
import android.app.Dialog
import android.content.DialogInterface
import android.os.Bundle
import androidx.appcompat.app.AppCompatActivity
import androidx.fragment.app.DialogFragment
import eu.qsfera.android.R
import eu.qsfera.android.extensions.avoidScreenshotsIfNeeded
class ConflictsResolveDialogFragment : DialogFragment() {
private lateinit var listener: OnConflictDecisionMadeListener
override fun onCreateDialog(savedInstanceState: Bundle?): Dialog {
val dialog = AlertDialog.Builder(requireActivity())
.setIcon(R.drawable.ic_warning)
.setTitle(R.string.conflict_title)
.setMessage(R.string.conflict_message)
.setPositiveButton(R.string.conflict_use_local_version) { _, _ ->
listener.conflictDecisionMade(Decision.KEEP_LOCAL)
}
.setNeutralButton(R.string.conflict_keep_both) { _, _ ->
listener.conflictDecisionMade(Decision.KEEP_BOTH)
}
.setNegativeButton(R.string.conflict_use_server_version) { _, _ ->
listener.conflictDecisionMade(Decision.KEEP_SERVER)
}
.create()
dialog.avoidScreenshotsIfNeeded()
return dialog
}
override fun onCancel(dialog: DialogInterface) {
listener.conflictDecisionMade(Decision.CANCEL)
}
fun showDialog(activity: AppCompatActivity) {
val previousFragment = activity.supportFragmentManager.findFragmentByTag("dialog")
val fragmentTransaction = activity.supportFragmentManager.beginTransaction()
if (previousFragment != null) {
fragmentTransaction.remove(previousFragment)
}
fragmentTransaction.addToBackStack(null)
this.show(fragmentTransaction, "dialog")
}
interface OnConflictDecisionMadeListener {
fun conflictDecisionMade(decision: Decision)
}
enum class Decision {
CANCEL,
KEEP_BOTH,
KEEP_LOCAL,
KEEP_SERVER
}
companion object {
fun newInstance(onConflictDecisionMadeListener: OnConflictDecisionMadeListener): ConflictsResolveDialogFragment =
ConflictsResolveDialogFragment().apply {
listener = onConflictDecisionMadeListener
}
}
}
@@ -0,0 +1,92 @@
/**
* qsfera Android client application
*
* @author Juan Carlos Garrote Gascón
*
* Copyright (C) 2023 ownCloud GmbH.
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License version 2,
* as published by the Free Software Foundation.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
package eu.qsfera.android.presentation.conflicts
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import eu.qsfera.android.domain.files.model.OCFile
import eu.qsfera.android.domain.files.usecases.GetFileByIdAsStreamUseCase
import eu.qsfera.android.providers.CoroutinesDispatcherProvider
import eu.qsfera.android.usecases.transfers.downloads.DownloadFileUseCase
import eu.qsfera.android.usecases.transfers.uploads.UploadFileInConflictUseCase
import eu.qsfera.android.usecases.transfers.uploads.UploadFilesFromSystemUseCase
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.launch
class ConflictsResolveViewModel(
private val downloadFileUseCase: DownloadFileUseCase,
private val uploadFileInConflictUseCase: UploadFileInConflictUseCase,
private val uploadFilesFromSystemUseCase: UploadFilesFromSystemUseCase,
getFileByIdAsStreamUseCase: GetFileByIdAsStreamUseCase,
private val coroutinesDispatcherProvider: CoroutinesDispatcherProvider,
ocFile: OCFile,
) : ViewModel() {
val currentFile: StateFlow<OCFile?> =
getFileByIdAsStreamUseCase(GetFileByIdAsStreamUseCase.Params(ocFile.id!!))
.stateIn(
viewModelScope,
started = SharingStarted.WhileSubscribed(),
initialValue = ocFile
)
fun downloadFile() {
val fileToDownload = currentFile.value ?: return
viewModelScope.launch(coroutinesDispatcherProvider.io) {
downloadFileUseCase(
DownloadFileUseCase.Params(
accountName = fileToDownload.owner,
file = fileToDownload
)
)
}
}
fun uploadFileInConflict() {
val fileToUpload = currentFile.value ?: return
viewModelScope.launch(coroutinesDispatcherProvider.io) {
uploadFileInConflictUseCase(
UploadFileInConflictUseCase.Params(
accountName = fileToUpload.owner,
localPath = fileToUpload.storagePath!!,
uploadFolderPath = fileToUpload.getParentRemotePath(),
spaceId = fileToUpload.spaceId,
)
)
}
}
fun uploadFileFromSystem() {
val fileToUpload = currentFile.value ?: return
viewModelScope.launch(coroutinesDispatcherProvider.io) {
uploadFilesFromSystemUseCase(
UploadFilesFromSystemUseCase.Params(
accountName = fileToUpload.owner,
listOfLocalPaths = listOf(fileToUpload.storagePath!!),
uploadFolderPath = fileToUpload.getParentRemotePath(),
spaceId = fileToUpload.spaceId,
)
)
}
}
}
@@ -0,0 +1,35 @@
/*
* qsfera Android client application
*
* @author Abel García de Prada
* Copyright (C) 2020 ownCloud GmbH.
* <p>
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License version 2,
* as published by the Free Software Foundation.
* <p>
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
* <p>
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
package eu.qsfera.android.presentation.documentsprovider
import android.content.Context
import android.provider.DocumentsContract
import eu.qsfera.android.R
object DocumentsProviderUtils {
/**
* Notify Document Provider to refresh roots
*/
fun notifyDocumentsProviderRoots(context: Context) {
val authority = context.resources.getString(R.string.document_provider_authority)
val rootsUri = DocumentsContract.buildRootsUri(authority)
context.contentResolver.notifyChange(rootsUri, null)
}
}
@@ -0,0 +1,812 @@
/**
* qsfera Android client application
*
* @author Bartosz Przybylski
* @author Christian Schabesberger
* @author David González Verdugo
* @author Abel García de Prada
* @author Shashvat Kedia
* @author Juan Carlos Garrote Gascón
*
* Copyright (C) 2015 Bartosz Przybylski
* Copyright (C) 2023 ownCloud GmbH.
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License version 2,
* as published by the Free Software Foundation.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
package eu.qsfera.android.presentation.documentsprovider
import android.content.res.AssetFileDescriptor
import android.database.Cursor
import android.database.MatrixCursor
import android.graphics.Point
import android.net.Uri
import android.os.CancellationSignal
import android.os.Handler
import android.os.ParcelFileDescriptor
import android.provider.DocumentsContract
import android.provider.DocumentsProvider
import eu.qsfera.android.MainApp
import eu.qsfera.android.R
import eu.qsfera.android.data.providers.SharedPreferencesProvider
import eu.qsfera.android.domain.UseCaseResult
import eu.qsfera.android.domain.capabilities.usecases.GetStoredCapabilitiesUseCase
import eu.qsfera.android.domain.exceptions.NoConnectionWithServerException
import eu.qsfera.android.domain.exceptions.validation.FileNameException
import eu.qsfera.android.domain.files.model.OCFile
import eu.qsfera.android.domain.files.model.OCFile.Companion.PATH_SEPARATOR
import eu.qsfera.android.domain.files.model.OCFile.Companion.ROOT_PATH
import eu.qsfera.android.domain.files.usecases.CopyFileUseCase
import eu.qsfera.android.domain.files.usecases.CreateFolderAsyncUseCase
import eu.qsfera.android.domain.files.usecases.GetFileByIdUseCase
import eu.qsfera.android.domain.files.usecases.GetFileByRemotePathUseCase
import eu.qsfera.android.domain.files.usecases.GetFolderContentUseCase
import eu.qsfera.android.domain.files.usecases.MoveFileUseCase
import eu.qsfera.android.domain.files.usecases.RemoveFileUseCase
import eu.qsfera.android.domain.files.usecases.RenameFileUseCase
import eu.qsfera.android.domain.spaces.usecases.GetPersonalAndProjectSpacesForAccountUseCase
import eu.qsfera.android.domain.spaces.usecases.RefreshSpacesFromServerAsyncUseCase
import eu.qsfera.android.presentation.authentication.AccountUtils
import eu.qsfera.android.presentation.documentsprovider.cursors.FileCursor
import eu.qsfera.android.presentation.documentsprovider.cursors.RootCursor
import eu.qsfera.android.presentation.documentsprovider.cursors.SpaceCursor
import eu.qsfera.android.presentation.settings.security.SettingsSecurityFragment.Companion.PREFERENCE_LOCK_ACCESS_FROM_DOCUMENT_PROVIDER
import eu.qsfera.android.usecases.synchronization.SynchronizeFileUseCase
import eu.qsfera.android.usecases.transfers.downloads.DownloadFileUseCase
import eu.qsfera.android.usecases.synchronization.SynchronizeFolderUseCase
import eu.qsfera.android.usecases.transfers.uploads.UploadFilesFromSystemUseCase
import eu.qsfera.android.utils.FileStorageUtils
import eu.qsfera.android.utils.NotificationUtils
import androidx.work.WorkInfo
import androidx.work.WorkManager
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import org.koin.android.ext.android.inject
import timber.log.Timber
import java.io.File
import java.io.FileNotFoundException
import java.io.IOException
import java.util.UUID
import java.util.Vector
import java.util.concurrent.ConcurrentHashMap
import java.util.concurrent.CompletableFuture
class DocumentsStorageProvider : DocumentsProvider() {
/**
* If a directory requires to sync, it will write the id of the directory into this variable.
* After the sync function gets triggered again over the same directory, it will see that a sync got already
* triggered, and does not need to be triggered again. This way a endless loop is prevented.
*/
private var requestedFolderIdForSync: Long = -1
private var syncRequired = true
private var spacesSyncRequired = true
private lateinit var fileToUpload: OCFile
// Cache to avoid redundant PROPFINDs when apps (e.g. Google Photos) call
// openDocument many times for the same file. Two layers:
// 1. In-flight dedup: concurrent calls for the same file share one PROPFIND via
// a CompletableFuture. The first caller does the actual work, others wait.
// 2. TTL cache: after a sync completes, skip re-checking the same file for a
// few seconds to handle rapid sequential calls.
private val inFlightSyncs = ConcurrentHashMap<Long, CompletableFuture<SynchronizeFileUseCase.SyncType?>>()
private val inFlightDownloads = ConcurrentHashMap<Long, CompletableFuture<Boolean>>()
private var propfindCacheFileId: Long? = null
private var propfindCacheTimestamp: Long = 0
override fun openDocument(
documentId: String,
mode: String,
signal: CancellationSignal?,
): ParcelFileDescriptor? {
Timber.d("Trying to open $documentId in mode $mode")
// If documentId == NONEXISTENT_DOCUMENT_ID only Upload is needed because file does not exist in our database yet.
var ocFile: OCFile
val uploadOnly: Boolean = documentId == NONEXISTENT_DOCUMENT_ID || documentId == "null"
var accessMode: Int = ParcelFileDescriptor.parseMode(mode)
val isWrite: Boolean = mode.contains("w")
if (!uploadOnly) {
ocFile = getFileByIdOrException(documentId.toInt())
if (!ocFile.isAvailableLocally) {
// File has never been downloaded. Enqueue the download directly —
// no need for a PROPFIND since we already know we need the file.
// Apps like Google Photos call openDocument concurrently for the
// same file — dedup so only one download is enqueued.
if (!downloadFileCoalesced(ocFile.id!!, ocFile, documentId.toInt(), signal)) {
return null
}
ocFile = getFileByIdOrException(documentId.toInt())
if (!ocFile.isAvailableLocally) {
return null
}
// Seed the TTL cache — the file was just downloaded, so there's no need
// for a PROPFIND if Google Photos immediately calls openDocument again.
propfindCacheFileId = ocFile.id
propfindCacheTimestamp = System.currentTimeMillis()
} else if (!isWrite) {
// File is available locally and opened for reading. Check with the server
// (PROPFIND) whether a newer version exists, and download it if so.
//
// Apps like Google Photos call openDocument many times concurrently for
// the same file. Without dedup, each call does its own PROPFIND, and due
// to the synchronized lock in QSferaClient.executeHttpMethod they
// serialize — causing 10+ second waits per extra call. We handle this
// with two layers:
// 1. TTL cache: skip if we just confirmed this file is up-to-date.
// 2. In-flight dedup: concurrent calls share one PROPFIND result.
val fileId = ocFile.id!!
val now = System.currentTimeMillis()
if (fileId == propfindCacheFileId && now - propfindCacheTimestamp <= PROPFIND_CACHE_TTL_MS) {
Timber.d("Skipping PROPFIND for file $fileId, recently synced ${now - propfindCacheTimestamp}ms ago")
} else {
val syncResult = syncFileWithServerCoalesced(ocFile)
when (syncResult) {
is SynchronizeFileUseCase.SyncType.AlreadySynchronized -> {
// File is up to date, nothing to wait for.
}
is SynchronizeFileUseCase.SyncType.DownloadEnqueued -> {
// A newer version exists. SynchronizeFileUseCase only enqueues
// a WorkManager download, it does not wait for it to finish.
if (!waitForDownload(syncResult.workerId, documentId.toInt(), signal)) {
return null
}
}
is SynchronizeFileUseCase.SyncType.ConflictDetected -> {
// File changed both locally and remotely. Notify the user and
// serve the local version (same behavior as before).
context?.let {
NotificationUtils.notifyConflict(fileInConflict = ocFile, context = it)
}
}
is SynchronizeFileUseCase.SyncType.FileNotFound -> {
return null
}
is SynchronizeFileUseCase.SyncType.UploadEnqueued -> {
// Local file is newer, upload was enqueued. Serve the local version.
}
null -> {
// Sync failed, serve the local version anyway.
}
}
propfindCacheFileId = fileId
propfindCacheTimestamp = System.currentTimeMillis()
// Re-read the file from DB to get the updated state after download.
ocFile = getFileByIdOrException(documentId.toInt())
if (!ocFile.isAvailableLocally) {
return null
}
}
}
} else {
ocFile = fileToUpload
accessMode = accessMode or ParcelFileDescriptor.MODE_CREATE
}
val fileToOpen = File(ocFile.storagePath)
return if (!isWrite) {
ParcelFileDescriptor.open(fileToOpen, accessMode)
} else {
val handler = Handler(MainApp.appContext.mainLooper)
// Attach a close listener if the document is opened in write mode.
try {
ParcelFileDescriptor.open(fileToOpen, accessMode, handler) {
// Update the file with the cloud server. The client is done writing.
Timber.d("A file with id $documentId has been closed! Time to synchronize it with server.")
// If only needs to upload that file
if (uploadOnly) {
ocFile.length = fileToOpen.length()
val uploadFilesUseCase: UploadFilesFromSystemUseCase by inject()
val uploadFilesUseCaseParams = UploadFilesFromSystemUseCase.Params(
accountName = ocFile.owner,
listOfLocalPaths = listOf(fileToOpen.path),
uploadFolderPath = ocFile.remotePath.substringBeforeLast(PATH_SEPARATOR).plus(PATH_SEPARATOR),
spaceId = ocFile.spaceId,
)
CoroutineScope(Dispatchers.IO).launch {
uploadFilesUseCase(uploadFilesUseCaseParams)
}
} else {
syncFileWithServerAsync(ocFile)
}
}
} catch (e: IOException) {
Timber.e(e, "Couldn't open document")
throw FileNotFoundException("Failed to open document with id $documentId and mode $mode")
}
}
}
override fun queryChildDocuments(
parentDocumentId: String,
projection: Array<String>?,
sortOrder: String?,
): Cursor {
val resultCursor: MatrixCursor
val folderId = try {
parentDocumentId.toLong()
} catch (numberFormatException: NumberFormatException) {
null
}
// Folder id is null, so at this point we need to list the spaces for the account.
if (folderId == null) {
resultCursor = SpaceCursor(projection)
val getPersonalAndProjectSpacesForAccountUseCase: GetPersonalAndProjectSpacesForAccountUseCase by inject()
val getFileByRemotePathUseCase: GetFileByRemotePathUseCase by inject()
getPersonalAndProjectSpacesForAccountUseCase(
GetPersonalAndProjectSpacesForAccountUseCase.Params(
accountName = parentDocumentId,
)
).forEach { space ->
if (!space.isDisabled) {
getFileByRemotePathUseCase(
GetFileByRemotePathUseCase.Params(
owner = space.accountName,
remotePath = ROOT_PATH,
spaceId = space.id,
)
).getDataOrNull()?.let { rootFolder ->
resultCursor.addSpace(space, rootFolder, context)
}
}
}
/**
* This will start syncing the spaces. User will only see this after updating his view with a
* pull down, or by accessing the spaces folder.
*/
if (spacesSyncRequired) {
syncSpacesWithServer(parentDocumentId)
resultCursor.setMoreToSync(true)
}
spacesSyncRequired = true
} else {
// Folder id is not null, so this is a regular folder
resultCursor = FileCursor(projection)
// Create result cursor before syncing folder again, in order to enable faster loading
getFolderContent(folderId.toInt()).forEach { file -> resultCursor.addFile(file) }
/**
* This will start syncing the current folder. User will only see this after updating his view with a
* pull down, or by accessing the folder again.
*/
if (requestedFolderIdForSync != folderId && syncRequired) {
// register for sync
syncDirectoryWithServer(parentDocumentId)
requestedFolderIdForSync = folderId
resultCursor.setMoreToSync(true)
} else {
requestedFolderIdForSync = -1
}
syncRequired = true
}
// Create notification listener
val notifyUri: Uri = toNotifyUri(toUri(parentDocumentId))
resultCursor.setNotificationUri(context?.contentResolver, notifyUri)
return resultCursor
}
override fun queryDocument(documentId: String, projection: Array<String>?): Cursor {
Timber.d("Query Document: $documentId")
if (documentId == NONEXISTENT_DOCUMENT_ID) return FileCursor(projection).apply {
addFile(fileToUpload)
}
val fileId = try {
documentId.toInt()
} catch (numberFormatException: NumberFormatException) {
null
}
return if (fileId != null) {
// file id is not null, this is a regular file.
FileCursor(projection).apply {
addFile(getFileByIdOrException(fileId))
}
} else {
// file id is null, so at this point this is the root folder for spaces supported account.
SpaceCursor(projection).apply {
addRootForSpaces(context = context, accountName = documentId)
}
}
}
override fun onCreate(): Boolean = true
override fun queryRoots(projection: Array<String>?): Cursor {
val result = RootCursor(projection)
val contextApp = context ?: return result
val accounts = AccountUtils.getAccounts(contextApp)
// If access from document provider is not allowed, return empty cursor
val preferences: SharedPreferencesProvider by inject()
val lockAccessFromDocumentProvider = preferences.getBoolean(PREFERENCE_LOCK_ACCESS_FROM_DOCUMENT_PROVIDER, false)
return if (lockAccessFromDocumentProvider && accounts.isNotEmpty()) {
result.apply { addProtectedRoot(contextApp) }
} else {
for (account in accounts) {
val getStoredCapabilitiesUseCase: GetStoredCapabilitiesUseCase by inject()
val capabilities = getStoredCapabilitiesUseCase(
GetStoredCapabilitiesUseCase.Params(
accountName = account.name
)
)
val spacesFeatureAllowedForAccount = AccountUtils.isSpacesFeatureAllowedForAccount(contextApp, account, capabilities)
result.addRoot(account, contextApp, spacesFeatureAllowedForAccount)
}
result
}
}
override fun openDocumentThumbnail(
documentId: String,
sizeHint: Point?,
signal: CancellationSignal?
): AssetFileDescriptor {
// To do: Show thumbnail for spaces
val file = getFileByIdOrException(documentId.toInt())
val realFile = File(file.storagePath)
return AssetFileDescriptor(
ParcelFileDescriptor.open(realFile, ParcelFileDescriptor.MODE_READ_ONLY), 0, AssetFileDescriptor.UNKNOWN_LENGTH
)
}
override fun querySearchDocuments(
rootId: String,
query: String,
projection: Array<String>?
): Cursor {
val result = FileCursor(projection)
val root = getFileByPathOrException(ROOT_PATH, AccountUtils.getCurrentQSferaAccount(context).name)
for (f in findFiles(root, query)) {
result.addFile(f)
}
return result
}
override fun createDocument(
parentDocumentId: String,
mimeType: String,
displayName: String,
): String {
Timber.d("Create Document ParentID $parentDocumentId Type $mimeType DisplayName $displayName")
val parentDocument = getFileByIdOrException(parentDocumentId.toInt())
return if (mimeType == DocumentsContract.Document.MIME_TYPE_DIR) {
createFolder(parentDocument, displayName)
} else {
createFile(parentDocument, mimeType, displayName)
}
}
override fun renameDocument(documentId: String, displayName: String): String? {
Timber.d("Trying to rename $documentId to $displayName")
val file = getFileByIdOrException(documentId.toInt())
val renameFileUseCase: RenameFileUseCase by inject()
renameFileUseCase(RenameFileUseCase.Params(file, displayName)).also {
checkUseCaseResult(
it, file.parentId.toString()
)
}
return null
}
override fun deleteDocument(documentId: String) {
Timber.d("Trying to delete $documentId")
val file = getFileByIdOrException(documentId.toInt())
val removeFileUseCase: RemoveFileUseCase by inject()
removeFileUseCase(RemoveFileUseCase.Params(listOf(file), false)).also {
checkUseCaseResult(
it, file.parentId.toString()
)
}
}
override fun copyDocument(sourceDocumentId: String, targetParentDocumentId: String): String {
Timber.d("Trying to copy $sourceDocumentId to $targetParentDocumentId")
val sourceFile = getFileByIdOrException(sourceDocumentId.toInt())
val targetParentFile = getFileByIdOrException(targetParentDocumentId.toInt())
val copyFileUseCase: CopyFileUseCase by inject()
copyFileUseCase(
CopyFileUseCase.Params(
listOfFilesToCopy = listOf(sourceFile),
targetFolder = targetParentFile,
replace = listOf(false),
isUserLogged = AccountUtils.getCurrentQSferaAccount(context) != null,
)
).also { result ->
syncRequired = false
checkUseCaseResult(result, targetParentFile.id.toString())
// Returns the document id of the document copied at the target destination
var newPath = targetParentFile.remotePath + sourceFile.fileName
if (sourceFile.isFolder) newPath += File.separator
val newFile = getFileByPathOrException(newPath, targetParentFile.owner)
return newFile.id.toString()
}
}
override fun moveDocument(
sourceDocumentId: String,
sourceParentDocumentId: String,
targetParentDocumentId: String,
): String {
Timber.d("Trying to move $sourceDocumentId to $targetParentDocumentId")
val sourceFile = getFileByIdOrException(sourceDocumentId.toInt())
val targetParentFile = getFileByIdOrException(targetParentDocumentId.toInt())
val moveFileUseCase: MoveFileUseCase by inject()
moveFileUseCase(
MoveFileUseCase.Params(
listOfFilesToMove = listOf(sourceFile),
targetFolder = targetParentFile,
replace = listOf(false),
isUserLogged = AccountUtils.getCurrentQSferaAccount(context) != null,
)
).also { result ->
syncRequired = false
checkUseCaseResult(result, targetParentFile.id.toString())
// Returns the document id of the document moved to the target destination
var newPath = targetParentFile.remotePath + sourceFile.fileName
if (sourceFile.isFolder) newPath += File.separator
val newFile = getFileByPathOrException(newPath, targetParentFile.owner)
return newFile.id.toString()
}
}
private fun checkUseCaseResult(result: UseCaseResult<Any>, folderToNotify: String) {
if (!result.isSuccess) {
Timber.e(result.getThrowableOrNull()!!)
if (result.getThrowableOrNull() is FileNameException) {
throw UnsupportedOperationException("Operation contains at least one invalid character")
}
if (result.getThrowableOrNull() !is NoConnectionWithServerException) {
notifyChangeInFolder(folderToNotify)
}
throw FileNotFoundException("Remote Operation failed")
}
syncRequired = false
notifyChangeInFolder(folderToNotify)
}
private fun createFolder(parentDocument: OCFile, displayName: String): String {
Timber.d("Trying to create a new folder with name $displayName and parent ${parentDocument.remotePath}")
val createFolderAsyncUseCase: CreateFolderAsyncUseCase by inject()
createFolderAsyncUseCase(CreateFolderAsyncUseCase.Params(displayName, parentDocument)).run {
checkUseCaseResult(this, parentDocument.id.toString())
val newPath = parentDocument.remotePath + displayName + File.separator
val newFolder = getFileByPathOrException(newPath, parentDocument.owner, parentDocument.spaceId)
return newFolder.id.toString()
}
}
private fun createFile(
parentDocument: OCFile,
mimeType: String,
displayName: String,
): String {
// We just need to return a Document ID, so we'll return an empty one. File does not exist in our db yet.
// File will be created at [openDocument] method.
val tempDir = File(FileStorageUtils.getTemporalPath(parentDocument.owner, parentDocument.spaceId))
val newFile = File(tempDir, displayName)
newFile.parentFile?.mkdirs()
fileToUpload = OCFile(
remotePath = parentDocument.remotePath + displayName,
mimeType = mimeType,
parentId = parentDocument.id,
owner = parentDocument.owner,
spaceId = parentDocument.spaceId
).apply {
storagePath = newFile.path
}
return NONEXISTENT_DOCUMENT_ID
}
/**
* Synchronize a file with the server, coalescing concurrent requests.
*
* If another thread is already syncing this file, we wait for its result instead of
* starting a second PROPFIND. This avoids the serialized lock contention in
* QSferaClient.executeHttpMethod when multiple binder threads call openDocument
* for the same file simultaneously.
*
* The future is always removed from [inFlightSyncs] when done (via finally),
* so errors or timeouts cannot leave stale entries that would block future syncs.
*/
private fun syncFileWithServerCoalesced(fileToSync: OCFile): SynchronizeFileUseCase.SyncType? {
val fileId = fileToSync.id!!
val newFuture = CompletableFuture<SynchronizeFileUseCase.SyncType?>()
val existingFuture = inFlightSyncs.putIfAbsent(fileId, newFuture)
if (existingFuture != null) {
// Another thread is already syncing this file. Wait for its result.
Timber.d("Sync for file $fileId already in flight, waiting for result")
return try {
existingFuture.get()
} catch (e: Exception) {
Timber.w(e, "In-flight sync for file $fileId failed, serving local version")
null
}
}
// We are the first thread — do the actual PROPFIND.
return try {
val result = syncFileWithServerBlocking(fileToSync)
newFuture.complete(result)
result
} catch (e: Exception) {
newFuture.completeExceptionally(e)
throw e
} finally {
inFlightSyncs.remove(fileId)
}
}
/**
* Download a file, deduplicating concurrent requests for the same file.
*
* Same pattern as [syncFileWithServerCoalesced]: the first thread enqueues the
* WorkManager download and waits; concurrent threads wait on the same future.
* This prevents apps like Google Photos (which call openDocument 4+ times
* concurrently) from enqueuing 4 separate download workers for the same file.
*
* @return true if the download succeeded, false otherwise.
*/
private fun downloadFileCoalesced(fileId: Long, ocFile: OCFile, docId: Int, signal: CancellationSignal?): Boolean {
val newFuture = CompletableFuture<Boolean>()
val existingFuture = inFlightDownloads.putIfAbsent(fileId, newFuture)
if (existingFuture != null) {
Timber.d("Download for file $fileId already in flight, waiting")
return try { existingFuture.get() } catch (_: Exception) { false }
}
return try {
val downloadFileUseCase: DownloadFileUseCase by inject()
val workerId = downloadFileUseCase(
DownloadFileUseCase.Params(accountName = ocFile.owner, file = ocFile)
)
val ok = waitForDownload(workerId, docId, signal)
newFuture.complete(ok)
ok
} catch (e: Exception) {
Timber.w(e, "Download for file $fileId failed")
newFuture.complete(false)
false
} finally {
inFlightDownloads.remove(fileId)
}
}
/**
* Synchronize a file with the server and return the result.
* Runs synchronously on the calling thread (blocks until the PROPFIND completes).
* Note: if a download is needed, this only *enqueues* it — use [waitForDownload] to
* wait for the actual download to finish.
*/
private fun syncFileWithServerBlocking(fileToSync: OCFile): SynchronizeFileUseCase.SyncType? {
Timber.d("Trying to sync file ${fileToSync.id} with server (blocking)")
val synchronizeFileUseCase: SynchronizeFileUseCase by inject()
val useCaseResult = synchronizeFileUseCase(
SynchronizeFileUseCase.Params(fileToSynchronize = fileToSync)
)
Timber.d("${fileToSync.remotePath} from ${fileToSync.owner} synced with result: $useCaseResult")
return useCaseResult.getDataOrNull()
}
/**
* Fire-and-forget sync: used in the close handler after writes,
* where we don't need to wait for the result.
*/
private fun syncFileWithServerAsync(fileToSync: OCFile) {
Timber.d("Trying to sync file ${fileToSync.id} with server (async)")
val synchronizeFileUseCase: SynchronizeFileUseCase by inject()
CoroutineScope(Dispatchers.IO).launch {
val useCaseResult = synchronizeFileUseCase(
SynchronizeFileUseCase.Params(fileToSynchronize = fileToSync)
)
Timber.d("${fileToSync.remotePath} from ${fileToSync.owner} synced with result: $useCaseResult")
if (useCaseResult.getDataOrNull() is SynchronizeFileUseCase.SyncType.ConflictDetected) {
context?.let {
NotificationUtils.notifyConflict(fileInConflict = fileToSync, context = it)
}
}
}
}
/**
* Wait for a download to finish.
*
* If [workerId] is non-null, we use WorkManager to wait directly for that specific job.
* If [workerId] is null, it means a download for this file was already in progress
* (enqueued by a previous call), so we fall back to polling the DB until the file
* becomes available locally.
*
* Note: openDocument can be called concurrently on multiple binder threads for the
* same file (e.g. the calling app retries or requests the file multiple times).
* The first call enqueues the download and gets a workerId; subsequent concurrent
* calls get null (DownloadFileUseCase deduplicates) and use the polling fallback.
*
* @return true if the file is ready, false if cancelled.
*/
private fun waitForDownload(workerId: UUID?, fileId: Int, signal: CancellationSignal?): Boolean {
if (workerId != null) {
// Poll WorkManager until this specific job reaches a terminal state.
// Note: getWorkInfoById().get() returns the *current* state immediately,
// it does NOT block until the work finishes.
Timber.d("Waiting for download worker $workerId to finish")
val workManager = WorkManager.getInstance(context!!)
do {
if (!waitOrGetCancelled(signal)) {
return false
}
val workInfo = workManager.getWorkInfoById(workerId).get()
Timber.d("Download worker $workerId state: ${workInfo.state}")
when (workInfo.state) {
WorkInfo.State.SUCCEEDED -> return true
WorkInfo.State.FAILED, WorkInfo.State.CANCELLED -> return false
else -> { /* ENQUEUED, RUNNING, BLOCKED — keep waiting */ }
}
} while (true)
}
// workerId is null — a download was already in progress from a previous request.
// Poll until the file appears locally, checking for cancellation each second.
Timber.d("Download already in progress for file $fileId, polling until available")
do {
if (!waitOrGetCancelled(signal)) {
return false
}
val file = getFileByIdOrException(fileId)
if (file.isAvailableLocally) return true
} while (true)
}
private fun syncDirectoryWithServer(parentDocumentId: String) {
Timber.d("Trying to sync $parentDocumentId with server")
val folderToSync = getFileByIdOrException(parentDocumentId.toInt())
val synchronizeFolderUseCase: SynchronizeFolderUseCase by inject()
val synchronizeFolderUseCaseParams = SynchronizeFolderUseCase.Params(
remotePath = folderToSync.remotePath,
accountName = folderToSync.owner,
spaceId = folderToSync.spaceId,
syncMode = SynchronizeFolderUseCase.SyncFolderMode.REFRESH_FOLDER,
)
CoroutineScope(Dispatchers.IO).launch {
val useCaseResult = synchronizeFolderUseCase(synchronizeFolderUseCaseParams)
Timber.d("${folderToSync.remotePath} from ${folderToSync.owner} was synced with server with result: $useCaseResult")
if (useCaseResult.isSuccess) {
notifyChangeInFolder(parentDocumentId)
}
}
}
private fun syncSpacesWithServer(parentDocumentId: String) {
Timber.d("Trying to sync spaces from account $parentDocumentId with server")
val refreshSpacesFromServerAsyncUseCase: RefreshSpacesFromServerAsyncUseCase by inject()
val refreshSpacesFromServerAsyncUseCaseParams = RefreshSpacesFromServerAsyncUseCase.Params(
accountName = parentDocumentId,
)
CoroutineScope(Dispatchers.IO).launch {
val useCaseResult = refreshSpacesFromServerAsyncUseCase(refreshSpacesFromServerAsyncUseCaseParams)
Timber.d("Spaces from account were synced with server with result: $useCaseResult")
if (useCaseResult.isSuccess) {
notifyChangeInFolder(parentDocumentId)
}
spacesSyncRequired = false
}
}
private fun waitOrGetCancelled(cancellationSignal: CancellationSignal?): Boolean {
try {
Thread.sleep(1000)
} catch (e: InterruptedException) {
return false
}
return cancellationSignal == null || !cancellationSignal.isCanceled
}
private fun findFiles(root: OCFile, query: String): Vector<OCFile> {
val result = Vector<OCFile>()
val folderContent = getFolderContent(root.id!!.toInt())
folderContent.forEach {
if (it.fileName.contains(query)) {
result.add(it)
if (it.isFolder) result.addAll(findFiles(it, query))
}
}
return result
}
private fun notifyChangeInFolder(folderToNotify: String) {
context?.contentResolver?.notifyChange(toNotifyUri(toUri(folderToNotify)), null)
}
private fun toNotifyUri(uri: Uri): Uri = DocumentsContract.buildDocumentUri(
context?.resources?.getString(R.string.document_provider_authority), uri.toString()
)
private fun toUri(documentId: String): Uri = Uri.parse(documentId)
private fun getFileByIdOrException(id: Int): OCFile {
val getFileByIdUseCase: GetFileByIdUseCase by inject()
val result = getFileByIdUseCase(GetFileByIdUseCase.Params(id.toLong()))
return result.getDataOrNull() ?: throw FileNotFoundException("File $id not found")
}
private fun getFileByPathOrException(remotePath: String, accountName: String, spaceId: String? = null): OCFile {
val getFileByRemotePathUseCase: GetFileByRemotePathUseCase by inject()
val result =
getFileByRemotePathUseCase(GetFileByRemotePathUseCase.Params(owner = accountName, remotePath = remotePath, spaceId = spaceId))
return result.getDataOrNull() ?: throw FileNotFoundException("File $remotePath not found")
}
private fun getFolderContent(id: Int): List<OCFile> {
val getFolderContentUseCase: GetFolderContentUseCase by inject()
val result = getFolderContentUseCase(GetFolderContentUseCase.Params(id.toLong()))
return result.getDataOrNull() ?: throw FileNotFoundException("Folder $id not found")
}
companion object {
const val NONEXISTENT_DOCUMENT_ID = "-1"
const val PROPFIND_CACHE_TTL_MS = 3000L
}
}
@@ -0,0 +1,80 @@
/**
* qsfera Android client application
*
* @author Bartosz Przybylski
* @author Abel García de Prada
* @author Juan Carlos Garrote Gascón
*
* Copyright (C) 2015 Bartosz Przybylski
* Copyright (C) 2023 ownCloud GmbH.
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License version 2,
* as published by the Free Software Foundation.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
package eu.qsfera.android.presentation.documentsprovider.cursors
import android.database.MatrixCursor
import android.os.Bundle
import android.provider.DocumentsContract
import android.provider.DocumentsContract.Document
import eu.qsfera.android.domain.files.model.OCFile
import eu.qsfera.android.utils.MimetypeIconUtil
class FileCursor(projection: Array<String>?) : MatrixCursor(projection ?: DEFAULT_DOCUMENT_PROJECTION) {
private var cursorExtras = Bundle.EMPTY
override fun getExtras(): Bundle = cursorExtras
fun setMoreToSync(hasMoreToSync: Boolean) {
cursorExtras = Bundle().apply { putBoolean(DocumentsContract.EXTRA_LOADING, hasMoreToSync) }
}
fun addFile(file: OCFile) {
val iconRes = MimetypeIconUtil.getFileTypeIconId(file.mimeType, file.fileName)
val mimeType = if (file.isFolder) Document.MIME_TYPE_DIR else file.mimeType
val imagePath = if (file.isImage && file.isAvailableLocally) file.storagePath else null
var flags = if (imagePath != null) Document.FLAG_SUPPORTS_THUMBNAIL else 0
flags = flags or Document.FLAG_SUPPORTS_DELETE
flags = flags or Document.FLAG_SUPPORTS_RENAME
flags = flags or Document.FLAG_SUPPORTS_COPY
flags = flags or Document.FLAG_SUPPORTS_MOVE
if (mimeType != Document.MIME_TYPE_DIR) { // If it is a file
flags = flags or Document.FLAG_SUPPORTS_WRITE
} else if (file.hasAddFilePermission && file.hasAddSubdirectoriesPermission) { // If it is a folder with writing permissions
flags = flags or Document.FLAG_DIR_SUPPORTS_CREATE
}
newRow()
.add(Document.COLUMN_DOCUMENT_ID, file.id.toString())
.add(Document.COLUMN_DISPLAY_NAME, file.fileName)
.add(Document.COLUMN_LAST_MODIFIED, file.modificationTimestamp)
.add(Document.COLUMN_SIZE, file.length)
.add(Document.COLUMN_FLAGS, flags)
.add(Document.COLUMN_ICON, iconRes)
.add(Document.COLUMN_MIME_TYPE, mimeType)
}
companion object {
val DEFAULT_DOCUMENT_PROJECTION = arrayOf(
Document.COLUMN_DOCUMENT_ID,
Document.COLUMN_DISPLAY_NAME,
Document.COLUMN_MIME_TYPE,
Document.COLUMN_SIZE,
Document.COLUMN_FLAGS,
Document.COLUMN_LAST_MODIFIED
)
}
}
@@ -0,0 +1,79 @@
/**
* qsfera Android client application
*
* @author Bartosz Przybylski
* @author Abel García de Prada
* @author Juan Carlos Garrote Gascón
*
* Copyright (C) 2015 Bartosz Przybylski
* Copyright (C) 2023 ownCloud GmbH.
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License version 2,
* as published by the Free Software Foundation.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
package eu.qsfera.android.presentation.documentsprovider.cursors
import android.accounts.Account
import android.content.Context
import android.database.MatrixCursor
import android.provider.DocumentsContract.Root
import eu.qsfera.android.R
import eu.qsfera.android.datamodel.FileDataStorageManager
class RootCursor(projection: Array<String>?) : MatrixCursor(projection ?: DEFAULT_ROOT_PROJECTION) {
fun addRoot(account: Account, context: Context, spacesAllowed: Boolean) {
val manager = FileDataStorageManager(account)
val mainDirId = if (spacesAllowed) {
// To display the list of spaces for an account, we need to do this trick.
// If the document id is not a number, we will know that it is the time to display the list of spaces for the account
account.name
} else {
// Root directory of the personal space or "Files" (old server)
manager.getRootPersonalFolder()?.id
}
val flags = Root.FLAG_SUPPORTS_SEARCH or Root.FLAG_SUPPORTS_CREATE
newRow()
.add(Root.COLUMN_ROOT_ID, account.name)
.add(Root.COLUMN_DOCUMENT_ID, mainDirId)
.add(Root.COLUMN_SUMMARY, account.name)
.add(Root.COLUMN_TITLE, context.getString(R.string.app_name))
.add(Root.COLUMN_ICON, R.mipmap.icon)
.add(Root.COLUMN_FLAGS, flags)
}
fun addProtectedRoot(context: Context) {
newRow()
.add(
Root.COLUMN_SUMMARY,
context.getString(R.string.document_provider_locked)
)
.add(Root.COLUMN_TITLE, context.getString(R.string.app_name))
.add(Root.COLUMN_ICON, R.mipmap.icon)
}
companion object {
private val DEFAULT_ROOT_PROJECTION = arrayOf(
Root.COLUMN_ROOT_ID,
Root.COLUMN_FLAGS,
Root.COLUMN_ICON,
Root.COLUMN_TITLE,
Root.COLUMN_DOCUMENT_ID,
Root.COLUMN_AVAILABLE_BYTES,
Root.COLUMN_SUMMARY,
Root.COLUMN_FLAGS
)
}
}
@@ -0,0 +1,75 @@
/**
* qsfera Android client application
*
* @author Juan Carlos Garrote Gascón
*
* Copyright (C) 2023 ownCloud GmbH.
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License version 2,
* as published by the Free Software Foundation.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
package eu.qsfera.android.presentation.documentsprovider.cursors
import android.content.Context
import android.database.MatrixCursor
import android.os.Bundle
import android.provider.DocumentsContract
import android.provider.DocumentsContract.Document
import eu.qsfera.android.R
import eu.qsfera.android.domain.files.model.OCFile
import eu.qsfera.android.domain.spaces.model.OCSpace
import eu.qsfera.android.presentation.documentsprovider.cursors.FileCursor.Companion.DEFAULT_DOCUMENT_PROJECTION
class SpaceCursor(projection: Array<String>?) : MatrixCursor(projection ?: DEFAULT_DOCUMENT_PROJECTION) {
private var cursorExtras = Bundle.EMPTY
override fun getExtras(): Bundle = cursorExtras
fun setMoreToSync(hasMoreToSync: Boolean) {
cursorExtras = Bundle().apply { putBoolean(DocumentsContract.EXTRA_LOADING, hasMoreToSync) }
}
fun addSpace(space: OCSpace, rootFolder: OCFile, context: Context?) {
val flags = if (rootFolder.hasAddFilePermission && rootFolder.hasAddSubdirectoriesPermission) {
Document.FLAG_DIR_SUPPORTS_CREATE
} else {
0
}
val name = if (space.isPersonal) context?.getString(R.string.bottom_nav_personal) else space.name
newRow()
.add(Document.COLUMN_DOCUMENT_ID, rootFolder.id)
.add(Document.COLUMN_DISPLAY_NAME, name)
.add(Document.COLUMN_LAST_MODIFIED, space.lastModifiedDateTime)
.add(Document.COLUMN_SIZE, space.quota?.used)
.add(Document.COLUMN_FLAGS, flags)
.add(Document.COLUMN_ICON, R.drawable.ic_spaces)
.add(Document.COLUMN_MIME_TYPE, Document.MIME_TYPE_DIR)
}
/**
* Add root for spaces. Main difference is that we add the account name as the document id,
* so we need to take it into account in order to display the list of spaces or
* the actual list of files inside the folder.
*/
fun addRootForSpaces(context: Context?, accountName: String) {
newRow()
.add(Document.COLUMN_DOCUMENT_ID, accountName)
.add(Document.COLUMN_DISPLAY_NAME, context?.getString(R.string.bottom_nav_spaces))
.add(Document.COLUMN_LAST_MODIFIED, null)
.add(Document.COLUMN_SIZE, null)
.add(Document.COLUMN_FLAGS, 0)
.add(Document.COLUMN_MIME_TYPE, Document.MIME_TYPE_DIR)
}
}
@@ -0,0 +1,109 @@
/**
* qsfera Android client application
*
* @author Abel García de Prada
* Copyright (C) 2020 ownCloud GmbH.
* <p>
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License version 2,
* as published by the Free Software Foundation.
* <p>
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
* <p>
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
package eu.qsfera.android.presentation.files
import android.os.Bundle
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import com.google.android.material.bottomsheet.BottomSheetBehavior
import com.google.android.material.bottomsheet.BottomSheetDialogFragment
import eu.qsfera.android.databinding.SortBottomSheetFragmentBinding
import eu.qsfera.android.utils.PreferenceUtils
class SortBottomSheetFragment : BottomSheetDialogFragment() {
var sortDialogListener: SortDialogListener? = null
lateinit var sortType: SortType
lateinit var sortOrder: SortOrder
private var _binding: SortBottomSheetFragmentBinding? = null
private val binding get() = _binding!!
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
sortType = arguments?.getParcelable(ARG_SORT_TYPE) ?: SortType.SORT_TYPE_BY_NAME
sortOrder = arguments?.getParcelable(ARG_SORT_ORDER) ?: SortOrder.SORT_ORDER_ASCENDING
}
override fun onCreateView(
inflater: LayoutInflater, container: ViewGroup?,
savedInstanceState: Bundle?
): View {
_binding = SortBottomSheetFragmentBinding.inflate(inflater, container, false)
return binding.root.apply {
// Allow or disallow touches with other visible windows
filterTouchesWhenObscured = PreferenceUtils.shouldDisallowTouchesWithOtherVisibleWindows(context)
}
}
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
when (sortType) {
SortType.SORT_TYPE_BY_NAME -> binding.sortByName.setSelected(sortOrder.toDrawableRes())
SortType.SORT_TYPE_BY_SIZE -> binding.sortBySize.setSelected(sortOrder.toDrawableRes())
SortType.SORT_TYPE_BY_DATE -> binding.sortByDate.setSelected(sortOrder.toDrawableRes())
}
binding.sortByName.setOnClickListener { onSortClick(SortType.SORT_TYPE_BY_NAME) }
binding.sortBySize.setOnClickListener { onSortClick(SortType.SORT_TYPE_BY_SIZE) }
binding.sortByDate.setOnClickListener { onSortClick(SortType.SORT_TYPE_BY_DATE) }
}
override fun onDestroy() {
super.onDestroy()
_binding = null
}
override fun onStart() {
super.onStart()
// Show bottom sheet expanded even in landscape, since there are just 3 options at the moment.
val behavior = BottomSheetBehavior.from(requireView().parent as View)
behavior.state = BottomSheetBehavior.STATE_EXPANDED
}
private fun onSortClick(sortType: SortType) {
sortDialogListener?.onSortSelected(sortType)
dismiss()
}
interface SortDialogListener {
fun onSortSelected(sortType: SortType)
}
companion object {
const val TAG = "SortBottomSheetFragment"
const val ARG_SORT_TYPE = "ARG_SORT_TYPE"
const val ARG_SORT_ORDER = "ARG_SORT_ORDER"
fun newInstance(
sortType: SortType,
sortOrder: SortOrder
): SortBottomSheetFragment {
val args = Bundle().apply {
putParcelable(ARG_SORT_TYPE, sortType)
putParcelable(ARG_SORT_ORDER, sortOrder)
}
return SortBottomSheetFragment().apply { arguments = args }
}
}
}
@@ -0,0 +1,155 @@
/**
* qsfera Android client application
*
* @author Abel García de Prada
* @author Aitor Ballesteros Pavón
*
* Copyright (C) 2024 ownCloud GmbH.
* <p>
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License version 2,
* as published by the Free Software Foundation.
* <p>
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
* <p>
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
package eu.qsfera.android.presentation.files
import android.content.Context
import android.util.AttributeSet
import android.view.LayoutInflater
import android.view.View
import android.widget.Button
import androidx.constraintlayout.widget.ConstraintLayout
import androidx.core.content.ContextCompat
import androidx.core.view.AccessibilityDelegateCompat
import androidx.core.view.ViewCompat
import androidx.core.view.accessibility.AccessibilityNodeInfoCompat
import eu.qsfera.android.R
import eu.qsfera.android.data.providers.SharedPreferencesProvider
import eu.qsfera.android.data.providers.implementation.OCSharedPreferencesProvider
import eu.qsfera.android.databinding.SortOptionsLayoutBinding
import eu.qsfera.android.extensions.setAccessibilityRole
import eu.qsfera.android.presentation.files.SortOrder.Companion.PREF_FILE_LIST_SORT_ORDER
import eu.qsfera.android.presentation.files.SortOrder.SORT_ORDER_ASCENDING
import eu.qsfera.android.presentation.files.SortType.Companion.PREF_FILE_LIST_SORT_TYPE
class SortOptionsView @JvmOverloads constructor(
context: Context,
attrs: AttributeSet? = null,
defStyle: Int = 0
) : ConstraintLayout(context, attrs, defStyle) {
var onSortOptionsListener: SortOptionsListener? = null
var onCreateFolderListener: CreateFolderListener? = null
private var _binding: SortOptionsLayoutBinding? = null
private val binding get() = _binding!!
// Enable list view by default.
var viewTypeSelected: ViewType = ViewType.VIEW_TYPE_LIST
set(viewType) {
binding.viewTypeSelector.setImageDrawable(ContextCompat.getDrawable(context, viewType.getOppositeViewType().toDrawableRes()))
field = viewType
}
// Enable sort by name by default.
var sortTypeSelected: SortType = SortType.SORT_TYPE_BY_NAME
set(sortType) {
if (field == sortType) {
// To do: Should be changed directly, not here.
sortOrderSelected = sortOrderSelected.getOppositeSortOrder()
}
binding.sortTypeTitle.text = context.getText(sortType.toStringRes())
field = sortType
}
// Enable sort ascending by default.
var sortOrderSelected: SortOrder = SortOrder.SORT_ORDER_ASCENDING
set(sortOrder) {
binding.sortTypeIcon.setImageDrawable(ContextCompat.getDrawable(context, sortOrder.toDrawableRes()))
field = sortOrder
}
init {
_binding = SortOptionsLayoutBinding.inflate(LayoutInflater.from(context), this, true)
val sharedPreferencesProvider: SharedPreferencesProvider = OCSharedPreferencesProvider(context)
// Select sort type and order according to preferences.
sortTypeSelected = SortType.values()[sharedPreferencesProvider.getInt(PREF_FILE_LIST_SORT_TYPE, SortType.SORT_TYPE_BY_NAME.ordinal)]
sortOrderSelected = SortOrder.values()[sharedPreferencesProvider.getInt(PREF_FILE_LIST_SORT_ORDER, SortOrder.SORT_ORDER_ASCENDING.ordinal)]
binding.sortTypeTitle.setAccessibilityRole(className = Button::class.java)
binding.sortTypeSelector.setOnClickListener {
onSortOptionsListener?.onSortTypeListener(
sortTypeSelected,
sortOrderSelected
)
}
binding.viewTypeSelector.setOnClickListener {
onSortOptionsListener?.onViewTypeListener(
viewTypeSelected.getOppositeViewType()
)
}
ViewCompat.setAccessibilityDelegate(binding.sortTypeSelector, object : AccessibilityDelegateCompat() {
override fun onInitializeAccessibilityNodeInfo(v: View, info: AccessibilityNodeInfoCompat) {
super.onInitializeAccessibilityNodeInfo(v, info)
val sortTitleText = binding.sortTypeTitle.text
if (sortOrderSelected == SORT_ORDER_ASCENDING) {
binding.sortTypeTitle.contentDescription = context.getString(R.string.content_description_sort_by_name_ascending, sortTitleText)
} else {
binding.sortTypeTitle.contentDescription = context.getString(R.string.content_description_sort_by_name_descending, sortTitleText)
}
}
})
}
fun selectAdditionalView(additionalView: AdditionalView) {
when (additionalView) {
AdditionalView.CREATE_FOLDER -> {
binding.viewTypeSelector.apply {
visibility = VISIBLE
contentDescription = context.getString(R.string.content_description_create_new_folder)
setImageDrawable(ContextCompat.getDrawable(context, R.drawable.ic_action_create_dir))
setOnClickListener {
onCreateFolderListener?.onCreateFolderListener()
}
}
}
AdditionalView.VIEW_TYPE -> {
viewTypeSelected = viewTypeSelected
binding.viewTypeSelector.apply {
visibility = VISIBLE
contentDescription = context.getString(R.string.content_description_type_view)
setOnClickListener {
onSortOptionsListener?.onViewTypeListener(
viewTypeSelected.getOppositeViewType()
)
}
}
}
AdditionalView.HIDDEN -> {
binding.viewTypeSelector.visibility = INVISIBLE
}
}
}
interface SortOptionsListener {
fun onSortTypeListener(sortType: SortType, sortOrder: SortOrder)
fun onViewTypeListener(viewType: ViewType)
}
interface CreateFolderListener {
fun onCreateFolderListener()
}
enum class AdditionalView {
CREATE_FOLDER, VIEW_TYPE, HIDDEN
}
}
@@ -0,0 +1,80 @@
/**
* qsfera Android client application
*
* @author Abel García de Prada
* Copyright (C) 2020 ownCloud GmbH.
* <p>
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License version 2,
* as published by the Free Software Foundation.
* <p>
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
* <p>
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
package eu.qsfera.android.presentation.files
import android.os.Parcelable
import androidx.annotation.DrawableRes
import androidx.annotation.StringRes
import eu.qsfera.android.R
import eu.qsfera.android.utils.FileStorageUtils
import kotlinx.parcelize.Parcelize
@Parcelize
enum class SortType : Parcelable {
SORT_TYPE_BY_NAME, SORT_TYPE_BY_DATE, SORT_TYPE_BY_SIZE;
@StringRes
fun toStringRes(): Int =
when (this) {
SORT_TYPE_BY_NAME -> R.string.global_name
SORT_TYPE_BY_DATE -> R.string.global_date
SORT_TYPE_BY_SIZE -> R.string.global_size
}
companion object {
const val PREF_FILE_LIST_SORT_TYPE = "PREF_FILE_LIST_SORT_TYPE"
fun fromPreference(value: Int): SortType =
when (value) {
FileStorageUtils.SORT_NAME -> SORT_TYPE_BY_NAME
FileStorageUtils.SORT_SIZE -> SORT_TYPE_BY_SIZE
FileStorageUtils.SORT_DATE -> SORT_TYPE_BY_DATE
else -> throw IllegalArgumentException("Sort type not supported")
}
}
}
@Parcelize
enum class SortOrder : Parcelable {
SORT_ORDER_ASCENDING, SORT_ORDER_DESCENDING;
fun getOppositeSortOrder(): SortOrder =
when (this) {
SORT_ORDER_ASCENDING -> SORT_ORDER_DESCENDING
SORT_ORDER_DESCENDING -> SORT_ORDER_ASCENDING
}
@DrawableRes
fun toDrawableRes(): Int =
when (this) {
SORT_ORDER_ASCENDING -> R.drawable.ic_baseline_arrow_upward
SORT_ORDER_DESCENDING -> R.drawable.ic_baseline_arrow_downward
}
companion object {
const val PREF_FILE_LIST_SORT_ORDER = "PREF_FILE_LIST_SORT_ORDER"
fun fromPreference(value: Int) =
when (value) {
SORT_ORDER_ASCENDING.ordinal -> SORT_ORDER_ASCENDING
SORT_ORDER_DESCENDING.ordinal -> SORT_ORDER_DESCENDING
else -> SORT_ORDER_ASCENDING
}
}
}
@@ -0,0 +1,39 @@
/**
* qsfera Android client application
*
* @author Abel García de Prada
* Copyright (C) 2020 ownCloud GmbH.
* <p>
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License version 2,
* as published by the Free Software Foundation.
* <p>
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
* <p>
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
package eu.qsfera.android.presentation.files
import androidx.annotation.DrawableRes
import eu.qsfera.android.R
enum class ViewType {
VIEW_TYPE_GRID, VIEW_TYPE_LIST;
fun getOppositeViewType(): ViewType =
when (this) {
VIEW_TYPE_LIST -> VIEW_TYPE_GRID
VIEW_TYPE_GRID -> VIEW_TYPE_LIST
}
@DrawableRes
fun toDrawableRes(): Int =
when (this) {
VIEW_TYPE_LIST -> R.drawable.ic_baseline_view_list
VIEW_TYPE_GRID -> R.drawable.ic_baseline_view_grid
}
}
@@ -0,0 +1,155 @@
/*
* qsfera Android client application
*
* @author David A. Velasco
* @author Christian Schabesberger
* @author David González Verdugo
* @authos Abel García de Prada
* Copyright (C) 2020 ownCloud GmbH.
* <p>
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License version 2,
* as published by the Free Software Foundation.
* <p>
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
* <p>
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
package eu.qsfera.android.presentation.files.createfolder
import android.app.Dialog
import android.os.Bundle
import android.view.WindowManager
import android.widget.EditText
import androidx.appcompat.app.AlertDialog
import androidx.coordinatorlayout.widget.CoordinatorLayout
import androidx.core.widget.doOnTextChanged
import androidx.fragment.app.DialogFragment
import com.google.android.material.textfield.TextInputLayout
import eu.qsfera.android.R
import eu.qsfera.android.domain.files.model.OCFile
import eu.qsfera.android.utils.PreferenceUtils
/**
* Dialog to input the name for a new folder to create.
*
*
* Triggers the folder creation when name is confirmed.
*/
class CreateFolderDialogFragment : DialogFragment() {
private lateinit var parentFolder: OCFile
private lateinit var createFolderListener: CreateFolderListener
private var isButtonEnabled: Boolean = false
private val maxFilenameLength = 223
override fun onCreateDialog(savedInstanceState: Bundle?): Dialog {
if (savedInstanceState != null) {
isButtonEnabled = savedInstanceState.getBoolean(IS_BUTTON_ENABLED_FLAG_KEY)
}
// Inflate the layout for the dialog
val inflater = requireActivity().layoutInflater
val view = inflater.inflate(R.layout.edit_box_dialog, null)
// Allow or disallow touches with other visible windows
view.filterTouchesWhenObscured = PreferenceUtils.shouldDisallowTouchesWithOtherVisibleWindows(context)
val coordinatorLayout: CoordinatorLayout = requireActivity().findViewById(R.id.coordinator_layout)
coordinatorLayout.filterTouchesWhenObscured =
PreferenceUtils.shouldDisallowTouchesWithOtherVisibleWindows(context)
// Request focus
val inputText: EditText = view.findViewById(R.id.user_input)
val inputLayout: TextInputLayout = view.findViewById(R.id.edit_box_input_text_layout)
var error: String? = null
inputText.requestFocus()
// Build the dialog
val builder = AlertDialog.Builder(requireActivity())
builder.setView(view)
.setPositiveButton(android.R.string.ok) { dialog, _ ->
createFolderListener.onFolderNameSet(
newFolderName = inputText.text.toString(),
parentFolder = parentFolder
)
dialog.dismiss()
}
.setNegativeButton(android.R.string.cancel, null)
.setTitle(R.string.uploader_info_dirname)
val alertDialog = builder.create()
alertDialog.setOnShowListener {
val okButton = alertDialog.getButton(AlertDialog.BUTTON_POSITIVE)
okButton.isEnabled = isButtonEnabled
okButton.setOnClickListener {
var fileName: String = inputText.text.toString()
createFolderListener.onFolderNameSet(fileName, parentFolder)
dialog?.dismiss()
}
}
inputText.doOnTextChanged { text, _, _, _ ->
val okButton = alertDialog.getButton(AlertDialog.BUTTON_POSITIVE)
if (text.isNullOrBlank()) {
okButton.isEnabled = false
error = getString(R.string.uploader_upload_text_dialog_filename_error_empty)
} else if (text.length > maxFilenameLength) {
error = String.format(
getString(R.string.uploader_upload_text_dialog_filename_error_length_max),
maxFilenameLength
)
} else if (forbiddenChars.any { text.contains(it) }) {
error = getString(R.string.filename_forbidden_characters)
} else {
okButton.isEnabled = true
error = null
inputLayout.error = error
}
if (error != null) {
okButton.isEnabled = false
inputLayout.error = error
}
}
alertDialog.window?.setSoftInputMode(WindowManager.LayoutParams.SOFT_INPUT_STATE_VISIBLE)
return alertDialog
}
override fun onSaveInstanceState(outState: Bundle) {
super.onSaveInstanceState(outState)
outState.putBoolean(IS_BUTTON_ENABLED_FLAG_KEY, isButtonEnabled)
}
interface CreateFolderListener {
fun onFolderNameSet(newFolderName: String, parentFolder: OCFile)
}
companion object {
const val CREATE_FOLDER_FRAGMENT = "CREATE_FOLDER_FRAGMENT"
private const val IS_BUTTON_ENABLED_FLAG_KEY = "IS_BUTTON_ENABLED_FLAG_KEY"
private val forbiddenChars = listOf('/', '\\')
/**
* Public factory method to create new CreateFolderDialogFragment instances.
*
* @param parentFolder Folder to create
* @return Dialog ready to show.
*/
@JvmStatic
fun newInstance(parent: OCFile, listener: CreateFolderListener): CreateFolderDialogFragment =
CreateFolderDialogFragment().apply {
createFolderListener = listener
parentFolder = parent
}
}
}
@@ -0,0 +1,147 @@
/**
* qsfera Android client application
*
* @author Aitor Ballesteros Pavón
*
* Copyright (C) 2024 ownCloud GmbH.
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License version 2,
* as published by the Free Software Foundation.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
package eu.qsfera.android.presentation.files.createshortcut
import android.os.Bundle
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import androidx.core.widget.doOnTextChanged
import androidx.fragment.app.DialogFragment
import eu.qsfera.android.R
import eu.qsfera.android.databinding.CreateShortcutDialogBinding
import eu.qsfera.android.domain.files.model.OCFile
import eu.qsfera.android.presentation.files.filelist.MainFileListFragment.Companion.MAX_FILENAME_LENGTH
import eu.qsfera.android.presentation.files.filelist.MainFileListFragment.Companion.forbiddenChars
import eu.qsfera.android.ui.activity.FileDisplayActivity
class CreateShortcutDialogFragment : DialogFragment() {
private lateinit var parentFolder: OCFile
private lateinit var createShortcutListener: CreateShortcutListener
private var _binding: CreateShortcutDialogBinding? = null
private val binding get() = _binding!!
private var isCreateShortcutButtonEnabled = false
override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View {
_binding = CreateShortcutDialogBinding.inflate(inflater, container, false)
return binding.root
}
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
binding.apply {
handleInputsUrlAndFileName()
cancelButton.setOnClickListener {
dialog?.dismiss()
}
}
}
private fun handleInputsUrlAndFileName() {
var isValidUrl = false
var isValidFileName = false
var hasForbiddenCharacters: Boolean
var hasMaxCharacters: Boolean
var hasEmptyValue: Boolean
binding.createShortcutDialogNameFileValue.doOnTextChanged { fileNameValue, _, _, _ ->
fileNameValue?.let {
hasForbiddenCharacters = forbiddenChars.any { fileNameValue.contains(it) }
hasMaxCharacters = fileNameValue.length > MAX_FILENAME_LENGTH
isValidFileName = fileNameValue.isNotBlank() && !hasForbiddenCharacters && !hasMaxCharacters
handleNameRequirements(hasForbiddenCharacters, hasMaxCharacters)
updateCreateShortcutButtonState(isValidFileName, isValidUrl)
}
}
binding.createShortcutDialogUrlValue.doOnTextChanged { urlValue, _, _, _ ->
urlValue?.let {
hasEmptyValue = urlValue.contains(" ")
isValidUrl = urlValue.isNotBlank() && !hasEmptyValue
handleUrlRequirements(hasEmptyValue)
updateCreateShortcutButtonState(isValidFileName, isValidUrl)
}
}
}
private fun updateCreateShortcutButtonState(isValidFileName: Boolean, isValidUrl: Boolean) {
isCreateShortcutButtonEnabled = isValidFileName && isValidUrl
enableCreateButton(isCreateShortcutButtonEnabled)
}
private fun handleNameRequirements(hasForbiddenCharacters: Boolean, hasMaxCharacters: Boolean) {
binding.createShortcutDialogNameFileLayout.apply {
error = when {
hasMaxCharacters -> getString(R.string.uploader_upload_text_dialog_filename_error_length_max, MAX_FILENAME_LENGTH)
hasForbiddenCharacters -> getString(R.string.filename_forbidden_characters)
else -> null
}
}
}
private fun handleUrlRequirements(hasSpace: Boolean) {
binding.createShortcutDialogUrlLayout.apply {
if (hasSpace) {
error = getString(R.string.create_shortcut_dialog_url_error_no_blanks)
} else {
error = null
}
}
}
private fun enableCreateButton(enable: Boolean) {
binding.createButton.apply {
isEnabled = enable
if (enable) {
setOnClickListener {
createShortcutListener.createShortcutFileFromApp(
fileName = binding.createShortcutDialogNameFileValue.text.toString(),
url = formatUrl(binding.createShortcutDialogUrlValue.text.toString()),
)
dialog?.dismiss()
}
setTextColor(resources.getColor(R.color.primary_button_background_color, null))
} else {
setOnClickListener(null)
setTextColor(resources.getColor(R.color.grey, null))
}
}
}
private fun formatUrl(url: String): String {
var formattedUrl = url
if (!url.startsWith(FileDisplayActivity.PROTOCOL_HTTP) && !url.startsWith(FileDisplayActivity.PROTOCOL_HTTPS)) {
formattedUrl = FileDisplayActivity.PROTOCOL_HTTPS + url
}
return formattedUrl
}
interface CreateShortcutListener {
fun createShortcutFileFromApp(fileName: String, url: String)
}
companion object {
fun newInstance(parentFolder: OCFile, listener: CreateShortcutListener): CreateShortcutDialogFragment =
CreateShortcutDialogFragment().apply {
createShortcutListener = listener
this.parentFolder = parentFolder
}
}
}
@@ -0,0 +1,643 @@
/**
* qsfera Android client application
*
* @author Abel García de Prada
* @author Juan Carlos Garrote Gascón
* @author Aitor Ballesteros Pavón
*
* Copyright (C) 2024 ownCloud GmbH.
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License version 2,
* as published by the Free Software Foundation.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
package eu.qsfera.android.presentation.files.details
import android.accounts.Account
import android.content.Intent
import android.net.Uri
import android.os.Build
import android.os.Bundle
import android.view.LayoutInflater
import android.view.Menu
import android.view.MenuItem
import android.view.View
import android.view.ViewGroup
import android.widget.ImageView
import androidx.browser.customtabs.CustomTabsIntent
import androidx.core.view.isVisible
import androidx.work.WorkInfo
import com.google.android.material.snackbar.Snackbar
import eu.qsfera.android.R
import coil.load
import eu.qsfera.android.databinding.FileDetailsFragmentBinding
import eu.qsfera.android.presentation.thumbnails.ThumbnailsRequester
import eu.qsfera.android.domain.exceptions.AccountNotFoundException
import eu.qsfera.android.domain.exceptions.InstanceNotConfiguredException
import eu.qsfera.android.domain.exceptions.TooEarlyException
import eu.qsfera.android.domain.files.model.OCFile
import eu.qsfera.android.domain.files.model.OCFileWithSyncInfo
import eu.qsfera.android.domain.utils.Event
import eu.qsfera.android.extensions.addOpenInWebMenuOptions
import eu.qsfera.android.extensions.collectLatestLifecycleFlow
import eu.qsfera.android.extensions.filterMenuOptions
import eu.qsfera.android.extensions.isDownload
import eu.qsfera.android.extensions.openOCFile
import eu.qsfera.android.extensions.sendDownloadedFilesByShareSheet
import eu.qsfera.android.extensions.showErrorInSnackbar
import eu.qsfera.android.extensions.showMessageInSnackbar
import eu.qsfera.android.presentation.authentication.ACTION_UPDATE_EXPIRED_TOKEN
import eu.qsfera.android.presentation.authentication.EXTRA_ACCOUNT
import eu.qsfera.android.presentation.authentication.EXTRA_ACTION
import eu.qsfera.android.presentation.authentication.LoginActivity
import eu.qsfera.android.presentation.common.UIResult
import eu.qsfera.android.presentation.conflicts.ConflictsResolveActivity
import eu.qsfera.android.presentation.files.details.FileDetailsViewModel.ActionsInDetailsView.NONE
import eu.qsfera.android.presentation.files.details.FileDetailsViewModel.ActionsInDetailsView.SYNC
import eu.qsfera.android.presentation.files.details.FileDetailsViewModel.ActionsInDetailsView.SYNC_AND_OPEN
import eu.qsfera.android.presentation.files.details.FileDetailsViewModel.ActionsInDetailsView.SYNC_AND_OPEN_WITH
import eu.qsfera.android.presentation.files.details.FileDetailsViewModel.ActionsInDetailsView.SYNC_AND_SEND
import eu.qsfera.android.presentation.files.operations.FileOperation.SetFilesAsAvailableOffline
import eu.qsfera.android.presentation.files.operations.FileOperation.SynchronizeFileOperation
import eu.qsfera.android.presentation.files.operations.FileOperation.UnsetFilesAsAvailableOffline
import eu.qsfera.android.presentation.files.operations.FileOperationsViewModel
import eu.qsfera.android.presentation.files.removefile.RemoveFilesDialogFragment
import eu.qsfera.android.presentation.files.removefile.RemoveFilesDialogFragment.Companion.TAG_REMOVE_FILES_DIALOG_FRAGMENT
import eu.qsfera.android.presentation.files.renamefile.RenameFileDialogFragment
import eu.qsfera.android.presentation.files.renamefile.RenameFileDialogFragment.Companion.FRAGMENT_TAG_RENAME_FILE
import eu.qsfera.android.ui.activity.FileActivity.REQUEST_CODE__UPDATE_CREDENTIALS
import eu.qsfera.android.ui.activity.FileDisplayActivity
import eu.qsfera.android.ui.fragment.FileFragment
import eu.qsfera.android.ui.preview.PreviewAudioFragment
import eu.qsfera.android.ui.preview.PreviewImageFragment
import eu.qsfera.android.ui.preview.PreviewTextFragment
import eu.qsfera.android.ui.preview.PreviewVideoActivity
import eu.qsfera.android.usecases.synchronization.SynchronizeFileUseCase
import eu.qsfera.android.utils.DisplayUtils
import eu.qsfera.android.utils.MimetypeIconUtil
import eu.qsfera.android.utils.PreferenceUtils
import eu.qsfera.android.workers.DownloadFileWorker
import org.koin.androidx.viewmodel.ext.android.viewModel
import org.koin.core.parameter.parametersOf
import timber.log.Timber
class FileDetailsFragment : FileFragment() {
private val fileDetailsViewModel by viewModel<FileDetailsViewModel> {
parametersOf(
requireArguments().getParcelable(ARG_ACCOUNT),
requireArguments().getParcelable(ARG_FILE),
requireArguments().getBoolean(ARG_SYNC_FILE_AT_OPEN),
)
}
private val fileOperationsViewModel by viewModel<FileOperationsViewModel>()
private var _binding: FileDetailsFragmentBinding? = null
private val binding get() = _binding!!
private var openInWebProviders: Map<String, Int> = hashMapOf()
override fun onCreateView(
inflater: LayoutInflater, container: ViewGroup?,
savedInstanceState: Bundle?
): View {
setHasOptionsMenu(true)
_binding = FileDetailsFragmentBinding.inflate(inflater, container, false)
return binding.root.apply {
filterTouchesWhenObscured = PreferenceUtils.shouldDisallowTouchesWithOtherVisibleWindows(context)
}
}
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
collectLatestLifecycleFlow(fileDetailsViewModel.currentFile) { ocFileWithSyncInfo: OCFileWithSyncInfo? ->
if (ocFileWithSyncInfo != null) {
file = ocFileWithSyncInfo.file
updateDetails(ocFileWithSyncInfo)
} else {
requireActivity().onBackPressed()
}
}
collectLatestLifecycleFlow(fileDetailsViewModel.appRegistryMimeType) { appRegistryMimeType ->
if (appRegistryMimeType != null) {
// Show or hide open in web options. Hidden by default.
requireActivity().invalidateOptionsMenu()
}
}
fileDetailsViewModel.openInWebUriLiveData.observe(viewLifecycleOwner, Event.EventObserver { uiResult: UIResult<String?> ->
if (uiResult is UIResult.Success) {
val builder = CustomTabsIntent.Builder().build()
builder.launchUrl(
requireActivity(),
Uri.parse(uiResult.data)
)
} else if (uiResult is UIResult.Error) {
// Mimetypes not supported via open in web, send 500
if (uiResult.error is InstanceNotConfiguredException) {
val message =
getString(R.string.open_in_web_error_generic) + " " + getString(R.string.error_reason) +
" " + getString(R.string.open_in_web_error_not_supported)
this.showMessageInSnackbar(message, Snackbar.LENGTH_LONG)
} else if (uiResult.error is TooEarlyException) {
this.showMessageInSnackbar(getString(R.string.open_in_web_error_too_early), Snackbar.LENGTH_LONG)
} else {
this.showErrorInSnackbar(
R.string.open_in_web_error_generic,
uiResult.error
)
}
}
})
fileOperationsViewModel.syncFileLiveData.observe(viewLifecycleOwner, Event.EventObserver { uiResult ->
when (uiResult) {
is UIResult.Error -> {
if (uiResult.error is AccountNotFoundException) {
Snackbar.make(view, getString(R.string.sync_fail_ticker_unauthorized), Snackbar.LENGTH_INDEFINITE)
.setAction(R.string.auth_oauth_failure_snackbar_action) {
val updateAccountCredentials = Intent(requireActivity(), LoginActivity::class.java)
updateAccountCredentials.apply {
putExtra(EXTRA_ACCOUNT, fileDetailsViewModel.getAccount())
putExtra(EXTRA_ACTION, ACTION_UPDATE_EXPIRED_TOKEN)
addFlags(Intent.FLAG_ACTIVITY_EXCLUDE_FROM_RECENTS)
}
startActivityForResult(updateAccountCredentials, REQUEST_CODE__UPDATE_CREDENTIALS)
}.show()
} else {
showErrorInSnackbar(R.string.sync_fail_ticker, uiResult.error)
fileDetailsViewModel.updateActionInDetailsView(NONE)
requireActivity().invalidateOptionsMenu()
}
}
is UIResult.Loading -> {}
is UIResult.Success -> {
when (uiResult.data) {
SynchronizeFileUseCase.SyncType.AlreadySynchronized -> {
showMessageInSnackbar(getString(R.string.sync_file_nothing_to_do_msg))
}
is SynchronizeFileUseCase.SyncType.ConflictDetected -> {
val showConflictActivityIntent = Intent(requireActivity(), ConflictsResolveActivity::class.java)
showConflictActivityIntent.putExtra(ConflictsResolveActivity.EXTRA_FILE, file)
startActivity(showConflictActivityIntent)
}
is SynchronizeFileUseCase.SyncType.DownloadEnqueued -> {
fileDetailsViewModel.startListeningToWorkInfo(uiResult.data.workerId)
}
SynchronizeFileUseCase.SyncType.FileNotFound -> {
showMessageInSnackbar(getString(R.string.sync_file_not_found_msg))
}
is SynchronizeFileUseCase.SyncType.UploadEnqueued -> {
fileDetailsViewModel.startListeningToWorkInfo(uiResult.data.workerId)
}
null -> {
showMessageInSnackbar(getString(R.string.common_error_unknown))
}
}
}
}
})
collectLatestLifecycleFlow(fileDetailsViewModel.actionsInDetailsView) { actions ->
val safeFile = fileDetailsViewModel.getCurrentFile()
if (actions.requiresSync() && safeFile != null)
fileOperationsViewModel.performOperation(
SynchronizeFileOperation(
fileToSync = safeFile.file,
accountName = fileDetailsViewModel.getAccount().name
)
)
}
startListeningToOngoingTransfers()
fileDetailsViewModel.checkOnGoingTransfersWhenOpening()
requireActivity().title = getString(R.string.details_label)
}
override fun onPrepareOptionsMenu(menu: Menu) {
super.onPrepareOptionsMenu(menu)
val safeFile = fileDetailsViewModel.getCurrentFile() ?: return
fileDetailsViewModel.filterMenuOptions(safeFile.file)
collectLatestLifecycleFlow(fileDetailsViewModel.menuOptions) { menuOptions ->
val hasWritePermission = safeFile.file.hasWritePermission
menu.filterMenuOptions(menuOptions, hasWritePermission)
}
menu.findItem(R.id.action_search)?.apply {
isVisible = false
isEnabled = false
}
val appRegistryProviders = fileDetailsViewModel.appRegistryMimeType.value?.appProviders
openInWebProviders = addOpenInWebMenuOptions(menu, openInWebProviders, appRegistryProviders)
setRolesAccessibilityToMenuItems(menu)
}
private fun setRolesAccessibilityToMenuItems(menu: Menu) {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
val roleAccessibilityDescription = getString(R.string.button_role_accessibility)
menu.findItem(R.id.action_rename_file)?.contentDescription = "${getString(R.string.common_rename)} $roleAccessibilityDescription"
menu.findItem(R.id.action_remove_file)?.contentDescription = "${getString(R.string.common_remove)} $roleAccessibilityDescription"
}
}
override fun onOptionsItemSelected(item: MenuItem): Boolean {
val safeFile = fileDetailsViewModel.getCurrentFile() ?: return false
// Let's match the ones that are dynamic first.
openInWebProviders.forEach { (openInWebProviderName, menuItemId) ->
if (menuItemId == item.itemId) {
fileDetailsViewModel.openInWeb(safeFile.file.remoteId!!, openInWebProviderName)
fileOperationsViewModel.setLastUsageFile(safeFile.file)
return true
}
}
return when (item.itemId) {
R.id.action_share_file -> {
mContainerActivity.fileOperationsHelper.showShareFile(safeFile.file)
true
}
R.id.action_open_file_with -> {
if (!safeFile.file.isAvailableLocally) { // Download the file
Timber.d("%s : File must be downloaded before opening it", safeFile.file.remotePath)
fileDetailsViewModel.updateActionInDetailsView(SYNC_AND_OPEN_WITH)
} else { // Already downloaded -> Open it
requireActivity().openOCFile(safeFile.file)
fileOperationsViewModel.setLastUsageFile(safeFile.file)
}
true
}
R.id.action_remove_file -> {
val dialog = RemoveFilesDialogFragment.newInstance(safeFile.file)
dialog.show(parentFragmentManager, TAG_REMOVE_FILES_DIALOG_FRAGMENT)
true
}
R.id.action_rename_file -> {
val dialog = RenameFileDialogFragment.newInstance(safeFile.file)
dialog.show(parentFragmentManager, FRAGMENT_TAG_RENAME_FILE)
true
}
R.id.action_cancel_sync -> {
fileDetailsViewModel.cancelCurrentTransfer()
true
}
R.id.action_download_file, R.id.action_sync_file -> {
fileDetailsViewModel.updateActionInDetailsView(SYNC)
true
}
R.id.action_send_file -> {
if (!safeFile.file.isAvailableLocally) { // Download the file
Timber.d("%s : File must be downloaded before sending it", safeFile.file.remotePath)
fileDetailsViewModel.updateActionInDetailsView(SYNC_AND_SEND)
} else { // Already downloaded -> Send it
requireActivity().sendDownloadedFilesByShareSheet(listOf(safeFile.file))
}
true
}
R.id.action_set_available_offline -> {
fileOperationsViewModel.performOperation(SetFilesAsAvailableOffline(listOf(safeFile.file)))
fileOperationsViewModel.performOperation(SynchronizeFileOperation(safeFile.file, safeFile.file.owner))
true
}
R.id.action_unset_available_offline -> {
fileOperationsViewModel.performOperation(UnsetFilesAsAvailableOffline(listOf(safeFile.file)))
true
}
else -> {
super.onOptionsItemSelected(item)
}
}
}
private fun updateDetails(ocFileWithSyncInfo: OCFileWithSyncInfo) {
binding.fdname.text = ocFileWithSyncInfo.file.fileName
binding.fdSize.text = DisplayUtils.bytesToHumanReadable(ocFileWithSyncInfo.file.length, requireContext(), true)
binding.fdPath.text = ocFileWithSyncInfo.file.getParentRemotePath()
setLastSync(ocFileWithSyncInfo.file)
setModified(ocFileWithSyncInfo.file)
setCreated(ocFileWithSyncInfo.file)
setIconPinAccordingToFilesLocalState(binding.badgeDetailFile, ocFileWithSyncInfo)
setMimeType(ocFileWithSyncInfo.file)
setSpaceName(ocFileWithSyncInfo)
requireActivity().invalidateOptionsMenu()
}
private fun setLastSync(ocFile: OCFile) {
if (ocFile.lastSyncDateForData?.let { it > ZERO_MILLISECOND_TIME } == true) {
binding.fdLastSync.visibility = View.VISIBLE
binding.fdLastSyncLabel.visibility = View.VISIBLE
binding.fdLastSync.text = DisplayUtils.unixTimeToHumanReadable(ocFile.lastSyncDateForData!!)
}
}
private fun setModified(ocFile: OCFile) {
if (ocFile.modificationTimestamp?.let { it > ZERO_MILLISECOND_TIME } == true) {
binding.fdModified.visibility = View.VISIBLE
binding.fdModifiedLabel.visibility = View.VISIBLE
binding.fdModified.text = DisplayUtils.unixTimeToHumanReadable(ocFile.modificationTimestamp)
}
}
private fun setCreated(ocFile: OCFile) {
if (ocFile.creationTimestamp?.let { it > ZERO_MILLISECOND_TIME } == true) {
binding.fdCreated.visibility = View.VISIBLE
binding.fdCreatedLabel.visibility = View.VISIBLE
binding.fdCreated.text = DisplayUtils.unixTimeToHumanReadable(ocFile.creationTimestamp!!)
}
}
private fun setSpaceName(ocFileWithSyncInfo: OCFileWithSyncInfo) {
val space = ocFileWithSyncInfo.space
if (space != null) {
binding.fdSpace.visibility = View.VISIBLE
binding.fdSpaceLabel.visibility = View.VISIBLE
binding.fdIconSpace.visibility = View.VISIBLE
if (space.isPersonal) {
binding.fdSpace.text = getString(R.string.bottom_nav_personal)
} else {
binding.fdSpace.text = space.name
}
}
}
private fun setIconPinAccordingToFilesLocalState(thumbnailImageView: ImageView, ocFileWithSyncInfo: OCFileWithSyncInfo) {
// local state
thumbnailImageView.bringToFront()
thumbnailImageView.isVisible = false
val file = ocFileWithSyncInfo.file
if (ocFileWithSyncInfo.isSynchronizing) {
thumbnailImageView.setImageResource(R.drawable.sync_pin)
thumbnailImageView.visibility = View.VISIBLE
} else if (file.etagInConflict != null) {
// conflict
thumbnailImageView.setImageResource(R.drawable.error_pin)
thumbnailImageView.visibility = View.VISIBLE
} else if (file.isAvailableOffline) {
thumbnailImageView.setImageResource(R.drawable.offline_available_pin)
thumbnailImageView.visibility = View.VISIBLE
} else if (file.isAvailableLocally) {
thumbnailImageView.setImageResource(R.drawable.downloaded_pin)
thumbnailImageView.visibility = View.VISIBLE
}
}
private fun setMimeType(ocFile: OCFile) {
binding.fdType.text = DisplayUtils.convertMIMEtoPrettyPrint(ocFile.mimeType)
binding.fdImageDetailFile.let { imageView ->
imageView.apply {
tag = ocFile.id
setOnClickListener {
if (!ocFile.isAvailableLocally) { // Download the file
Timber.d("%s : File must be downloaded before opening it", ocFile.remotePath)
fileDetailsViewModel.updateActionInDetailsView(SYNC_AND_OPEN)
} else { // Already downloaded -> Open it
navigateToPreviewOrOpenFile(ocFile)
}
}
}
if (ocFile.isImage) {
imageView.load(
ThumbnailsRequester.getPreviewUriForFile(
OCFileWithSyncInfo(ocFile, null),
fileDetailsViewModel.getAccount(),
1024,
1024
),
ThumbnailsRequester.getContentAddressedImageLoader(fileDetailsViewModel.getAccount())
) {
placeholder(MimetypeIconUtil.getFileTypeIconId(ocFile.mimeType, ocFile.fileName))
error(MimetypeIconUtil.getFileTypeIconId(ocFile.mimeType, ocFile.fileName))
crossfade(true)
}
} else {
// Name of the file, to deduce the icon to use in case the MIME type is not precise enough
imageView.setImageResource(MimetypeIconUtil.getFileTypeIconId(ocFile.mimeType, ocFile.fileName))
}
}
}
private fun startListeningToOngoingTransfers() {
fileDetailsViewModel.ongoingTransfer.observe(viewLifecycleOwner, Event.EventObserver { workInfo ->
workInfo ?: return@EventObserver
when (workInfo.state) {
WorkInfo.State.ENQUEUED -> updateLayoutForEnqueuedTransfer(workInfo)
WorkInfo.State.RUNNING -> updateLayoutForRunningTransfer(workInfo)
WorkInfo.State.SUCCEEDED -> updateLayoutForSucceededTransfer(workInfo)
WorkInfo.State.FAILED -> updateLayoutForFailedTransfer(workInfo)
WorkInfo.State.BLOCKED -> {}
WorkInfo.State.CANCELLED -> updateLayoutForCancelledTransfer(workInfo)
}
})
}
private fun updateLayoutForEnqueuedTransfer(workInfo: WorkInfo) {
val safeFile = fileDetailsViewModel.getCurrentFile() ?: return
showProgressView(isTransferGoingOn = true)
binding.fdProgressText.text = if (workInfo.isDownload()) {
getString(R.string.downloader_download_enqueued_ticker, safeFile.file.fileName)
} else { // Transfer is upload (?)
getString(R.string.uploader_upload_enqueued_ticker, safeFile.file.fileName)
}
binding.fdProgressBar.apply {
progress = 0
isIndeterminate = false
}
}
private fun updateLayoutForRunningTransfer(workInfo: WorkInfo) {
fileDetailsViewModel.getCurrentFile() ?: return
showProgressView(isTransferGoingOn = true)
binding.fdProgressText.text = if (workInfo.isDownload()) {
getString(R.string.downloader_download_in_progress_ticker)
} else { // Transfer is upload (?)
getString(R.string.uploader_upload_in_progress_ticker)
}
val workProgress = workInfo.progress.getInt(DownloadFileWorker.WORKER_KEY_PROGRESS, -1)
binding.fdProgressBar.apply {
if (workProgress == -1) {
isIndeterminate = true
} else {
isIndeterminate = false
progress = workProgress
invalidate()
}
}
binding.fdCancelBtn.setOnClickListener { fileDetailsViewModel.cancelCurrentTransfer() }
}
private fun updateLayoutForSucceededTransfer(workInfo: WorkInfo) {
val safeFile = fileDetailsViewModel.getCurrentFile() ?: return
showProgressView(isTransferGoingOn = false)
if (workInfo.isDownload()) {
when (fileDetailsViewModel.actionsInDetailsView.value) {
NONE -> {}
SYNC -> {
fileDetailsViewModel.updateActionInDetailsView(NONE)
}
SYNC_AND_OPEN -> {
navigateToPreviewOrOpenFile(file)
fileDetailsViewModel.updateActionInDetailsView(NONE)
}
SYNC_AND_OPEN_WITH -> {
requireActivity().openOCFile(safeFile.file)
fileDetailsViewModel.updateActionInDetailsView(NONE)
}
SYNC_AND_SEND -> {
requireActivity().sendDownloadedFilesByShareSheet(listOf(safeFile.file))
fileDetailsViewModel.updateActionInDetailsView(NONE)
}
}
} else { // Transfer is upload (?)
// Nothing to do at the moment
}
}
private fun updateLayoutForFailedTransfer(workInfo: WorkInfo) {
showProgressView(isTransferGoingOn = false)
val message = if (workInfo.isDownload()) {
getString(R.string.downloader_download_failed_ticker)
} else { // Transfer is upload (?)
getString(R.string.uploader_upload_failed_ticker)
}
showMessageInSnackbar(message)
}
private fun updateLayoutForCancelledTransfer(workInfo: WorkInfo) {
showProgressView(isTransferGoingOn = false)
val message = if (workInfo.isDownload()) {
getString(R.string.downloader_download_canceled_ticker)
} else { // Transfer is upload (?)
getString(R.string.uploader_upload_canceled_ticker)
}
showMessageInSnackbar(message)
fileDetailsViewModel.updateActionInDetailsView(NONE)
}
/**
* Show or hide progress for transfers.
*/
private fun showProgressView(isTransferGoingOn: Boolean) {
binding.fdProgressBar.isVisible = isTransferGoingOn
binding.fdProgressText.isVisible = isTransferGoingOn
binding.fdCancelBtn.isVisible = isTransferGoingOn
// Invalidate to reset the menu items -> Show/Hide Download/Sync/Cancel
requireActivity().invalidateOptionsMenu()
}
// To do: Move navigation to a common place.
private fun navigateToPreviewOrOpenFile(fileWaitingToPreview: OCFile) {
val fileDisplayActivity = requireActivity() as FileDisplayActivity
when {
PreviewImageFragment.canBePreviewed(fileWaitingToPreview) -> {
fileDisplayActivity.startImagePreview(fileWaitingToPreview)
}
PreviewAudioFragment.canBePreviewed(fileWaitingToPreview) -> {
fileDisplayActivity.startAudioPreview(fileWaitingToPreview, 0)
}
PreviewVideoActivity.canBePreviewed(fileWaitingToPreview) -> {
fileDisplayActivity.startVideoPreview(fileWaitingToPreview, 0)
}
PreviewTextFragment.canBePreviewed(fileWaitingToPreview) -> {
fileDisplayActivity.startTextPreview(fileWaitingToPreview)
}
else -> {
fileDisplayActivity.openOCFile(fileWaitingToPreview)
}
}
fileOperationsViewModel.setLastUsageFile(fileWaitingToPreview)
}
override fun updateViewForSyncInProgress() {
// Not yet implemented
}
override fun updateViewForSyncOff() {
// Not yet implemented
}
override fun onFileMetadataChanged(updatedFile: OCFile?) {
// Nothing to do here. We are observing the oCFile from database, so it should be refreshed automatically
}
override fun onFileMetadataChanged() {
// Not yet implemented
}
override fun onFileContentChanged() {
// Not yet implemented
}
companion object {
private const val ARG_FILE = "FILE"
private const val ARG_ACCOUNT = "ACCOUNT"
private const val ARG_SYNC_FILE_AT_OPEN = "SYNC_FILE_AT_OPEN"
private const val ZERO_MILLISECOND_TIME = 0
/**
* Public factory method to create new FileDetailsFragment instances.
*
*
* @param fileToDetail An [OCFile] to show in the fragment
* @param account An qsfera account; needed to start downloads
* @return New fragment with arguments set
*/
fun newInstance(fileToDetail: OCFile, account: Account, syncFileAtOpen: Boolean = true): FileDetailsFragment =
FileDetailsFragment().apply {
arguments = Bundle().apply {
putParcelable(ARG_FILE, fileToDetail)
putParcelable(ARG_ACCOUNT, account)
putBoolean(ARG_SYNC_FILE_AT_OPEN, syncFileAtOpen)
}
}
}
}
@@ -0,0 +1,212 @@
/**
* qsfera Android client application
*
* @author Abel García de Prada
* @author Juan Carlos Garrote Gascón
*
* Copyright (C) 2023 ownCloud GmbH.
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License version 2,
* as published by the Free Software Foundation.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
package eu.qsfera.android.presentation.files.details
import android.accounts.Account
import androidx.lifecycle.LiveData
import androidx.lifecycle.MediatorLiveData
import androidx.lifecycle.MutableLiveData
import androidx.lifecycle.ViewModel
import androidx.lifecycle.map
import androidx.lifecycle.switchMap
import androidx.lifecycle.viewModelScope
import androidx.work.WorkInfo
import androidx.work.WorkManager
import eu.qsfera.android.R
import eu.qsfera.android.domain.appregistry.model.AppRegistryMimeType
import eu.qsfera.android.domain.appregistry.usecases.GetAppRegistryForMimeTypeAsStreamUseCase
import eu.qsfera.android.domain.appregistry.usecases.GetUrlToOpenInWebUseCase
import eu.qsfera.android.domain.capabilities.usecases.RefreshCapabilitiesFromServerAsyncUseCase
import eu.qsfera.android.domain.extensions.isOneOf
import eu.qsfera.android.domain.files.model.FileMenuOption
import eu.qsfera.android.domain.files.model.OCFile
import eu.qsfera.android.domain.files.model.OCFileWithSyncInfo
import eu.qsfera.android.domain.files.usecases.GetFileWithSyncInfoByIdUseCase
import eu.qsfera.android.domain.utils.Event
import eu.qsfera.android.extensions.ViewModelExt.runUseCaseWithResult
import eu.qsfera.android.extensions.getRunningWorkInfosByTags
import eu.qsfera.android.extensions.isDownload
import eu.qsfera.android.extensions.isUpload
import eu.qsfera.android.presentation.common.UIResult
import eu.qsfera.android.presentation.files.details.FileDetailsViewModel.ActionsInDetailsView.NONE
import eu.qsfera.android.presentation.files.details.FileDetailsViewModel.ActionsInDetailsView.SYNC_AND_OPEN
import eu.qsfera.android.providers.ContextProvider
import eu.qsfera.android.providers.CoroutinesDispatcherProvider
import eu.qsfera.android.usecases.files.FilterFileMenuOptionsUseCase
import eu.qsfera.android.usecases.transfers.downloads.CancelDownloadForFileUseCase
import eu.qsfera.android.usecases.transfers.uploads.CancelUploadForFileUseCase
import eu.qsfera.android.workers.DownloadFileWorker
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
import java.util.UUID
class FileDetailsViewModel(
private val openInWebUseCase: GetUrlToOpenInWebUseCase,
refreshCapabilitiesFromServerAsyncUseCase: RefreshCapabilitiesFromServerAsyncUseCase,
getAppRegistryForMimeTypeAsStreamUseCase: GetAppRegistryForMimeTypeAsStreamUseCase,
private val cancelDownloadForFileUseCase: CancelDownloadForFileUseCase,
private val cancelUploadForFileUseCase: CancelUploadForFileUseCase,
private val filterFileMenuOptionsUseCase: FilterFileMenuOptionsUseCase,
getFileWithSyncInfoByIdUseCase: GetFileWithSyncInfoByIdUseCase,
val contextProvider: ContextProvider,
private val coroutinesDispatcherProvider: CoroutinesDispatcherProvider,
private val workManager: WorkManager,
account: Account,
ocFile: OCFile,
shouldSyncFile: Boolean,
) : ViewModel() {
private val _openInWebUriLiveData: MediatorLiveData<Event<UIResult<String?>>> = MediatorLiveData()
val openInWebUriLiveData: LiveData<Event<UIResult<String?>>> = _openInWebUriLiveData
val appRegistryMimeType: StateFlow<AppRegistryMimeType?> =
getAppRegistryForMimeTypeAsStreamUseCase(
GetAppRegistryForMimeTypeAsStreamUseCase.Params(accountName = account.name, ocFile.mimeType)
).stateIn(
viewModelScope,
started = SharingStarted.WhileSubscribed(5_000),
initialValue = null
)
private val account: StateFlow<Account> = MutableStateFlow(account)
private val ocFileWithSyncInfo = OCFileWithSyncInfo(
file = ocFile,
uploadWorkerUuid = UUID.randomUUID(),
downloadWorkerUuid = UUID.randomUUID(),
isSynchronizing = true,
space = null
)
val currentFile: StateFlow<OCFileWithSyncInfo?> =
getFileWithSyncInfoByIdUseCase(GetFileWithSyncInfoByIdUseCase.Params(ocFile.id!!))
.stateIn(
viewModelScope,
started = SharingStarted.WhileSubscribed(5_000),
initialValue = ocFileWithSyncInfo
)
private val _ongoingTransferUUID = MutableLiveData<UUID>()
private val _ongoingTransfer = _ongoingTransferUUID.switchMap { transferUUID ->
workManager.getWorkInfoByIdLiveData(transferUUID)
}.map { Event(it) }
val ongoingTransfer: LiveData<Event<WorkInfo?>> = _ongoingTransfer
init {
viewModelScope.launch(coroutinesDispatcherProvider.io) {
refreshCapabilitiesFromServerAsyncUseCase(RefreshCapabilitiesFromServerAsyncUseCase.Params(account.name))
}
}
private val _actionsInDetailsView: MutableStateFlow<ActionsInDetailsView> = MutableStateFlow(if (shouldSyncFile) SYNC_AND_OPEN else NONE)
val actionsInDetailsView: StateFlow<ActionsInDetailsView> = _actionsInDetailsView
private val _menuOptions: MutableStateFlow<List<FileMenuOption>> = MutableStateFlow(emptyList())
val menuOptions: StateFlow<List<FileMenuOption>> = _menuOptions
fun getCurrentFile(): OCFileWithSyncInfo? = currentFile.value
fun getAccount() = account.value
fun updateActionInDetailsView(actionsInDetailsView: ActionsInDetailsView) {
_actionsInDetailsView.update { actionsInDetailsView }
}
fun startListeningToWorkInfo(uuid: UUID?) {
uuid?.let {
_ongoingTransferUUID.postValue(it)
}
}
fun checkOnGoingTransfersWhenOpening() {
val safeFile = currentFile.value ?: return
val listOfWorkers =
workManager.getRunningWorkInfosByTags(listOf(safeFile.file.id!!.toString(), getAccount().name, DownloadFileWorker::class.java.name))
listOfWorkers.firstOrNull()?.let { workInfo ->
_ongoingTransferUUID.postValue(workInfo.id)
}
}
fun cancelCurrentTransfer() {
viewModelScope.launch(coroutinesDispatcherProvider.io) {
val currentTransfer = ongoingTransfer.value?.peekContent() ?: return@launch
val safeFile = currentFile.value ?: return@launch
if (currentTransfer.isUpload()) {
cancelUploadForFileUseCase(CancelUploadForFileUseCase.Params(safeFile.file))
} else if (currentTransfer.isDownload()) {
cancelDownloadForFileUseCase(CancelDownloadForFileUseCase.Params(safeFile.file))
}
}
}
fun openInWeb(fileId: String, appName: String) {
runUseCaseWithResult(
coroutineDispatcher = coroutinesDispatcherProvider.io,
liveData = _openInWebUriLiveData,
useCase = openInWebUseCase,
useCaseParams = GetUrlToOpenInWebUseCase.Params(
fileId = fileId,
accountName = getAccount().name,
appName = appName,
),
showLoading = false,
requiresConnection = true,
)
}
fun filterMenuOptions(file: OCFile) {
val shareViaLinkAllowed = contextProvider.getBoolean(R.bool.share_via_link_feature)
val shareWithUsersAllowed = contextProvider.getBoolean(R.bool.share_with_users_feature)
val sendAllowed = contextProvider.getString(R.string.send_files_to_other_apps).equals("on", ignoreCase = true)
viewModelScope.launch(coroutinesDispatcherProvider.io) {
val result = filterFileMenuOptionsUseCase(
FilterFileMenuOptionsUseCase.Params(
files = listOf(file),
accountName = getAccount().name,
isAnyFileVideoPreviewing = false,
displaySelectAll = false,
displaySelectInverse = false,
onlyAvailableOfflineFiles = false,
onlySharedByLinkFiles = false,
shareViaLinkAllowed = shareViaLinkAllowed,
shareWithUsersAllowed = shareWithUsersAllowed,
sendAllowed = sendAllowed,
)
)
result.apply {
remove(FileMenuOption.DETAILS)
remove(FileMenuOption.MOVE)
remove(FileMenuOption.COPY)
}
_menuOptions.update { result }
}
}
enum class ActionsInDetailsView {
NONE, SYNC, SYNC_AND_OPEN, SYNC_AND_OPEN_WITH, SYNC_AND_SEND;
fun requiresSync(): Boolean = this.isOneOf(SYNC, SYNC_AND_OPEN, SYNC_AND_OPEN_WITH, SYNC_AND_SEND)
}
}
@@ -0,0 +1,56 @@
/**
* qsfera Android client application
*
* @author Fernando Sanz Velasco
* Copyright (C) 2022 ownCloud GmbH.
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License version 2,
* as published by the Free Software Foundation.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*
*/
package eu.qsfera.android.presentation.files.filelist
import android.content.Context
import android.util.DisplayMetrics
import android.view.View
/**
* This class dynamically calculates the number of columns
* based on the device screen for the RecyclerView Grid mode.
*/
class ColumnQuantity(context: Context, viewId: Int) {
private var width: Int = 0
private var height: Int = 0
private var remaining: Int = 0
private var displayMetrics: DisplayMetrics
init {
val view: View = View.inflate(context, viewId, null)
view.measure(View.MeasureSpec.UNSPECIFIED, View.MeasureSpec.UNSPECIFIED)
width = view.measuredWidth
height = view.measuredHeight
displayMetrics = context.resources.displayMetrics
}
fun calculateNoOfColumns(): Int {
var numberOfColumns = displayMetrics.widthPixels.div(width)
remaining = displayMetrics.widthPixels.minus(numberOfColumns.times(width))
if (remaining.div(numberOfColumns.times(2)) < 15) {
numberOfColumns.minus(1)
remaining = displayMetrics.widthPixels.minus(numberOfColumns.times(width))
}
return numberOfColumns
}
}
@@ -0,0 +1,494 @@
/**
* qsfera Android client application
*
* @author Fernando Sanz Velasco
* @author Juan Carlos Garrote Gascón
* @author Manuel Plazas Palacio
* @author Aitor Ballesteros Pavón
*
* Copyright (C) 2024 ownCloud GmbH.
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License version 2,
* as published by the Free Software Foundation.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
package eu.qsfera.android.presentation.files.filelist
import android.accounts.Account
import android.content.Context
import android.graphics.Color
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import android.widget.ImageView
import android.widget.LinearLayout
import androidx.core.content.ContextCompat
import androidx.core.view.isVisible
import androidx.recyclerview.widget.DiffUtil
import androidx.recyclerview.widget.RecyclerView
import androidx.recyclerview.widget.StaggeredGridLayoutManager
import eu.qsfera.android.R
import eu.qsfera.android.databinding.GridItemBinding
import eu.qsfera.android.databinding.ItemFileListBinding
import eu.qsfera.android.databinding.ListFooterBinding
import coil.load
import coil.dispose
import eu.qsfera.android.presentation.thumbnails.ThumbnailsRequester
import eu.qsfera.android.domain.files.model.FileListOption
import eu.qsfera.android.domain.files.model.OCFileWithSyncInfo
import eu.qsfera.android.domain.files.model.OCFooterFile
import eu.qsfera.android.presentation.authentication.AccountUtils
import eu.qsfera.android.utils.DisplayUtils
import eu.qsfera.android.utils.MimetypeIconUtil
import eu.qsfera.android.utils.PreferenceUtils
class FileListAdapter(
private val context: Context,
private val isPickerMode: Boolean,
private val layoutManager: StaggeredGridLayoutManager,
private val listener: FileListAdapterListener,
) : SelectableAdapter<RecyclerView.ViewHolder>() {
var files = mutableListOf<Any>()
private var account: Account? = AccountUtils.getCurrentQSferaAccount(context)
private var fileListOption: FileListOption = FileListOption.ALL_FILES
private val disallowTouchesWithOtherWindows =
PreferenceUtils.shouldDisallowTouchesWithOtherVisibleWindows(context)
init {
setHasStableIds(true)
}
fun updateFileList(filesToAdd: List<OCFileWithSyncInfo>, fileListOption: FileListOption) {
val listWithFooter = mutableListOf<Any>()
listWithFooter.addAll(filesToAdd)
if (listWithFooter.isNotEmpty() && !isPickerMode) {
listWithFooter.add(OCFooterFile(manageListOfFilesAndGenerateText(filesToAdd)))
}
val diffUtilCallback = FileListDiffCallback(
oldList = files,
newList = listWithFooter,
oldFileListOption = this.fileListOption,
newFileListOption = fileListOption,
)
val diffResult = DiffUtil.calculateDiff(diffUtilCallback)
files.clear()
files.addAll(listWithFooter)
this.fileListOption = fileListOption
diffResult.dispatchUpdatesTo(this)
}
override fun getItemId(position: Int): Long {
val item = files.getOrNull(position)
return when (item) {
is OCFileWithSyncInfo -> item.file.id ?: item.file.remotePath.hashCode().toLong()
is OCFooterFile -> Long.MIN_VALUE + position
else -> position.toLong()
}
}
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): RecyclerView.ViewHolder =
when (viewType) {
ViewType.LIST_ITEM.ordinal -> {
val binding = ItemFileListBinding.inflate(LayoutInflater.from(parent.context), parent, false)
binding.root.apply {
tag = ViewType.LIST_ITEM
filterTouchesWhenObscured = disallowTouchesWithOtherWindows
}
ListViewHolder(binding)
}
ViewType.GRID_IMAGE.ordinal -> {
val binding = GridItemBinding.inflate(LayoutInflater.from(parent.context), parent, false)
binding.root.apply {
tag = ViewType.GRID_IMAGE
filterTouchesWhenObscured = disallowTouchesWithOtherWindows
}
GridImageViewHolder(binding)
}
ViewType.GRID_ITEM.ordinal -> {
val binding = GridItemBinding.inflate(LayoutInflater.from(parent.context), parent, false)
binding.root.apply {
tag = ViewType.GRID_ITEM
filterTouchesWhenObscured = disallowTouchesWithOtherWindows
}
GridViewHolder(binding)
}
else -> {
val binding = ListFooterBinding.inflate(LayoutInflater.from(parent.context), parent, false)
binding.root.apply {
tag = ViewType.FOOTER
filterTouchesWhenObscured = disallowTouchesWithOtherWindows
}
FooterViewHolder(binding)
}
}
override fun getItemCount(): Int = files.size
private fun hasFooter(): Boolean = files.lastOrNull() is OCFooterFile
private fun isFooter(position: Int) = files.getOrNull(position) is OCFooterFile
private fun selectableItemCount(): Int = files.size - if (hasFooter()) 1 else 0
override fun getItemViewType(position: Int): Int =
if (isFooter(position)) {
ViewType.FOOTER.ordinal
} else {
when {
layoutManager.spanCount == 1 -> {
ViewType.LIST_ITEM.ordinal
}
(files[position] as OCFileWithSyncInfo).file.isImage -> {
ViewType.GRID_IMAGE.ordinal
}
else -> {
ViewType.GRID_ITEM.ordinal
}
}
}
fun getCheckedItems(): List<OCFileWithSyncInfo> {
val checkedItems = mutableListOf<OCFileWithSyncInfo>()
val checkedPositions = getSelectedItems()
for (i in checkedPositions) {
val checkedFile: Any? = files.getOrNull(i)
if (checkedFile is OCFileWithSyncInfo) {
checkedItems.add(checkedFile)
}
}
return checkedItems
}
fun selectAll() {
// Last item on list is the footer, so that element must be excluded from selection
selectAll(totalItems = selectableItemCount())
}
fun selectInverse() {
// Last item on list is the footer, so that element must be excluded from selection
toggleSelectionInBulk(totalItems = selectableItemCount())
}
override fun onBindViewHolder(holder: RecyclerView.ViewHolder, position: Int) {
val viewType = getItemViewType(position)
AccountUtils.getCurrentQSferaAccount(context)?.let { currentAccount ->
if (currentAccount != account) {
account = currentAccount
}
} ?: run {
if (account != null) {
account = null
}
}
if (viewType != ViewType.FOOTER.ordinal) { // Is Item
val hasActiveSelection = selectedItemCount > 0
val fileWithSyncInfo = files[position] as OCFileWithSyncInfo
val file = fileWithSyncInfo.file
val name = file.fileName
val fileIcon = holder.itemView.findViewById<ImageView>(R.id.thumbnail).apply {
tag = file.id
}
holder.itemView.findViewById<LinearLayout>(R.id.ListItemLayout)?.apply {
contentDescription = "LinearLayout-$name"
// Allow or disallow touches with other visible windows
filterTouchesWhenObscured = disallowTouchesWithOtherWindows
}
holder.itemView.findViewById<LinearLayout>(R.id.share_icons_layout).isVisible =
file.sharedByLink || file.sharedWithSharee == true || file.isSharedWithMe
holder.itemView.findViewById<ImageView>(R.id.shared_by_link_icon).isVisible = file.sharedByLink
holder.itemView.findViewById<ImageView>(R.id.shared_via_users_icon).isVisible =
file.sharedWithSharee == true || file.isSharedWithMe
setSpecificViewHolder(viewType, holder, fileWithSyncInfo, hasActiveSelection)
setIconPinAccordingToFilesLocalState(holder.itemView.findViewById(R.id.localFileIndicator), fileWithSyncInfo)
holder.itemView.setOnClickListener {
val adapterPosition = holder.bindingAdapterPosition
if (adapterPosition == RecyclerView.NO_POSITION) {
return@setOnClickListener
}
val currentItem = files.getOrNull(adapterPosition) as? OCFileWithSyncInfo ?: return@setOnClickListener
listener.onItemClick(
ocFileWithSyncInfo = currentItem,
position = adapterPosition
)
}
holder.itemView.setOnLongClickListener {
val adapterPosition = holder.bindingAdapterPosition
if (adapterPosition == RecyclerView.NO_POSITION) {
return@setOnLongClickListener false
}
listener.onLongItemClick(
position = adapterPosition
)
}
holder.itemView.setBackgroundColor(Color.WHITE)
val checkBoxV = holder.itemView.findViewById<ImageView>(R.id.custom_checkbox).apply {
isVisible = hasActiveSelection
}
if (isSelected(position)) {
holder.itemView.setBackgroundColor(ContextCompat.getColor(context, R.color.selected_item_background))
checkBoxV.setImageResource(R.drawable.ic_checkbox_marked)
} else {
holder.itemView.setBackgroundColor(Color.WHITE)
checkBoxV.setImageResource(R.drawable.ic_checkbox_blank_outline)
}
if (file.isFolder) {
// Folder
fileIcon.dispose()
fileIcon.setImageResource(R.drawable.ic_menu_archive)
fileIcon.setBackgroundColor(Color.TRANSPARENT)
} else {
// Set file icon depending on its mimetype. Ask for thumbnail later.
fileIcon.setImageResource(MimetypeIconUtil.getFileTypeIconId(file.mimeType, file.fileName))
if (file.isImage) {
account?.let { acc ->
fileIcon.load(ThumbnailsRequester.getPreviewUriForFile(fileWithSyncInfo, acc),
ThumbnailsRequester.getContentAddressedImageLoader(acc)) {
placeholder(MimetypeIconUtil.getFileTypeIconId(file.mimeType, file.fileName))
error(MimetypeIconUtil.getFileTypeIconId(file.mimeType, file.fileName))
crossfade(true)
}
}
} else {
fileIcon.dispose()
}
if (file.mimeType.equals("image/png", ignoreCase = true)) {
fileIcon.setBackgroundColor(ContextCompat.getColor(context, R.color.background_color))
} else {
fileIcon.setBackgroundColor(Color.TRANSPARENT)
}
}
} else { // Is Footer
if (!isPickerMode) {
val view = holder as FooterViewHolder
val file = files[position] as OCFooterFile
(view.itemView.layoutParams as StaggeredGridLayoutManager.LayoutParams).apply {
isFullSpan = true
}
view.binding.footerText.text = file.text
}
}
}
private fun setSpecificViewHolder(
viewType: Int,
holder: RecyclerView.ViewHolder,
fileWithSyncInfo: OCFileWithSyncInfo,
hasActiveSelection: Boolean,
) {
val file = fileWithSyncInfo.file
when (viewType) {
ViewType.LIST_ITEM.ordinal -> {
val view = holder as ListViewHolder
view.binding.let {
it.fileListConstraintLayout.filterTouchesWhenObscured = disallowTouchesWithOtherWindows
it.Filename.text = file.fileName
it.fileListSize.text = DisplayUtils.bytesToHumanReadable(file.length, context, true)
it.fileListLastMod.text = DisplayUtils.getRelativeTimestamp(context, file.modificationTimestamp)
it.threeDotMenu.isVisible = !hasActiveSelection
it.threeDotMenu.contentDescription = context.getString(R.string.content_description_file_operations, file.fileName)
if (fileListOption.isAvailableOffline() || (fileListOption.isSharedByLink() && fileWithSyncInfo.space == null)) {
it.spacePathLine.path.apply {
text = file.getParentRemotePath()
isVisible = true
}
fileWithSyncInfo.space?.let { space ->
it.spacePathLine.spaceIcon.isVisible = true
it.spacePathLine.spaceName.isVisible = true
if (space.isPersonal) {
it.spacePathLine.spaceIcon.setImageResource(R.drawable.ic_folder)
it.spacePathLine.spaceName.setText(R.string.bottom_nav_personal)
} else {
it.spacePathLine.spaceName.text = space.name
}
}
} else {
it.spacePathLine.path.isVisible = false
it.spacePathLine.spaceIcon.isVisible = false
it.spacePathLine.spaceName.isVisible = false
}
it.threeDotMenu.setOnClickListener {
listener.onThreeDotButtonClick(fileWithSyncInfo = fileWithSyncInfo)
}
}
}
ViewType.GRID_ITEM.ordinal -> {
// Filename
val view = holder as GridViewHolder
view.binding.Filename.text = file.fileName
}
ViewType.GRID_IMAGE.ordinal -> {
val view = holder as GridImageViewHolder
val fileIcon = holder.itemView.findViewById<ImageView>(R.id.thumbnail)
val layoutParams = fileIcon.layoutParams as ViewGroup.MarginLayoutParams
view.binding.Filename.apply {
text = ""
isVisible = false
}
manageGridLayoutParams(
layoutParams = layoutParams,
marginVertical = context.resources.getDimensionPixelSize(R.dimen.item_file_image_grid_margin),
height = ViewGroup.LayoutParams.MATCH_PARENT,
width = ViewGroup.LayoutParams.MATCH_PARENT,
)
}
}
}
private fun manageGridLayoutParams(layoutParams: ViewGroup.MarginLayoutParams, marginVertical: Int, height: Int, width: Int) {
val marginHorizontal = context.resources.getDimensionPixelSize(R.dimen.item_file_image_grid_margin)
layoutParams.setMargins(marginHorizontal, marginVertical, marginHorizontal, marginVertical)
layoutParams.height = height
layoutParams.width = width
}
private fun manageListOfFilesAndGenerateText(list: List<OCFileWithSyncInfo>): String {
var filesCount = 0
var foldersCount = 0
for (fileWithSyncInfo in list) {
if (fileWithSyncInfo.file.isFolder) {
foldersCount++
} else {
if (!fileWithSyncInfo.file.isHidden) {
filesCount++
}
}
}
return generateFooterText(filesCount, foldersCount)
}
private fun setIconPinAccordingToFilesLocalState(localStateView: ImageView, fileWithSyncInfo: OCFileWithSyncInfo) {
// local state
localStateView.bringToFront()
localStateView.isVisible = false
val file = fileWithSyncInfo.file
if (fileWithSyncInfo.isSynchronizing) {
localStateView.setImageResource(R.drawable.sync_pin)
localStateView.visibility = View.VISIBLE
} else if (file.etagInConflict != null) {
// conflict
localStateView.setImageResource(R.drawable.error_pin)
localStateView.visibility = View.VISIBLE
} else if (file.isAvailableOffline) {
localStateView.visibility = View.VISIBLE
localStateView.setImageResource(R.drawable.offline_available_pin)
} else if (file.isAvailableLocally) {
localStateView.visibility = View.VISIBLE
localStateView.setImageResource(R.drawable.downloaded_pin)
}
}
private fun generateFooterText(filesCount: Int, foldersCount: Int): String =
when {
filesCount <= 0 -> {
when {
foldersCount <= 0 -> {
""
}
foldersCount == 1 -> {
context.getString(R.string.file_list__footer__folder)
}
else -> { // foldersCount > 1
context.getString(R.string.file_list__footer__folders, foldersCount)
}
}
}
filesCount == 1 -> {
when {
foldersCount <= 0 -> {
context.getString(R.string.file_list__footer__file)
}
foldersCount == 1 -> {
context.getString(R.string.file_list__footer__file_and_folder)
}
else -> { // foldersCount > 1
context.getString(R.string.file_list__footer__file_and_folders, foldersCount)
}
}
}
else -> { // filesCount > 1
when {
foldersCount <= 0 -> {
context.getString(R.string.file_list__footer__files, filesCount)
}
foldersCount == 1 -> {
context.getString(R.string.file_list__footer__files_and_folder, filesCount)
}
else -> { // foldersCount > 1
context.getString(
R.string.file_list__footer__files_and_folders, filesCount, foldersCount
)
}
}
}
}
interface FileListAdapterListener {
fun onItemClick(ocFileWithSyncInfo: OCFileWithSyncInfo, position: Int)
fun onLongItemClick(position: Int): Boolean = true
fun onThreeDotButtonClick(fileWithSyncInfo: OCFileWithSyncInfo)
}
inner class GridViewHolder(val binding: GridItemBinding) : RecyclerView.ViewHolder(binding.root)
inner class GridImageViewHolder(val binding: GridItemBinding) : RecyclerView.ViewHolder(binding.root)
inner class ListViewHolder(val binding: ItemFileListBinding) : RecyclerView.ViewHolder(binding.root)
inner class FooterViewHolder(val binding: ListFooterBinding) : RecyclerView.ViewHolder(binding.root)
enum class ViewType {
LIST_ITEM, GRID_IMAGE, GRID_ITEM, FOOTER
}
}
@@ -0,0 +1,60 @@
/**
* qsfera Android client application
*
* @author Fernando Sanz Velasco
* @author Juan Carlos Garrote Gascón
*
* Copyright (C) 2023 ownCloud GmbH.
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License version 2,
* as published by the Free Software Foundation.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
package eu.qsfera.android.presentation.files.filelist
import androidx.recyclerview.widget.DiffUtil
import eu.qsfera.android.domain.files.model.FileListOption
import eu.qsfera.android.domain.files.model.OCFileWithSyncInfo
import eu.qsfera.android.domain.files.model.OCFooterFile
class FileListDiffCallback(
private val oldList: List<Any>,
private val newList: List<Any>,
private val oldFileListOption: FileListOption,
private val newFileListOption: FileListOption,
) : DiffUtil.Callback() {
override fun getOldListSize(): Int = oldList.size
override fun getNewListSize(): Int = newList.size
override fun areItemsTheSame(oldItemPosition: Int, newItemPosition: Int): Boolean {
val oldItem = oldList[oldItemPosition]
val newItem = newList[newItemPosition]
return if (oldItem is Unit && newItem is Unit) {
true
} else if (oldItem is Boolean && newItem is Boolean) {
true
} else if (oldItem is OCFileWithSyncInfo && newItem is OCFileWithSyncInfo) {
oldItem.file.id == newItem.file.id
} else if (oldItem is OCFooterFile && newItem is OCFooterFile) {
oldItem.text == newItem.text
} else {
false
}
}
override fun areContentsTheSame(oldItemPosition: Int, newItemPosition: Int): Boolean =
oldList[oldItemPosition] == newList[newItemPosition] && oldFileListOption == newFileListOption
}
@@ -0,0 +1,59 @@
/**
* qsfera Android client application
*
* @author Jorge Aguado Recio
*
* Copyright (C) 2024 ownCloud GmbH.
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License version 2,
* as published by the Free Software Foundation.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
package eu.qsfera.android.presentation.files.filelist
import android.os.Bundle
import androidx.fragment.app.Fragment
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import android.widget.TextView
import eu.qsfera.android.R
import eu.qsfera.android.databinding.MainEmptyListFragmentBinding
class MainEmptyListFragment : Fragment() {
private var _binding: MainEmptyListFragmentBinding? = null
private val binding get() = _binding!!
override fun onCreateView(
inflater: LayoutInflater, container: ViewGroup?,
savedInstanceState: Bundle?
): View {
_binding = MainEmptyListFragmentBinding.inflate(inflater, container, false)
return binding.root
}
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
binding.emptyDataParent.apply {
listEmptyDatasetIcon.setImageResource(R.drawable.ic_folder)
listEmptyDatasetTitle.setText(R.string.file_list_empty_title_all_files)
listEmptyDatasetSubTitle.setText(R.string.light_users_subtitle)
}
val titleToolbar = requireActivity().findViewById<TextView>(R.id.root_toolbar_title)
titleToolbar.apply {
setCompoundDrawablesRelativeWithIntrinsicBounds(0, 0, 0, 0)
isClickable = false
}
}
}
@@ -0,0 +1,460 @@
/**
* qsfera Android client application
*
* @author Fernando Sanz Velasco
* @author Jose Antonio Barros Ramos
* @author Juan Carlos Garrote Gascón
*
* Copyright (C) 2023 ownCloud GmbH.
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License version 2,
* as published by the Free Software Foundation.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
package eu.qsfera.android.presentation.files.filelist
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import eu.qsfera.android.R
import eu.qsfera.android.data.providers.SharedPreferencesProvider
import eu.qsfera.android.domain.appregistry.model.AppRegistryMimeType
import eu.qsfera.android.domain.appregistry.usecases.GetAppRegistryForMimeTypeAsStreamUseCase
import eu.qsfera.android.domain.appregistry.usecases.GetAppRegistryWhichAllowCreationAsStreamUseCase
import eu.qsfera.android.domain.appregistry.usecases.GetUrlToOpenInWebUseCase
import eu.qsfera.android.domain.availableoffline.usecases.GetFilesAvailableOfflineFromAccountAsStreamUseCase
import eu.qsfera.android.domain.files.model.FileListOption
import eu.qsfera.android.domain.files.model.FileMenuOption
import eu.qsfera.android.domain.files.model.OCFile
import eu.qsfera.android.domain.files.model.OCFile.Companion.ROOT_PARENT_ID
import eu.qsfera.android.domain.files.model.OCFile.Companion.ROOT_PATH
import eu.qsfera.android.domain.files.model.OCFileSyncInfo
import eu.qsfera.android.domain.files.model.OCFileWithSyncInfo
import eu.qsfera.android.domain.files.usecases.GetFileByIdUseCase
import eu.qsfera.android.domain.files.usecases.GetFileByRemotePathUseCase
import eu.qsfera.android.domain.files.usecases.GetFolderContentAsStreamUseCase
import eu.qsfera.android.domain.files.usecases.GetSharedByLinkForAccountAsStreamUseCase
import eu.qsfera.android.domain.files.usecases.SortFilesWithSyncInfoUseCase
import eu.qsfera.android.domain.spaces.model.OCSpace
import eu.qsfera.android.domain.spaces.usecases.GetSpaceWithSpecialsByIdForAccountUseCase
import eu.qsfera.android.domain.utils.Event
import eu.qsfera.android.extensions.ViewModelExt.runUseCaseWithResult
import eu.qsfera.android.presentation.common.UIResult
import eu.qsfera.android.presentation.files.SortOrder
import eu.qsfera.android.presentation.files.SortOrder.Companion.PREF_FILE_LIST_SORT_ORDER
import eu.qsfera.android.presentation.files.SortType
import eu.qsfera.android.presentation.files.SortType.Companion.PREF_FILE_LIST_SORT_TYPE
import eu.qsfera.android.presentation.settings.advanced.SettingsAdvancedFragment.Companion.PREF_SHOW_HIDDEN_FILES
import eu.qsfera.android.providers.ContextProvider
import eu.qsfera.android.providers.CoroutinesDispatcherProvider
import eu.qsfera.android.usecases.files.FilterFileMenuOptionsUseCase
import eu.qsfera.android.usecases.synchronization.SynchronizeFolderUseCase
import eu.qsfera.android.usecases.synchronization.SynchronizeFolderUseCase.SyncFolderMode.SYNC_CONTENTS
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharedFlow
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.firstOrNull
import kotlinx.coroutines.flow.flatMapLatest
import kotlinx.coroutines.flow.flowOf
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
import eu.qsfera.android.domain.files.usecases.SortType.Companion as SortTypeDomain
class MainFileListViewModel(
private val getFolderContentAsStreamUseCase: GetFolderContentAsStreamUseCase,
private val getSharedByLinkForAccountAsStreamUseCase: GetSharedByLinkForAccountAsStreamUseCase,
private val getFilesAvailableOfflineFromAccountAsStreamUseCase: GetFilesAvailableOfflineFromAccountAsStreamUseCase,
private val getFileByIdUseCase: GetFileByIdUseCase,
private val getFileByRemotePathUseCase: GetFileByRemotePathUseCase,
private val getSpaceWithSpecialsByIdForAccountUseCase: GetSpaceWithSpecialsByIdForAccountUseCase,
private val sortFilesWithSyncInfoUseCase: SortFilesWithSyncInfoUseCase,
private val synchronizeFolderUseCase: SynchronizeFolderUseCase,
getAppRegistryWhichAllowCreationAsStreamUseCase: GetAppRegistryWhichAllowCreationAsStreamUseCase,
private val getAppRegistryForMimeTypeAsStreamUseCase: GetAppRegistryForMimeTypeAsStreamUseCase,
private val getUrlToOpenInWebUseCase: GetUrlToOpenInWebUseCase,
private val filterFileMenuOptionsUseCase: FilterFileMenuOptionsUseCase,
private val contextProvider: ContextProvider,
private val coroutinesDispatcherProvider: CoroutinesDispatcherProvider,
private val sharedPreferencesProvider: SharedPreferencesProvider,
initialFolderToDisplay: OCFile,
fileListOptionParam: FileListOption,
) : ViewModel() {
private val showHiddenFiles: Boolean = sharedPreferencesProvider.getBoolean(PREF_SHOW_HIDDEN_FILES, false)
val currentFolderDisplayed: MutableStateFlow<OCFile> = MutableStateFlow(initialFolderToDisplay)
val fileListOption: MutableStateFlow<FileListOption> = MutableStateFlow(fileListOptionParam)
private val searchFilter: MutableStateFlow<String> = MutableStateFlow("")
private val sortTypeAndOrder = MutableStateFlow(Pair(SortType.SORT_TYPE_BY_NAME, SortOrder.SORT_ORDER_ASCENDING))
val space: MutableStateFlow<OCSpace?> = MutableStateFlow(null)
val appRegistryToCreateFiles: StateFlow<List<AppRegistryMimeType>> =
getAppRegistryWhichAllowCreationAsStreamUseCase(
GetAppRegistryWhichAllowCreationAsStreamUseCase.Params(
accountName = initialFolderToDisplay.owner
)
).stateIn(
viewModelScope,
started = SharingStarted.WhileSubscribed(5_000),
initialValue = emptyList()
)
private val _appRegistryMimeType: MutableSharedFlow<AppRegistryMimeType?> = MutableSharedFlow()
val appRegistryMimeType: SharedFlow<AppRegistryMimeType?> = _appRegistryMimeType
private val _appRegistryMimeTypeSingleFile: MutableSharedFlow<AppRegistryMimeType?> = MutableSharedFlow()
val appRegistryMimeTypeSingleFile: SharedFlow<AppRegistryMimeType?> = _appRegistryMimeTypeSingleFile
/** File list ui state combines the other fields and generate a new state whenever any of them changes */
val fileListUiState: StateFlow<FileListUiState> =
combine(
currentFolderDisplayed,
fileListOption,
searchFilter,
sortTypeAndOrder,
space,
) { currentFolderDisplayed, fileListOption, searchFilter, sortTypeAndOrder, space ->
composeFileListUiStateForThisParams(
currentFolderDisplayed = currentFolderDisplayed,
fileListOption = fileListOption,
searchFilter = searchFilter,
sortTypeAndOrder = sortTypeAndOrder,
space = space,
)
}
.flatMapLatest { it }
.stateIn(
scope = viewModelScope,
started = SharingStarted.WhileSubscribed(5_000),
initialValue = FileListUiState.Loading
)
private val _openInWebFlow = MutableStateFlow<Event<UIResult<String>>?>(null)
val openInWebFlow: StateFlow<Event<UIResult<String>>?> = _openInWebFlow
private val _menuOptions: MutableSharedFlow<List<FileMenuOption>> = MutableSharedFlow()
val menuOptions: SharedFlow<List<FileMenuOption>> = _menuOptions
private val _menuOptionsSingleFile: MutableSharedFlow<List<FileMenuOption>> = MutableSharedFlow()
val menuOptionsSingleFile: SharedFlow<List<FileMenuOption>> = _menuOptionsSingleFile
init {
val sortTypeSelected = SortType.values()[sharedPreferencesProvider.getInt(PREF_FILE_LIST_SORT_TYPE, SortType.SORT_TYPE_BY_NAME.ordinal)]
val sortOrderSelected =
SortOrder.values()[sharedPreferencesProvider.getInt(PREF_FILE_LIST_SORT_ORDER, SortOrder.SORT_ORDER_ASCENDING.ordinal)]
sortTypeAndOrder.update { Pair(sortTypeSelected, sortOrderSelected) }
updateSpace()
viewModelScope.launch(coroutinesDispatcherProvider.io) {
synchronizeFolderUseCase(
SynchronizeFolderUseCase.Params(
remotePath = initialFolderToDisplay.remotePath,
accountName = initialFolderToDisplay.owner,
spaceId = initialFolderToDisplay.spaceId,
syncMode = SYNC_CONTENTS,
)
)
}
}
fun navigateToFolderId(folderId: Long) {
viewModelScope.launch(coroutinesDispatcherProvider.io) {
val result = getFileByIdUseCase(GetFileByIdUseCase.Params(fileId = folderId))
result.getDataOrNull()?.let {
updateFolderToDisplay(it)
}
}
}
fun getFile(): OCFile =
currentFolderDisplayed.value
fun getSpace(): OCSpace? =
space.value
fun setGridModeAsPreferred() {
savePreferredLayoutManager(true)
}
fun setListModeAsPreferred() {
savePreferredLayoutManager(false)
}
private fun savePreferredLayoutManager(isGridModeSelected: Boolean) {
sharedPreferencesProvider.putBoolean(RECYCLER_VIEW_PREFERRED, isGridModeSelected)
}
fun isGridModeSetAsPreferred() = sharedPreferencesProvider.getBoolean(RECYCLER_VIEW_PREFERRED, false)
private fun sortList(filesWithSyncInfo: List<OCFileWithSyncInfo>, sortTypeAndOrder: Pair<SortType, SortOrder>): List<OCFileWithSyncInfo> =
sortFilesWithSyncInfoUseCase(
SortFilesWithSyncInfoUseCase.Params(
listOfFiles = filesWithSyncInfo,
sortType = SortTypeDomain.fromPreferences(sortTypeAndOrder.first.ordinal),
ascending = sortTypeAndOrder.second == SortOrder.SORT_ORDER_ASCENDING
)
)
fun manageBrowseUp() {
viewModelScope.launch(coroutinesDispatcherProvider.io) {
val currentFolder = currentFolderDisplayed.value
val parentId = currentFolder.parentId
val parentDir: OCFile?
// browsing back to not shared by link or av offline should update to root
if (parentId != null && parentId != ROOT_PARENT_ID) {
// Browsing to parent folder. Not root
val fileByIdResult = getFileByIdUseCase(GetFileByIdUseCase.Params(parentId))
when (fileListOption.value) {
FileListOption.ALL_FILES -> {
parentDir = fileByIdResult.getDataOrNull()
}
FileListOption.SHARED_BY_LINK -> {
val fileById = fileByIdResult.getDataOrNull()
parentDir =
if (fileById != null && (!fileById.sharedByLink || fileById.sharedWithSharee != true) && fileById.spaceId == null) {
getFileByRemotePathUseCase(GetFileByRemotePathUseCase.Params(fileById.owner, ROOT_PATH)).getDataOrNull()
} else {
fileById
}
}
FileListOption.AV_OFFLINE -> {
val fileById = fileByIdResult.getDataOrNull()
parentDir = if (fileById != null && (!fileById.isAvailableOffline)) {
getFileByRemotePathUseCase(GetFileByRemotePathUseCase.Params(fileById.owner, ROOT_PATH)).getDataOrNull()
} else {
fileById
}
}
FileListOption.SPACES_LIST -> {
parentDir = TODO("Move it to usecase if possible")
}
}
} else if (parentId == ROOT_PARENT_ID) {
// Browsing to parent folder. Root
val rootFolderForAccountResult = getFileByRemotePathUseCase(
GetFileByRemotePathUseCase.Params(
remotePath = ROOT_PATH,
owner = currentFolder.owner,
)
)
parentDir = rootFolderForAccountResult.getDataOrNull()
} else {
// Browsing to non existing parent folder.
TODO()
}
parentDir?.let { updateFolderToDisplay(it) }
}
}
fun updateFolderToDisplay(newFolderToDisplay: OCFile) {
currentFolderDisplayed.update { newFolderToDisplay }
searchFilter.update { "" }
updateSpace()
}
fun updateSearchFilter(newSearchFilter: String) {
searchFilter.update { newSearchFilter }
}
fun updateFileListOption(newFileListOption: FileListOption) {
fileListOption.update { newFileListOption }
}
fun updateSortTypeAndOrder(sortType: SortType, sortOrder: SortOrder) {
sharedPreferencesProvider.putInt(PREF_FILE_LIST_SORT_TYPE, sortType.ordinal)
sharedPreferencesProvider.putInt(PREF_FILE_LIST_SORT_ORDER, sortOrder.ordinal)
sortTypeAndOrder.update { Pair(sortType, sortOrder) }
}
fun openInWeb(fileId: String, appName: String) {
runUseCaseWithResult(
coroutineDispatcher = coroutinesDispatcherProvider.io,
flow = _openInWebFlow,
useCase = getUrlToOpenInWebUseCase,
useCaseParams = GetUrlToOpenInWebUseCase.Params(
fileId = fileId,
accountName = getFile().owner,
appName = appName,
),
showLoading = false,
requiresConnection = true,
)
}
fun resetOpenInWebFlow() {
_openInWebFlow.value = null
}
fun filterMenuOptions(
files: List<OCFile>, filesSyncInfo: List<OCFileSyncInfo>,
displaySelectAll: Boolean, isMultiselection: Boolean
) {
val shareViaLinkAllowed = contextProvider.getBoolean(R.bool.share_via_link_feature)
val shareWithUsersAllowed = contextProvider.getBoolean(R.bool.share_with_users_feature)
val sendAllowed = contextProvider.getString(R.string.send_files_to_other_apps).equals("on", ignoreCase = true)
viewModelScope.launch(coroutinesDispatcherProvider.io) {
val result = filterFileMenuOptionsUseCase(
FilterFileMenuOptionsUseCase.Params(
files = files,
filesSyncInfo = filesSyncInfo,
accountName = currentFolderDisplayed.value.owner,
isAnyFileVideoPreviewing = false,
displaySelectAll = displaySelectAll,
displaySelectInverse = isMultiselection,
onlyAvailableOfflineFiles = fileListOption.value.isAvailableOffline(),
onlySharedByLinkFiles = fileListOption.value.isSharedByLink(),
shareViaLinkAllowed = shareViaLinkAllowed,
shareWithUsersAllowed = shareWithUsersAllowed,
sendAllowed = sendAllowed,
)
)
if (isMultiselection) {
_menuOptions.emit(result)
} else {
_menuOptionsSingleFile.emit(result)
}
}
}
fun getAppRegistryForMimeType(mimeType: String, isMultiselection: Boolean) {
viewModelScope.launch(coroutinesDispatcherProvider.io) {
val result = getAppRegistryForMimeTypeAsStreamUseCase(
GetAppRegistryForMimeTypeAsStreamUseCase.Params(accountName = getFile().owner, mimeType)
)
if (isMultiselection) {
_appRegistryMimeType.emit(result.firstOrNull())
} else {
_appRegistryMimeTypeSingleFile.emit(result.firstOrNull())
}
}
}
private fun updateSpace() {
val folderToDisplay = currentFolderDisplayed.value
viewModelScope.launch(coroutinesDispatcherProvider.io) {
if (folderToDisplay.remotePath == ROOT_PATH) {
val currentSpace = getSpaceWithSpecialsByIdForAccountUseCase(
GetSpaceWithSpecialsByIdForAccountUseCase.Params(
spaceId = folderToDisplay.spaceId,
accountName = folderToDisplay.owner,
)
)
space.update { currentSpace }
}
}
}
private fun composeFileListUiStateForThisParams(
currentFolderDisplayed: OCFile,
fileListOption: FileListOption,
searchFilter: String?,
sortTypeAndOrder: Pair<SortType, SortOrder>,
space: OCSpace?,
): Flow<FileListUiState> =
when (fileListOption) {
FileListOption.ALL_FILES -> retrieveFlowForAllFiles(currentFolderDisplayed, currentFolderDisplayed.owner)
FileListOption.SHARED_BY_LINK -> retrieveFlowForShareByLink(currentFolderDisplayed, currentFolderDisplayed.owner)
FileListOption.AV_OFFLINE -> retrieveFlowForAvailableOffline(currentFolderDisplayed, currentFolderDisplayed.owner)
FileListOption.SPACES_LIST -> flowOf()
}.toFileListUiState(
currentFolderDisplayed,
fileListOption,
searchFilter,
sortTypeAndOrder,
space,
)
private fun retrieveFlowForAllFiles(
currentFolderDisplayed: OCFile,
accountName: String,
): Flow<List<OCFileWithSyncInfo>> =
getFolderContentAsStreamUseCase(
GetFolderContentAsStreamUseCase.Params(
folderId = currentFolderDisplayed.id
?: getFileByRemotePathUseCase(GetFileByRemotePathUseCase.Params(accountName, ROOT_PATH)).getDataOrNull()!!.id!!
)
)
/**
* In root folder, all the shared by link files should be shown. Otherwise, the folder content should be shown.
* Logic to handle the browse back in [manageBrowseUp]
*/
private fun retrieveFlowForShareByLink(
currentFolderDisplayed: OCFile,
accountName: String,
): Flow<List<OCFileWithSyncInfo>> =
if (currentFolderDisplayed.remotePath == ROOT_PATH && currentFolderDisplayed.spaceId == null) {
getSharedByLinkForAccountAsStreamUseCase(GetSharedByLinkForAccountAsStreamUseCase.Params(accountName))
} else {
retrieveFlowForAllFiles(currentFolderDisplayed, accountName)
}
/**
* In root folder, all the available offline files should be shown. Otherwise, the folder content should be shown.
* Logic to handle the browse back in [manageBrowseUp]
*/
private fun retrieveFlowForAvailableOffline(
currentFolderDisplayed: OCFile,
accountName: String,
): Flow<List<OCFileWithSyncInfo>> =
if (currentFolderDisplayed.remotePath == ROOT_PATH) {
getFilesAvailableOfflineFromAccountAsStreamUseCase(GetFilesAvailableOfflineFromAccountAsStreamUseCase.Params(accountName))
} else {
retrieveFlowForAllFiles(currentFolderDisplayed, accountName)
}
private fun Flow<List<OCFileWithSyncInfo>>.toFileListUiState(
currentFolderDisplayed: OCFile,
fileListOption: FileListOption,
searchFilter: String?,
sortTypeAndOrder: Pair<SortType, SortOrder>,
space: OCSpace?,
) = this.map { folderContent ->
FileListUiState.Success(
folderToDisplay = currentFolderDisplayed,
folderContent = folderContent.filter { fileWithSyncInfo ->
fileWithSyncInfo.file.fileName.contains(
searchFilter ?: "",
ignoreCase = true
) && (showHiddenFiles || !fileWithSyncInfo.file.fileName.startsWith("."))
}.let { sortList(it, sortTypeAndOrder) },
fileListOption = fileListOption,
searchFilter = searchFilter,
space = space,
)
}
sealed interface FileListUiState {
object Loading : FileListUiState
data class Success(
val folderToDisplay: OCFile?,
val folderContent: List<OCFileWithSyncInfo>,
val fileListOption: FileListOption,
val searchFilter: String?,
val space: OCSpace?,
) : FileListUiState
}
companion object {
private const val RECYCLER_VIEW_PREFERRED = "RECYCLER_VIEW_PREFERRED"
}
}
@@ -0,0 +1,99 @@
/**
* qsfera Android client application
*
* @author Fernando Sanz Velasco
* Copyright (C) 2022 ownCloud GmbH.
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License version 2,
* as published by the Free Software Foundation.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*
*/
package eu.qsfera.android.presentation.files.filelist
import android.util.SparseBooleanArray
import androidx.recyclerview.widget.RecyclerView
abstract class SelectableAdapter<VH : RecyclerView.ViewHolder?> :
RecyclerView.Adapter<VH>() {
private val selectedItems: SparseBooleanArray = SparseBooleanArray()
/**
* Count the selected items
* @return Selected items count
*/
val selectedItemCount: Int
get() = selectedItems.size()
/**
* Indicates if the item at position position is selected
* @param position Position of the item to check
* @return true if the item is selected, false otherwise
*/
fun isSelected(position: Int): Boolean =
getSelectedItems().contains(position)
/**
* Toggle the selection status of the item at a given position
* @param position Position of the item to toggle the selection status for
*/
fun toggleSelection(position: Int) {
if (selectedItems[position, false]) {
selectedItems.delete(position)
} else {
selectedItems.put(position, true)
}
notifyItemChanged(position)
}
/**
* Clear the selection status for all items
*/
fun clearSelection() {
selectedItems.clear()
notifyDataSetChanged()
}
/**
* Indicates the list of selected items
* @return List of selected items ids
*/
fun getSelectedItems(): List<Int> {
val items: MutableList<Int> = ArrayList(selectedItems.size())
for (i in 0 until selectedItems.size()) {
items.add(selectedItems.keyAt(i))
}
return items
}
/**
* Toggle selected items in bulk. Basically to do a select inverse.
* Doing it individually will cost a lot of time since we do a notifyDataSetChanged for each item.
*/
fun toggleSelectionInBulk(totalItems: Int) {
for (i in 0 until totalItems) {
if (selectedItems[i, false]) {
selectedItems.delete(i)
} else {
selectedItems.put(i, true)
}
}
notifyDataSetChanged()
}
fun selectAll(totalItems: Int) {
for (i in 0 until totalItems) {
selectedItems.put(i, true)
}
notifyDataSetChanged()
}
}
@@ -0,0 +1,57 @@
/**
* qsfera Android client application
*
* @author Abel García de Prada
* @author Juan Carlos Garrote Gascón
* @author Aitor Ballesteros Pavón
*
* Copyright (C) 2024 ownCloud GmbH.
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License version 2,
* as published by the Free Software Foundation.
*
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <http:></http:>//www.gnu.org/licenses/>.
*/
package eu.qsfera.android.presentation.files.operations
import eu.qsfera.android.domain.files.model.OCFile
sealed interface FileOperation {
data class CopyOperation(
val listOfFilesToCopy: List<OCFile>,
val targetFolder: OCFile?,
val replace: List<Boolean?> = emptyList(),
val isUserLogged: Boolean,
) : FileOperation
data class CreateFolder(val folderName: String, val parentFile: OCFile) : FileOperation
data class MoveOperation(
val listOfFilesToMove: List<OCFile>,
val targetFolder: OCFile?,
val replace: List<Boolean?> = emptyList(),
val isUserLogged: Boolean,
) :
FileOperation
data class RemoveOperation(val listOfFilesToRemove: List<OCFile>, val removeOnlyLocalCopy: Boolean) : FileOperation
data class RenameOperation(val ocFileToRename: OCFile, val newName: String) : FileOperation
data class SynchronizeFileOperation(val fileToSync: OCFile, val accountName: String) : FileOperation
data class SynchronizeFolderOperation(
val folderToSync: OCFile,
val accountName: String,
val isActionSetFolderAvailableOfflineOrSynchronize: Boolean = false,
) : FileOperation
data class RefreshFolderOperation(val folderToRefresh: OCFile, val shouldSyncContents: Boolean) : FileOperation
data class CreateFileWithAppProviderOperation(val accountName: String, val parentContainerId: String, val filename: String) : FileOperation
data class SetFilesAsAvailableOffline(val filesToUpdate: List<OCFile>) : FileOperation
data class UnsetFilesAsAvailableOffline(val filesToUpdate: List<OCFile>) : FileOperation
}
@@ -0,0 +1,342 @@
/**
* qsfera Android client application
*
* @author Abel García de Prada
* @author Juan Carlos Garrote Gascón
* @author Aitor Ballesteros Pavón
*
* Copyright (C) 2024 ownCloud GmbH.
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License version 2,
* as published by the Free Software Foundation.
*
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <http:></http:>//www.gnu.org/licenses/>.
*/
package eu.qsfera.android.presentation.files.operations
import android.net.Uri
import androidx.lifecycle.LiveData
import androidx.lifecycle.MediatorLiveData
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import eu.qsfera.android.domain.BaseUseCaseWithResult
import eu.qsfera.android.domain.UseCaseResult
import eu.qsfera.android.domain.appregistry.usecases.CreateFileWithAppProviderUseCase
import eu.qsfera.android.domain.availableoffline.usecases.SetFilesAsAvailableOfflineUseCase
import eu.qsfera.android.domain.availableoffline.usecases.UnsetFilesAsAvailableOfflineUseCase
import eu.qsfera.android.domain.exceptions.NoNetworkConnectionException
import eu.qsfera.android.domain.files.model.OCFile
import eu.qsfera.android.domain.files.usecases.CopyFileUseCase
import eu.qsfera.android.domain.files.usecases.CreateFolderAsyncUseCase
import eu.qsfera.android.domain.files.usecases.IsAnyFileAvailableLocallyAndNotAvailableOfflineUseCase
import eu.qsfera.android.domain.files.usecases.ManageDeepLinkUseCase
import eu.qsfera.android.domain.files.usecases.MoveFileUseCase
import eu.qsfera.android.domain.files.usecases.RemoveFileUseCase
import eu.qsfera.android.domain.files.usecases.RenameFileUseCase
import eu.qsfera.android.domain.files.usecases.SetLastUsageFileUseCase
import eu.qsfera.android.domain.utils.Event
import eu.qsfera.android.extensions.ViewModelExt.runUseCaseWithResult
import eu.qsfera.android.presentation.common.UIResult
import eu.qsfera.android.providers.ContextProvider
import eu.qsfera.android.providers.CoroutinesDispatcherProvider
import eu.qsfera.android.ui.dialog.FileAlreadyExistsDialog
import eu.qsfera.android.usecases.synchronization.SynchronizeFileUseCase
import eu.qsfera.android.usecases.synchronization.SynchronizeFolderUseCase
import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharedFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.launch
import timber.log.Timber
import java.net.URI
class FileOperationsViewModel(
private val createFolderAsyncUseCase: CreateFolderAsyncUseCase,
private val copyFileUseCase: CopyFileUseCase,
private val moveFileUseCase: MoveFileUseCase,
private val removeFileUseCase: RemoveFileUseCase,
private val renameFileUseCase: RenameFileUseCase,
private val synchronizeFileUseCase: SynchronizeFileUseCase,
private val synchronizeFolderUseCase: SynchronizeFolderUseCase,
private val createFileWithAppProviderUseCase: CreateFileWithAppProviderUseCase,
private val setFilesAsAvailableOfflineUseCase: SetFilesAsAvailableOfflineUseCase,
private val unsetFilesAsAvailableOfflineUseCase: UnsetFilesAsAvailableOfflineUseCase,
private val manageDeepLinkUseCase: ManageDeepLinkUseCase,
private val setLastUsageFileUseCase: SetLastUsageFileUseCase,
private val isAnyFileAvailableLocallyAndNotAvailableOfflineUseCase: IsAnyFileAvailableLocallyAndNotAvailableOfflineUseCase,
private val contextProvider: ContextProvider,
private val coroutinesDispatcherProvider: CoroutinesDispatcherProvider,
) : ViewModel() {
private val _createFolder = MediatorLiveData<Event<UIResult<Unit>>>()
val createFolder: LiveData<Event<UIResult<Unit>>> = _createFolder
private val _copyFileLiveData = MediatorLiveData<Event<UIResult<List<OCFile>>>>()
val copyFileLiveData: LiveData<Event<UIResult<List<OCFile>>>> = _copyFileLiveData
private val _moveFileLiveData = MediatorLiveData<Event<UIResult<List<OCFile>>>>()
val moveFileLiveData: LiveData<Event<UIResult<List<OCFile>>>> = _moveFileLiveData
private val _removeFileLiveData = MediatorLiveData<Event<UIResult<List<OCFile>>>>()
val removeFileLiveData: LiveData<Event<UIResult<List<OCFile>>>> = _removeFileLiveData
private val _renameFileLiveData = MediatorLiveData<Event<UIResult<OCFile>>>()
val renameFileLiveData: LiveData<Event<UIResult<OCFile>>> = _renameFileLiveData
private val _syncFileLiveData = MediatorLiveData<Event<UIResult<SynchronizeFileUseCase.SyncType>>>()
val syncFileLiveData: LiveData<Event<UIResult<SynchronizeFileUseCase.SyncType>>> = _syncFileLiveData
private val _syncFolderLiveData = MediatorLiveData<Event<UIResult<Unit>>>()
val syncFolderLiveData: LiveData<Event<UIResult<Unit>>> = _syncFolderLiveData
private val _refreshFolderLiveData = MediatorLiveData<Event<UIResult<Unit>>>()
val refreshFolderLiveData: LiveData<Event<UIResult<Unit>>> = _refreshFolderLiveData
private val _createFileWithAppProviderFlow = MutableStateFlow<Event<UIResult<String>>?>(null)
val createFileWithAppProviderFlow: StateFlow<Event<UIResult<String>>?> = _createFileWithAppProviderFlow
private val _deepLinkFlow = MutableStateFlow<Event<UIResult<OCFile?>>?>(null)
val deepLinkFlow: StateFlow<Event<UIResult<OCFile?>>?> = _deepLinkFlow
private val _checkIfFileIsLocalAndNotAvailableOfflineSharedFlow = MutableSharedFlow<UIResult<Boolean>>()
val checkIfFileIsLocalAndNotAvailableOfflineSharedFlow: SharedFlow<UIResult<Boolean>> = _checkIfFileIsLocalAndNotAvailableOfflineSharedFlow
val openDialogs = mutableListOf<FileAlreadyExistsDialog>()
// Used to save the last operation folder
private var lastTargetFolder: OCFile? = null
fun performOperation(fileOperation: FileOperation) {
when (fileOperation) {
is FileOperation.MoveOperation -> moveOperation(fileOperation)
is FileOperation.RemoveOperation -> removeOperation(fileOperation)
is FileOperation.RenameOperation -> renameOperation(fileOperation)
is FileOperation.CopyOperation -> copyOperation(fileOperation)
is FileOperation.SynchronizeFileOperation -> syncFileOperation(fileOperation)
is FileOperation.CreateFolder -> createFolderOperation(fileOperation)
is FileOperation.SetFilesAsAvailableOffline -> setFileAsAvailableOffline(fileOperation)
is FileOperation.UnsetFilesAsAvailableOffline -> unsetFileAsAvailableOffline(fileOperation)
is FileOperation.SynchronizeFolderOperation -> syncFolderOperation(fileOperation)
is FileOperation.RefreshFolderOperation -> refreshFolderOperation(fileOperation)
is FileOperation.CreateFileWithAppProviderOperation -> createFileWithAppProvider(fileOperation)
}
}
fun showRemoveDialog(filesToRemove: List<OCFile>) {
runUseCaseWithResult(
coroutineDispatcher = coroutinesDispatcherProvider.io,
showLoading = true,
sharedFlow = _checkIfFileIsLocalAndNotAvailableOfflineSharedFlow,
useCase = isAnyFileAvailableLocallyAndNotAvailableOfflineUseCase,
useCaseParams = IsAnyFileAvailableLocallyAndNotAvailableOfflineUseCase.Params(filesToRemove),
requiresConnection = false
)
}
fun setLastUsageFile(file: OCFile) {
viewModelScope.launch(coroutinesDispatcherProvider.io) {
setLastUsageFileUseCase(
SetLastUsageFileUseCase.Params(
fileId = file.id!!,
lastUsage = System.currentTimeMillis(),
isAvailableLocally = file.isAvailableLocally,
isFolder = file.isFolder,
)
)
}
}
fun handleDeepLink(uri: Uri, accountName: String) {
runUseCaseWithResult(
coroutineDispatcher = coroutinesDispatcherProvider.io,
showLoading = true,
flow = _deepLinkFlow,
useCase = manageDeepLinkUseCase,
useCaseParams = ManageDeepLinkUseCase.Params(URI(uri.toString()), accountName),
)
}
private fun createFolderOperation(fileOperation: FileOperation.CreateFolder) {
runOperation(
liveData = _createFolder,
useCase = createFolderAsyncUseCase,
useCaseParams = CreateFolderAsyncUseCase.Params(fileOperation.folderName, fileOperation.parentFile),
postValue = Unit
)
}
private fun copyOperation(fileOperation: FileOperation.CopyOperation) {
val targetFolder = if (fileOperation.targetFolder != null) {
lastTargetFolder = fileOperation.targetFolder
fileOperation.targetFolder
} else {
lastTargetFolder
}
targetFolder?.let { folder ->
runUseCaseWithResult(
coroutineDispatcher = coroutinesDispatcherProvider.io,
liveData = _copyFileLiveData,
useCase = copyFileUseCase,
useCaseParams = CopyFileUseCase.Params(
listOfFilesToCopy = fileOperation.listOfFilesToCopy,
targetFolder = folder,
replace = fileOperation.replace,
isUserLogged = fileOperation.isUserLogged,
),
showLoading = true,
)
}
}
private fun moveOperation(fileOperation: FileOperation.MoveOperation) {
val targetFolder = if (fileOperation.targetFolder != null) {
lastTargetFolder = fileOperation.targetFolder
fileOperation.targetFolder
} else {
lastTargetFolder
}
targetFolder?.let { folder ->
runUseCaseWithResult(
coroutineDispatcher = coroutinesDispatcherProvider.io,
liveData = _moveFileLiveData,
useCase = moveFileUseCase,
useCaseParams = MoveFileUseCase.Params(
listOfFilesToMove = fileOperation.listOfFilesToMove,
targetFolder = folder,
replace = fileOperation.replace,
isUserLogged = fileOperation.isUserLogged,
),
showLoading = true,
)
}
}
private fun removeOperation(fileOperation: FileOperation.RemoveOperation) {
runOperation(
liveData = _removeFileLiveData,
useCase = removeFileUseCase,
useCaseParams = RemoveFileUseCase.Params(fileOperation.listOfFilesToRemove, fileOperation.removeOnlyLocalCopy),
postValue = fileOperation.listOfFilesToRemove,
requiresConnection = !fileOperation.removeOnlyLocalCopy,
)
}
private fun renameOperation(fileOperation: FileOperation.RenameOperation) {
runOperation(
liveData = _renameFileLiveData,
useCase = renameFileUseCase,
useCaseParams = RenameFileUseCase.Params(fileOperation.ocFileToRename, fileOperation.newName),
postValue = fileOperation.ocFileToRename
)
}
private fun syncFileOperation(fileOperation: FileOperation.SynchronizeFileOperation) {
runUseCaseWithResult(
coroutineDispatcher = coroutinesDispatcherProvider.io,
requiresConnection = true,
liveData = _syncFileLiveData,
useCase = synchronizeFileUseCase,
useCaseParams = SynchronizeFileUseCase.Params(fileOperation.fileToSync)
)
}
private fun syncFolderOperation(fileOperation: FileOperation.SynchronizeFolderOperation) {
runUseCaseWithResult(
coroutineDispatcher = coroutinesDispatcherProvider.io,
liveData = _syncFolderLiveData,
useCase = synchronizeFolderUseCase,
showLoading = false,
useCaseParams = SynchronizeFolderUseCase.Params(
remotePath = fileOperation.folderToSync.remotePath,
accountName = fileOperation.folderToSync.owner,
spaceId = fileOperation.folderToSync.spaceId,
syncMode = SynchronizeFolderUseCase.SyncFolderMode.SYNC_FOLDER_RECURSIVELY,
isActionSetFolderAvailableOfflineOrSynchronize = fileOperation.isActionSetFolderAvailableOfflineOrSynchronize,
)
)
}
private fun refreshFolderOperation(fileOperation: FileOperation.RefreshFolderOperation) {
runUseCaseWithResult(
coroutineDispatcher = coroutinesDispatcherProvider.io,
liveData = _refreshFolderLiveData,
useCase = synchronizeFolderUseCase,
showLoading = true,
useCaseParams = SynchronizeFolderUseCase.Params(
remotePath = fileOperation.folderToRefresh.remotePath,
accountName = fileOperation.folderToRefresh.owner,
spaceId = fileOperation.folderToRefresh.spaceId,
syncMode = if (fileOperation.shouldSyncContents) SynchronizeFolderUseCase.SyncFolderMode.SYNC_CONTENTS
else SynchronizeFolderUseCase.SyncFolderMode.REFRESH_FOLDER
)
)
}
private fun createFileWithAppProvider(fileOperation: FileOperation.CreateFileWithAppProviderOperation) {
runUseCaseWithResult(
coroutineDispatcher = coroutinesDispatcherProvider.io,
flow = _createFileWithAppProviderFlow,
useCase = createFileWithAppProviderUseCase,
useCaseParams = CreateFileWithAppProviderUseCase.Params(
accountName = fileOperation.accountName,
parentContainerId = fileOperation.parentContainerId,
filename = fileOperation.filename,
),
showLoading = false,
requiresConnection = true,
)
}
private fun setFileAsAvailableOffline(fileOperation: FileOperation.SetFilesAsAvailableOffline) {
viewModelScope.launch(coroutinesDispatcherProvider.io) {
setFilesAsAvailableOfflineUseCase(SetFilesAsAvailableOfflineUseCase.Params(fileOperation.filesToUpdate))
}
}
private fun unsetFileAsAvailableOffline(fileOperation: FileOperation.UnsetFilesAsAvailableOffline) {
viewModelScope.launch(coroutinesDispatcherProvider.io) {
unsetFilesAsAvailableOfflineUseCase(UnsetFilesAsAvailableOfflineUseCase.Params(fileOperation.filesToUpdate))
}
}
private fun <Type, Params, PostResult> runOperation(
liveData: MediatorLiveData<Event<UIResult<PostResult>>>,
useCase: BaseUseCaseWithResult<Type, Params>,
useCaseParams: Params,
postValue: PostResult? = null,
requiresConnection: Boolean = true,
) {
viewModelScope.launch(coroutinesDispatcherProvider.io) {
liveData.postValue(Event(UIResult.Loading()))
if (!contextProvider.isConnected() && requiresConnection) {
liveData.postValue(Event(UIResult.Error(error = NoNetworkConnectionException())))
Timber.w("${useCase.javaClass.simpleName} will not be executed due to lack of network connection")
return@launch
}
val useCaseResult = useCase(useCaseParams).also {
Timber.d("Use case executed: ${useCase.javaClass.simpleName} with result: $it")
}
when (useCaseResult) {
is UseCaseResult.Success -> {
liveData.postValue(Event(UIResult.Success(postValue)))
}
is UseCaseResult.Error -> {
liveData.postValue(Event(UIResult.Error(error = useCaseResult.throwable)))
}
}
}
}
}
@@ -0,0 +1,163 @@
/**
* qsfera Android client application
*
* @author David A. Velasco
* @author Abel García de Prada
* @author Aitor Ballesteros Pavón
*
* Copyright (C) 2024 ownCloud GmbH.
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License version 2,
* as published by the Free Software Foundation.
*
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <http:></http:>//www.gnu.org/licenses/>.
*/
package eu.qsfera.android.presentation.files.removefile
import android.os.Bundle
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import android.widget.ImageView
import androidx.fragment.app.DialogFragment
import eu.qsfera.android.R
import eu.qsfera.android.databinding.RemoveFilesDialogBinding
import coil.load
import eu.qsfera.android.presentation.thumbnails.ThumbnailsRequester
import eu.qsfera.android.presentation.authentication.AccountUtils
import eu.qsfera.android.domain.files.model.OCFile
import eu.qsfera.android.presentation.files.operations.FileOperation
import eu.qsfera.android.presentation.files.operations.FileOperationsViewModel
import eu.qsfera.android.utils.MimetypeIconUtil
import org.koin.androidx.viewmodel.ext.android.sharedViewModel
/**
* Dialog requiring confirmation before removing a collection of given OCFiles.
*
* Triggers the removal according to the user response.
*/
class RemoveFilesDialogFragment : DialogFragment() {
private val fileOperationViewModel: FileOperationsViewModel by sharedViewModel()
private var _binding: RemoveFilesDialogBinding? = null
private val binding get() = _binding!!
private lateinit var targetFiles: List<OCFile>
private var isAvailableLocallyAndNotAvailableOffline: Boolean = false
override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View {
_binding = RemoveFilesDialogBinding.inflate(inflater, container, false)
return binding.root
}
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
var containsFolder = false
var messageStringId: Int
val messageArguments: String
requireArguments().apply {
targetFiles = getParcelableArrayList(ARG_TARGET_FILES) ?: emptyList()
isAvailableLocallyAndNotAvailableOffline = getBoolean(ARG_IS_AVAILABLE_LOCALLY_AND_NOT_AVAILABLE_OFFLINE)
}
binding.apply {
for (file in targetFiles) {
if (file.isFolder) {
containsFolder = true
}
}
handleThumbnail(targetFiles, dialogRemoveThumbnail)
messageStringId = if (targetFiles.size == 1) {
// choose message for a single file
val file = targetFiles.first()
messageArguments = file.fileName
if (file.isFolder) {
R.string.confirmation_remove_folder_alert
} else {
R.string.confirmation_remove_file_alert
}
} else {
// choose message for more than one file
messageArguments = targetFiles.size.toString()
if (containsFolder) {
R.string.confirmation_remove_folders_alert
} else {
R.string.confirmation_remove_files_alert
}
}
dialogRemoveInformation.text = requireContext().getString(messageStringId, messageArguments)
if (isAvailableLocallyAndNotAvailableOffline) {
dialogRemoveLocalOnly.text = requireContext().getString(R.string.confirmation_remove_local)
} else {
dialogRemoveLocalOnly.visibility = View.GONE
}
dialogRemoveLocalOnly.setOnClickListener {
fileOperationViewModel.performOperation(FileOperation.RemoveOperation(targetFiles.toList(), removeOnlyLocalCopy = true))
dismiss()
}
dialogRemoveYes.setOnClickListener {
fileOperationViewModel.performOperation(FileOperation.RemoveOperation(targetFiles.toList(), removeOnlyLocalCopy = false))
dismiss()
}
dialogRemoveNo.setOnClickListener { dismiss() }
}
}
private fun handleThumbnail(files: List<OCFile>, thumbnailImageView: ImageView) {
if (files.size == 1) {
val file = files[0]
// Show the thumbnail when the file has one
val account = AccountUtils.getCurrentQSferaAccount(requireContext())
thumbnailImageView.load(ThumbnailsRequester.getPreviewUriForFile(file, account),
ThumbnailsRequester.getContentAddressedImageLoader(account)) {
placeholder(MimetypeIconUtil.getFileTypeIconId(file.mimeType, file.fileName))
error(MimetypeIconUtil.getFileTypeIconId(file.mimeType, file.fileName))
crossfade(true)
}
} else {
thumbnailImageView.visibility = View.GONE
}
}
companion object {
const val TAG_REMOVE_FILES_DIALOG_FRAGMENT = "TAG_REMOVE_FILES_DIALOG_FRAGMENT"
private const val ARG_TARGET_FILES = "ARG_TARGET_FILES"
private const val ARG_IS_AVAILABLE_LOCALLY_AND_NOT_AVAILABLE_OFFLINE = "ARG_IS_AVAILABLE_LOCALLY_AND_NOT_AVAILABLE_OFFLINE"
fun newInstance(files: ArrayList<OCFile>, isAvailableLocallyAndNotAvailableOffline: Boolean): RemoveFilesDialogFragment {
val args = Bundle().apply {
putParcelableArrayList(ARG_TARGET_FILES, files)
putBoolean(ARG_IS_AVAILABLE_LOCALLY_AND_NOT_AVAILABLE_OFFLINE, isAvailableLocallyAndNotAvailableOffline)
}
return RemoveFilesDialogFragment().apply { arguments = args }
}
/**
* Convenience factory method to create new RemoveFilesDialogFragment instances for a single file
*
* @param file File to remove.
* @return Dialog ready to show.
*/
@JvmStatic
@JvmName("newInstanceForSingleFile")
fun newInstance(file: OCFile): RemoveFilesDialogFragment =
newInstance(files = arrayListOf(file), isAvailableLocallyAndNotAvailableOffline = file.isAvailableLocally)
}
}
@@ -0,0 +1,163 @@
/**
* qsfera Android client application
*
* @author David A. Velasco
* @author Christian Schabesberger
* @author David González Verdugo
* Copyright (C) 2021 ownCloud GmbH.
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License version 2,
* as published by the Free Software Foundation.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <http:></http:>//www.gnu.org/licenses/>.
*/
package eu.qsfera.android.presentation.files.renamefile
import android.app.Dialog
import android.content.DialogInterface
import android.os.Bundle
import android.view.View
import android.view.WindowManager
import android.widget.EditText
import android.widget.TextView
import androidx.appcompat.app.AlertDialog
import androidx.core.widget.doOnTextChanged
import androidx.fragment.app.DialogFragment
import com.google.android.material.textfield.TextInputLayout
import eu.qsfera.android.R
import eu.qsfera.android.domain.files.model.OCFile
import eu.qsfera.android.extensions.avoidScreenshotsIfNeeded
import eu.qsfera.android.presentation.files.operations.FileOperation
import eu.qsfera.android.presentation.files.operations.FileOperationsViewModel
import eu.qsfera.android.utils.PreferenceUtils
import org.koin.androidx.viewmodel.ext.android.sharedViewModel
/**
* Dialog to input a new name for a file or folder to rename.
*
* Triggers the rename operation when name is confirmed.
*/
class RenameFileDialogFragment : DialogFragment(), DialogInterface.OnClickListener {
private var targetFile: OCFile? = null
private val filesViewModel: FileOperationsViewModel by sharedViewModel()
private var isButtonEnabled = true
private val maxFilenameLength = 223
override fun onCreateDialog(savedInstanceState: Bundle?): Dialog {
if (savedInstanceState != null) {
isButtonEnabled = savedInstanceState.getBoolean(IS_BUTTON_ENABLED_FLAG_KEY)
}
targetFile = requireArguments().getParcelable(ARG_TARGET_FILE)
// Inflate the layout for the dialog
val inflater = requireActivity().layoutInflater
val view = inflater.inflate(R.layout.edit_box_dialog, null)
// Allow or disallow touches with other visible windows
view.filterTouchesWhenObscured =
PreferenceUtils.shouldDisallowTouchesWithOtherVisibleWindows(context)
// Setup layout
val currentName = targetFile!!.fileName
var error: String? = null
val inputLayout: TextInputLayout = view.findViewById(R.id.edit_box_input_text_layout)
val inputText = view.findViewById<EditText>(R.id.user_input)
inputText.setText(currentName)
val selectionStart = 0
val extensionStart = if (targetFile!!.isFolder) -1 else currentName.lastIndexOf(".")
val selectionEnd = if (extensionStart >= 0) extensionStart else currentName.length
if (selectionStart >= 0 && selectionEnd >= 0) {
inputText.setSelection(
selectionStart.coerceAtMost(selectionEnd),
selectionStart.coerceAtLeast(selectionEnd)
)
}
inputText.requestFocus()
// Build the dialog
return AlertDialog.Builder(requireActivity()).apply {
setView(view)
setPositiveButton(android.R.string.ok, this@RenameFileDialogFragment)
setNegativeButton(android.R.string.cancel, this@RenameFileDialogFragment)
setTitle(R.string.rename_dialog_title)
}.create().apply {
window?.setSoftInputMode(WindowManager.LayoutParams.SOFT_INPUT_STATE_VISIBLE)
avoidScreenshotsIfNeeded()
setOnShowListener {
val okButton = getButton(AlertDialog.BUTTON_POSITIVE)
okButton.isEnabled = isButtonEnabled
}
inputText.doOnTextChanged { text, _, _, _ ->
val okButton = getButton(AlertDialog.BUTTON_POSITIVE)
if (text.isNullOrBlank()) {
okButton.isEnabled = false
error = getString(R.string.uploader_upload_text_dialog_filename_error_empty)
} else if (text.length > maxFilenameLength) {
error = String.format(
getString(R.string.uploader_upload_text_dialog_filename_error_length_max),
maxFilenameLength
)
} else if (forbiddenChars.any { text.contains(it) }) {
error = getString(R.string.filename_forbidden_characters)
} else {
okButton.isEnabled = true
error = null
inputLayout.error = error
}
if (error != null) {
okButton.isEnabled = false
inputLayout.error = error
}
}
}
}
override fun onSaveInstanceState(outState: Bundle) {
super.onSaveInstanceState(outState)
outState.putBoolean(IS_BUTTON_ENABLED_FLAG_KEY, isButtonEnabled)
}
override fun onClick(dialog: DialogInterface, which: Int) {
if (which == AlertDialog.BUTTON_POSITIVE) {
// These checks are done in the RenameFileUseCase too, we could remove them too.
val newFileName = (getDialog()!!.findViewById<View>(R.id.user_input) as TextView).text.toString()
filesViewModel.performOperation(FileOperation.RenameOperation(targetFile!!, newFileName))
}
}
companion object {
const val FRAGMENT_TAG_RENAME_FILE = "RENAME_FILE_FRAGMENT"
private const val ARG_TARGET_FILE = "TARGET_FILE"
private const val IS_BUTTON_ENABLED_FLAG_KEY = "IS_BUTTON_ENABLED_FLAG_KEY"
private val forbiddenChars = listOf('/', '\\')
/**
* Public factory method to create new RenameFileDialogFragment instances.
*
* @param file File to rename.
* @return Dialog ready to show.
*/
@JvmStatic
fun newInstance(file: OCFile): RenameFileDialogFragment {
val args = Bundle().apply {
putParcelable(ARG_TARGET_FILE, file)
}
return RenameFileDialogFragment().apply { arguments = args }
}
}
}
@@ -0,0 +1,38 @@
/*
* qsfera Android client application
*
* @author Fernando Sanz Velasco
* Copyright (C) 2021 ownCloud GmbH.
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License version 2,
* as published by the Free Software Foundation.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*
*/
package eu.qsfera.android.presentation.logging
import androidx.lifecycle.ViewModel
import eu.qsfera.android.data.providers.LocalStorageProvider
import java.io.File
class LogListViewModel(
private val localStorageProvider: LocalStorageProvider
) : ViewModel() {
private fun getLogsDirectory(): File {
val logsPath = localStorageProvider.getLogsPath()
return File(logsPath)
}
fun getLogsFiles(): List<File> =
getLogsDirectory().listFiles()?.toList()?.sortedBy { it.name } ?: listOf()
}
@@ -0,0 +1,37 @@
/*
* qsfera Android client application
*
* @author Fernando Sanz Velasco
* Copyright (C) 2021 ownCloud GmbH.
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License version 2,
* as published by the Free Software Foundation.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*
*/
package eu.qsfera.android.presentation.logging
import androidx.recyclerview.widget.DiffUtil
import java.io.File
class LoggingDiffUtil(private val oldList: List<File>, private val newList: List<File>) : DiffUtil.Callback() {
override fun getOldListSize(): Int = oldList.size
override fun getNewListSize(): Int = newList.size
override fun areItemsTheSame(oldItemPosition: Int, newItemPosition: Int): Boolean =
oldItemPosition == newItemPosition
override fun areContentsTheSame(oldItemPosition: Int, newItemPosition: Int): Boolean =
oldList[oldItemPosition].name === newList[newItemPosition].name
}
@@ -0,0 +1,224 @@
/*
* qsfera Android client application
*
* @author Fernando Sanz Velasco
* Copyright (C) 2021 ownCloud GmbH.
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License version 2,
* as published by the Free Software Foundation.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*
*/
package eu.qsfera.android.presentation.logging
import android.app.DownloadManager
import android.content.ActivityNotFoundException
import android.content.ContentValues
import android.content.Intent
import android.os.Build
import android.os.Bundle
import android.provider.MediaStore
import android.view.MenuItem
import androidx.annotation.RequiresApi
import androidx.appcompat.app.AlertDialog
import androidx.appcompat.app.AppCompatActivity
import androidx.appcompat.widget.Toolbar
import androidx.constraintlayout.widget.ConstraintLayout
import androidx.core.view.isVisible
import androidx.core.view.updatePadding
import androidx.recyclerview.widget.DefaultItemAnimator
import androidx.recyclerview.widget.DividerItemDecoration
import androidx.recyclerview.widget.LinearLayoutManager
import eu.qsfera.android.R
import eu.qsfera.android.databinding.LogsListActivityBinding
import eu.qsfera.android.extensions.openFile
import eu.qsfera.android.extensions.sendFile
import eu.qsfera.android.extensions.showMessageInSnackbar
import eu.qsfera.android.presentation.settings.logging.SettingsLogsViewModel
import eu.qsfera.android.providers.LogsProvider
import eu.qsfera.android.providers.MdmProvider
import eu.qsfera.android.ui.activity.enableEdgeToEdgePostSetContentView
import eu.qsfera.android.ui.activity.enableEdgeToEdgePreSetContentView
import org.koin.androidx.viewmodel.ext.android.viewModel
import timber.log.Timber
import java.io.File
import java.io.FileInputStream
import java.io.IOException
class LogsListActivity : AppCompatActivity() {
private val viewModel by viewModel<LogListViewModel>()
private val logsViewModel by viewModel<SettingsLogsViewModel>()
private var _binding: LogsListActivityBinding? = null
private var createNewLogFile: Boolean = false
val binding get() = _binding!!
private val recyclerViewLogsAdapter = RecyclerViewLogsAdapter(object : RecyclerViewLogsAdapter.Listener {
override fun share(file: File) {
sendFile(file)
}
override fun delete(file: File, isLastLogFileDeleted: Boolean) {
file.delete()
setData()
createNewLogFile = isLastLogFileDeleted
}
override fun open(file: File) {
openFile(file)
}
override fun download(file: File) {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
downloadFileQOrAbove(file)
}
}
}, context = this)
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
// edge-to-edge
enableEdgeToEdgePreSetContentView(false)
_binding = LogsListActivityBinding.inflate(layoutInflater)
setContentView(binding.root)
initToolbar()
initList()
// edge-to-edge
enableEdgeToEdgePostSetContentView { insets ->
binding.toolbarActivityLogsList.root.updatePadding(top = insets.top)
binding.recyclerViewActivityLogsList.updatePadding(bottom = insets.bottom)
}
}
private fun initToolbar() {
val toolbar = findViewById<Toolbar>(R.id.standard_toolbar)
toolbar.isVisible = true
findViewById<ConstraintLayout>(R.id.root_toolbar).isVisible = false
setSupportActionBar(toolbar)
supportActionBar?.apply {
setDisplayHomeAsUpEnabled(true)
}
}
override fun onOptionsItemSelected(item: MenuItem): Boolean {
if (createNewLogFile && logsViewModel.isLoggingEnabled()) {
val mdmProvider = MdmProvider(applicationContext)
LogsProvider(applicationContext, mdmProvider).startLogging()
}
when (item.itemId) {
android.R.id.home -> finish()
}
return super.onOptionsItemSelected(item)
}
private fun initList() {
binding.recyclerViewActivityLogsList.apply {
layoutManager = LinearLayoutManager(context)
itemAnimator = DefaultItemAnimator()
adapter = recyclerViewLogsAdapter
addItemDecoration(DividerItemDecoration(context, DividerItemDecoration.VERTICAL))
}
setData()
}
private fun setData() {
val items = viewModel.getLogsFiles()
if (items.isEmpty()) {
val mdmProvider = MdmProvider(applicationContext)
LogsProvider(applicationContext, mdmProvider).stopLogging()
}
binding.recyclerViewActivityLogsList.isVisible = items.isNotEmpty()
binding.logsListEmpty.apply {
root.isVisible = items.isEmpty()
listEmptyDatasetIcon.setImageResource(R.drawable.ic_logs)
listEmptyDatasetTitle.setText(R.string.prefs_log_no_logs_list_view)
listEmptyDatasetSubTitle.setText(R.string.prefs_log_empty_subtitle)
}
recyclerViewLogsAdapter.setData(items)
}
@RequiresApi(Build.VERSION_CODES.Q)
private fun downloadFileQOrAbove(file: File) {
val originalName = file.name
val resolver = applicationContext.contentResolver
val contentValues = ContentValues().apply {
put(MediaStore.Downloads.DISPLAY_NAME, originalName)
put(MediaStore.Downloads.MIME_TYPE, "application/octet-stream")
put(MediaStore.Downloads.IS_PENDING, 1)
}
val uri = resolver.insert(MediaStore.Downloads.EXTERNAL_CONTENT_URI, contentValues)
try {
uri?.let { downloadUri ->
resolver.openOutputStream(downloadUri)?.use { outputStream ->
FileInputStream(file).use { fileInputStream ->
fileInputStream.copyTo(outputStream)
}
}
contentValues.clear()
contentValues.put(MediaStore.Downloads.IS_PENDING, 0)
resolver.update(downloadUri, contentValues, null, null)
val cursor = resolver.query(downloadUri, null, null, null, null)
cursor?.use {
if (it.moveToFirst()) {
val displayNameIndex = it.getColumnIndex(MediaStore.Downloads.DISPLAY_NAME)
if (displayNameIndex != -1) {
val finalName = it.getString(displayNameIndex)
showDownloadDialog(finalName)
}
}
}
}
} catch (e: IOException) {
Timber.e(e, "There was a problem to download the file to Downloads folder.")
}
}
private fun showDownloadDialog(fileName: String) {
val dialog = AlertDialog.Builder(this)
.setTitle(getString(R.string.log_file_downloaded))
.setIcon(R.drawable.ic_baseline_download_grey)
.setMessage(getString(R.string.log_file_downloaded_description, fileName))
.setPositiveButton(R.string.go_to_download_folder) { dialog, _ ->
dialog.dismiss()
openDownloadsFolder()
}
.setNegativeButton(R.string.drawer_close) { dialog, _ ->
dialog.dismiss()
}
.create()
dialog.show()
}
private fun openDownloadsFolder() {
try {
val intent = Intent(DownloadManager.ACTION_VIEW_DOWNLOADS)
startActivity(intent)
} catch (e: ActivityNotFoundException) {
showMessageInSnackbar(message = this.getString(R.string.file_list_no_app_for_perform_action))
Timber.e("No Activity found to handle Intent")
}
}
}
@@ -0,0 +1,92 @@
/*
* qsfera Android client application
*
* @author Fernando Sanz Velasco
* Copyright (C) 2021 ownCloud GmbH.
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License version 2,
* as published by the Free Software Foundation.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*
*/
package eu.qsfera.android.presentation.logging
import android.content.Context
import android.os.Build
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import androidx.core.view.isVisible
import androidx.recyclerview.widget.DiffUtil
import androidx.recyclerview.widget.RecyclerView
import eu.qsfera.android.R
import eu.qsfera.android.databinding.LogListItemBinding
import eu.qsfera.android.extensions.toLegibleStringSize
import java.io.File
class RecyclerViewLogsAdapter(
private val listener: Listener,
private val context: Context,
) : RecyclerView.Adapter<RecyclerViewLogsAdapter.ViewHolder>() {
private val logsList = ArrayList<File>()
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): ViewHolder {
val inflater = LayoutInflater.from(parent.context)
val view = inflater.inflate(R.layout.log_list_item, parent, false)
return ViewHolder(view)
}
override fun onBindViewHolder(holder: ViewHolder, position: Int) {
val log = logsList[position]
holder.binding.apply {
textViewTitleActivityLogsList.text = log.name
textViewSubtitleActivityLogsList.text = log.toLegibleStringSize(context)
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.Q) {
imageViewDownloadActivityLogsList.isVisible = false
}
imageViewShareActivityLogsList.setOnClickListener {
listener.share(log)
}
imageViewDeleteActivityLogsList.setOnClickListener {
listener.delete(log, logsList.last() == log)
}
imageViewDownloadActivityLogsList.setOnClickListener {
listener.download(log)
}
layoutContainerActivityLogsList.setOnClickListener {
listener.open(log)
}
}
}
fun setData(logs: List<File>) {
val diffCallback = LoggingDiffUtil(logsList, logs)
val diffResult = DiffUtil.calculateDiff(diffCallback)
logsList.clear()
logsList.addAll(logs)
diffResult.dispatchUpdatesTo(this)
}
override fun getItemCount(): Int = logsList.size
class ViewHolder(itemView: View) : RecyclerView.ViewHolder(itemView) {
val binding = LogListItemBinding.bind(itemView)
}
interface Listener {
fun share(file: File)
fun delete(file: File, isLastLogFileDeleted: Boolean)
fun open(file: File)
fun download(file: File)
}
}
@@ -0,0 +1,54 @@
/**
* qsfera Android client application
*
* @author Abel García de Prada
* <p>
* Copyright (C) 2021 ownCloud GmbH.
* <p>
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License version 2,
* as published by the Free Software Foundation.
* <p>
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
* <p>
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
package eu.qsfera.android.presentation.migration
import android.os.Bundle
import android.view.View
import android.widget.Button
import android.widget.TextView
import androidx.core.view.isVisible
import androidx.fragment.app.Fragment
import eu.qsfera.android.R
import eu.qsfera.android.utils.DisplayUtils.bytesToHumanReadable
import org.koin.androidx.viewmodel.ext.android.sharedViewModel
class MigrationChoiceFragment : Fragment(R.layout.fragment_migration_choice) {
private val migrationViewModel: MigrationViewModel by sharedViewModel()
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
val migrationChoiceState = migrationViewModel.migrationState.value?.peekContent() as MigrationState.MigrationChoiceState
view.findViewById<TextView>(R.id.migration_choice_subtitle)?.apply {
text = getString(
R.string.scoped_storage_wizard_explanation,
bytesToHumanReadable(migrationChoiceState.legacyStorageSpaceInBytes, context, true)
)
}
view.findViewById<Button>(R.id.migration_choice_migrate_now_button)?.setOnClickListener {
migrationViewModel.moveToNextState()
}
view.findViewById<TextView>(R.id.migration_choice_not_enough_space_warning)?.isVisible = !migrationViewModel.isThereEnoughSpaceInDevice()
}
}
@@ -0,0 +1,37 @@
/**
* qsfera Android client application
*
* @author Abel García de Prada
* <p>
* Copyright (C) 2021 ownCloud GmbH.
* <p>
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License version 2,
* as published by the Free Software Foundation.
* <p>
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
* <p>
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
package eu.qsfera.android.presentation.migration
import android.os.Bundle
import android.view.View
import android.widget.Button
import androidx.fragment.app.Fragment
import eu.qsfera.android.R
class MigrationCompletedFragment : Fragment(R.layout.fragment_migration_completed) {
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
view.findViewById<Button>(R.id.migration_completed_button)?.setOnClickListener {
activity?.finish()
}
}
}
@@ -0,0 +1,41 @@
/**
* qsfera Android client application
*
* @author Abel García de Prada
* <p>
* Copyright (C) 2021 ownCloud GmbH.
* <p>
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License version 2,
* as published by the Free Software Foundation.
* <p>
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
* <p>
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
package eu.qsfera.android.presentation.migration
import android.os.Bundle
import android.view.View
import android.widget.Button
import androidx.fragment.app.Fragment
import eu.qsfera.android.R
import org.koin.androidx.viewmodel.ext.android.sharedViewModel
class MigrationIntroFragment : Fragment(R.layout.fragment_migration_intro) {
private val migrationViewModel: MigrationViewModel by sharedViewModel()
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
view.findViewById<Button>(R.id.migration_info_button)?.setOnClickListener {
migrationViewModel.moveToNextState()
}
}
}
@@ -0,0 +1,42 @@
/**
* qsfera Android client application
*
* @author Abel García de Prada
* <p>
* Copyright (C) 2021 ownCloud GmbH.
* <p>
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License version 2,
* as published by the Free Software Foundation.
* <p>
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
* <p>
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
package eu.qsfera.android.presentation.migration
import android.os.Bundle
import android.view.View
import android.widget.Button
import androidx.fragment.app.Fragment
import eu.qsfera.android.R
import org.koin.androidx.viewmodel.ext.android.sharedViewModel
class MigrationProgressFragment : Fragment(R.layout.fragment_migration_progress) {
private val migrationViewModel: MigrationViewModel by sharedViewModel()
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
view.findViewById<Button>(R.id.migration_progress_button)?.setOnClickListener {
migrationViewModel.moveToNextState()
}
migrationViewModel.moveLegacyStorageToScopedStorage()
}
}
@@ -0,0 +1,33 @@
/**
* qsfera Android client application
*
* @author Abel García de Prada
* <p>
* Copyright (C) 2021 ownCloud GmbH.
* <p>
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License version 2,
* as published by the Free Software Foundation.
* <p>
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
* <p>
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
package eu.qsfera.android.presentation.migration
sealed class MigrationState {
object MigrationIntroState : MigrationState()
data class MigrationChoiceState(
val legacyStorageSpaceInBytes: Long,
) : MigrationState()
object MigrationProgressState : MigrationState()
object MigrationCompletedState : MigrationState()
}
@@ -0,0 +1,133 @@
/**
* qsfera Android client application
*
* @author David González Verdugo
* @author Abel García de Prada
* @author Juan Carlos Garrote Gascón
*
* Copyright (C) 2022 ownCloud GmbH.
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License version 2,
* as published by the Free Software Foundation.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
package eu.qsfera.android.presentation.migration
import android.os.Environment
import android.os.StatFs
import androidx.lifecycle.LiveData
import androidx.lifecycle.MediatorLiveData
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import eu.qsfera.android.data.providers.SharedPreferencesProvider
import eu.qsfera.android.data.providers.LegacyStorageProvider
import eu.qsfera.android.data.providers.LocalStorageProvider
import eu.qsfera.android.domain.files.usecases.UpdateAlreadyDownloadedFilesPathUseCase
import eu.qsfera.android.domain.transfers.usecases.GetAllTransfersUseCase
import eu.qsfera.android.domain.transfers.usecases.UpdatePendingUploadsPathUseCase
import eu.qsfera.android.domain.utils.Event
import eu.qsfera.android.presentation.migration.StorageMigrationActivity.Companion.PREFERENCE_ALREADY_MIGRATED_TO_SCOPED_STORAGE
import eu.qsfera.android.providers.AccountProvider
import eu.qsfera.android.providers.CoroutinesDispatcherProvider
import kotlinx.coroutines.launch
import java.io.File
/**
* View Model to keep a reference to the capability repository and an up-to-date capability
*/
class MigrationViewModel(
rootFolder: String,
private val localStorageProvider: LocalStorageProvider,
private val preferencesProvider: SharedPreferencesProvider,
private val accountProvider: AccountProvider,
private val updatePendingUploadsPathUseCase: UpdatePendingUploadsPathUseCase,
private val updateAlreadyDownloadedFilesPathUseCase: UpdateAlreadyDownloadedFilesPathUseCase,
private val getAllTransfersUseCase: GetAllTransfersUseCase,
private val coroutineDispatcherProvider: CoroutinesDispatcherProvider,
) : ViewModel() {
private val _migrationState = MediatorLiveData<Event<MigrationState>>()
val migrationState: LiveData<Event<MigrationState>> = _migrationState
private val legacyStorageDirectoryPath = LegacyStorageProvider(rootFolder).getRootFolderPath()
init {
_migrationState.postValue(Event(MigrationState.MigrationIntroState))
}
private fun getLegacyStorageSizeInBytes(): Long {
val legacyStorageDirectory = File(legacyStorageDirectoryPath)
return localStorageProvider.sizeOfDirectory(legacyStorageDirectory)
}
fun moveLegacyStorageToScopedStorage() {
viewModelScope.launch(coroutineDispatcherProvider.io) {
localStorageProvider.moveLegacyToScopedStorage()
updatePendingUploadsPath()
updateAlreadyDownloadedFilesPath()
moveToNextState()
}
}
private fun saveAlreadyMigratedPreference() {
preferencesProvider.putBoolean(key = PREFERENCE_ALREADY_MIGRATED_TO_SCOPED_STORAGE, value = true)
}
private fun updatePendingUploadsPath() {
updatePendingUploadsPathUseCase(
UpdatePendingUploadsPathUseCase.Params(
oldDirectory = legacyStorageDirectoryPath,
newDirectory = localStorageProvider.getRootFolderPath()
)
)
val uploads = getAllTransfersUseCase(Unit)
val accountsNames = mutableListOf<String>()
accountProvider.getLoggedAccounts().forEach { account ->
accountsNames.add(localStorageProvider.getTemporalPath(account.name))
}
localStorageProvider.clearUnrelatedTemporalFiles(uploads, accountsNames)
}
private fun updateAlreadyDownloadedFilesPath() {
updateAlreadyDownloadedFilesPathUseCase(
UpdateAlreadyDownloadedFilesPathUseCase.Params(
oldDirectory = legacyStorageDirectoryPath,
newDirectory = localStorageProvider.getRootFolderPath()
)
)
}
fun moveToNextState() {
val nextState: MigrationState = when (_migrationState.value?.peekContent()) {
is MigrationState.MigrationIntroState -> MigrationState.MigrationChoiceState(
legacyStorageSpaceInBytes = getLegacyStorageSizeInBytes()
)
is MigrationState.MigrationChoiceState -> MigrationState.MigrationProgressState
is MigrationState.MigrationProgressState -> MigrationState.MigrationCompletedState
is MigrationState.MigrationCompletedState -> MigrationState.MigrationCompletedState
null -> MigrationState.MigrationIntroState
}
if (nextState is MigrationState.MigrationCompletedState) {
saveAlreadyMigratedPreference()
}
_migrationState.postValue(Event(nextState))
}
fun isThereEnoughSpaceInDevice(): Boolean {
val stat = StatFs(Environment.getDataDirectory().path)
val availableBytes = stat.availableBlocksLong * stat.blockSizeLong
return availableBytes > getLegacyStorageSizeInBytes()
}
}
@@ -0,0 +1,117 @@
/**
* qsfera Android client application
*
* @author Abel García de Prada
* <p>
* Copyright (C) 2021 ownCloud GmbH.
* <p>
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License version 2,
* as published by the Free Software Foundation.
* <p>
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
* <p>
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
package eu.qsfera.android.presentation.migration
import android.content.Context
import android.content.Intent
import android.os.Bundle
import android.view.View
import androidx.appcompat.app.AppCompatActivity
import androidx.core.view.updatePadding
import androidx.fragment.app.Fragment
import eu.qsfera.android.MainApp
import eu.qsfera.android.R
import eu.qsfera.android.data.providers.LegacyStorageProvider
import eu.qsfera.android.data.providers.implementation.OCSharedPreferencesProvider
import eu.qsfera.android.ui.activity.enableEdgeToEdgePostSetContentView
import eu.qsfera.android.ui.activity.enableEdgeToEdgePreSetContentView
import org.koin.androidx.viewmodel.ext.android.viewModel
import timber.log.Timber
import java.io.File
class StorageMigrationActivity : AppCompatActivity() {
private val migrationViewModel: MigrationViewModel by viewModel()
private val fragmentMigrationIntro = MigrationIntroFragment()
private val fragmentMigrationChoice = MigrationChoiceFragment()
private val fragmentMigrationProgress = MigrationProgressFragment()
private val fragmentMigrationCompleted = MigrationCompletedFragment()
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
// edge-to-edge
enableEdgeToEdgePreSetContentView(true)
setContentView(R.layout.activity_storage_migration)
// edge-to-edge
enableEdgeToEdgePostSetContentView { insets ->
findViewById<View>(android.R.id.content).updatePadding(
top = insets.top,
bottom = insets.bottom
)
}
supportActionBar?.elevation = 0f
migrationViewModel.migrationState.observe(this) {
navigateToNextMigrationScreen(it.peekContent())
}
}
override fun onBackPressed() {}
private fun navigateToNextMigrationScreen(migrationState: MigrationState) {
val targetFragment: Fragment = when (migrationState) {
is MigrationState.MigrationIntroState -> fragmentMigrationIntro
is MigrationState.MigrationChoiceState -> fragmentMigrationChoice
is MigrationState.MigrationProgressState -> fragmentMigrationProgress
is MigrationState.MigrationCompletedState -> fragmentMigrationCompleted
}
supportFragmentManager
.beginTransaction()
.replace(R.id.migration_frame_layout, targetFragment)
.commit()
}
companion object {
private val legacyStorageFolder = File(LegacyStorageProvider(MainApp.dataFolder).getRootFolderPath())
const val PREFERENCE_ALREADY_MIGRATED_TO_SCOPED_STORAGE = "MIGRATED_TO_SCOPED_STORAGE"
private fun hasDataInLegacyStorage(): Boolean =
legacyStorageFolder.exists() && !legacyStorageFolder.listFiles().isNullOrEmpty()
private fun hasAccessToLegacyStorage(): Boolean =
legacyStorageFolder.canRead() && legacyStorageFolder.canWrite()
private fun hasAlreadyMigratedToScopedStorage(context: Context): Boolean {
val preferenceProvider = OCSharedPreferencesProvider(context = context)
return preferenceProvider.getBoolean(key = PREFERENCE_ALREADY_MIGRATED_TO_SCOPED_STORAGE, defaultValue = false)
}
fun runIfNeeded(context: Context) {
if (context is StorageMigrationActivity) {
return
}
if (shouldShow(context)) {
Timber.i("Migration to scoped storage should be done. Showing wizard to migrate...")
context.startActivity(Intent(context, StorageMigrationActivity::class.java))
}
}
private fun shouldShow(context: Context): Boolean =
!hasAlreadyMigratedToScopedStorage(context) && hasDataInLegacyStorage() && hasAccessToLegacyStorage()
}
}
@@ -0,0 +1,82 @@
/**
* qsfera Android client application
*
* @author Juan Carlos Garrote Gascón
* @author Jorge Aguado Recio
*
* Copyright (C) 2024 ownCloud GmbH.
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License version 2,
* as published by the Free Software Foundation.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
package eu.qsfera.android.presentation.previews
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import eu.qsfera.android.R
import eu.qsfera.android.domain.files.model.FileMenuOption
import eu.qsfera.android.domain.files.model.OCFile
import eu.qsfera.android.domain.files.usecases.GetFileByIdAsStreamUseCase
import eu.qsfera.android.providers.ContextProvider
import eu.qsfera.android.providers.CoroutinesDispatcherProvider
import eu.qsfera.android.usecases.files.FilterFileMenuOptionsUseCase
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
class PreviewAudioViewModel(
private val filterFileMenuOptionsUseCase: FilterFileMenuOptionsUseCase,
getFileByIdAsStreamUseCase: GetFileByIdAsStreamUseCase,
private val contextProvider: ContextProvider,
private val coroutinesDispatcherProvider: CoroutinesDispatcherProvider,
ocFile: OCFile,
) : ViewModel() {
private val _menuOptions: MutableStateFlow<List<FileMenuOption>> = MutableStateFlow(emptyList())
val menuOptions: StateFlow<List<FileMenuOption>> = _menuOptions
private val currentFile: Flow<OCFile?> = getFileByIdAsStreamUseCase(GetFileByIdAsStreamUseCase.Params(ocFile.id!!))
fun getCurrentFile(): Flow<OCFile?> = currentFile
fun filterMenuOptions(file: OCFile, accountName: String) {
val shareViaLinkAllowed = contextProvider.getBoolean(R.bool.share_via_link_feature)
val shareWithUsersAllowed = contextProvider.getBoolean(R.bool.share_with_users_feature)
val sendAllowed = contextProvider.getString(R.string.send_files_to_other_apps).equals("on", ignoreCase = true)
viewModelScope.launch(coroutinesDispatcherProvider.io) {
val result = filterFileMenuOptionsUseCase(
FilterFileMenuOptionsUseCase.Params(
files = listOf(file),
accountName = accountName,
isAnyFileVideoPreviewing = false,
displaySelectAll = false,
displaySelectInverse = false,
onlyAvailableOfflineFiles = false,
onlySharedByLinkFiles = false,
shareViaLinkAllowed = shareViaLinkAllowed,
shareWithUsersAllowed = shareWithUsersAllowed,
sendAllowed = sendAllowed,
)
)
result.apply {
remove(FileMenuOption.RENAME)
remove(FileMenuOption.MOVE)
remove(FileMenuOption.COPY)
remove(FileMenuOption.SYNC)
}
_menuOptions.update { result }
}
}
}
@@ -0,0 +1,88 @@
/**
* qsfera Android client application
*
* @author Juan Carlos Garrote Gascón
* @author Parneet Singh
* @author Jorge Aguado Recio
*
* Copyright (C) 2024 ownCloud GmbH.
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License version 2,
* as published by the Free Software Foundation.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
package eu.qsfera.android.presentation.previews
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import eu.qsfera.android.R
import eu.qsfera.android.domain.files.model.FileMenuOption
import eu.qsfera.android.domain.files.model.OCFile
import eu.qsfera.android.domain.files.usecases.GetFileByIdAsStreamUseCase
import eu.qsfera.android.providers.ContextProvider
import eu.qsfera.android.providers.CoroutinesDispatcherProvider
import eu.qsfera.android.usecases.files.FilterFileMenuOptionsUseCase
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
class PreviewTextViewModel(
private val filterFileMenuOptionsUseCase: FilterFileMenuOptionsUseCase,
getFileByIdAsStreamUseCase: GetFileByIdAsStreamUseCase,
private val contextProvider: ContextProvider,
private val coroutinesDispatcherProvider: CoroutinesDispatcherProvider,
ocFile: OCFile,
) : ViewModel() {
private val _menuOptions: MutableStateFlow<List<FileMenuOption>> = MutableStateFlow(emptyList())
val menuOptions: StateFlow<List<FileMenuOption>> = _menuOptions
private val currentFile: Flow<OCFile?> = getFileByIdAsStreamUseCase(GetFileByIdAsStreamUseCase.Params(ocFile.id!!))
fun getCurrentFile(): Flow<OCFile?> = currentFile
fun filterMenuOptions(file: OCFile, accountName: String) {
val shareViaLinkAllowed = contextProvider.getBoolean(R.bool.share_via_link_feature)
val shareWithUsersAllowed = contextProvider.getBoolean(R.bool.share_with_users_feature)
val sendAllowed = contextProvider.getString(R.string.send_files_to_other_apps).equals("on", ignoreCase = true)
viewModelScope.launch(coroutinesDispatcherProvider.io) {
val result = filterFileMenuOptionsUseCase(
FilterFileMenuOptionsUseCase.Params(
files = listOf(file),
accountName = accountName,
isAnyFileVideoPreviewing = false,
displaySelectAll = false,
displaySelectInverse = false,
onlyAvailableOfflineFiles = false,
onlySharedByLinkFiles = false,
shareViaLinkAllowed = shareViaLinkAllowed,
shareWithUsersAllowed = shareWithUsersAllowed,
sendAllowed = sendAllowed,
)
)
result.apply {
remove(FileMenuOption.RENAME)
remove(FileMenuOption.MOVE)
remove(FileMenuOption.COPY)
remove(FileMenuOption.SYNC)
}
_menuOptions.update { result }
}
}
companion object {
const val NO_POSITION: Int = -1
const val TAB_MARKDOWN_POSITION: Int = 0
const val TAB_TEXT_POSITION: Int = 1
}
}
@@ -0,0 +1,76 @@
/**
* qsfera Android client application
*
* @author Juan Carlos Garrote Gascón
* @author Jorge Aguado Recio
*
* Copyright (C) 2024 ownCloud GmbH.
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License version 2,
* as published by the Free Software Foundation.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
package eu.qsfera.android.presentation.previews
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import eu.qsfera.android.R
import eu.qsfera.android.domain.files.model.FileMenuOption
import eu.qsfera.android.domain.files.model.OCFile
import eu.qsfera.android.domain.files.usecases.GetFileByIdAsStreamUseCase
import eu.qsfera.android.providers.ContextProvider
import eu.qsfera.android.providers.CoroutinesDispatcherProvider
import eu.qsfera.android.usecases.files.FilterFileMenuOptionsUseCase
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
class PreviewVideoViewModel(
private val filterFileMenuOptionsUseCase: FilterFileMenuOptionsUseCase,
getFileByIdAsStreamUseCase: GetFileByIdAsStreamUseCase,
private val contextProvider: ContextProvider,
private val coroutinesDispatcherProvider: CoroutinesDispatcherProvider,
ocFile: OCFile,
) : ViewModel() {
private val _menuOptions: MutableStateFlow<List<FileMenuOption>> = MutableStateFlow(emptyList())
val menuOptions: StateFlow<List<FileMenuOption>> = _menuOptions
private val currentFile: Flow<OCFile?> = getFileByIdAsStreamUseCase(GetFileByIdAsStreamUseCase.Params(ocFile.id!!))
fun getCurrentFile(): Flow<OCFile?> = currentFile
fun filterMenuOptions(file: OCFile, accountName: String) {
val shareViaLinkAllowed = contextProvider.getBoolean(R.bool.share_via_link_feature)
val shareWithUsersAllowed = contextProvider.getBoolean(R.bool.share_with_users_feature)
val sendAllowed = contextProvider.getString(R.string.send_files_to_other_apps).equals("on", ignoreCase = true)
viewModelScope.launch(coroutinesDispatcherProvider.io) {
val result = filterFileMenuOptionsUseCase(
FilterFileMenuOptionsUseCase.Params(
files = listOf(file),
accountName = accountName,
isAnyFileVideoPreviewing = true,
displaySelectAll = false,
displaySelectInverse = false,
onlyAvailableOfflineFiles = false,
onlySharedByLinkFiles = false,
shareViaLinkAllowed = shareViaLinkAllowed,
shareWithUsersAllowed = shareWithUsersAllowed,
sendAllowed = sendAllowed,
)
)
_menuOptions.update { result }
}
}
}
@@ -0,0 +1,37 @@
/**
* qsfera Android client application
*
* @author David Crespo Ríos
* Copyright (C) 2022 ownCloud GmbH.
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License version 2,
* as published by the Free Software Foundation.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
package eu.qsfera.android.presentation.releasenotes
import androidx.annotation.DrawableRes
import androidx.annotation.StringRes
import eu.qsfera.android.R
data class ReleaseNote(
@StringRes val title: Int,
@StringRes val subtitle: Int,
val type: ReleaseNoteType
)
enum class ReleaseNoteType(@DrawableRes val drawableRes: Int) {
BUGFIX(R.drawable.ic_release_notes_healing),
CHANGE(R.drawable.ic_release_notes_autorenew),
ENHANCEMENT(R.drawable.ic_release_notes_architecture),
SECURITY(R.drawable.ic_lock)
}
@@ -0,0 +1,122 @@
/**
* qsfera Android client application
*
* @author David Crespo Ríos
* Copyright (C) 2022 ownCloud GmbH.
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License version 2,
* as published by the Free Software Foundation.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
package eu.qsfera.android.presentation.releasenotes
import android.content.Context
import android.content.Intent
import android.os.Bundle
import androidx.appcompat.app.AppCompatActivity
import androidx.core.view.updateLayoutParams
import androidx.core.view.updatePadding
import androidx.recyclerview.widget.LinearLayoutManager
import eu.qsfera.android.BuildConfig
import eu.qsfera.android.MainApp
import eu.qsfera.android.MainApp.Companion.versionCode
import eu.qsfera.android.R
import eu.qsfera.android.databinding.ReleaseNotesActivityBinding
import eu.qsfera.android.presentation.authentication.LoginActivity
import eu.qsfera.android.ui.activity.FileDisplayActivity
import eu.qsfera.android.ui.activity.enableEdgeToEdgePostSetContentView
import eu.qsfera.android.ui.activity.enableEdgeToEdgePreSetContentView
import org.koin.androidx.viewmodel.ext.android.viewModel
class ReleaseNotesActivity : AppCompatActivity() {
// ViewModel
private val releaseNotesViewModel by viewModel<ReleaseNotesViewModel>()
private var _binding: ReleaseNotesActivityBinding? = null
val binding get() = _binding!!
private val releaseNotesAdapter = ReleaseNotesAdapter()
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
// edge-to-edge
enableEdgeToEdgePreSetContentView(false)
_binding = ReleaseNotesActivityBinding.inflate(layoutInflater)
setContentView(binding.root)
setData()
initView()
// edge-to-edge
enableEdgeToEdgePostSetContentView { insets ->
binding.topSpacer.updateLayoutParams {
height = insets.top
}
binding.root.updatePadding(bottom = insets.bottom)
}
supportActionBar?.elevation = 0f;
}
private fun initView() {
binding.releaseNotes.let {
it.layoutManager = LinearLayoutManager(this)
it.adapter = releaseNotesAdapter
}
binding.btnProceed.setOnClickListener {
releaseNotesViewModel.updateVersionCode()
setResult(RESULT_OK)
finish()
}
}
private fun setData() {
releaseNotesAdapter.setData(releaseNotesViewModel.getReleaseNotes())
val header = String.format(
getString(R.string.release_notes_header),
getString(R.string.app_name)
)
val footer = String.format(
getString(R.string.release_notes_footer),
getString(R.string.app_name)
)
binding.txtHeader.text = header
binding.txtFooter.text = footer
}
companion object {
fun runIfNeeded(context: Context) {
if (context is ReleaseNotesActivity) {
return
}
if (shouldShow(context)) {
context.startActivity(Intent(context, ReleaseNotesActivity::class.java))
}
}
private fun shouldShow(context: Context): Boolean {
val showReleaseNotes = context.resources.getBoolean(R.bool.release_notes_enabled) && !BuildConfig.DEBUG
return firstRunAfterUpdate() && showReleaseNotes &&
ReleaseNotesViewModel.releaseNotesList.isNotEmpty() &&
(context is FileDisplayActivity || context is LoginActivity)
}
private fun firstRunAfterUpdate(): Boolean =
MainApp.getLastSeenVersionCode() != versionCode
}
}
@@ -0,0 +1,71 @@
/**
* qsfera Android client application
*
* @author David Crespo Ríos
* Copyright (C) 2022 ownCloud GmbH.
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License version 2,
* as published by the Free Software Foundation.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
package eu.qsfera.android.presentation.releasenotes
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import androidx.recyclerview.widget.RecyclerView
import eu.qsfera.android.R
import eu.qsfera.android.databinding.ReleaseNotesItemBinding
class ReleaseNotesAdapter : RecyclerView.Adapter<ReleaseNotesAdapter.ViewHolder>() {
private val dataSet = ArrayList<ReleaseNote>()
/**
* Provide a reference to the type of views that you are using
* (custom ViewHolder).
*/
class ViewHolder(view: View) : RecyclerView.ViewHolder(view) {
val binding = ReleaseNotesItemBinding.bind(view)
}
// Create new views (invoked by the layout manager)
override fun onCreateViewHolder(viewGroup: ViewGroup, viewType: Int): ViewHolder {
// Create a new view, which defines the UI of the list item
val view = LayoutInflater.from(viewGroup.context)
.inflate(R.layout.release_notes_item, viewGroup, false)
return ViewHolder(view)
}
// Replace the contents of a view (invoked by the layout manager)
override fun onBindViewHolder(viewHolder: ViewHolder, position: Int) {
// Get element from your dataset at this position and replace the
// contents of the view with that element
val releaseNote = dataSet[position]
viewHolder.binding.run {
titleReleaseNote.setText(releaseNote.title)
subtitleReleaseNote.setText(releaseNote.subtitle)
iconReleaseNote.setImageResource(releaseNote.type.drawableRes)
}
}
fun setData(releaseNotes: List<ReleaseNote>) {
dataSet.clear()
dataSet.addAll(releaseNotes)
}
// Return the size of your dataset (invoked by the layout manager)
override fun getItemCount() = dataSet.size
}
@@ -0,0 +1,85 @@
/**
* qsfera Android client application
*
* @author David Crespo Ríos
* Copyright (C) 2022 ownCloud GmbH.
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License version 2,
* as published by the Free Software Foundation.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
package eu.qsfera.android.presentation.releasenotes
import androidx.lifecycle.ViewModel
import eu.qsfera.android.MainApp
import eu.qsfera.android.MainApp.Companion.versionCode
import eu.qsfera.android.R
import eu.qsfera.android.data.providers.SharedPreferencesProvider
import eu.qsfera.android.providers.ContextProvider
class ReleaseNotesViewModel(
private val preferencesProvider: SharedPreferencesProvider,
private val contextProvider: ContextProvider
) : ViewModel() {
fun getReleaseNotes(): List<ReleaseNote> =
releaseNotesList
fun updateVersionCode() {
preferencesProvider.putInt(MainApp.PREFERENCE_KEY_LAST_SEEN_VERSION_CODE, versionCode)
}
fun shouldWhatsNewSectionBeVisible(): Boolean =
contextProvider.getBoolean(R.bool.release_notes_enabled) && getReleaseNotes().isNotEmpty()
companion object {
val releaseNotesList = listOf(
ReleaseNote(
title = R.string.release_notes_title,
subtitle = R.string.release_notes_initial_release,
type = ReleaseNoteType.ENHANCEMENT
),
/*
ReleaseNote(
title = R.string.release_notes_4_5_0_title_quota_improvements,
subtitle = R.string.release_notes_4_5_0_subtitle_quota_improvements,
type = ReleaseNoteType.ENHANCEMENT
),
ReleaseNote(
title = R.string.release_notes_4_5_0_title_light_users,
subtitle = R.string.release_notes_4_5_0_subtitle_light_users,
type = ReleaseNoteType.ENHANCEMENT
),
ReleaseNote(
title = R.string.release_notes_title_enhanced_bottom_nav_bar,
subtitle = R.string.release_notes_subtitle_bottom_nav_bar,
type = ReleaseNoteType.ENHANCEMENT
),
ReleaseNote(
title = R.string.release_notes_4_5_0_title_feedback_in_previews,
subtitle = R.string.release_notes_4_5_0_subtitle_feedback_in_previews,
type = ReleaseNoteType.ENHANCEMENT
),
ReleaseNote(
title = R.string.release_notes_4_5_1_title_strange_behaviour_apps_provider,
subtitle = R.string.release_notes_4_5_1_subtitle_strange_behaviour_apps_provider,
type = ReleaseNoteType.BUGFIX
),
ReleaseNote(
title = R.string.release_notes_bugfixes_title,
subtitle = R.string.release_notes_bugfixes_subtitle,
type = ReleaseNoteType.BUGFIX
),
*/
)
}
}

Some files were not shown because too many files have changed in this diff Show More