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

Commit cc09704b authored by Mohammed Althaf T's avatar Mohammed Althaf T 😊
Browse files

AM: Add Murena account support

parent bccc568f
Loading
Loading
Loading
Loading
Loading
+4 −0
Original line number Diff line number Diff line
@@ -5,6 +5,10 @@ stages:
  - build

before_script:
  - echo MURENA_CLIENT_ID=$MURENA_CLIENT_ID >> local.properties
  - echo MURENA_REDIRECT_URI=$MURENA_REDIRECT_URI >> local.properties
  - echo MURENA_BASE_URL=$MURENA_BASE_URL_STAGING >> local.properties
  - echo MURENA_DISCOVERY_END_POINT=$MURENA_DISCOVERY_END_POINT_STAGING >> local.properties
  - export JAVA_HOME=/usr/lib/jvm/java-21-openjdk-amd64
  - export GRADLE_USER_HOME=$(pwd)/.gradle
  - chmod +x ./gradlew
+1 −0
Original line number Diff line number Diff line
build
target
ose
+26 −0
Original line number Diff line number Diff line
import java.util.Properties

/*
 * Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details.
 */
@@ -58,6 +60,18 @@ android {
        create("ose") {
            dimension = "distribution"
            versionNameSuffix = "-ose"

            buildConfigField("String", "MURENA_CLIENT_ID", "\"${retrieveKey("MURENA_CLIENT_ID")}\"")
            buildConfigField("String", "MURENA_REDIRECT_URI", "\"${retrieveKey("MURENA_REDIRECT_URI")}\"")
            buildConfigField("String", "MURENA_BASE_URL", "\"${retrieveKey("MURENA_BASE_URL")}\"")
            buildConfigField("String", "MURENA_DISCOVERY_END_POINT", "\"${retrieveKey("MURENA_DISCOVERY_END_POINT")}\"")

            manifestPlaceholders.putAll(
                mapOf<String, Any>(
                    "appAuthRedirectScheme" to "$applicationId",
                    "murenaAuthRedirectScheme" to retrieveKey("MURENA_REDIRECT_URI"),
                )
            )
        }
    }

@@ -127,6 +141,15 @@ android {
    }
}

fun retrieveKey(keyName: String): String {
    val properties = Properties().apply {
        load(rootProject.file("local.properties").inputStream())
    }

    return properties.getProperty(keyName)
        ?: throw GradleException("$keyName property not found in local.properties file")
}

ksp {
    arg("room.schemaLocation", "$projectDir/schemas")
}
@@ -213,6 +236,9 @@ dependencies {
    implementation(libs.commons.codec)
    implementation(libs.commons.lang)

    // e-Specific dependencies - (Avoid moving into toml)
    implementation("androidx.compose.runtime:runtime-livedata:1.8.3")

    // for tests
    androidTestImplementation(libs.androidx.arch.core.testing)
    androidTestImplementation(libs.androidx.test.core)
+51 −0
Original line number Diff line number Diff line
/*
 * Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details.
 */

package foundation.e.accountmanager.network

import android.net.Uri
import androidx.core.net.toUri
import at.bitfire.davdroid.BuildConfig
import net.openid.appauth.AuthorizationRequest
import net.openid.appauth.AuthorizationServiceConfiguration
import net.openid.appauth.ResponseTypeValues
import java.net.URI
import kotlin.coroutines.resume
import kotlin.coroutines.resumeWithException
import kotlin.coroutines.suspendCoroutine

object OAuthMurena {
    private val SCOPES = arrayOf("openid", "profile", "email", "offline_access")

    const val CLIENT_ID = BuildConfig.MURENA_CLIENT_ID

    private val DOMAIN: String by lazy { URI(BuildConfig.MURENA_BASE_URL).host }

    val baseUri = "https://$DOMAIN"
    val redirectURI = "${BuildConfig.MURENA_REDIRECT_URI}:/redirect".toUri()

    val discoveryUrl = BuildConfig.MURENA_DISCOVERY_END_POINT.toUri()

    suspend fun fetchOAuthConfigSuspend(discoveryUrl: Uri): AuthorizationServiceConfiguration =
        suspendCoroutine { cont ->
            AuthorizationServiceConfiguration.fetchFromUrl(discoveryUrl) { config, ex ->
                if (config != null) cont.resume(config)
                else cont.resumeWithException(ex ?: Exception("Unknown error"))
            }
        }

    fun signIn(email: String?, locale: String?, config: AuthorizationServiceConfiguration): AuthorizationRequest {
        val builder = AuthorizationRequest.Builder(
            config,
            CLIENT_ID,
            ResponseTypeValues.CODE,
            redirectURI
        )
        return builder
            .setScopes(*SCOPES)
            .setLoginHint(email)
            .setUiLocales(locale)
            .build()
    }
}
+331 −0
Original line number Diff line number Diff line
/*
 * Copyright (C) 2025 eFoundation
 *
 * 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.accountmanager.ui.setup

import android.net.Uri
import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.defaultMinSize
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.ExpandLess
import androidx.compose.material.icons.filled.ExpandMore
import androidx.compose.material3.Button
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedButton
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.SnackbarHostState
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.livedata.observeAsState
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.focus.FocusRequester
import androidx.compose.ui.focus.focusRequester
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.input.ImeAction
import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import androidx.core.net.toUri
import androidx.hilt.navigation.compose.hiltViewModel
import at.bitfire.davdroid.R
import at.bitfire.davdroid.ui.setup.LoginInfo
import at.bitfire.davdroid.ui.setup.LoginType
import foundation.e.accountmanager.network.OAuthMurena
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import java.util.logging.Level
import java.util.logging.Logger

object MurenaLogin : LoginType {
    override val title: Int
        get() = R.string.eelo_account_name

    override val helpUrl: Uri
        get() = "https://doc.e.foundation/support-topics".toUri()

    @Composable
    override fun LoginScreen(
        snackbarHostState: SnackbarHostState,
        initialLoginInfo: LoginInfo,
        onLogin: (LoginInfo) -> Unit
    ) {
        val model: MurenaLoginModel = hiltViewModel(
            creationCallback = { factory: MurenaLoginModel.Factory ->
                factory.create(loginInfo = initialLoginInfo)
            }
        )

        // continue to resource detection when result is set in model
        val uiState = model.uiState
        LaunchedEffect(uiState.result) {
            if (uiState.result != null) {
                onLogin(uiState.result)
                model.resetResult()
            }
        }

        // contract to open the browser for authentication
        val authRequestContract = rememberLauncherForActivityResult(model.authorizationContract()) { authResponse ->
            if (authResponse != null)
                model.authenticate(authResponse)
            else
                model.authCodeFailed()
        }

        LaunchedEffect(uiState.error) {
            if (uiState.error != null) {
                snackbarHostState.showSnackbar(uiState.error)
            }
        }

        val isLoading by model.isLoading.observeAsState(false)
        if (isLoading) {
            Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
                CircularProgressIndicator()
            }
            return
        }

        MurenaLoginScreen(
            email = uiState.email,
            serverUri = uiState.baseUri,
            discoveryEndPoint = uiState.discoveryEndPoint,
            onSetEmail = model::setEmail,
            onSetServerUrl = model::setCustomBareUri,
            onSetDiscoveryEndPoint = model::setDiscoveryEndPoint,
            canContinue = uiState.canContinue,
            onLogin = {
                if (uiState.canContinue) {
                    CoroutineScope(Dispatchers.Main).launch {
                        try {
                            val authRequest = model.signIn()
                            authRequestContract.launch(authRequest)
                        } catch (e: Exception) {
                            Logger.getGlobal().log(Level.WARNING, "Couldn't start OAuth intent", e)
                            model.signInFailed()
                        }
                    }
                }
            }
        )
    }
}

@Composable
fun MurenaLoginScreen(
    email: String,
    serverUri: String,
    discoveryEndPoint: String,
    onSetEmail: (String) -> Unit = {},
    onSetServerUrl: (String) -> Unit = {},
    onSetDiscoveryEndPoint: (String) -> Unit = {},
    canContinue: Boolean,
    onLogin: () -> Unit = {},
) {
    var showServerField by remember { mutableStateOf(false) }

    Column(
        modifier = Modifier
            .fillMaxSize()
            .padding(horizontal = 24.dp)
            .verticalScroll(rememberScrollState()),
        horizontalAlignment = Alignment.CenterHorizontally
    ) {
        Spacer(modifier = Modifier.height(40.dp))

        Text(
            text = stringResource(R.string.login_eelo_title),
            textAlign = TextAlign.Center,
            style = MaterialTheme.typography.bodyMedium
        )

        Spacer(modifier = Modifier.height(24.dp))

        val focusRequester = remember { FocusRequester() }
        OutlinedTextField(
            value = email,
            onValueChange = onSetEmail,
            singleLine = true,
            label = { Text(stringResource(R.string.login_user_id)) },
            placeholder = { Text("example@murena.io") },
            keyboardOptions = KeyboardOptions(
                keyboardType = KeyboardType.Email,
                imeAction = ImeAction.Done
            ),
            modifier = Modifier
                .fillMaxWidth()
                .focusRequester(focusRequester)
        )

        // Suggestion buttons to add domain suffixes
        if (!email.contains("@")) {
            Row(
                horizontalArrangement = Arrangement.spacedBy(8.dp),
                modifier = Modifier
                    .fillMaxWidth()
                    .padding(top = 8.dp)
            ) {
                OutlinedButton(
                    onClick = { onSetEmail("$email@murena.io") },
                    contentPadding = PaddingValues(horizontal = 8.dp, vertical = 4.dp),
                    shape = RoundedCornerShape(12.dp),
                    modifier = Modifier
                        .defaultMinSize(minHeight = 32.dp)
                ) {
                    Text(
                        text = "@murena.io",
                        style = MaterialTheme.typography.bodySmall
                    )
                }

                OutlinedButton(
                    onClick = { onSetEmail("$email@e.email") },
                    contentPadding = PaddingValues(horizontal = 8.dp, vertical = 4.dp),
                    shape = RoundedCornerShape(12.dp),
                    modifier = Modifier
                        .defaultMinSize(minHeight = 32.dp)
                ) {
                    Text(
                        text = "@e.email",
                        style = MaterialTheme.typography.bodySmall
                    )
                }
            }
        }

        if (showServerField) {
            OutlinedTextField(
                value = serverUri,
                onValueChange = onSetServerUrl,
                singleLine = true,
                label = { Text(stringResource(R.string.login_server_url)) },
                placeholder = { Text(OAuthMurena.baseUri) },
                keyboardOptions = KeyboardOptions(
                    keyboardType = KeyboardType.Uri,
                    imeAction = ImeAction.Done
                ),
                modifier = Modifier
                    .fillMaxWidth()
                    .focusRequester(focusRequester)
            )
            OutlinedTextField(
                value = discoveryEndPoint,
                onValueChange = onSetDiscoveryEndPoint,
                label = { Text(stringResource(R.string.login_discovery_endpoint)) },
                placeholder = { Text(OAuthMurena.discoveryUrl.toString()) },
                keyboardOptions = KeyboardOptions(
                    keyboardType = KeyboardType.Uri,
                    imeAction = ImeAction.Done
                ),
                modifier = Modifier
                    .fillMaxWidth()
                    .focusRequester(focusRequester)
            )
        } else {
            onSetServerUrl("")
            onSetDiscoveryEndPoint("")
        }

        LaunchedEffect(Unit) {
            if (email.isEmpty()) focusRequester.requestFocus()
        }

        Spacer(modifier = Modifier.height(14.dp))

        Button(
            onClick = onLogin,
            enabled = canContinue,
            modifier = Modifier
                .fillMaxWidth()
                .height(48.dp)
        ) {
            Text(stringResource(R.string.login_nextcloud_login_flow_sign_in))
        }

        Spacer(modifier = Modifier.height(16.dp))

        Column (
            modifier = Modifier
                .clickable { showServerField = !showServerField }
                .padding(vertical = 8.dp),
            horizontalAlignment = Alignment.CenterHorizontally
        ) {
            Text(
                text = stringResource(R.string.login_eelo_server_uri_title),
                color = Color.Gray,
                style = MaterialTheme.typography.bodyMedium
            )

            Spacer(modifier = Modifier.width(4.dp))

            Icon(
                imageVector = if (showServerField) Icons.Default.ExpandLess else Icons.Default.ExpandMore,
                contentDescription = if (showServerField) stringResource(R.string.collapse) else stringResource(R.string.expand),
                tint = Color.Gray
            )
        }
    }
}

@Composable
@Preview(showBackground = true)
fun MurenaLoginScreen_Preview_Empty() {
    MurenaLoginScreen(
        email = "",
        serverUri = "",
        discoveryEndPoint = "",
        canContinue = false,
    )
}

@Composable
@Preview(showBackground = true)
fun MurenaLoginScreen_Preview_WithDefaultEmail() {
    MurenaLoginScreen(
        email = "example@murena.io",
        serverUri = OAuthMurena.baseUri,
        discoveryEndPoint = OAuthMurena.discoveryUrl.toString(),
        canContinue = true,
    )
}
Loading