Initial QSfera import
This commit is contained in:
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
+123
@@ -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()) }
|
||||
}
|
||||
+81
@@ -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
|
||||
}
|
||||
+86
@@ -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)
|
||||
}
|
||||
+69
@@ -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
|
||||
}
|
||||
+281
@@ -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)
|
||||
}
|
||||
+106
@@ -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
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
|
||||
+61
@@ -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")
|
||||
}
|
||||
}
|
||||
}
|
||||
+137
@@ -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);
|
||||
}
|
||||
}
|
||||
+245
@@ -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)
|
||||
}
|
||||
|
||||
}
|
||||
+288
@@ -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 }
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
+95
@@ -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
|
||||
}
|
||||
}
|
||||
+472
@@ -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;
|
||||
}
|
||||
}
|
||||
+41
@@ -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();
|
||||
}
|
||||
|
||||
}
|
||||
+249
@@ -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)
|
||||
);
|
||||
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+282
@@ -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)
|
||||
}
|
||||
}
|
||||
+45
@@ -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"
|
||||
+1190
File diff suppressed because it is too large
Load Diff
+133
@@ -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()
|
||||
}
|
||||
}
|
||||
+94
@@ -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)
|
||||
}
|
||||
}
|
||||
+83
@@ -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
|
||||
}
|
||||
}
|
||||
+91
@@ -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
|
||||
}
|
||||
}
|
||||
+102
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
+60
@@ -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)
|
||||
}
|
||||
}
|
||||
+106
@@ -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"
|
||||
}
|
||||
}
|
||||
+92
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
+92
@@ -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,
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
+35
@@ -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)
|
||||
}
|
||||
}
|
||||
+812
@@ -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
|
||||
}
|
||||
}
|
||||
+80
@@ -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
|
||||
)
|
||||
|
||||
}
|
||||
}
|
||||
+79
@@ -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
|
||||
)
|
||||
}
|
||||
}
|
||||
+75
@@ -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)
|
||||
}
|
||||
}
|
||||
+109
@@ -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 }
|
||||
}
|
||||
}
|
||||
}
|
||||
+155
@@ -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
|
||||
}
|
||||
}
|
||||
+155
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
+147
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
+643
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+212
@@ -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)
|
||||
}
|
||||
}
|
||||
+56
@@ -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
|
||||
}
|
||||
|
||||
}
|
||||
+494
@@ -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
|
||||
}
|
||||
}
|
||||
+60
@@ -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
|
||||
}
|
||||
+59
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
+1633
File diff suppressed because it is too large
Load Diff
+460
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
+99
@@ -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()
|
||||
}
|
||||
}
|
||||
+57
@@ -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
|
||||
}
|
||||
+342
@@ -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)))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+163
@@ -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)
|
||||
}
|
||||
}
|
||||
+163
@@ -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 }
|
||||
}
|
||||
}
|
||||
}
|
||||
+38
@@ -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()
|
||||
}
|
||||
+37
@@ -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
|
||||
|
||||
}
|
||||
+224
@@ -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")
|
||||
}
|
||||
}
|
||||
}
|
||||
+92
@@ -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)
|
||||
}
|
||||
}
|
||||
+54
@@ -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()
|
||||
}
|
||||
}
|
||||
+37
@@ -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()
|
||||
}
|
||||
}
|
||||
}
|
||||
+41
@@ -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()
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
+42
@@ -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()
|
||||
}
|
||||
}
|
||||
+33
@@ -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()
|
||||
}
|
||||
+133
@@ -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()
|
||||
}
|
||||
}
|
||||
+117
@@ -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()
|
||||
}
|
||||
}
|
||||
+82
@@ -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 }
|
||||
}
|
||||
}
|
||||
}
|
||||
+88
@@ -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
|
||||
}
|
||||
}
|
||||
+76
@@ -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 }
|
||||
}
|
||||
}
|
||||
}
|
||||
+37
@@ -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)
|
||||
}
|
||||
+122
@@ -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
|
||||
}
|
||||
}
|
||||
+71
@@ -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
|
||||
|
||||
}
|
||||
+85
@@ -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
Reference in New Issue
Block a user