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

Commit 7b91355d authored by Nishith  Khanna's avatar Nishith Khanna
Browse files

Merge branch '2097-murena-sso-users-migration' into 'main'

feat: add Murena SSO migration

See merge request !151
parents e4de98c0 4e3ce265
Loading
Loading
Loading
Loading
Loading
+4 −2
Original line number Diff line number Diff line
@@ -129,7 +129,6 @@
            android:name=".ui.setup.LoginActivity"
            android:label="@string/login_title"
            android:parentActivityName=".ui.AccountsActivity"
            android:excludeFromRecents="true"
            android:exported="true">
            <intent-filter>
                <action android:name="android.intent.action.MAIN"/>
@@ -213,6 +212,9 @@
            android:exported="true" />

        <!-- account type "DAVx⁵" -->
        <service
            android:name=".murenasso.MurenaSsoMigrationService"
            android:exported="false"/>
        <service
            android:name=".syncadapter.AccountAuthenticatorService"
            android:exported="false">
+42 −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 at.bitfire.davdroid.murenasso

import android.content.Context
import androidx.core.content.edit

object MurenaSsoMigrationPreferences {

    private const val PREFERENCES_NAME = "murena_sso_migration"
    private const val KEY_IS_MURENA_SSO_MIGRATION_RUNNING = "is_sso_migration_running"

    fun isSsoMigrationRunning(context: Context): Boolean {
        val preferences = context.getSharedPreferences(PREFERENCES_NAME, Context.MODE_PRIVATE)
        return preferences.getBoolean(KEY_IS_MURENA_SSO_MIGRATION_RUNNING, false)
    }

    fun updateSsoMigrationStatus(context: Context, status: MigrationStatus) {
        val preferences = context.getSharedPreferences(PREFERENCES_NAME, Context.MODE_PRIVATE)
        val isRunning = (status == MigrationStatus.InProgress)
        preferences.edit { putBoolean(KEY_IS_MURENA_SSO_MIGRATION_RUNNING, isRunning) }
    }

    enum class MigrationStatus {
        InProgress, Completed
    }
}
+119 −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 at.bitfire.davdroid.murenasso

import android.accounts.AccountManager
import android.accounts.AccountManagerFuture
import android.app.Service
import android.content.Intent
import android.os.Build.VERSION
import android.os.Build.VERSION_CODES
import android.os.Bundle
import android.os.IBinder
import androidx.core.app.NotificationManagerCompat
import androidx.core.content.ContextCompat
import at.bitfire.davdroid.R
import at.bitfire.davdroid.log.Logger
import at.bitfire.davdroid.murenasso.MurenaSsoMigrationPreferences.MigrationStatus
import at.bitfire.davdroid.ui.NotificationUtils
import at.bitfire.davdroid.ui.setup.LoginActivity

class MurenaSsoMigrationService : Service() {

    companion object {
        const val NOTIFICATION_TAG_MURENA_SSO = "MIGRATE_TO_MURENA_SSO"
    }

    override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
        val accountName = intent?.getStringExtra(LoginActivity.EXTRA_USERNAME)
        if (accountName.isNullOrBlank()) {
            stopSelf()
            return START_NOT_STICKY
        }
        openMurenaLoginUi(accountName)

        return START_STICKY
    }

    private fun openMurenaLoginUi(accountName: String?) {
        val accountManager = AccountManager.get(applicationContext)
        val eAccountType = applicationContext.getString(R.string.eelo_account_type)

        accountManager.addAccount(
            eAccountType,
            null,
            null,
            null,
            null,
            { future ->
                val intent = getIntentFromFuture(future)
                if (intent == null) {
                    stopSelf() // Stops the service and dismisses the notification as migration isn't possible.
                    return@addAccount
                }

                setMigrationStatusInProgress()

                // Calling startActivity() from outside of an Activity context
                // requires the FLAG_ACTIVITY_NEW_TASK flag
                intent.apply {
                    putExtra(LoginActivity.EXTRA_USERNAME, accountName)
                    addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
                }
                ContextCompat.startActivity(applicationContext, intent, null)
            },
            null
        )
    }

    private fun getIntentFromFuture(future: AccountManagerFuture<Bundle>): Intent? {
        val bundle = future.result

        return try {
            if (VERSION.SDK_INT >= VERSION_CODES.TIRAMISU) {
                bundle?.getParcelable(AccountManager.KEY_INTENT, Intent::class.java)
            } else {
                @Suppress("DEPRECATION")
                bundle?.getParcelable(AccountManager.KEY_INTENT)
            }
        } catch (exception: Exception) {
            Logger.log.warning("${exception.javaClass}: can't add account: ${exception.message}")
            null
        }
    }

    private fun setMigrationStatusInProgress() {
        MurenaSsoMigrationPreferences.updateSsoMigrationStatus(
            applicationContext,
            MigrationStatus.InProgress
        )
    }

    override fun onBind(intent: Intent?): IBinder? {
        return null
    }

    override fun onDestroy() {
        NotificationManagerCompat.from(applicationContext).cancel(
            NOTIFICATION_TAG_MURENA_SSO,
            NotificationUtils.NOTIFY_MIGRATE_TO_MURENA_SSO
        )
        Logger.log.info("Stopping MurenaSsoMigrationService.")
        super.onDestroy()
    }
}
+98 −4
Original line number Diff line number Diff line
@@ -4,12 +4,31 @@

package at.bitfire.davdroid.receiver

import android.Manifest.permission.POST_NOTIFICATIONS
import android.accounts.Account
import android.app.PendingIntent
import android.content.BroadcastReceiver
import android.content.Context
import android.content.Intent
import android.content.pm.PackageManager.PERMISSION_GRANTED
import android.os.Build.VERSION
import android.os.Build.VERSION_CODES
import androidx.core.app.NotificationManagerCompat
import at.bitfire.davdroid.R
import at.bitfire.davdroid.log.Logger
import at.bitfire.davdroid.murenasso.MurenaSsoMigrationService
import at.bitfire.davdroid.murenasso.MurenaSsoMigrationService.Companion.NOTIFICATION_TAG_MURENA_SSO
import at.bitfire.davdroid.settings.AccountSettings
import at.bitfire.davdroid.syncadapter.AccountUtils
import at.bitfire.davdroid.ui.NotificationUtils
import at.bitfire.davdroid.ui.NotificationUtils.CHANNEL_GENERAL
import at.bitfire.davdroid.ui.NotificationUtils.NOTIFY_MIGRATE_TO_MURENA_SSO
import at.bitfire.davdroid.ui.NotificationUtils.notifyIfPossible
import at.bitfire.davdroid.ui.setup.LoginActivity
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.launch

/**
 * There are circumstances when Android drops automatic sync of accounts and resets them
@@ -19,15 +38,90 @@ import at.bitfire.davdroid.syncadapter.AccountUtils
 * is started, it checks (and repairs, if necessary) the sync intervals in [App.onCreate].
 */
class BootCompletedReceiver: BroadcastReceiver() {

    override fun onReceive(context: Context, intent: Intent) {
        Logger.log.info("Device has been rebooted; checking sync intervals etc.")
        // sync intervals are checked in App.onCreate()
        val pendingResult = goAsync()
        val job = SupervisorJob()
        val scope = CoroutineScope(job + Dispatchers.IO)

        scope.launch {
            try {
                initializeSync(context)
                handleMurenaSsoMigration(context)
            } finally {
                pendingResult.finish()
                job.cancel()
            }
        }
    }

    private fun initializeSync(context: Context) {
        AccountUtils.getMainAccounts(context)
            .forEach {
                // sync intervals are checked in App.onCreate()
                val accountSettings = AccountSettings(context, it)
                accountSettings.initSync()
            }
    }

    private fun handleMurenaSsoMigration(
        context: Context
    ) {
        val account = findAccountForSsoMigration(context)
        if (account != null) {
            notifySsoMigration(context, account)
        }
    }

    private fun findAccountForSsoMigration(context: Context): Account? {
        return AccountUtils.getMainAccounts(context)
            .firstOrNull {
                AccountUtils.isMurenaAccount(context, it) &&
                        !AccountUtils.isLoggedInWithMurenaSso(context, it)
            }
    }

    private fun notifySsoMigration(context: Context, account: Account) {
        if (!hasPermission(context)) {
            Logger.log.warning("Notification permission is not granted, skipping SSO migration.")
            return
        }

        val title = context.getString(R.string.notification_title_murena_sso, account.name)
        val contentText = context.getString(R.string.notification_text_murena_sso)
        val intent = createSsoMigrationIntent(context, account.name)

        val notification = NotificationUtils.newBuilder(context, CHANNEL_GENERAL)
            .setOngoing(true)
            .setContentTitle(title)
            .setContentText(contentText)
            .setSmallIcon(R.drawable.ic_info)
            .setContentIntent(intent)
            .setAutoCancel(false)
            .build()

        val notificationManager = NotificationManagerCompat.from(context)
        notificationManager.notifyIfPossible(NOTIFICATION_TAG_MURENA_SSO, NOTIFY_MIGRATE_TO_MURENA_SSO, notification)
    }

    private fun hasPermission(context: Context): Boolean {
        if (VERSION.SDK_INT >= VERSION_CODES.TIRAMISU) {
            val permission = POST_NOTIFICATIONS
            if (context.checkSelfPermission(permission) != PERMISSION_GRANTED) {
                return false
            }
        }
        return true
    }

    private fun createSsoMigrationIntent(context: Context, accountName: String): PendingIntent {
        val serviceIntent = Intent(context, MurenaSsoMigrationService::class.java).apply {
            putExtra(LoginActivity.EXTRA_USERNAME, accountName)
            flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TOP
        }

        return PendingIntent.getService(context,
            0,
            serviceIntent,
            PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT)
    }
}
Loading