Donate to e Foundation | Murena handsets with /e/OS | Own a part of Murena! Learn more

Commit 231de74b authored by Fahim M. Choudhury's avatar Fahim M. Choudhury
Browse files

feat: Implement device to cloud push for all the files to resolve Murena.io recovery


eDrive checks if a recovery is needed and logs out and logs in the user to delete and recreate its database. Because of the recreation of the database, all the files and (syncable) folders are pushed to cloud storage.

When the process completes, a separate SharedPreference is used to track whether recovery is needed or not. It is done so because if a user logs out and logs in from AccountManager, eDrive's initial preference is cleared. However, this recovery preference will not be cleared unless the user explicitly clears eDrive's data.


In EdriveApplication's onCreate() method, an instance of RecoveryManager initiates the recovery process.

Inside RecoveryManager, it checks whether the recovery is needed using RecoveryPreferences. RecoveryPreferences keeps a separate SharedPreferences which is not cleared when the user logs out and logs in via AccountManager.

For recovery to happen, RecoveryManager logs out and logs in the user consecutively. This process eventually deletes eDrive's database and the default SharedPreferences and then creates them, enabling all the files and folders to be synced again with the cloud.
parent 9a290f19
Loading
Loading
Loading
Loading
+23 −5
Original line number Diff line number Diff line
/*
 * Copyright © ECORP SAS 2022-2023.
 * All rights reserved. This program and the accompanying materials
 * are made available under the terms of the GNU Public License v3.0
 * which accompanies this distribution, and is available at
 * http://www.gnu.org/licenses/gpl.html
 * Copyright (C) 2025 e Foundation
 * Copyright (C) ECORP SAS 2022-2023
 *
 * This program is free software: you can redistribute it and/or modify
 * it under the terms of the GNU General Public License as published by
 * the Free Software Foundation, either version 3 of the License, or
 * (at your option) any later version.
 *
 * 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 <https://www.gnu.org/licenses/>.
 */

package foundation.e.drive;
@@ -20,6 +30,7 @@ import androidx.annotation.NonNull;

import foundation.e.drive.database.FailedSyncPrefsManager;
import foundation.e.drive.fileObservers.FileObserverManager;
import foundation.e.drive.recovery.RecoveryManager;
import foundation.e.drive.utils.AppConstants;
import foundation.e.drive.utils.CommonUtils;
import foundation.e.drive.utils.ReleaseTree;
@@ -59,6 +70,13 @@ public class EdriveApplication extends Application {
        }

        FailedSyncPrefsManager.getInstance(getApplicationContext()).clearPreferences();

        setupEdriveRecovery();
    }

    private void setupEdriveRecovery() {
        RecoveryManager recoveryManager = new RecoveryManager(getApplicationContext());
        recoveryManager.initiateRecovery();
    }

    synchronized public void startRecursiveFileObserver() {
+104 −0
Original line number Diff line number Diff line
/*
 * Copyright (C) 2025 e Foundation
 *
 * This program is free software: you can redistribute it and/or modify
 * it under the terms of the GNU General Public License as published by
 * the Free Software Foundation, either version 3 of the License, or
 * (at your option) any later version.
 *
 * 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 <https://www.gnu.org/licenses/>.
 *
 */

package foundation.e.drive.account

import android.accounts.AccountManager
import android.content.Context
import android.content.SharedPreferences
import foundation.e.drive.R
import foundation.e.drive.utils.AppConstants
import foundation.e.drive.utils.DavClientProvider
import foundation.e.drive.work.WorkLauncher
import timber.log.Timber

class AccountAdder(private val context: Context) {

    private val preferences: SharedPreferences by lazy {
        context.applicationContext.getSharedPreferences(
            AppConstants.SHARED_PREFERENCE_NAME, Context.MODE_PRIVATE
        )
    }

    fun addAccount(name: String, type: String) {
        if (!canStart(name, type, preferences, context)) return

        updateAccountNameOnPreference(name)

        val workLauncher = WorkLauncher.getInstance(context)
        if (workLauncher.enqueueSetupWorkers(context)) {
            DavClientProvider.getInstance().cleanUp()
            workLauncher.enqueuePeriodicUserInfoFetching()
        }
    }

    private fun updateAccountNameOnPreference(name: String) {
        preferences.edit()
            .putString(AccountManager.KEY_ACCOUNT_NAME, name)
            .apply()
    }

    /**
     * Check that conditions to start are met:
     * - Setup has not already been done
     * - AccountName is not empty
     * - AccountType is /e/ account
     * - the account is effectively available through accountManager
     */
    private fun canStart(
        accountName: String,
        accountType: String,
        prefs: SharedPreferences,
        context: Context
    ): Boolean {
        if (isSetupAlreadyDone(prefs)) {
            Timber.w("Set up is already done, skipping account addition.")
            return false
        }

        if (accountName.isEmpty()) {
            Timber.w("Account name is empty, skipping account addition.")
            return false
        }

        if (isInvalidAccountType(accountType, context)) {
            Timber.w("Account type: $accountType is invalid, skipping account addition.")
            return false
        }

        if (!isExistingAccount(accountName, context)) {
            Timber.w("No account exists for $accountName, skipping account addition.")
            return false
        }

        return true
    }

    private fun isSetupAlreadyDone(prefs: SharedPreferences): Boolean {
        return prefs.getBoolean(AppConstants.SETUP_COMPLETED, false)
    }

    private fun isInvalidAccountType(accountType: String, context: Context): Boolean {
        val validAccountType = context.getString(R.string.eelo_account_type)
        return accountType != validAccountType
    }

    private fun isExistingAccount(accountName: String, context: Context): Boolean {
        return AccountUtils.getAccount(accountName, context) != null
    }
}
+116 −0
Original line number Diff line number Diff line
/*
 * Copyright (C) 2025 e Foundation
 *
 * This program is free software: you can redistribute it and/or modify
 * it under the terms of the GNU General Public License as published by
 * the Free Software Foundation, either version 3 of the License, or
 * (at your option) any later version.
 *
 * 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 <https://www.gnu.org/licenses/>.
 *
 */

package foundation.e.drive.account

import android.accounts.AccountManager
import android.app.Application
import android.app.NotificationManager
import android.content.Context
import android.content.SharedPreferences
import androidx.work.WorkManager
import foundation.e.drive.database.DbHelper
import foundation.e.drive.database.FailedSyncPrefsManager
import foundation.e.drive.synchronization.SyncProxy
import foundation.e.drive.utils.AppConstants
import foundation.e.drive.utils.AppConstants.INITIAL_FOLDER_NUMBER
import foundation.e.drive.utils.AppConstants.SETUP_COMPLETED
import foundation.e.drive.utils.DavClientProvider
import timber.log.Timber
import java.io.File

class AccountRemover(
    private val context: Context,
) {
    private val preferences: SharedPreferences by lazy {
        context.applicationContext.getSharedPreferences(
            AppConstants.SHARED_PREFERENCE_NAME, Context.MODE_PRIVATE
        )
    }

    fun removeAccount() {
        cancelWorkers()
        setSyncStateToIdle()
        deleteDatabase()
        cleanSharedPreferences()
        removeCachedFiles()
        deleteNotificationChannels()
        cleanUpDavClient()
    }

    private fun cleanUpDavClient() {
        DavClientProvider.getInstance().cleanUp()
    }

    private fun setSyncStateToIdle() {
        SyncProxy.moveToIdle(context.applicationContext as Application)
    }

    private fun cancelWorkers() {
        val workManager = WorkManager.getInstance(context)
        workManager.cancelAllWorkByTag(AppConstants.WORK_GENERIC_TAG)
    }

    private fun deleteDatabase() {
        val result = context.applicationContext.deleteDatabase(DbHelper.DATABASE_NAME)
        Timber.d("Remove Database: %s", result)
    }

    private fun cleanSharedPreferences() {
        if (!context.applicationContext.deleteSharedPreferences(AppConstants.SHARED_PREFERENCE_NAME)) {
            //If removal failed, clear all data inside
            preferences.edit().remove(AccountManager.KEY_ACCOUNT_NAME)
                .remove(AccountManager.KEY_ACCOUNT_TYPE)
                .remove(SETUP_COMPLETED)
                .remove(INITIAL_FOLDER_NUMBER)
                .remove(AppConstants.KEY_LAST_SCAN_TIME)
                .apply()
        }
        context.applicationContext.deleteSharedPreferences(FailedSyncPrefsManager.PREF_NAME)
    }

    private fun removeCachedFiles() {
        try {
            deleteDir(context.applicationContext.externalCacheDir)
        } catch (e: SecurityException) {
            Timber.e(e, "failed to delete cached file on account removal call")
        }
    }

    @Throws(SecurityException::class)
    private fun deleteDir(dir: File?): Boolean {
        if (dir == null) {
            Timber.w("cache file returned null. preventing a NPE")
            return false
        }

        return dir.deleteRecursively()
    }

    @Suppress("TooGenericExceptionCaught")
    private fun deleteNotificationChannels() {
        val notificationManager =
            context.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager

        try {
            notificationManager.cancelAll()
        } catch (exception: RuntimeException) {
            Timber.e(exception, "Cannot cancel all notifications")
        }
    }
}
+19 −70
Original line number Diff line number Diff line
/*
 * Copyright © MURENA SAS 2023-2024.
 * All rights reserved. This program and the accompanying materials
 * are made available under the terms of the GNU Public License v3.0
 * which accompanies this distribution, and is available at
 * http://www.gnu.org/licenses/gpl.html
 * Copyright (C) 2025 e Foundation
 * Copyright (C) MURENA SAS 2023-2024
 *
 * This program is free software: you can redistribute it and/or modify
 * it under the terms of the GNU General Public License as published by
 * the Free Software Foundation, either version 3 of the License, or
 * (at your option) any later version.
 *
 * 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 <https://www.gnu.org/licenses/>.
 */
package foundation.e.drive.account.receivers

@@ -11,12 +21,7 @@ import android.accounts.AccountManager
import android.content.BroadcastReceiver
import android.content.Context
import android.content.Intent
import android.content.SharedPreferences
import foundation.e.drive.R
import foundation.e.drive.account.AccountUtils
import foundation.e.drive.utils.AppConstants
import foundation.e.drive.utils.DavClientProvider
import foundation.e.drive.work.WorkLauncher
import foundation.e.drive.account.AccountAdder
import timber.log.Timber

/**
@@ -24,7 +29,7 @@ import timber.log.Timber
 * Triggered by AccountManager
 * @author Vincent Bourgmayer
 */
class AccountAddedReceiver() : BroadcastReceiver() {
class AccountAddedReceiver : BroadcastReceiver() {
    override fun onReceive(context: Context?, intent: Intent?) {
        Timber.d("\"Account added\" intent received")

@@ -33,66 +38,10 @@ class AccountAddedReceiver() : BroadcastReceiver() {
        val extras = intent.extras!!
        val accountName = extras.getString(AccountManager.KEY_ACCOUNT_NAME, "")
        val accountType = extras.getString(AccountManager.KEY_ACCOUNT_TYPE, "")
        val prefs = context.getSharedPreferences(AppConstants.SHARED_PREFERENCE_NAME,
            Context.MODE_PRIVATE)

        Timber.d("AccountAddedReceiver.onReceive with name: %s and type: %s", accountName, accountType)

        if (!canStart(accountName, accountType, prefs, context)) return

        prefs.edit()
            .putString(AccountManager.KEY_ACCOUNT_NAME, accountName)
            .apply()

        val workLauncher = WorkLauncher.getInstance(context)
        if (workLauncher.enqueueSetupWorkers(context)) {
            DavClientProvider.getInstance().cleanUp()
            workLauncher.enqueuePeriodicUserInfoFetching()
        }
}

    /**
     * Check that conditions to start are met:
     * - Setup has not already been done
     * - AccountName is not empty
     * - AccountType is /e/ account
     * - the account is effectively available through accountManager
     */
    private fun canStart(
        accountName: String,
        accountType: String,
        prefs: SharedPreferences,
        context: Context
    ): Boolean {
        if (isSetupAlreadyDone(prefs)) {
            return false
        }

        if (accountName.isEmpty()) {
            return false
        }

        if (isInvalidAccountType(accountType, context)) {
            return false
        }

        if (!isExistingAccount(accountName, context)) {
            Timber.w("No account exist for username: %s ", accountType, accountName)
            return false
        }
        return true
    }

    private fun isInvalidAccountType(accountType: String, context: Context): Boolean {
        val validAccountType = context.getString(R.string.eelo_account_type)
        return accountType != validAccountType
    }

    private fun isSetupAlreadyDone(prefs: SharedPreferences): Boolean {
        return prefs.getBoolean(AppConstants.SETUP_COMPLETED, false)
    }

    private fun isExistingAccount(accountName: String, context: Context): Boolean {
        return AccountUtils.getAccount(accountName, context) != null
        val accountAdder = AccountAdder(context)
        accountAdder.addAccount(accountName, accountType)
    }
}
+21 −93
Original line number Diff line number Diff line
/*
 * Copyright © ECORP SAS 2023.
 * All rights reserved. This program and the accompanying materials
 * are made available under the terms of the GNU Public License v3.0
 * which accompanies this distribution, and is available at
 * http://www.gnu.org/licenses/gpl.html
 * Copyright (C) 2025 e Foundation
 * Copyright (C) ECORP SAS 2023
 *
 * This program is free software: you can redistribute it and/or modify
 * it under the terms of the GNU General Public License as published by
 * the Free Software Foundation, either version 3 of the License, or
 * (at your option) any later version.
 *
 * 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 <https://www.gnu.org/licenses/>.
 */

package foundation.e.drive.account.receivers;

import static foundation.e.drive.utils.AppConstants.INITIAL_FOLDER_NUMBER;
import static foundation.e.drive.utils.AppConstants.SETUP_COMPLETED;

import android.accounts.AccountManager;
import android.annotation.SuppressLint;
import android.app.Application;
import android.app.NotificationManager;
import android.content.BroadcastReceiver;
import android.content.Context;
import android.content.Intent;
import android.content.SharedPreferences;

import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import androidx.work.WorkManager;

import java.io.File;

import foundation.e.drive.R;
import foundation.e.drive.database.DbHelper;
import foundation.e.drive.database.FailedSyncPrefsManager;
import foundation.e.drive.synchronization.SyncProxy;
import foundation.e.drive.account.AccountRemover;
import foundation.e.drive.utils.AppConstants;
import foundation.e.drive.utils.DavClientProvider;
import foundation.e.drive.utils.ViewUtils;
import timber.log.Timber;

public class AccountRemoveCallbackReceiver extends BroadcastReceiver {

    private static final String ACTION_ACCOUNT_REMOVED = "android.accounts.action.ACCOUNT_REMOVED";

    @SuppressLint("UnsafeProtectedBroadcastReceiver")
    @Override
    public void onReceive(@NonNull Context context, @NonNull Intent intent) {
@@ -50,29 +50,12 @@ public class AccountRemoveCallbackReceiver extends BroadcastReceiver {
            return;
        }

        cancelWorkers(applicationContext);
        SyncProxy.INSTANCE.moveToIdle((Application) applicationContext);
        deleteDatabase(applicationContext);
        cleanSharedPreferences(applicationContext, preferences);
        removeCachedFiles(applicationContext);
        deleteNotificationChannels(applicationContext);

        DavClientProvider.getInstance().cleanUp();
        AccountRemover accountRemover = new AccountRemover(context);
        accountRemover.removeAccount();

        ViewUtils.updateWidgetView(applicationContext);
    }


    private void cancelWorkers(@NonNull Context context) {
        final WorkManager workManager = WorkManager.getInstance(context);
        workManager.cancelAllWorkByTag(AppConstants.WORK_GENERIC_TAG);
    }

    private void deleteDatabase(@NonNull Context applicationContext) {
        final boolean result = applicationContext.deleteDatabase(DbHelper.DATABASE_NAME);
        Timber.d("Remove Database: %s", result);
    }

    private boolean shouldProceedWithRemoval(@NonNull Intent intent, @NonNull SharedPreferences preferences, @NonNull Context context) {
        if (isInvalidAction(intent) || intent.getExtras() == null) {
            Timber.w("Invalid account removal request");
@@ -94,61 +77,6 @@ public class AccountRemoveCallbackReceiver extends BroadcastReceiver {
    }

    private boolean isInvalidAction(@NonNull Intent intent) {
        return !"android.accounts.action.ACCOUNT_REMOVED".equals(intent.getAction());
    }

    private void cleanSharedPreferences(@NonNull Context applicationContext, @NonNull SharedPreferences prefs) {
        if (!applicationContext.deleteSharedPreferences(AppConstants.SHARED_PREFERENCE_NAME)) {
            //If removal failed, clear all data inside
            prefs.edit().remove(AccountManager.KEY_ACCOUNT_NAME)
                    .remove(AccountManager.KEY_ACCOUNT_TYPE)
                    .remove(SETUP_COMPLETED)
                    .remove(INITIAL_FOLDER_NUMBER)
                    .remove(AppConstants.KEY_LAST_SCAN_TIME)
                    .apply();
        }

        applicationContext.deleteSharedPreferences(FailedSyncPrefsManager.PREF_NAME);
    }

    private void removeCachedFiles(@NonNull Context applicationContext) {
        try {
            deleteDir(applicationContext.getExternalCacheDir());
        } catch (SecurityException e) {
            Timber.e(e, "failed to delete cached file on account removal call");
        }
    }

    private boolean deleteDir(@Nullable File dir) throws SecurityException {
        if (dir == null) {
            Timber.w("cache file returned null. preventing a NPE");
            return false;
        }

        if (dir.isDirectory()) {
            String[] children = dir.list();
            if (children == null) {
                return dir.delete();
            }

            for (String child : children) {
                boolean isSuccess = deleteDir(new File(dir, child));
                if (!isSuccess) {
                    Timber.w("Failed to remove the cached file: %s", child);
                }
            }
        }

        return dir.delete();
    }

    private void deleteNotificationChannels(Context context) {
        NotificationManager notificationManager = (NotificationManager) context.getSystemService(Context.NOTIFICATION_SERVICE);

        try {
            notificationManager.cancelAll();
        } catch (Exception exception) {
            Timber.e(exception, "Cannot cancel all notifications");
        }
        return !ACTION_ACCOUNT_REMOVED.equals(intent.getAction());
    }
}
Loading