From 69b0f3d16e397c0636f6941cb2d4b8db5f75e3b7 Mon Sep 17 00:00:00 2001 From: Andrey Slyusar Date: Fri, 17 Nov 2023 14:18:34 +0300 Subject: [PATCH 01/13] chore: Update AGP - Update AGP version to 8.2.0 Signed-off-by: Andrey Slyusar --- .idea/gradle.xml | 5 ++--- .idea/migrations.xml | 10 ++++++++++ gradle/libs.versions.toml | 2 +- 3 files changed, 13 insertions(+), 4 deletions(-) create mode 100644 .idea/migrations.xml diff --git a/.idea/gradle.xml b/.idea/gradle.xml index ae388c2..0897082 100644 --- a/.idea/gradle.xml +++ b/.idea/gradle.xml @@ -4,16 +4,15 @@ diff --git a/.idea/migrations.xml b/.idea/migrations.xml new file mode 100644 index 0000000..f8051a6 --- /dev/null +++ b/.idea/migrations.xml @@ -0,0 +1,10 @@ + + + + + + \ No newline at end of file diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 2574ce2..d605c72 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -1,6 +1,6 @@ [versions] activity-compose = "1.7.2" -agp = "8.1.3" +agp = "8.2.0" androidx-test-ext-junit = "1.1.5" compose-bom = "2023.06.01" core-ktx = "1.10.1" From ea0ae93e75aaca872ea60a81be59dc733a2719e1 Mon Sep 17 00:00:00 2001 From: Andrey Slyusar Date: Wed, 13 Dec 2023 20:56:50 +0300 Subject: [PATCH 02/13] chore: Migrate to Android 14 - Update Java to 17 Signed-off-by: Andrey Slyusar --- app/build.gradle | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/app/build.gradle b/app/build.gradle index 5fc4159..f4a030d 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -5,12 +5,12 @@ plugins { android { namespace 'com.reysand.files' - compileSdk 33 + compileSdk 34 defaultConfig { applicationId "com.reysand.files" minSdk 33 - targetSdk 33 + targetSdk 34 versionCode 3 versionName "0.1.0" @@ -27,11 +27,11 @@ android { } } compileOptions { - sourceCompatibility JavaVersion.VERSION_1_8 - targetCompatibility JavaVersion.VERSION_1_8 + sourceCompatibility JavaVersion.VERSION_17 + targetCompatibility JavaVersion.VERSION_17 } kotlinOptions { - jvmTarget = '1.8' + jvmTarget = '17' } buildFeatures { compose true From cf096558ddbc75d664c66400b53902e31343eed9 Mon Sep 17 00:00:00 2001 From: Andrey Slyusar Date: Thu, 14 Dec 2023 00:09:31 +0300 Subject: [PATCH 03/13] chore: Update Kotlin and dependencies - Update kotlin compiler extension version to 1.5.6 - Update activity-compose version to 1.8.1 - Update compose-bom version to 2023.10.01 - Update core-ktx version to 1.12.0 - Update kotlin version to 1.9.21 - Update navigation-compose version to 2.7.5 Signed-off-by: Andrey Slyusar --- .idea/kotlinc.xml | 2 +- app/build.gradle | 2 +- gradle/libs.versions.toml | 10 +++++----- 3 files changed, 7 insertions(+), 7 deletions(-) diff --git a/.idea/kotlinc.xml b/.idea/kotlinc.xml index e805548..ae3f30a 100644 --- a/.idea/kotlinc.xml +++ b/.idea/kotlinc.xml @@ -1,6 +1,6 @@ - \ No newline at end of file diff --git a/app/build.gradle b/app/build.gradle index f4a030d..703e4cc 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -37,7 +37,7 @@ android { compose true } composeOptions { - kotlinCompilerExtensionVersion '1.5.4' + kotlinCompilerExtensionVersion '1.5.6' } packaging { resources { diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index d605c72..0d87bd2 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -1,14 +1,14 @@ [versions] -activity-compose = "1.7.2" +activity-compose = "1.8.1" agp = "8.2.0" androidx-test-ext-junit = "1.1.5" -compose-bom = "2023.06.01" -core-ktx = "1.10.1" +compose-bom = "2023.10.01" +core-ktx = "1.12.0" espresso-core = "3.5.1" junit = "4.13.2" -kotlin = "1.9.20" +kotlin = "1.9.21" lifecycle-runtime-ktx = "2.6.2" -navigation-compose = "2.6.0" +navigation-compose = "2.7.5" [libraries] activity-compose = { group = "androidx.activity", name = "activity-compose", version.ref = "activity-compose" } From 94e0134d8e5704526517f1f01bea52406e117094 Mon Sep 17 00:00:00 2001 From: Andrey Slyusar Date: Thu, 14 Dec 2023 00:51:43 +0300 Subject: [PATCH 04/13] build: Migrate to Kotlin DSL Signed-off-by: Andrey Slyusar --- app/build.gradle | 71 ------------------------- app/build.gradle.kts | 89 ++++++++++++++++++++++++++++++++ app/proguard-rules.pro | 2 +- build.gradle => build.gradle.kts | 4 +- 4 files changed, 92 insertions(+), 74 deletions(-) delete mode 100644 app/build.gradle create mode 100644 app/build.gradle.kts rename build.gradle => build.gradle.kts (50%) diff --git a/app/build.gradle b/app/build.gradle deleted file mode 100644 index 703e4cc..0000000 --- a/app/build.gradle +++ /dev/null @@ -1,71 +0,0 @@ -plugins { - alias libs.plugins.android.application - alias libs.plugins.kotlin.android -} - -android { - namespace 'com.reysand.files' - compileSdk 34 - - defaultConfig { - applicationId "com.reysand.files" - minSdk 33 - targetSdk 34 - versionCode 3 - versionName "0.1.0" - - testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner" - vectorDrawables { - useSupportLibrary true - } - } - - buildTypes { - release { - minifyEnabled false - proguardFiles getDefaultProguardFile('proguard-android-optimize.txt'), 'proguard-rules.pro' - } - } - compileOptions { - sourceCompatibility JavaVersion.VERSION_17 - targetCompatibility JavaVersion.VERSION_17 - } - kotlinOptions { - jvmTarget = '17' - } - buildFeatures { - compose true - } - composeOptions { - kotlinCompilerExtensionVersion '1.5.6' - } - packaging { - resources { - excludes += '/META-INF/{AL2.0,LGPL2.1}' - } - } -} - -dependencies { - - implementation libs.core.ktx - implementation libs.lifecycle.runtime.ktx - implementation libs.activity.compose - - implementation platform(libs.compose.bom) - implementation libs.ui - implementation libs.ui.graphics - implementation libs.ui.tooling.preview - implementation libs.material3 - implementation libs.lifecycle.viewmodel.compose - implementation libs.navigation.compose - - testImplementation libs.junit - androidTestImplementation libs.androidx.test.ext.junit - androidTestImplementation libs.espresso.core - androidTestImplementation platform(libs.compose.bom) - androidTestImplementation libs.ui.test.junit4 - - debugImplementation libs.ui.tooling - debugImplementation libs.ui.test.manifest -} \ No newline at end of file diff --git a/app/build.gradle.kts b/app/build.gradle.kts new file mode 100644 index 0000000..c74069c --- /dev/null +++ b/app/build.gradle.kts @@ -0,0 +1,89 @@ +/* + * Copyright 2023 Andrey Slyusar + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +plugins { + alias(libs.plugins.android.application) + alias(libs.plugins.kotlin.android) +} + +android { + namespace = "com.reysand.files" + compileSdk = 34 + + defaultConfig { + applicationId = "com.reysand.files" + minSdk = 33 + targetSdk = 34 + versionCode = 3 + versionName = "0.1.0" + + testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" + vectorDrawables { + useSupportLibrary = true + } + } + + buildTypes { + release { + isMinifyEnabled = false + proguardFiles( + getDefaultProguardFile("proguard-android-optimize.txt"), + "proguard-rules.pro" + ) + } + } + compileOptions { + sourceCompatibility = JavaVersion.VERSION_17 + targetCompatibility = JavaVersion.VERSION_17 + } + kotlinOptions { + jvmTarget = "17" + } + buildFeatures { + compose = true + } + composeOptions { + kotlinCompilerExtensionVersion = "1.5.6" + } + packaging { + resources { + excludes += "/META-INF/{AL2.0,LGPL2.1}" + } + } +} + +dependencies { + + implementation(libs.core.ktx) + implementation(libs.lifecycle.runtime.ktx) + implementation(libs.activity.compose) + + implementation(platform(libs.compose.bom)) + implementation(libs.ui) + implementation(libs.ui.graphics) + implementation(libs.ui.tooling.preview) + implementation(libs.material3) + implementation(libs.lifecycle.viewmodel.compose) + implementation(libs.navigation.compose) + + testImplementation(libs.junit) + androidTestImplementation(libs.androidx.test.ext.junit) + androidTestImplementation(libs.espresso.core) + androidTestImplementation(platform(libs.compose.bom)) + androidTestImplementation(libs.ui.test.junit4) + + debugImplementation(libs.ui.tooling) + debugImplementation(libs.ui.test.manifest) +} \ No newline at end of file diff --git a/app/proguard-rules.pro b/app/proguard-rules.pro index 481bb43..ff59496 100644 --- a/app/proguard-rules.pro +++ b/app/proguard-rules.pro @@ -1,6 +1,6 @@ # Add project specific ProGuard rules here. # You can control the set of applied configuration files using the -# proguardFiles setting in build.gradle. +# proguardFiles setting in build.gradle.kts. # # For more details, see # http://developer.android.com/guide/developing/tools/proguard.html diff --git a/build.gradle b/build.gradle.kts similarity index 50% rename from build.gradle rename to build.gradle.kts index 87a11f3..922f551 100644 --- a/build.gradle +++ b/build.gradle.kts @@ -1,5 +1,5 @@ // Top-level build file where you can add configuration options common to all sub-projects/modules. plugins { - alias libs.plugins.android.application apply false - alias libs.plugins.kotlin.android apply false + alias(libs.plugins.android.application) apply false + alias(libs.plugins.kotlin.android) apply false } \ No newline at end of file From 12e944a8f0901cc45a9f57aa987859d7b2f4bf9e Mon Sep 17 00:00:00 2001 From: Andrey Slyusar Date: Thu, 11 Jan 2024 16:53:53 +0300 Subject: [PATCH 05/13] feat: Add Microsoft Sign-In - Implementation of saving credentials using dataStore Signed-off-by: Andrey Slyusar --- app/build.gradle.kts | 32 +++++ app/src/main/AndroidManifest.xml | 20 ++- .../com/reysand/files/FilesApplication.kt | 2 +- .../com/reysand/files/data/AppContainer.kt | 11 +- .../com/reysand/files/data/model/AuthModel.kt | 25 ++++ .../files/data/remote/AuthDataStore.kt | 69 +++++++++++ .../files/data/repository/AuthRepository.kt | 44 +++++++ .../java/com/reysand/files/ui/FilesApp.kt | 17 ++- .../reysand/files/ui/navigation/NavGraph.kt | 5 +- .../files/ui/screens/SettingsScreen.kt | 44 ++++++- .../reysand/files/ui/util/OneDriveService.kt | 114 ++++++++++++++++++ .../files/ui/viewmodel/FilesViewModel.kt | 39 +++++- app/src/main/res/values/strings.xml | 3 + gradle/libs.versions.toml | 8 ++ settings.gradle | 3 + 15 files changed, 423 insertions(+), 13 deletions(-) create mode 100644 app/src/main/java/com/reysand/files/data/model/AuthModel.kt create mode 100644 app/src/main/java/com/reysand/files/data/remote/AuthDataStore.kt create mode 100644 app/src/main/java/com/reysand/files/data/repository/AuthRepository.kt create mode 100644 app/src/main/java/com/reysand/files/ui/util/OneDriveService.kt diff --git a/app/build.gradle.kts b/app/build.gradle.kts index c74069c..bff8313 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -13,11 +13,22 @@ * See the License for the specific language governing permissions and * limitations under the License. */ +import java.io.FileInputStream +import java.util.Properties + plugins { alias(libs.plugins.android.application) alias(libs.plugins.kotlin.android) } +val keystorePropertiesFile = rootProject.file("keystore.properties") +val keystoreProperties = Properties() +keystoreProperties.load(FileInputStream(keystorePropertiesFile)) + +val secretsPropertiesFile = rootProject.file("secrets.properties") +val secretsProperties = Properties() +secretsProperties.load(FileInputStream(secretsPropertiesFile)) + android { namespace = "com.reysand.files" compileSdk = 34 @@ -35,8 +46,22 @@ android { } } + signingConfigs { + create("release") { + storeFile = file(keystoreProperties["storeFile"] as String) + storePassword = keystoreProperties["storePassword"] as String + keyAlias = keystoreProperties["keyAlias"] as String + keyPassword = keystoreProperties["keyPassword"] as String + } + } + buildTypes { + debug { + manifestPlaceholders["signatureHash"] = secretsProperties["DEBUG_SIGNATURE_HASH"] as String + } release { + manifestPlaceholders["signatureHash"] = secretsProperties["RELEASE_SIGNATURE_HASH"] as String + signingConfig = signingConfigs.getByName("release") isMinifyEnabled = false proguardFiles( getDefaultProguardFile("proguard-android-optimize.txt"), @@ -77,6 +102,13 @@ dependencies { implementation(libs.material3) implementation(libs.lifecycle.viewmodel.compose) implementation(libs.navigation.compose) + implementation(libs.datastore.preferences) + + implementation(libs.msal) { + exclude(group = "io.opentelemetry") + } + implementation(libs.opentelemetry.api) + implementation(libs.opentelemetry.context) testImplementation(libs.junit) androidTestImplementation(libs.androidx.test.ext.junit) diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 849ef44..94f3f77 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -16,7 +16,7 @@ - + @@ -41,6 +41,24 @@ + + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/java/com/reysand/files/FilesApplication.kt b/app/src/main/java/com/reysand/files/FilesApplication.kt index bffcfdd..aad815a 100644 --- a/app/src/main/java/com/reysand/files/FilesApplication.kt +++ b/app/src/main/java/com/reysand/files/FilesApplication.kt @@ -25,6 +25,6 @@ class FilesApplication : Application() { override fun onCreate() { super.onCreate() - container = DefaultAppContainer() + container = DefaultAppContainer(this) } } \ No newline at end of file diff --git a/app/src/main/java/com/reysand/files/data/AppContainer.kt b/app/src/main/java/com/reysand/files/data/AppContainer.kt index 4dd582d..c2cc1e1 100644 --- a/app/src/main/java/com/reysand/files/data/AppContainer.kt +++ b/app/src/main/java/com/reysand/files/data/AppContainer.kt @@ -15,17 +15,26 @@ */ package com.reysand.files.data +import android.content.Context import com.reysand.files.data.local.FileLocalDataSource +import com.reysand.files.data.remote.AuthDataStore +import com.reysand.files.data.repository.AuthRepository import com.reysand.files.data.repository.FileRepository interface AppContainer { val fileRepository: FileRepository + + val authRepository: AuthRepository } -class DefaultAppContainer : AppContainer { +class DefaultAppContainer(context: Context) : AppContainer { override val fileRepository: FileRepository by lazy { FileLocalDataSource() } + + override val authRepository: AuthRepository by lazy { + AuthDataStore(context) + } } \ No newline at end of file diff --git a/app/src/main/java/com/reysand/files/data/model/AuthModel.kt b/app/src/main/java/com/reysand/files/data/model/AuthModel.kt new file mode 100644 index 0000000..5f30cbd --- /dev/null +++ b/app/src/main/java/com/reysand/files/data/model/AuthModel.kt @@ -0,0 +1,25 @@ +/* + * Copyright 2023 Andrey Slyusar + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.reysand.files.data.model + +/** + * Data class representing a credential. + * + * @property email The email of the user. + */ +data class AuthModel( + val email: String +) diff --git a/app/src/main/java/com/reysand/files/data/remote/AuthDataStore.kt b/app/src/main/java/com/reysand/files/data/remote/AuthDataStore.kt new file mode 100644 index 0000000..a15bcd4 --- /dev/null +++ b/app/src/main/java/com/reysand/files/data/remote/AuthDataStore.kt @@ -0,0 +1,69 @@ +/* + * Copyright 2023 Andrey Slyusar + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.reysand.files.data.remote + +import android.content.Context +import androidx.datastore.preferences.core.edit +import androidx.datastore.preferences.core.stringPreferencesKey +import androidx.datastore.preferences.preferencesDataStore +import com.reysand.files.data.model.AuthModel +import com.reysand.files.data.repository.AuthRepository +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.map + +private const val USER_PREFERENCES_NAME = "user_preferences" + +val Context.dataStore by preferencesDataStore( + name = USER_PREFERENCES_NAME +) + +/** + * Implementation of [AuthRepository] using DataStore for storing user credentials. + * + * @param context The application context. + */ +class AuthDataStore(context: Context) : AuthRepository { + + private val dataStore = context.dataStore + + private object PreferencesKeys { + val EMAIL = stringPreferencesKey("email") + } + + override suspend fun saveAuth(email: String) { + val authInfo = AuthModel(email) + dataStore.edit { preferences -> + preferences[PreferencesKeys.EMAIL] = authInfo.email + } + } + + override suspend fun removeAuth() { + dataStore.edit { preferences -> + preferences.remove(PreferencesKeys.EMAIL) + } + } + + override fun getAuth(): Flow { + return dataStore.data.map { preferences -> + val email = preferences[PreferencesKeys.EMAIL] ?: "" + if (email.isNotEmpty()) { + AuthModel(email) + } else { + null + } + } + } +} diff --git a/app/src/main/java/com/reysand/files/data/repository/AuthRepository.kt b/app/src/main/java/com/reysand/files/data/repository/AuthRepository.kt new file mode 100644 index 0000000..23cc4ff --- /dev/null +++ b/app/src/main/java/com/reysand/files/data/repository/AuthRepository.kt @@ -0,0 +1,44 @@ +/* + * Copyright 2023 Andrey Slyusar + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.reysand.files.data.repository + +import com.reysand.files.data.model.AuthModel +import kotlinx.coroutines.flow.Flow + +/** + * Interface defining operations for interacting with authentication. + */ +interface AuthRepository { + + /** + * Saves the user credentials. + * + * @param email The email of the user. + */ + suspend fun saveAuth(email: String) + + /** + * Removes the user credentials. + */ + suspend fun removeAuth() + + /** + * Gets the user credentials. + * + * @return [Flow] of [AuthModel] that emits the user credentials. + */ + fun getAuth(): Flow +} \ No newline at end of file diff --git a/app/src/main/java/com/reysand/files/ui/FilesApp.kt b/app/src/main/java/com/reysand/files/ui/FilesApp.kt index 6733602..e578403 100644 --- a/app/src/main/java/com/reysand/files/ui/FilesApp.kt +++ b/app/src/main/java/com/reysand/files/ui/FilesApp.kt @@ -33,6 +33,7 @@ import androidx.compose.material3.TopAppBarDefaults import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.res.stringResource import androidx.lifecycle.viewmodel.compose.viewModel import androidx.navigation.compose.currentBackStackEntryAsState @@ -41,6 +42,7 @@ import com.reysand.files.R import com.reysand.files.ui.components.PathTabs import com.reysand.files.ui.navigation.Destinations import com.reysand.files.ui.navigation.NavGraph +import com.reysand.files.ui.util.OneDriveService import com.reysand.files.ui.viewmodel.FilesViewModel /** @@ -54,6 +56,8 @@ fun FilesApp(filesViewModel: FilesViewModel = viewModel(factory = FilesViewModel // Create a navigation controller val navController = rememberNavController() + val context = LocalContext.current + // Get the current route from the navigation stack val navBackStackEntry by navController.currentBackStackEntryAsState() val currentRoute = navBackStackEntry?.destination?.route @@ -68,15 +72,14 @@ fun FilesApp(filesViewModel: FilesViewModel = viewModel(factory = FilesViewModel Scaffold(topBar = { CenterAlignedTopAppBar( title = { - Text( - text = stringResource(id = topBarTitle) - ) + Text(text = stringResource(id = topBarTitle)) }, navigationIcon = { if (currentRoute != Destinations.HOME) { IconButton(onClick = { if (filesViewModel.currentDirectory.value != filesViewModel.homeDirectory && - currentRoute == Destinations.FILE_LIST) { + currentRoute == Destinations.FILE_LIST + ) { filesViewModel.navigateUp() } else { navController.popBackStack() @@ -99,7 +102,7 @@ fun FilesApp(filesViewModel: FilesViewModel = viewModel(factory = FilesViewModel }, colors = TopAppBarDefaults.centerAlignedTopAppBarColors( containerColor = MaterialTheme.colorScheme.primaryContainer.copy(alpha = 0.5F) - ), + ) ) }) { Surface( @@ -117,7 +120,9 @@ fun FilesApp(filesViewModel: FilesViewModel = viewModel(factory = FilesViewModel } } NavGraph( - filesViewModel = filesViewModel, navController = navController + filesViewModel = filesViewModel, + navController = navController, + oneDriveService = OneDriveService(context) ) } } diff --git a/app/src/main/java/com/reysand/files/ui/navigation/NavGraph.kt b/app/src/main/java/com/reysand/files/ui/navigation/NavGraph.kt index 626b226..7295c14 100644 --- a/app/src/main/java/com/reysand/files/ui/navigation/NavGraph.kt +++ b/app/src/main/java/com/reysand/files/ui/navigation/NavGraph.kt @@ -23,6 +23,7 @@ import androidx.navigation.compose.composable import com.reysand.files.ui.screens.FileListScreen import com.reysand.files.ui.screens.HomeScreen import com.reysand.files.ui.screens.SettingsScreen +import com.reysand.files.ui.util.OneDriveService import com.reysand.files.ui.viewmodel.FilesViewModel /** @@ -30,12 +31,14 @@ import com.reysand.files.ui.viewmodel.FilesViewModel * * @param filesViewModel The [FilesViewModel] providing data for the screen. * @param navController NavHostController for managing navigation within the app. + * @param oneDriveService The [OneDriveService] for accessing OneDrive. * @param modifier Modifier for customizing the layout. */ @Composable fun NavGraph( filesViewModel: FilesViewModel, navController: NavHostController, + oneDriveService: OneDriveService, modifier: Modifier = Modifier ) { NavHost(navController = navController, startDestination = Destinations.HOME) { @@ -46,7 +49,7 @@ fun NavGraph( FileListScreen(filesViewModel = filesViewModel) } composable(Destinations.SETTINGS) { - SettingsScreen() + SettingsScreen(filesViewModel = filesViewModel, oneDriveService = oneDriveService) } } } \ No newline at end of file diff --git a/app/src/main/java/com/reysand/files/ui/screens/SettingsScreen.kt b/app/src/main/java/com/reysand/files/ui/screens/SettingsScreen.kt index efb556f..6382f7e 100644 --- a/app/src/main/java/com/reysand/files/ui/screens/SettingsScreen.kt +++ b/app/src/main/java/com/reysand/files/ui/screens/SettingsScreen.kt @@ -28,6 +28,9 @@ import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Surface import androidx.compose.material3.Text import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.ui.Modifier import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.res.painterResource @@ -38,17 +41,56 @@ import androidx.compose.ui.unit.dp import com.reysand.files.R import com.reysand.files.ui.components.allowPermission import com.reysand.files.ui.theme.FilesTheme +import com.reysand.files.ui.util.OneDriveService +import com.reysand.files.ui.viewmodel.FilesViewModel +import kotlinx.coroutines.launch /** * Composable function for displaying the settings screen. + * + * @param filesViewModel The [FilesViewModel] providing data for the screen. + * @param oneDriveService The [OneDriveService] for accessing OneDrive. + */ @Composable -fun SettingsScreen() { +fun SettingsScreen(filesViewModel: FilesViewModel, oneDriveService: OneDriveService) { + val context = LocalContext.current + val oneDriveAccount by remember { filesViewModel.oneDriveAccount } Column( modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp) ) { + SettingsSection(title = stringResource(id = R.string.settings_account_title)) { + val scope = rememberCoroutineScope() + + ListItem( + headlineContent = { + Text(text = stringResource(id = R.string.onedrive_storage)) + }, + modifier = Modifier.clickable(onClick = { + scope.launch { + if (oneDriveService.isSignedIn()) { + oneDriveService.signOut() + filesViewModel.oneDriveAccount.value = null + filesViewModel.removeAuthInfo() + } else { + oneDriveService.signIn { account -> + filesViewModel.oneDriveAccount.value = account + filesViewModel.setAuthInfo(account!!) + } + } + } + }), + trailingContent = { + Text( + text = oneDriveAccount + ?: stringResource(id = R.string.settings_account_sign_in), + style = MaterialTheme.typography.bodyMedium + ) + } + ) + } SettingsSection(title = stringResource(id = R.string.settings_privacy_title)) { ListItem( headlineContent = { diff --git a/app/src/main/java/com/reysand/files/ui/util/OneDriveService.kt b/app/src/main/java/com/reysand/files/ui/util/OneDriveService.kt new file mode 100644 index 0000000..d961bb7 --- /dev/null +++ b/app/src/main/java/com/reysand/files/ui/util/OneDriveService.kt @@ -0,0 +1,114 @@ +/* + * Copyright 2023 Andrey Slyusar + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.reysand.files.ui.util + +import android.app.Activity +import android.content.Context +import android.util.Log +import com.microsoft.identity.client.AuthenticationCallback +import com.microsoft.identity.client.IAccount +import com.microsoft.identity.client.IAuthenticationResult +import com.microsoft.identity.client.ISingleAccountPublicClientApplication +import com.microsoft.identity.client.PublicClientApplication +import com.microsoft.identity.client.SignInParameters +import com.microsoft.identity.client.exception.MsalException +import com.reysand.files.R +import kotlinx.coroutines.Deferred +import kotlinx.coroutines.DelicateCoroutinesApi +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.GlobalScope +import kotlinx.coroutines.async +import kotlinx.coroutines.withContext + +private const val TAG = "OneDriveService" + +/** + * Service class for accessing OneDrive. + * + * @param context The context of the app. + */ +class OneDriveService(val context: Context) { + + var mAccount: IAccount? = null + private val scopes: List = listOf("User.Read") + + /** + * Gets the [ISingleAccountPublicClientApplication] instance. + */ + @OptIn(DelicateCoroutinesApi::class) + private val msalPublicClient: Deferred by lazy { + GlobalScope.async(Dispatchers.IO) { + PublicClientApplication.createSingleAccountPublicClientApplication( + context, + R.raw.auth_config_single_account + ) + } + } + + /** + * Signs in the user. + * + * @param callback The callback to be invoked when the sign in process finishes. + */ + suspend fun signIn(callback: (String?) -> Unit) { + val signInParameters = SignInParameters.builder() + .withActivity(context as Activity) + .withScopes(scopes) + .withCallback(object : AuthenticationCallback { + override fun onSuccess(authenticationResult: IAuthenticationResult?) { + mAccount = authenticationResult?.account + callback(mAccount?.username) + Log.d(TAG, "signIn: Success") + } + + override fun onError(exception: MsalException?) { + exception?.printStackTrace() + } + + override fun onCancel() { + Log.d(TAG, "signIn: Cancelled") + } + }) + .build() + + val client = msalPublicClient.await() + client.signIn(signInParameters) + } + + /** + * Checks whether the user is signed in. + * + * @return A boolean value indicating whether the user is signed in. + */ + suspend fun isSignedIn(): Boolean = withContext(Dispatchers.IO) { + try { + val client = msalPublicClient.await() + val account = client.currentAccount + account.currentAccount != null + } catch (e: Exception) { + false + } + } + + /** + * Signs out the current account. + */ + suspend fun signOut() = withContext(Dispatchers.IO) { + val client = msalPublicClient.await() + client.signOut() + Log.d(TAG, "signOut: Success") + } +} \ No newline at end of file diff --git a/app/src/main/java/com/reysand/files/ui/viewmodel/FilesViewModel.kt b/app/src/main/java/com/reysand/files/ui/viewmodel/FilesViewModel.kt index 688e125..c9aba01 100644 --- a/app/src/main/java/com/reysand/files/ui/viewmodel/FilesViewModel.kt +++ b/app/src/main/java/com/reysand/files/ui/viewmodel/FilesViewModel.kt @@ -26,6 +26,7 @@ import androidx.lifecycle.viewmodel.viewModelFactory import com.reysand.files.FilesApplication import com.reysand.files.R import com.reysand.files.data.model.FileModel +import com.reysand.files.data.repository.AuthRepository import com.reysand.files.data.repository.FileRepository import com.reysand.files.ui.util.ContextWrapper import kotlinx.coroutines.flow.MutableStateFlow @@ -41,7 +42,8 @@ import java.io.File */ class FilesViewModel( private val fileRepository: FileRepository, - private val contextWrapper: ContextWrapper + private val contextWrapper: ContextWrapper, + private val authRepository: AuthRepository ) : ViewModel() { // MutableStateFlow holding the list of files @@ -55,9 +57,37 @@ class FilesViewModel( // State indicating whether to show the permission dialog val showPermissionDialog = mutableStateOf(!Environment.isExternalStorageManager()) + val oneDriveAccount = mutableStateOf(null) + // Initialize the ViewModel by loading files from the home directory init { getFiles(homeDirectory) + + viewModelScope.launch { + authRepository.getAuth().collect { + oneDriveAccount.value = it?.email + } + } + } + + /** + * Set the email of the authenticated user. + * + * @param email The email of the authenticated user. + */ + fun setAuthInfo(email: String) { + viewModelScope.launch { + authRepository.saveAuth(email) + } + } + + /** + * Remove the user credentials. + */ + fun removeAuthInfo() { + viewModelScope.launch { + authRepository.removeAuth() + } } /** @@ -161,7 +191,12 @@ class FilesViewModel( val application = (this[APPLICATION_KEY] as FilesApplication) val fileRepository = application.container.fileRepository val contextWrapper = ContextWrapper(application.applicationContext) - FilesViewModel(fileRepository = fileRepository, contextWrapper = contextWrapper) + val authRepository = application.container.authRepository + FilesViewModel( + fileRepository = fileRepository, + contextWrapper = contextWrapper, + authRepository = authRepository + ) } } } diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 970c671..7ed51f7 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -35,9 +35,12 @@ Internal Storage + OneDrive %1$s free Settings + Accounts Privacy + Sign in \ No newline at end of file diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 0d87bd2..806609b 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -4,23 +4,31 @@ agp = "8.2.0" androidx-test-ext-junit = "1.1.5" compose-bom = "2023.10.01" core-ktx = "1.12.0" +datastore-preferences = "1.0.0" espresso-core = "3.5.1" junit = "4.13.2" kotlin = "1.9.21" lifecycle-runtime-ktx = "2.6.2" +msal = "4.10.0" navigation-compose = "2.7.5" +opentelemetry-api = "1.18.0" +opentelemetry-context = "1.18.0" [libraries] activity-compose = { group = "androidx.activity", name = "activity-compose", version.ref = "activity-compose" } androidx-test-ext-junit = { group = "androidx.test.ext", name = "junit", version.ref = "androidx-test-ext-junit" } compose-bom = { group = "androidx.compose", name = "compose-bom", version.ref = "compose-bom" } core-ktx = { group = "androidx.core", name = "core-ktx", version.ref = "core-ktx" } +datastore-preferences = { group = "androidx.datastore", name = "datastore-preferences", version.ref = "datastore-preferences" } espresso-core = { group = "androidx.test.espresso", name = "espresso-core", version.ref = "espresso-core" } junit = { group = "junit", name = "junit", version.ref = "junit" } lifecycle-runtime-ktx = { group = "androidx.lifecycle", name = "lifecycle-runtime-ktx", version.ref = "lifecycle-runtime-ktx" } lifecycle-viewmodel-compose = { group = "androidx.lifecycle", name = "lifecycle-viewmodel-compose", version.ref = "lifecycle-runtime-ktx" } material3 = { group = "androidx.compose.material3", name = "material3" } +msal = { group = "com.microsoft.identity.client", name = "msal", version.ref = "msal" } navigation-compose = { group = "androidx.navigation", name = "navigation-compose", version.ref = "navigation-compose" } +opentelemetry-api = { group = "io.opentelemetry", name = "opentelemetry-api", version.ref = "opentelemetry-api" } +opentelemetry-context = { group = "io.opentelemetry", name = "opentelemetry-context", version.ref = "opentelemetry-context" } ui = { group = "androidx.compose.ui", name = "ui" } ui-graphics = { group = "androidx.compose.ui", name = "ui-graphics" } ui-test-junit4 = { group = "androidx.compose.ui", name = "ui-test-junit4" } diff --git a/settings.gradle b/settings.gradle index f59482a..86ddad1 100644 --- a/settings.gradle +++ b/settings.gradle @@ -10,6 +10,9 @@ dependencyResolutionManagement { repositories { google() mavenCentral() + maven { + url 'https://pkgs.dev.azure.com/MicrosoftDeviceSDK/DuoSDK-Public/_packaging/Duo-SDK-Feed/maven/v1' + } } } From ea3dd7687a006eee27557394851ff2fcb424c4fb Mon Sep 17 00:00:00 2001 From: Andrey Slyusar Date: Thu, 11 Jan 2024 19:54:10 +0300 Subject: [PATCH 06/13] ci: Update android.yml - Generate necessary files from secrets - Decode and create keystore.jks - Build signed release apk - Upload a build artifact Signed-off-by: Andrey Slyusar --- .github/workflows/android.yml | 36 +++++++++++++++++++++++++++++++---- 1 file changed, 32 insertions(+), 4 deletions(-) diff --git a/.github/workflows/android.yml b/.github/workflows/android.yml index a5fcdd9..eaab641 100644 --- a/.github/workflows/android.yml +++ b/.github/workflows/android.yml @@ -2,9 +2,9 @@ name: Android CI on: push: - branches: [ "master" ] + branches: [ "master", "dev" ] pull_request: - branches: [ "master" ] + branches: [ "master", "dev" ] jobs: build: @@ -13,14 +13,42 @@ jobs: steps: - uses: actions/checkout@v4 - - name: set up JDK 17 + - name: Set up JDK 17 uses: actions/setup-java@v3 with: java-version: '17' distribution: 'temurin' cache: gradle + - name: Generate auth_config_single_account.json + run: | + mkdir ./app/src/main/res/raw + echo "${{ secrets.AUTH_CONFIG_SINGLE_ACCOUNT }} > ./app/src/main/res/raw/auth_config_single_account.json + + - name: Generate secrets.properties + run: | + echo "${{ secrets.SECRETS_PROPERTIES }} > ./secrets.properties + + - name: Generate keystore.properties + run: | + cat < keystore.properties + storeFile:${{ vars.KEYSTORE_STORE_FILE_PATH }} + storePassword:${{ secrets.KEYSTORE_STORE_PASSWORD }} + keyAlias:${{ secrets.KEYSTORE_KEY_ALIAS }} + keyPassword:${{ secrets.KEYSTORE_KEY_PASSWORD }} + EOF + + - name: Decode keystore and create jks + run: | + echo "${{ secrets.KEYSTORE_JKS }}" | base64 --decode > keystore.jks + - name: Grant execute permission for gradlew run: chmod +x gradlew - name: Build with Gradle - run: ./gradlew build + run: ./gradlew assembleRelease + + - name: Upload a Build Artifact + uses: actions/upload-artifact@v3.1.3 + with: + name: files + path: ./app/build/outputs/apk/release/app-release.apk From cb2cd4fedf04f1b2827ecf8164b05459520a033b Mon Sep 17 00:00:00 2001 From: Andrey Slyusar Date: Mon, 15 Jan 2024 16:24:49 +0300 Subject: [PATCH 07/13] refactor: rename OneDriveService - Save access token Signed-off-by: Andrey Slyusar --- app/build.gradle.kts | 2 +- .../com/reysand/files/data/model/AuthModel.kt | 3 ++- .../reysand/files/data/remote/AuthDataStore.kt | 12 ++++++++---- .../files/data/repository/AuthRepository.kt | 2 +- .../main/java/com/reysand/files/ui/FilesApp.kt | 4 ++-- .../com/reysand/files/ui/navigation/NavGraph.kt | 8 ++++---- .../reysand/files/ui/screens/SettingsScreen.kt | 14 +++++++------- .../{OneDriveService.kt => MicrosoftService.kt} | 15 +++++++++------ .../reysand/files/ui/viewmodel/FilesViewModel.kt | 6 ++++-- 9 files changed, 38 insertions(+), 28 deletions(-) rename app/src/main/java/com/reysand/files/ui/util/{OneDriveService.kt => MicrosoftService.kt} (89%) diff --git a/app/build.gradle.kts b/app/build.gradle.kts index bff8313..a4eb80e 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -38,7 +38,7 @@ android { minSdk = 33 targetSdk = 34 versionCode = 3 - versionName = "0.1.0" + versionName = "0.1.1" testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" vectorDrawables { diff --git a/app/src/main/java/com/reysand/files/data/model/AuthModel.kt b/app/src/main/java/com/reysand/files/data/model/AuthModel.kt index 5f30cbd..e4c113b 100644 --- a/app/src/main/java/com/reysand/files/data/model/AuthModel.kt +++ b/app/src/main/java/com/reysand/files/data/model/AuthModel.kt @@ -21,5 +21,6 @@ package com.reysand.files.data.model * @property email The email of the user. */ data class AuthModel( - val email: String + val email: String, + val token: String ) diff --git a/app/src/main/java/com/reysand/files/data/remote/AuthDataStore.kt b/app/src/main/java/com/reysand/files/data/remote/AuthDataStore.kt index a15bcd4..f25fc07 100644 --- a/app/src/main/java/com/reysand/files/data/remote/AuthDataStore.kt +++ b/app/src/main/java/com/reysand/files/data/remote/AuthDataStore.kt @@ -41,26 +41,30 @@ class AuthDataStore(context: Context) : AuthRepository { private object PreferencesKeys { val EMAIL = stringPreferencesKey("email") + val TOKEN = stringPreferencesKey("token") } - override suspend fun saveAuth(email: String) { - val authInfo = AuthModel(email) + override suspend fun saveAuth(email: String, token: String) { + val authInfo = AuthModel(email, token) dataStore.edit { preferences -> preferences[PreferencesKeys.EMAIL] = authInfo.email + preferences[PreferencesKeys.TOKEN] = authInfo.token } } override suspend fun removeAuth() { dataStore.edit { preferences -> preferences.remove(PreferencesKeys.EMAIL) + preferences.remove(PreferencesKeys.TOKEN) } } override fun getAuth(): Flow { return dataStore.data.map { preferences -> val email = preferences[PreferencesKeys.EMAIL] ?: "" - if (email.isNotEmpty()) { - AuthModel(email) + val token = preferences[PreferencesKeys.TOKEN] ?: "" + if (email.isNotEmpty() && token.isNotEmpty()) { + AuthModel(email, token) } else { null } diff --git a/app/src/main/java/com/reysand/files/data/repository/AuthRepository.kt b/app/src/main/java/com/reysand/files/data/repository/AuthRepository.kt index 23cc4ff..dbd52eb 100644 --- a/app/src/main/java/com/reysand/files/data/repository/AuthRepository.kt +++ b/app/src/main/java/com/reysand/files/data/repository/AuthRepository.kt @@ -28,7 +28,7 @@ interface AuthRepository { * * @param email The email of the user. */ - suspend fun saveAuth(email: String) + suspend fun saveAuth(email: String, token: String) /** * Removes the user credentials. diff --git a/app/src/main/java/com/reysand/files/ui/FilesApp.kt b/app/src/main/java/com/reysand/files/ui/FilesApp.kt index e578403..213edfa 100644 --- a/app/src/main/java/com/reysand/files/ui/FilesApp.kt +++ b/app/src/main/java/com/reysand/files/ui/FilesApp.kt @@ -42,7 +42,7 @@ import com.reysand.files.R import com.reysand.files.ui.components.PathTabs import com.reysand.files.ui.navigation.Destinations import com.reysand.files.ui.navigation.NavGraph -import com.reysand.files.ui.util.OneDriveService +import com.reysand.files.ui.util.MicrosoftService import com.reysand.files.ui.viewmodel.FilesViewModel /** @@ -122,7 +122,7 @@ fun FilesApp(filesViewModel: FilesViewModel = viewModel(factory = FilesViewModel NavGraph( filesViewModel = filesViewModel, navController = navController, - oneDriveService = OneDriveService(context) + microsoftService = MicrosoftService(context) ) } } diff --git a/app/src/main/java/com/reysand/files/ui/navigation/NavGraph.kt b/app/src/main/java/com/reysand/files/ui/navigation/NavGraph.kt index 7295c14..dab3027 100644 --- a/app/src/main/java/com/reysand/files/ui/navigation/NavGraph.kt +++ b/app/src/main/java/com/reysand/files/ui/navigation/NavGraph.kt @@ -23,7 +23,7 @@ import androidx.navigation.compose.composable import com.reysand.files.ui.screens.FileListScreen import com.reysand.files.ui.screens.HomeScreen import com.reysand.files.ui.screens.SettingsScreen -import com.reysand.files.ui.util.OneDriveService +import com.reysand.files.ui.util.MicrosoftService import com.reysand.files.ui.viewmodel.FilesViewModel /** @@ -31,14 +31,14 @@ import com.reysand.files.ui.viewmodel.FilesViewModel * * @param filesViewModel The [FilesViewModel] providing data for the screen. * @param navController NavHostController for managing navigation within the app. - * @param oneDriveService The [OneDriveService] for accessing OneDrive. + * @param microsoftService The [MicrosoftService] for accessing OneDrive. * @param modifier Modifier for customizing the layout. */ @Composable fun NavGraph( filesViewModel: FilesViewModel, navController: NavHostController, - oneDriveService: OneDriveService, + microsoftService: MicrosoftService, modifier: Modifier = Modifier ) { NavHost(navController = navController, startDestination = Destinations.HOME) { @@ -49,7 +49,7 @@ fun NavGraph( FileListScreen(filesViewModel = filesViewModel) } composable(Destinations.SETTINGS) { - SettingsScreen(filesViewModel = filesViewModel, oneDriveService = oneDriveService) + SettingsScreen(filesViewModel = filesViewModel, microsoftService = microsoftService) } } } \ No newline at end of file diff --git a/app/src/main/java/com/reysand/files/ui/screens/SettingsScreen.kt b/app/src/main/java/com/reysand/files/ui/screens/SettingsScreen.kt index 6382f7e..07277ac 100644 --- a/app/src/main/java/com/reysand/files/ui/screens/SettingsScreen.kt +++ b/app/src/main/java/com/reysand/files/ui/screens/SettingsScreen.kt @@ -41,7 +41,7 @@ import androidx.compose.ui.unit.dp import com.reysand.files.R import com.reysand.files.ui.components.allowPermission import com.reysand.files.ui.theme.FilesTheme -import com.reysand.files.ui.util.OneDriveService +import com.reysand.files.ui.util.MicrosoftService import com.reysand.files.ui.viewmodel.FilesViewModel import kotlinx.coroutines.launch @@ -49,11 +49,11 @@ import kotlinx.coroutines.launch * Composable function for displaying the settings screen. * * @param filesViewModel The [FilesViewModel] providing data for the screen. - * @param oneDriveService The [OneDriveService] for accessing OneDrive. + * @param microsoftService The [MicrosoftService] for accessing OneDrive. */ @Composable -fun SettingsScreen(filesViewModel: FilesViewModel, oneDriveService: OneDriveService) { +fun SettingsScreen(filesViewModel: FilesViewModel, microsoftService: MicrosoftService) { val context = LocalContext.current val oneDriveAccount by remember { filesViewModel.oneDriveAccount } @@ -70,14 +70,14 @@ fun SettingsScreen(filesViewModel: FilesViewModel, oneDriveService: OneDriveServ }, modifier = Modifier.clickable(onClick = { scope.launch { - if (oneDriveService.isSignedIn()) { - oneDriveService.signOut() + if (microsoftService.isSignedIn()) { + microsoftService.signOut() filesViewModel.oneDriveAccount.value = null filesViewModel.removeAuthInfo() } else { - oneDriveService.signIn { account -> + microsoftService.signIn { account, token -> filesViewModel.oneDriveAccount.value = account - filesViewModel.setAuthInfo(account!!) + filesViewModel.setAuthInfo(account!!, token!!) } } } diff --git a/app/src/main/java/com/reysand/files/ui/util/OneDriveService.kt b/app/src/main/java/com/reysand/files/ui/util/MicrosoftService.kt similarity index 89% rename from app/src/main/java/com/reysand/files/ui/util/OneDriveService.kt rename to app/src/main/java/com/reysand/files/ui/util/MicrosoftService.kt index d961bb7..4ae09ac 100644 --- a/app/src/main/java/com/reysand/files/ui/util/OneDriveService.kt +++ b/app/src/main/java/com/reysand/files/ui/util/MicrosoftService.kt @@ -33,16 +33,17 @@ import kotlinx.coroutines.GlobalScope import kotlinx.coroutines.async import kotlinx.coroutines.withContext -private const val TAG = "OneDriveService" +private const val TAG = "MicrosoftService" /** * Service class for accessing OneDrive. * * @param context The context of the app. */ -class OneDriveService(val context: Context) { +class MicrosoftService(val context: Context) { var mAccount: IAccount? = null + var mAccessToken: String? = null private val scopes: List = listOf("User.Read") /** @@ -63,14 +64,15 @@ class OneDriveService(val context: Context) { * * @param callback The callback to be invoked when the sign in process finishes. */ - suspend fun signIn(callback: (String?) -> Unit) { + suspend fun signIn(callback: (String?, String?) -> Unit) { val signInParameters = SignInParameters.builder() .withActivity(context as Activity) .withScopes(scopes) .withCallback(object : AuthenticationCallback { override fun onSuccess(authenticationResult: IAuthenticationResult?) { mAccount = authenticationResult?.account - callback(mAccount?.username) + mAccessToken = authenticationResult?.accessToken + callback(mAccount?.username, mAccessToken) Log.d(TAG, "signIn: Success") } @@ -96,8 +98,7 @@ class OneDriveService(val context: Context) { suspend fun isSignedIn(): Boolean = withContext(Dispatchers.IO) { try { val client = msalPublicClient.await() - val account = client.currentAccount - account.currentAccount != null + client.currentAccount.currentAccount != null } catch (e: Exception) { false } @@ -109,6 +110,8 @@ class OneDriveService(val context: Context) { suspend fun signOut() = withContext(Dispatchers.IO) { val client = msalPublicClient.await() client.signOut() + mAccount = null + mAccessToken = null Log.d(TAG, "signOut: Success") } } \ No newline at end of file diff --git a/app/src/main/java/com/reysand/files/ui/viewmodel/FilesViewModel.kt b/app/src/main/java/com/reysand/files/ui/viewmodel/FilesViewModel.kt index c9aba01..6ad34eb 100644 --- a/app/src/main/java/com/reysand/files/ui/viewmodel/FilesViewModel.kt +++ b/app/src/main/java/com/reysand/files/ui/viewmodel/FilesViewModel.kt @@ -58,6 +58,7 @@ class FilesViewModel( val showPermissionDialog = mutableStateOf(!Environment.isExternalStorageManager()) val oneDriveAccount = mutableStateOf(null) + val oneDriveToken = mutableStateOf(null) // Initialize the ViewModel by loading files from the home directory init { @@ -66,6 +67,7 @@ class FilesViewModel( viewModelScope.launch { authRepository.getAuth().collect { oneDriveAccount.value = it?.email + oneDriveToken.value = it?.token } } } @@ -75,9 +77,9 @@ class FilesViewModel( * * @param email The email of the authenticated user. */ - fun setAuthInfo(email: String) { + fun setAuthInfo(email: String, token: String) { viewModelScope.launch { - authRepository.saveAuth(email) + authRepository.saveAuth(email, token) } } From a88138a276d5a5f5b619f1390d4a08866c1fcdf6 Mon Sep 17 00:00:00 2001 From: Andrey Slyusar Date: Tue, 30 Jan 2024 17:26:44 +0300 Subject: [PATCH 08/13] refactor: Move MicrosoftService to data layer - Add a silent token receiving - Remove Data Store preferences Signed-off-by: Andrey Slyusar --- app/build.gradle.kts | 1 - .../com/reysand/files/data/AppContainer.kt | 9 +-- .../com/reysand/files/data/model/AuthModel.kt | 26 ------- .../files/data/remote/AuthDataStore.kt | 73 ------------------- .../files/data/repository/AuthRepository.kt | 44 ----------- .../{ui => data}/util/MicrosoftService.kt | 47 +++++++++++- .../java/com/reysand/files/ui/FilesApp.kt | 7 +- .../reysand/files/ui/navigation/NavGraph.kt | 5 +- .../files/ui/screens/SettingsScreen.kt | 16 +--- .../files/ui/viewmodel/FilesViewModel.kt | 42 +++++------ 10 files changed, 71 insertions(+), 199 deletions(-) delete mode 100644 app/src/main/java/com/reysand/files/data/model/AuthModel.kt delete mode 100644 app/src/main/java/com/reysand/files/data/remote/AuthDataStore.kt delete mode 100644 app/src/main/java/com/reysand/files/data/repository/AuthRepository.kt rename app/src/main/java/com/reysand/files/{ui => data}/util/MicrosoftService.kt (70%) diff --git a/app/build.gradle.kts b/app/build.gradle.kts index a4eb80e..07f900b 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -102,7 +102,6 @@ dependencies { implementation(libs.material3) implementation(libs.lifecycle.viewmodel.compose) implementation(libs.navigation.compose) - implementation(libs.datastore.preferences) implementation(libs.msal) { exclude(group = "io.opentelemetry") diff --git a/app/src/main/java/com/reysand/files/data/AppContainer.kt b/app/src/main/java/com/reysand/files/data/AppContainer.kt index c2cc1e1..9259277 100644 --- a/app/src/main/java/com/reysand/files/data/AppContainer.kt +++ b/app/src/main/java/com/reysand/files/data/AppContainer.kt @@ -17,15 +17,14 @@ package com.reysand.files.data import android.content.Context import com.reysand.files.data.local.FileLocalDataSource -import com.reysand.files.data.remote.AuthDataStore -import com.reysand.files.data.repository.AuthRepository import com.reysand.files.data.repository.FileRepository +import com.reysand.files.data.util.MicrosoftService interface AppContainer { val fileRepository: FileRepository - val authRepository: AuthRepository + val microsoftService: MicrosoftService } class DefaultAppContainer(context: Context) : AppContainer { @@ -34,7 +33,7 @@ class DefaultAppContainer(context: Context) : AppContainer { FileLocalDataSource() } - override val authRepository: AuthRepository by lazy { - AuthDataStore(context) + override val microsoftService: MicrosoftService by lazy { + MicrosoftService(context) } } \ No newline at end of file diff --git a/app/src/main/java/com/reysand/files/data/model/AuthModel.kt b/app/src/main/java/com/reysand/files/data/model/AuthModel.kt deleted file mode 100644 index e4c113b..0000000 --- a/app/src/main/java/com/reysand/files/data/model/AuthModel.kt +++ /dev/null @@ -1,26 +0,0 @@ -/* - * Copyright 2023 Andrey Slyusar - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ -package com.reysand.files.data.model - -/** - * Data class representing a credential. - * - * @property email The email of the user. - */ -data class AuthModel( - val email: String, - val token: String -) diff --git a/app/src/main/java/com/reysand/files/data/remote/AuthDataStore.kt b/app/src/main/java/com/reysand/files/data/remote/AuthDataStore.kt deleted file mode 100644 index f25fc07..0000000 --- a/app/src/main/java/com/reysand/files/data/remote/AuthDataStore.kt +++ /dev/null @@ -1,73 +0,0 @@ -/* - * Copyright 2023 Andrey Slyusar - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ -package com.reysand.files.data.remote - -import android.content.Context -import androidx.datastore.preferences.core.edit -import androidx.datastore.preferences.core.stringPreferencesKey -import androidx.datastore.preferences.preferencesDataStore -import com.reysand.files.data.model.AuthModel -import com.reysand.files.data.repository.AuthRepository -import kotlinx.coroutines.flow.Flow -import kotlinx.coroutines.flow.map - -private const val USER_PREFERENCES_NAME = "user_preferences" - -val Context.dataStore by preferencesDataStore( - name = USER_PREFERENCES_NAME -) - -/** - * Implementation of [AuthRepository] using DataStore for storing user credentials. - * - * @param context The application context. - */ -class AuthDataStore(context: Context) : AuthRepository { - - private val dataStore = context.dataStore - - private object PreferencesKeys { - val EMAIL = stringPreferencesKey("email") - val TOKEN = stringPreferencesKey("token") - } - - override suspend fun saveAuth(email: String, token: String) { - val authInfo = AuthModel(email, token) - dataStore.edit { preferences -> - preferences[PreferencesKeys.EMAIL] = authInfo.email - preferences[PreferencesKeys.TOKEN] = authInfo.token - } - } - - override suspend fun removeAuth() { - dataStore.edit { preferences -> - preferences.remove(PreferencesKeys.EMAIL) - preferences.remove(PreferencesKeys.TOKEN) - } - } - - override fun getAuth(): Flow { - return dataStore.data.map { preferences -> - val email = preferences[PreferencesKeys.EMAIL] ?: "" - val token = preferences[PreferencesKeys.TOKEN] ?: "" - if (email.isNotEmpty() && token.isNotEmpty()) { - AuthModel(email, token) - } else { - null - } - } - } -} diff --git a/app/src/main/java/com/reysand/files/data/repository/AuthRepository.kt b/app/src/main/java/com/reysand/files/data/repository/AuthRepository.kt deleted file mode 100644 index dbd52eb..0000000 --- a/app/src/main/java/com/reysand/files/data/repository/AuthRepository.kt +++ /dev/null @@ -1,44 +0,0 @@ -/* - * Copyright 2023 Andrey Slyusar - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ -package com.reysand.files.data.repository - -import com.reysand.files.data.model.AuthModel -import kotlinx.coroutines.flow.Flow - -/** - * Interface defining operations for interacting with authentication. - */ -interface AuthRepository { - - /** - * Saves the user credentials. - * - * @param email The email of the user. - */ - suspend fun saveAuth(email: String, token: String) - - /** - * Removes the user credentials. - */ - suspend fun removeAuth() - - /** - * Gets the user credentials. - * - * @return [Flow] of [AuthModel] that emits the user credentials. - */ - fun getAuth(): Flow -} \ No newline at end of file diff --git a/app/src/main/java/com/reysand/files/ui/util/MicrosoftService.kt b/app/src/main/java/com/reysand/files/data/util/MicrosoftService.kt similarity index 70% rename from app/src/main/java/com/reysand/files/ui/util/MicrosoftService.kt rename to app/src/main/java/com/reysand/files/data/util/MicrosoftService.kt index 4ae09ac..bb83675 100644 --- a/app/src/main/java/com/reysand/files/ui/util/MicrosoftService.kt +++ b/app/src/main/java/com/reysand/files/data/util/MicrosoftService.kt @@ -1,5 +1,5 @@ /* - * Copyright 2023 Andrey Slyusar + * Copyright 2024 Andrey Slyusar * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -13,11 +13,12 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -package com.reysand.files.ui.util +package com.reysand.files.data.util import android.app.Activity import android.content.Context import android.util.Log +import com.microsoft.identity.client.AcquireTokenSilentParameters import com.microsoft.identity.client.AuthenticationCallback import com.microsoft.identity.client.IAccount import com.microsoft.identity.client.IAuthenticationResult @@ -31,6 +32,8 @@ import kotlinx.coroutines.DelicateCoroutinesApi import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.GlobalScope import kotlinx.coroutines.async +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.flow import kotlinx.coroutines.withContext private const val TAG = "MicrosoftService" @@ -40,12 +43,17 @@ private const val TAG = "MicrosoftService" * * @param context The context of the app. */ +@OptIn(DelicateCoroutinesApi::class) class MicrosoftService(val context: Context) { var mAccount: IAccount? = null var mAccessToken: String? = null private val scopes: List = listOf("User.Read") + val usernameFlow: Flow = flow { + emit(mAccount?.username) + } + /** * Gets the [ISingleAccountPublicClientApplication] instance. */ @@ -62,9 +70,10 @@ class MicrosoftService(val context: Context) { /** * Signs in the user. * + * @param context The context of the app. * @param callback The callback to be invoked when the sign in process finishes. */ - suspend fun signIn(callback: (String?, String?) -> Unit) { + suspend fun signIn(context: Context, callback: (String?) -> Unit) { val signInParameters = SignInParameters.builder() .withActivity(context as Activity) .withScopes(scopes) @@ -72,7 +81,7 @@ class MicrosoftService(val context: Context) { override fun onSuccess(authenticationResult: IAuthenticationResult?) { mAccount = authenticationResult?.account mAccessToken = authenticationResult?.accessToken - callback(mAccount?.username, mAccessToken) + callback(mAccount?.username) Log.d(TAG, "signIn: Success") } @@ -90,6 +99,36 @@ class MicrosoftService(val context: Context) { client.signIn(signInParameters) } + /** + * Acquires an access token silently. + */ + suspend fun acquireTokenSilently() = withContext(Dispatchers.IO) { + val client = msalPublicClient.await() + mAccount = client.currentAccount.currentAccount + + val silentParameters: AcquireTokenSilentParameters = AcquireTokenSilentParameters.Builder() + .forAccount(mAccount) + .fromAuthority(mAccount?.authority) + .withScopes(scopes) + .withCallback(object : AuthenticationCallback { + override fun onSuccess(authenticationResult: IAuthenticationResult?) { + mAccessToken = authenticationResult?.accessToken + Log.d(TAG, "acquireTokenSilently: Success") + } + + override fun onError(exception: MsalException?) { + exception?.printStackTrace() + } + + override fun onCancel() { + Log.d(TAG, "acquireTokenSilently: Cancelled") + } + }) + .build() + + client.acquireTokenSilentAsync(silentParameters) + } + /** * Checks whether the user is signed in. * diff --git a/app/src/main/java/com/reysand/files/ui/FilesApp.kt b/app/src/main/java/com/reysand/files/ui/FilesApp.kt index 213edfa..ca9de6d 100644 --- a/app/src/main/java/com/reysand/files/ui/FilesApp.kt +++ b/app/src/main/java/com/reysand/files/ui/FilesApp.kt @@ -33,7 +33,6 @@ import androidx.compose.material3.TopAppBarDefaults import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue import androidx.compose.ui.Modifier -import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.res.stringResource import androidx.lifecycle.viewmodel.compose.viewModel import androidx.navigation.compose.currentBackStackEntryAsState @@ -42,7 +41,6 @@ import com.reysand.files.R import com.reysand.files.ui.components.PathTabs import com.reysand.files.ui.navigation.Destinations import com.reysand.files.ui.navigation.NavGraph -import com.reysand.files.ui.util.MicrosoftService import com.reysand.files.ui.viewmodel.FilesViewModel /** @@ -56,8 +54,6 @@ fun FilesApp(filesViewModel: FilesViewModel = viewModel(factory = FilesViewModel // Create a navigation controller val navController = rememberNavController() - val context = LocalContext.current - // Get the current route from the navigation stack val navBackStackEntry by navController.currentBackStackEntryAsState() val currentRoute = navBackStackEntry?.destination?.route @@ -121,8 +117,7 @@ fun FilesApp(filesViewModel: FilesViewModel = viewModel(factory = FilesViewModel } NavGraph( filesViewModel = filesViewModel, - navController = navController, - microsoftService = MicrosoftService(context) + navController = navController ) } } diff --git a/app/src/main/java/com/reysand/files/ui/navigation/NavGraph.kt b/app/src/main/java/com/reysand/files/ui/navigation/NavGraph.kt index dab3027..e27dae5 100644 --- a/app/src/main/java/com/reysand/files/ui/navigation/NavGraph.kt +++ b/app/src/main/java/com/reysand/files/ui/navigation/NavGraph.kt @@ -23,7 +23,6 @@ import androidx.navigation.compose.composable import com.reysand.files.ui.screens.FileListScreen import com.reysand.files.ui.screens.HomeScreen import com.reysand.files.ui.screens.SettingsScreen -import com.reysand.files.ui.util.MicrosoftService import com.reysand.files.ui.viewmodel.FilesViewModel /** @@ -31,14 +30,12 @@ import com.reysand.files.ui.viewmodel.FilesViewModel * * @param filesViewModel The [FilesViewModel] providing data for the screen. * @param navController NavHostController for managing navigation within the app. - * @param microsoftService The [MicrosoftService] for accessing OneDrive. * @param modifier Modifier for customizing the layout. */ @Composable fun NavGraph( filesViewModel: FilesViewModel, navController: NavHostController, - microsoftService: MicrosoftService, modifier: Modifier = Modifier ) { NavHost(navController = navController, startDestination = Destinations.HOME) { @@ -49,7 +46,7 @@ fun NavGraph( FileListScreen(filesViewModel = filesViewModel) } composable(Destinations.SETTINGS) { - SettingsScreen(filesViewModel = filesViewModel, microsoftService = microsoftService) + SettingsScreen(filesViewModel = filesViewModel) } } } \ No newline at end of file diff --git a/app/src/main/java/com/reysand/files/ui/screens/SettingsScreen.kt b/app/src/main/java/com/reysand/files/ui/screens/SettingsScreen.kt index 07277ac..45ab918 100644 --- a/app/src/main/java/com/reysand/files/ui/screens/SettingsScreen.kt +++ b/app/src/main/java/com/reysand/files/ui/screens/SettingsScreen.kt @@ -41,7 +41,6 @@ import androidx.compose.ui.unit.dp import com.reysand.files.R import com.reysand.files.ui.components.allowPermission import com.reysand.files.ui.theme.FilesTheme -import com.reysand.files.ui.util.MicrosoftService import com.reysand.files.ui.viewmodel.FilesViewModel import kotlinx.coroutines.launch @@ -49,11 +48,9 @@ import kotlinx.coroutines.launch * Composable function for displaying the settings screen. * * @param filesViewModel The [FilesViewModel] providing data for the screen. - * @param microsoftService The [MicrosoftService] for accessing OneDrive. - */ @Composable -fun SettingsScreen(filesViewModel: FilesViewModel, microsoftService: MicrosoftService) { +fun SettingsScreen(filesViewModel: FilesViewModel) { val context = LocalContext.current val oneDriveAccount by remember { filesViewModel.oneDriveAccount } @@ -70,16 +67,7 @@ fun SettingsScreen(filesViewModel: FilesViewModel, microsoftService: MicrosoftSe }, modifier = Modifier.clickable(onClick = { scope.launch { - if (microsoftService.isSignedIn()) { - microsoftService.signOut() - filesViewModel.oneDriveAccount.value = null - filesViewModel.removeAuthInfo() - } else { - microsoftService.signIn { account, token -> - filesViewModel.oneDriveAccount.value = account - filesViewModel.setAuthInfo(account!!, token!!) - } - } + filesViewModel.toggleMicrosoftSignIn(context) } }), trailingContent = { diff --git a/app/src/main/java/com/reysand/files/ui/viewmodel/FilesViewModel.kt b/app/src/main/java/com/reysand/files/ui/viewmodel/FilesViewModel.kt index 6ad34eb..fa0df62 100644 --- a/app/src/main/java/com/reysand/files/ui/viewmodel/FilesViewModel.kt +++ b/app/src/main/java/com/reysand/files/ui/viewmodel/FilesViewModel.kt @@ -15,7 +15,9 @@ */ package com.reysand.files.ui.viewmodel +import android.content.Context import android.os.Environment +import android.util.Log import androidx.compose.runtime.mutableStateOf import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModelProvider @@ -26,8 +28,8 @@ import androidx.lifecycle.viewmodel.viewModelFactory import com.reysand.files.FilesApplication import com.reysand.files.R import com.reysand.files.data.model.FileModel -import com.reysand.files.data.repository.AuthRepository import com.reysand.files.data.repository.FileRepository +import com.reysand.files.data.util.MicrosoftService import com.reysand.files.ui.util.ContextWrapper import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.asStateFlow @@ -43,7 +45,7 @@ import java.io.File class FilesViewModel( private val fileRepository: FileRepository, private val contextWrapper: ContextWrapper, - private val authRepository: AuthRepository + private val microsoftService: MicrosoftService ) : ViewModel() { // MutableStateFlow holding the list of files @@ -58,37 +60,33 @@ class FilesViewModel( val showPermissionDialog = mutableStateOf(!Environment.isExternalStorageManager()) val oneDriveAccount = mutableStateOf(null) - val oneDriveToken = mutableStateOf(null) // Initialize the ViewModel by loading files from the home directory init { getFiles(homeDirectory) viewModelScope.launch { - authRepository.getAuth().collect { - oneDriveAccount.value = it?.email - oneDriveToken.value = it?.token + microsoftService.acquireTokenSilently() + microsoftService.usernameFlow.collect { username -> + oneDriveAccount.value = username + Log.d("FilesViewModel", "oneDriveAccount: ${oneDriveAccount.value}") } } } /** - * Set the email of the authenticated user. + * Toggle the Microsoft sign-in state. * - * @param email The email of the authenticated user. + * @param context The context of the app. */ - fun setAuthInfo(email: String, token: String) { - viewModelScope.launch { - authRepository.saveAuth(email, token) - } - } - - /** - * Remove the user credentials. - */ - fun removeAuthInfo() { - viewModelScope.launch { - authRepository.removeAuth() + suspend fun toggleMicrosoftSignIn(context: Context) { + if (microsoftService.isSignedIn()) { + microsoftService.signOut() + oneDriveAccount.value = null + } else { + microsoftService.signIn(context) { account -> + oneDriveAccount.value = account + } } } @@ -193,11 +191,11 @@ class FilesViewModel( val application = (this[APPLICATION_KEY] as FilesApplication) val fileRepository = application.container.fileRepository val contextWrapper = ContextWrapper(application.applicationContext) - val authRepository = application.container.authRepository + val microsoftService = application.container.microsoftService FilesViewModel( fileRepository = fileRepository, contextWrapper = contextWrapper, - authRepository = authRepository + microsoftService = microsoftService ) } } From a53d98910eaefd15b9d5badc2b770bf106f20987 Mon Sep 17 00:00:00 2001 From: Andrey Slyusar Date: Thu, 1 Feb 2024 17:35:00 +0300 Subject: [PATCH 09/13] feat: Add OneDrive integration Signed-off-by: Andrey Slyusar --- app/build.gradle.kts | 3 + .../com/reysand/files/data/AppContainer.kt | 8 ++ .../com/reysand/files/data/model/FileModel.kt | 33 +---- .../com/reysand/files/data/model/OneDrive.kt | 53 +++++++ .../reysand/files/data/model/OneDriveQuota.kt | 34 +++++ .../data/remote/FileOneDriveDataSource.kt | 131 ++++++++++++++++++ .../data/repository/OneDriveRepository.kt | 74 ++++++++++ .../files/data/util/FileDateFormatter.kt | 72 ++++++++++ .../files/data/util/MicrosoftService.kt | 3 +- .../files/data/util/OneDriveService.kt | 35 +++++ .../java/com/reysand/files/ui/FilesApp.kt | 21 ++- .../files/ui/screens/FileListScreen.kt | 4 +- .../reysand/files/ui/screens/HomeScreen.kt | 33 ++++- .../files/ui/viewmodel/FilesViewModel.kt | 53 ++++++- .../main/res/drawable/ic_cloud_storage.xml | 20 +++ gradle/libs.versions.toml | 3 + 16 files changed, 538 insertions(+), 42 deletions(-) create mode 100644 app/src/main/java/com/reysand/files/data/model/OneDrive.kt create mode 100644 app/src/main/java/com/reysand/files/data/model/OneDriveQuota.kt create mode 100644 app/src/main/java/com/reysand/files/data/remote/FileOneDriveDataSource.kt create mode 100644 app/src/main/java/com/reysand/files/data/repository/OneDriveRepository.kt create mode 100644 app/src/main/java/com/reysand/files/data/util/FileDateFormatter.kt create mode 100644 app/src/main/java/com/reysand/files/data/util/OneDriveService.kt create mode 100644 app/src/main/res/drawable/ic_cloud_storage.xml diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 07f900b..a0363d4 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -109,6 +109,9 @@ dependencies { implementation(libs.opentelemetry.api) implementation(libs.opentelemetry.context) + implementation(libs.retrofit) + implementation(libs.retrofit.gson) + testImplementation(libs.junit) androidTestImplementation(libs.androidx.test.ext.junit) androidTestImplementation(libs.espresso.core) diff --git a/app/src/main/java/com/reysand/files/data/AppContainer.kt b/app/src/main/java/com/reysand/files/data/AppContainer.kt index 9259277..e129493 100644 --- a/app/src/main/java/com/reysand/files/data/AppContainer.kt +++ b/app/src/main/java/com/reysand/files/data/AppContainer.kt @@ -17,13 +17,17 @@ package com.reysand.files.data import android.content.Context import com.reysand.files.data.local.FileLocalDataSource +import com.reysand.files.data.remote.FileOneDriveDataSource import com.reysand.files.data.repository.FileRepository +import com.reysand.files.data.repository.OneDriveRepository import com.reysand.files.data.util.MicrosoftService interface AppContainer { val fileRepository: FileRepository + val oneDriveRepository: OneDriveRepository + val microsoftService: MicrosoftService } @@ -33,6 +37,10 @@ class DefaultAppContainer(context: Context) : AppContainer { FileLocalDataSource() } + override val oneDriveRepository: OneDriveRepository by lazy { + FileOneDriveDataSource(microsoftService) + } + override val microsoftService: MicrosoftService by lazy { MicrosoftService(context) } diff --git a/app/src/main/java/com/reysand/files/data/model/FileModel.kt b/app/src/main/java/com/reysand/files/data/model/FileModel.kt index e8ee60c..2637b79 100644 --- a/app/src/main/java/com/reysand/files/data/model/FileModel.kt +++ b/app/src/main/java/com/reysand/files/data/model/FileModel.kt @@ -15,9 +15,8 @@ */ package com.reysand.files.data.model +import com.reysand.files.data.util.FileDateFormatter import com.reysand.files.data.util.FileSizeFormatter -import java.util.Calendar -import java.util.Locale /** * Data class representing a file. @@ -43,33 +42,7 @@ data class FileModel( OTHER } - /** - * Gets a human-readable formatted size. - * - * @return A string representing the formatted size (e.g., "2.5 MB"). - */ - fun getFormattedSize(): String { - return FileSizeFormatter.getFormattedSize(size) - } - - /** - * Gets the last modified date in a human-readable format. - * - * @return A string representing the last modified date (e.g., "Sep 12, 2023). - */ - fun getLastModified(): String { - val current = Calendar.getInstance() - val date = Calendar.getInstance().apply { timeInMillis = lastModified } + fun getFormattedSize(): String = FileSizeFormatter.getFormattedSize(size) - val displayMonth = date.getDisplayName(Calendar.MONTH, Calendar.SHORT, Locale.getDefault()) - val dayOfMonth = date.get(Calendar.DAY_OF_MONTH) - - val formattedDate = buildString { - append("$displayMonth $dayOfMonth") - if (date.get(Calendar.YEAR) != current.get(Calendar.YEAR)) { - append(", ${date.get(Calendar.YEAR)}") - } - } - return formattedDate - } + fun getLastModified(): String = FileDateFormatter.getLastModified(lastModified) } diff --git a/app/src/main/java/com/reysand/files/data/model/OneDrive.kt b/app/src/main/java/com/reysand/files/data/model/OneDrive.kt new file mode 100644 index 0000000..a943310 --- /dev/null +++ b/app/src/main/java/com/reysand/files/data/model/OneDrive.kt @@ -0,0 +1,53 @@ +/* + * Copyright 2024 Andrey Slyusar + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.reysand.files.data.model + +/** + * Data class representing a OneDrive object. + * + * @property value The list of files. + */ +data class OneDrive( + val value: List +) + +/** + * Data class representing a file from OneDrive. + * + * @property name The name of the file. + * @property size The size of the file in bytes. + * @property folder Is the file a folder. + * @property file Is the file a file. + * @property lastModifiedDateTime The timestamp of the last modification in ISO8601. + * @property parentReference The parent reference of the file. + */ +data class OneDriveFile( + val name: String, + val size: Long, + val folder: Any?, + val file: Any?, + val lastModifiedDateTime: String, + val parentReference: ParentReference +) + +/** + * Data class representing the parent reference of a file from OneDrive. + * + * @property path The path of the parent reference. + */ +data class ParentReference( + val path: String +) diff --git a/app/src/main/java/com/reysand/files/data/model/OneDriveQuota.kt b/app/src/main/java/com/reysand/files/data/model/OneDriveQuota.kt new file mode 100644 index 0000000..0f0dc4f --- /dev/null +++ b/app/src/main/java/com/reysand/files/data/model/OneDriveQuota.kt @@ -0,0 +1,34 @@ +/* + * Copyright 2024 Andrey Slyusar + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.reysand.files.data.model + +/** + * Data class representing the quota of a OneDrive account. + * + * @property quota The quota of the OneDrive account. + */ +data class OneDriveQuota( + val quota: Quota +) + +/** + * Data class representing the quota details. + * + * @property remaining The remaining quota in bytes. + */ +data class Quota( + val remaining: Long +) diff --git a/app/src/main/java/com/reysand/files/data/remote/FileOneDriveDataSource.kt b/app/src/main/java/com/reysand/files/data/remote/FileOneDriveDataSource.kt new file mode 100644 index 0000000..ae4ba90 --- /dev/null +++ b/app/src/main/java/com/reysand/files/data/remote/FileOneDriveDataSource.kt @@ -0,0 +1,131 @@ +/* + * Copyright 2024 Andrey Slyusar + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.reysand.files.data.remote + +import android.util.Log +import com.reysand.files.data.model.FileModel +import com.reysand.files.data.model.OneDriveFile +import com.reysand.files.data.repository.OneDriveRepository +import com.reysand.files.data.util.FileDateFormatter +import com.reysand.files.data.util.FileSizeFormatter +import com.reysand.files.data.util.MicrosoftService +import com.reysand.files.data.util.OneDriveService +import kotlinx.coroutines.DelicateCoroutinesApi +import kotlinx.coroutines.GlobalScope +import kotlinx.coroutines.launch +import retrofit2.Retrofit +import retrofit2.converter.gson.GsonConverterFactory + +private const val TAG = "FileOneDriveDataSource" + +@OptIn(DelicateCoroutinesApi::class) +class FileOneDriveDataSource(private val microsoftService: MicrosoftService) : OneDriveRepository { + + private val oneDriveService: OneDriveService by lazy { + Retrofit.Builder() + .baseUrl("https://graph.microsoft.com/v1.0/") + .addConverterFactory(GsonConverterFactory.create()) + .build() + .create(OneDriveService::class.java) + } + + init { + GlobalScope.launch { + microsoftService.acquireTokenSilently() + } + } + + override suspend fun getStorageFreeSpace(): String { + var freeSpace = 0L + + try { + val response = oneDriveService.getDrive("Bearer ${microsoftService.mAccessToken}") + Log.d(TAG, "OneDriveService: $response") + + if (response.isSuccessful) { + freeSpace = response.body()?.quota?.remaining ?: 0 + } + } catch (e: Exception) { + e.printStackTrace() + } + return FileSizeFormatter.getFormattedSize(freeSpace) + } + + override suspend fun getFiles(path: String): List { + val files = mutableListOf() + + try { + val response = oneDriveService.getFiles("Bearer ${microsoftService.mAccessToken}", path) + Log.d(TAG, "response: $response") + + if (response.isSuccessful) { + for (item in response.body()?.value!!) { + files.add(createFileModel(item)) + } + } + } catch (e: Exception) { + e.printStackTrace() + } + + return files + } + + override suspend fun moveFile(source: String, destination: String): Boolean { + TODO("Not yet implemented") + } + + override suspend fun copyFile(source: String, destination: String): Boolean { + TODO("Not yet implemented") + } + + override suspend fun renameFile(from: String, to: String): Boolean { + TODO("Not yet implemented") + } + + override suspend fun deleteFile(path: String): Boolean { + TODO("Not yet implemented") + } + + /** + * Creates a [FileModel] object from a [OneDriveFile] instance. + * + * @param file The [OneDriveFile] instance to create a [FileModel] from. + * @return A [FileModel] object representing the given file. + */ + private fun createFileModel(file: OneDriveFile): FileModel { + return FileModel( + name = file.name, + path = getPath(file.name, file.parentReference.path), + fileType = if (file.folder != null) FileModel.FileType.DIRECTORY else FileModel.FileType.OTHER, + size = file.size, + lastModified = FileDateFormatter.convertToUnixTimestamp(file.lastModifiedDateTime) + ) + } + + /** + * Generate a new path for a file. + * Removes the "/drive/root:" part of the path. + * + * @param file The file to generate a new path for. + * @param path The path of the file. + */ + private fun getPath(file: String, path: String): String { + var newPath: String = path.substringAfterLast(":") + newPath = if (newPath.isEmpty()) file else "$newPath/$file" + + return if (newPath.startsWith('/')) newPath.drop(1) else newPath + } +} \ No newline at end of file diff --git a/app/src/main/java/com/reysand/files/data/repository/OneDriveRepository.kt b/app/src/main/java/com/reysand/files/data/repository/OneDriveRepository.kt new file mode 100644 index 0000000..0932602 --- /dev/null +++ b/app/src/main/java/com/reysand/files/data/repository/OneDriveRepository.kt @@ -0,0 +1,74 @@ +/* + * Copyright 2024 Andrey Slyusar + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.reysand.files.data.repository + +import com.reysand.files.data.model.FileModel + +/** + * Interface defining operations for interacting with files from OneDrive. + */ +interface OneDriveRepository { + + /** + * Retrieves the free space of the storage. + * + * @return A formatted string containing the free space of the storage. + */ + suspend fun getStorageFreeSpace(): String + + /** + * Retrieves a list of [FileModel] objects from the specified path. + * + * @param path The path to the directory containing the files. + * @return A list of [FileModel] objects. + */ + suspend fun getFiles(path: String): List + + /** + * Move a file from one path to another. + * + * @param source The original path of the file. + * @param destination The new path of the file. + * @return A boolean value indicating the success of the operation. + */ + suspend fun moveFile(source: String, destination: String): Boolean + + /** + * Copy a file from one path to another. + * + * @param source The original path of the file. + * @param destination The new path of the file. + * @return A boolean value indicating the success of the operation. + */ + suspend fun copyFile(source: String, destination: String): Boolean + + /** + * Rename a file. + * + * @param from The original path of the file. + * @param to The new path of the file. + * @return A boolean value indicating the success of the operation. + */ + suspend fun renameFile(from: String, to: String): Boolean + + /** + * Delete a file. + * + * @param path The path of the file to delete. + * @return A boolean value indicating the success of the operation. + */ + suspend fun deleteFile(path: String): Boolean +} \ No newline at end of file diff --git a/app/src/main/java/com/reysand/files/data/util/FileDateFormatter.kt b/app/src/main/java/com/reysand/files/data/util/FileDateFormatter.kt new file mode 100644 index 0000000..acecebe --- /dev/null +++ b/app/src/main/java/com/reysand/files/data/util/FileDateFormatter.kt @@ -0,0 +1,72 @@ +/* + * Copyright 2024 Andrey Slyusar + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.reysand.files.data.util + +import java.text.SimpleDateFormat +import java.util.Calendar +import java.util.Locale +import java.util.TimeZone + +/** + * Utility class for formatting file dates. + */ +object FileDateFormatter { + + /** + * Converts the given date to a Unix timestamp. + * + * @param date The date to convert. + * @return The Unix timestamp. + */ + fun convertToUnixTimestamp(date: String): Long { + var formattedDate = date + if (date.contains(".")) { + val fractionalSeconds = date.split(".")[1].split("Z")[0] + if (fractionalSeconds.length < 3) { + formattedDate = date.replace(".$fractionalSeconds", ".$fractionalSeconds" + .padEnd(4, '0')) + } + } else { + formattedDate = date.replace("Z", ".000Z") + } + val inputFormat = SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss.SSS'Z'", Locale.getDefault()) + inputFormat.timeZone = TimeZone.getTimeZone("UTC") + val timestamp = inputFormat.parse(formattedDate) + return timestamp?.time ?: 0 + } + + /** + * Gets the last modified date in a human-readable format. + * + * @param lastModified The timestamp of the last modification. + * @return A string representing the last modified date (e.g., "Sep 12, 2023). + */ + fun getLastModified(lastModified: Long): String { + val current = Calendar.getInstance() + val date = Calendar.getInstance().apply { timeInMillis = lastModified } + + val displayMonth = date.getDisplayName(Calendar.MONTH, Calendar.SHORT, Locale.getDefault()) + val dayOfMonth = date.get(Calendar.DAY_OF_MONTH) + + val formattedDate = buildString { + append("$displayMonth $dayOfMonth") + if (date.get(Calendar.YEAR) != current.get(Calendar.YEAR)) { + append(", ${date.get(Calendar.YEAR)}") + } + } + return formattedDate + } +} \ No newline at end of file diff --git a/app/src/main/java/com/reysand/files/data/util/MicrosoftService.kt b/app/src/main/java/com/reysand/files/data/util/MicrosoftService.kt index bb83675..48145be 100644 --- a/app/src/main/java/com/reysand/files/data/util/MicrosoftService.kt +++ b/app/src/main/java/com/reysand/files/data/util/MicrosoftService.kt @@ -43,12 +43,11 @@ private const val TAG = "MicrosoftService" * * @param context The context of the app. */ -@OptIn(DelicateCoroutinesApi::class) class MicrosoftService(val context: Context) { var mAccount: IAccount? = null var mAccessToken: String? = null - private val scopes: List = listOf("User.Read") + private val scopes: List = listOf("User.Read", "Files.ReadWrite.All") val usernameFlow: Flow = flow { emit(mAccount?.username) diff --git a/app/src/main/java/com/reysand/files/data/util/OneDriveService.kt b/app/src/main/java/com/reysand/files/data/util/OneDriveService.kt new file mode 100644 index 0000000..50a34c3 --- /dev/null +++ b/app/src/main/java/com/reysand/files/data/util/OneDriveService.kt @@ -0,0 +1,35 @@ +/* + * Copyright 2024 Andrey Slyusar + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.reysand.files.data.util + +import com.reysand.files.data.model.OneDrive +import com.reysand.files.data.model.OneDriveQuota +import retrofit2.Response +import retrofit2.http.GET +import retrofit2.http.Header +import retrofit2.http.Path + +interface OneDriveService { + + @GET("me/drive") + suspend fun getDrive(@Header("Authorization") accessToken: String): Response + + @GET("me/drive/root:/{path}:/children") + suspend fun getFiles( + @Header("Authorization") accessToken: String, + @Path("path") path: String + ): Response +} diff --git a/app/src/main/java/com/reysand/files/ui/FilesApp.kt b/app/src/main/java/com/reysand/files/ui/FilesApp.kt index ca9de6d..0985edc 100644 --- a/app/src/main/java/com/reysand/files/ui/FilesApp.kt +++ b/app/src/main/java/com/reysand/files/ui/FilesApp.kt @@ -31,6 +31,7 @@ import androidx.compose.material3.Surface import androidx.compose.material3.Text import androidx.compose.material3.TopAppBarDefaults import androidx.compose.runtime.Composable +import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue import androidx.compose.ui.Modifier import androidx.compose.ui.res.stringResource @@ -58,9 +59,15 @@ fun FilesApp(filesViewModel: FilesViewModel = viewModel(factory = FilesViewModel val navBackStackEntry by navController.currentBackStackEntryAsState() val currentRoute = navBackStackEntry?.destination?.route + val currentStorage = filesViewModel.currentStorage.collectAsState() + val storageTitle = when (currentStorage.value) { + "Local" -> R.string.internal_storage + else -> R.string.onedrive_storage + } + // Determine the title for the top app bar based on the current route val topBarTitle = when (currentRoute) { - Destinations.FILE_LIST -> R.string.internal_storage + Destinations.FILE_LIST -> storageTitle Destinations.SETTINGS -> R.string.settings_title else -> R.string.app_name } @@ -100,11 +107,11 @@ fun FilesApp(filesViewModel: FilesViewModel = viewModel(factory = FilesViewModel containerColor = MaterialTheme.colorScheme.primaryContainer.copy(alpha = 0.5F) ) ) - }) { + }) { innerPadding -> Surface( modifier = Modifier .fillMaxSize() - .padding(it), + .padding(innerPadding), color = MaterialTheme.colorScheme.background ) { Column { @@ -112,7 +119,13 @@ fun FilesApp(filesViewModel: FilesViewModel = viewModel(factory = FilesViewModel PathTabs( filesViewModel.homeDirectory, filesViewModel.currentDirectory.value ) { newPath -> - filesViewModel.getFiles(newPath) + val oneDrivePath = + if (newPath != "/") newPath.dropWhile { it == '/' } else newPath + + when (currentStorage.value) { + "Local" -> filesViewModel.getFiles(newPath) + "OneDrive" -> filesViewModel.getFiles(oneDrivePath) + } } } NavGraph( diff --git a/app/src/main/java/com/reysand/files/ui/screens/FileListScreen.kt b/app/src/main/java/com/reysand/files/ui/screens/FileListScreen.kt index 0a58260..b53272d 100644 --- a/app/src/main/java/com/reysand/files/ui/screens/FileListScreen.kt +++ b/app/src/main/java/com/reysand/files/ui/screens/FileListScreen.kt @@ -41,7 +41,9 @@ fun FileListScreen( val files by filesViewModel.files.collectAsState(initial = emptyList()) // Display permission alert dialog if needed - PermissionAlertDialog(showPermissionDialog = filesViewModel.showPermissionDialog) + if (filesViewModel.currentStorage.collectAsState().value == "Local") { + PermissionAlertDialog(showPermissionDialog = filesViewModel.showPermissionDialog) + } LazyColumn(modifier = modifier) { items(files) { file -> diff --git a/app/src/main/java/com/reysand/files/ui/screens/HomeScreen.kt b/app/src/main/java/com/reysand/files/ui/screens/HomeScreen.kt index 6434c8b..9ae9664 100644 --- a/app/src/main/java/com/reysand/files/ui/screens/HomeScreen.kt +++ b/app/src/main/java/com/reysand/files/ui/screens/HomeScreen.kt @@ -19,8 +19,14 @@ import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp import androidx.navigation.NavHostController import com.reysand.files.R import com.reysand.files.ui.components.StorageCard @@ -40,15 +46,38 @@ fun HomeScreen( navController: NavHostController, modifier: Modifier = Modifier ) { + val oneDriveAccount by remember { filesViewModel.oneDriveAccount } + val oneDriveStorageFreeSpace = remember { mutableStateOf("Not signed in") } + Column( modifier = modifier.fillMaxSize(), - verticalArrangement = Arrangement.Center + verticalArrangement = Arrangement.spacedBy( + space = 8.dp, + alignment = Alignment.CenterVertically + ) ) { StorageCard( title = stringResource(id = R.string.internal_storage), leadingIcon = R.drawable.ic_internal_storage, - info = filesViewModel.getStorageFreeSpace(), + info = filesViewModel.getStorageFreeSpace() + ) { + filesViewModel.setCurrentStorage("Local") + navController.navigate(Destinations.FILE_LIST) + } + + LaunchedEffect(oneDriveAccount) { + oneDriveAccount?.let { + oneDriveStorageFreeSpace.value = filesViewModel.getOneDriveStorageFreeSpace() + } + } + + StorageCard( + title = stringResource(id = R.string.onedrive_storage), + enabled = oneDriveAccount != null, + leadingIcon = R.drawable.ic_cloud_storage, + info = oneDriveStorageFreeSpace.value ) { + filesViewModel.setCurrentStorage("OneDrive") navController.navigate(Destinations.FILE_LIST) } } diff --git a/app/src/main/java/com/reysand/files/ui/viewmodel/FilesViewModel.kt b/app/src/main/java/com/reysand/files/ui/viewmodel/FilesViewModel.kt index fa0df62..6e47704 100644 --- a/app/src/main/java/com/reysand/files/ui/viewmodel/FilesViewModel.kt +++ b/app/src/main/java/com/reysand/files/ui/viewmodel/FilesViewModel.kt @@ -29,13 +29,17 @@ import com.reysand.files.FilesApplication import com.reysand.files.R import com.reysand.files.data.model.FileModel import com.reysand.files.data.repository.FileRepository +import com.reysand.files.data.repository.OneDriveRepository import com.reysand.files.data.util.MicrosoftService import com.reysand.files.ui.util.ContextWrapper +import kotlinx.coroutines.async import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.launch import java.io.File +private const val TAG = "FilesViewModel" + /** * ViewModel for managing file-related data and operations. * @@ -44,6 +48,7 @@ import java.io.File */ class FilesViewModel( private val fileRepository: FileRepository, + private val oneDriveRepository: OneDriveRepository, private val contextWrapper: ContextWrapper, private val microsoftService: MicrosoftService ) : ViewModel() { @@ -53,9 +58,13 @@ class FilesViewModel( val files = _files.asStateFlow() // Paths for the home and current directories - val homeDirectory = Environment.getExternalStorageDirectory().path + var homeDirectory = Environment.getExternalStorageDirectory().path val currentDirectory = mutableStateOf(homeDirectory) + // State indicating the current data source + private var _currentStorage = MutableStateFlow("Local") + val currentStorage = _currentStorage.asStateFlow() + // State indicating whether to show the permission dialog val showPermissionDialog = mutableStateOf(!Environment.isExternalStorageManager()) @@ -74,6 +83,16 @@ class FilesViewModel( } } + fun setCurrentStorage(storage: String) { + _currentStorage.value = storage + when (storage) { + "Local" -> homeDirectory = Environment.getExternalStorageDirectory().path + "OneDrive" -> homeDirectory = "/" + } + currentDirectory.value = homeDirectory + getFiles(homeDirectory) + } + /** * Toggle the Microsoft sign-in state. * @@ -97,7 +116,10 @@ class FilesViewModel( */ fun getFiles(path: String) { viewModelScope.launch { - _files.value = fileRepository.getFiles(path) + when (currentStorage.value) { + "Local" -> _files.value = fileRepository.getFiles(path) + "OneDrive" -> _files.value = oneDriveRepository.getFiles(path) + } currentDirectory.value = path } } @@ -106,7 +128,16 @@ class FilesViewModel( * Navigate up to the parent directory, */ fun navigateUp() { - val parentDirectory = File(currentDirectory.value).parent + val parentDirectory = when (currentStorage.value) { + "Local" -> File(currentDirectory.value).parent + "OneDrive" -> if (!currentDirectory.value.contains('/')) { + "/" + } else { + currentDirectory.value.substringBeforeLast('/') + } + + else -> null + } if (currentDirectory.value != homeDirectory && parentDirectory != null) { getFiles(parentDirectory) @@ -125,6 +156,20 @@ class FilesViewModel( ) } + /** + * Gets the free space of the OneDrive storage. + * + * @return A string representing the free space of the OneDrive storage. + */ + suspend fun getOneDriveStorageFreeSpace(): String { + return viewModelScope.async { + contextWrapper.getContext().getString( + R.string.storage_free_space, + oneDriveRepository.getStorageFreeSpace() + ) + }.await() + } + /** * Move a file from one path to another. * @@ -190,10 +235,12 @@ class FilesViewModel( initializer { val application = (this[APPLICATION_KEY] as FilesApplication) val fileRepository = application.container.fileRepository + val oneDriveRepository = application.container.oneDriveRepository val contextWrapper = ContextWrapper(application.applicationContext) val microsoftService = application.container.microsoftService FilesViewModel( fileRepository = fileRepository, + oneDriveRepository = oneDriveRepository, contextWrapper = contextWrapper, microsoftService = microsoftService ) diff --git a/app/src/main/res/drawable/ic_cloud_storage.xml b/app/src/main/res/drawable/ic_cloud_storage.xml new file mode 100644 index 0000000..73274f8 --- /dev/null +++ b/app/src/main/res/drawable/ic_cloud_storage.xml @@ -0,0 +1,20 @@ + + + + diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 806609b..c039ee7 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -13,6 +13,7 @@ msal = "4.10.0" navigation-compose = "2.7.5" opentelemetry-api = "1.18.0" opentelemetry-context = "1.18.0" +retrofit = "2.9.0" [libraries] activity-compose = { group = "androidx.activity", name = "activity-compose", version.ref = "activity-compose" } @@ -29,6 +30,8 @@ msal = { group = "com.microsoft.identity.client", name = "msal", version.ref = " navigation-compose = { group = "androidx.navigation", name = "navigation-compose", version.ref = "navigation-compose" } opentelemetry-api = { group = "io.opentelemetry", name = "opentelemetry-api", version.ref = "opentelemetry-api" } opentelemetry-context = { group = "io.opentelemetry", name = "opentelemetry-context", version.ref = "opentelemetry-context" } +retrofit = { group = "com.squareup.retrofit2", name = "retrofit", version.ref = "retrofit" } +retrofit-gson = { group = "com.squareup.retrofit2", name = "converter-gson", version.ref = "retrofit" } ui = { group = "androidx.compose.ui", name = "ui" } ui-graphics = { group = "androidx.compose.ui", name = "ui-graphics" } ui-test-junit4 = { group = "androidx.compose.ui", name = "ui-test-junit4" } From fb2af27a8b998bbc992ad2201ef011dee5e0b3e0 Mon Sep 17 00:00:00 2001 From: Andrey Slyusar Date: Fri, 2 Feb 2024 00:48:44 +0300 Subject: [PATCH 10/13] feat: Implement file operations Signed-off-by: Andrey Slyusar --- .../com/reysand/files/data/model/OneDrive.kt | 18 +++++++++ .../data/remote/FileOneDriveDataSource.kt | 40 ++++++++++++++++--- .../files/data/util/OneDriveService.kt | 33 +++++++++++++++ .../files/ui/viewmodel/FilesViewModel.kt | 38 +++++++++++++++--- 4 files changed, 119 insertions(+), 10 deletions(-) diff --git a/app/src/main/java/com/reysand/files/data/model/OneDrive.kt b/app/src/main/java/com/reysand/files/data/model/OneDrive.kt index a943310..5ad2cc6 100644 --- a/app/src/main/java/com/reysand/files/data/model/OneDrive.kt +++ b/app/src/main/java/com/reysand/files/data/model/OneDrive.kt @@ -51,3 +51,21 @@ data class OneDriveFile( data class ParentReference( val path: String ) + +/** + * Data class representing the body of a move and copy request. + * + * @property parentReference The parent reference of the file. + */ +data class FileOperationRequest( + val parentReference: ParentReference +) + +/** + * Data class representing the body of a rename request. + * + * @property name The new name of the file. + */ +data class RenameRequest( + val name: String +) diff --git a/app/src/main/java/com/reysand/files/data/remote/FileOneDriveDataSource.kt b/app/src/main/java/com/reysand/files/data/remote/FileOneDriveDataSource.kt index ae4ba90..74a96d5 100644 --- a/app/src/main/java/com/reysand/files/data/remote/FileOneDriveDataSource.kt +++ b/app/src/main/java/com/reysand/files/data/remote/FileOneDriveDataSource.kt @@ -17,7 +17,10 @@ package com.reysand.files.data.remote import android.util.Log import com.reysand.files.data.model.FileModel +import com.reysand.files.data.model.FileOperationRequest import com.reysand.files.data.model.OneDriveFile +import com.reysand.files.data.model.ParentReference +import com.reysand.files.data.model.RenameRequest import com.reysand.files.data.repository.OneDriveRepository import com.reysand.files.data.util.FileDateFormatter import com.reysand.files.data.util.FileSizeFormatter @@ -69,7 +72,7 @@ class FileOneDriveDataSource(private val microsoftService: MicrosoftService) : O try { val response = oneDriveService.getFiles("Bearer ${microsoftService.mAccessToken}", path) - Log.d(TAG, "response: $response") + Log.d(TAG, "OneDriveService: $response") if (response.isSuccessful) { for (item in response.body()?.value!!) { @@ -84,19 +87,46 @@ class FileOneDriveDataSource(private val microsoftService: MicrosoftService) : O } override suspend fun moveFile(source: String, destination: String): Boolean { - TODO("Not yet implemented") + val moveRequest = FileOperationRequest(ParentReference("/drive/root:$destination")) + val response = oneDriveService.moveFile( + "Bearer ${microsoftService.mAccessToken}", + source, + moveRequest + ) + Log.d(TAG, "OneDriveService: $response") + + return response.isSuccessful } override suspend fun copyFile(source: String, destination: String): Boolean { - TODO("Not yet implemented") + val copyRequest = FileOperationRequest(ParentReference("/drive/root:$destination")) + val response = oneDriveService.copyFile( + "Bearer ${microsoftService.mAccessToken}", + source, + copyRequest + ) + Log.d(TAG, "OneDriveService: $response") + + return response.isSuccessful } override suspend fun renameFile(from: String, to: String): Boolean { - TODO("Not yet implemented") + val renameRequest = RenameRequest(to) + val response = oneDriveService.renameFile( + "Bearer ${microsoftService.mAccessToken}", + from, + renameRequest + ) + Log.d(TAG, "OneDriveService: $response") + + return response.isSuccessful } override suspend fun deleteFile(path: String): Boolean { - TODO("Not yet implemented") + val response = oneDriveService.deleteFile("Bearer ${microsoftService.mAccessToken}", path) + Log.d(TAG, "OneDriveService: $response") + + return response.isSuccessful } /** diff --git a/app/src/main/java/com/reysand/files/data/util/OneDriveService.kt b/app/src/main/java/com/reysand/files/data/util/OneDriveService.kt index 50a34c3..8c632b7 100644 --- a/app/src/main/java/com/reysand/files/data/util/OneDriveService.kt +++ b/app/src/main/java/com/reysand/files/data/util/OneDriveService.kt @@ -15,11 +15,17 @@ */ package com.reysand.files.data.util +import com.reysand.files.data.model.FileOperationRequest import com.reysand.files.data.model.OneDrive import com.reysand.files.data.model.OneDriveQuota +import com.reysand.files.data.model.RenameRequest import retrofit2.Response +import retrofit2.http.Body +import retrofit2.http.DELETE import retrofit2.http.GET import retrofit2.http.Header +import retrofit2.http.PATCH +import retrofit2.http.POST import retrofit2.http.Path interface OneDriveService { @@ -32,4 +38,31 @@ interface OneDriveService { @Header("Authorization") accessToken: String, @Path("path") path: String ): Response + + @PATCH("me/drive/root:/{path}") + suspend fun moveFile( + @Header("Authorization") accessToken: String, + @Path("path") path: String, + @Body moveRequest: FileOperationRequest + ): Response + + @POST("me/drive/root:/{path}:/copy") + suspend fun copyFile( + @Header("Authorization") accessToken: String, + @Path("path") path: String, + @Body copyRequest: FileOperationRequest + ): Response + + @PATCH("me/drive/root:/{path}") + suspend fun renameFile( + @Header("Authorization") accessToken: String, + @Path("path") path: String, + @Body renameRequest: RenameRequest + ): Response + + @DELETE("me/drive/root:/{path}") + suspend fun deleteFile( + @Header("Authorization") accessToken: String, + @Path("path") path: String + ): Response } diff --git a/app/src/main/java/com/reysand/files/ui/viewmodel/FilesViewModel.kt b/app/src/main/java/com/reysand/files/ui/viewmodel/FilesViewModel.kt index 6e47704..06e6e65 100644 --- a/app/src/main/java/com/reysand/files/ui/viewmodel/FilesViewModel.kt +++ b/app/src/main/java/com/reysand/files/ui/viewmodel/FilesViewModel.kt @@ -178,7 +178,15 @@ class FilesViewModel( */ fun moveFile(file: FileModel, destination: String) { viewModelScope.launch { - if (fileRepository.moveFile(file.path, homeDirectory.plus(destination))) { + val result = when (currentStorage.value) { + "Local" -> fileRepository.moveFile(file.path, homeDirectory.plus(destination)) + else -> oneDriveRepository.moveFile( + file.path, + homeDirectory.plus(destination.substringBeforeLast('/')) + ) + } + + if (result) { getFiles(currentDirectory.value) } } @@ -192,7 +200,17 @@ class FilesViewModel( */ fun copyFile(file: FileModel, destination: String) { viewModelScope.launch { - if (fileRepository.copyFile(file.path, homeDirectory.plus(destination))) { + val result = when (currentStorage.value) { + "Local" -> fileRepository.copyFile(file.path, homeDirectory.plus(destination)) + else -> oneDriveRepository.copyFile( + file.path, + homeDirectory.plus(destination.substringBeforeLast('/')) + ) + } + + Log.d(TAG, "copyFile: ${file.path}") + + if (result) { getFiles(currentDirectory.value) } } @@ -206,11 +224,16 @@ class FilesViewModel( */ fun renameFile(file: FileModel, newName: String) { viewModelScope.launch { - if (fileRepository.renameFile( + val result = when (currentStorage.value) { + "Local" -> fileRepository.renameFile( file.path, file.path.removeSuffix(file.name).plus(newName) ) - ) { + + else -> oneDriveRepository.renameFile(file.path, newName) + } + + if (result) { getFiles(currentDirectory.value) } } @@ -223,7 +246,12 @@ class FilesViewModel( */ fun deleteFile(path: String) { viewModelScope.launch { - if (fileRepository.deleteFile(path)) { + val result = when (currentStorage.value) { + "Local" -> fileRepository.deleteFile(path) + else -> oneDriveRepository.deleteFile(path) + } + + if (result) { getFiles(currentDirectory.value) } } From fd4b5b3a93568e4234a1d0c5cffe7eb34139ed61 Mon Sep 17 00:00:00 2001 From: Andrey Slyusar Date: Fri, 2 Feb 2024 14:31:03 +0300 Subject: [PATCH 11/13] chore: Update Kotlin and dependencies - Update kotlin compiler extension version to 1.5.8 - Update activity-compose version to 1.8.2 - Update AGP version to 8.2.2 - Update compose-bom version to 2024.01.00 - Update kotlin version to 1.9.22 - Update lifecycle-runtime-ktx version to 2.7.0 - Update msal version to 5.0.1 - Update navigation-compose version to 2.7.6 - Update gradle version to 8.5 Signed-off-by: Andrey Slyusar --- .idea/kotlinc.xml | 2 +- app/build.gradle.kts | 4 ++-- .../main/java/com/reysand/files/ui/FilesApp.kt | 4 ++-- gradle/libs.versions.toml | 16 +++++++--------- gradle/wrapper/gradle-wrapper.properties | 4 ++-- 5 files changed, 14 insertions(+), 16 deletions(-) diff --git a/.idea/kotlinc.xml b/.idea/kotlinc.xml index ae3f30a..8d81632 100644 --- a/.idea/kotlinc.xml +++ b/.idea/kotlinc.xml @@ -1,6 +1,6 @@ - \ No newline at end of file diff --git a/app/build.gradle.kts b/app/build.gradle.kts index a0363d4..2ea4558 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -38,7 +38,7 @@ android { minSdk = 33 targetSdk = 34 versionCode = 3 - versionName = "0.1.1" + versionName = "0.1.2" testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" vectorDrawables { @@ -80,7 +80,7 @@ android { compose = true } composeOptions { - kotlinCompilerExtensionVersion = "1.5.6" + kotlinCompilerExtensionVersion = "1.5.8" } packaging { resources { diff --git a/app/src/main/java/com/reysand/files/ui/FilesApp.kt b/app/src/main/java/com/reysand/files/ui/FilesApp.kt index 0985edc..302c330 100644 --- a/app/src/main/java/com/reysand/files/ui/FilesApp.kt +++ b/app/src/main/java/com/reysand/files/ui/FilesApp.kt @@ -19,7 +19,7 @@ import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.padding import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.filled.ArrowBack +import androidx.compose.material.icons.automirrored.filled.ArrowBack import androidx.compose.material.icons.rounded.Settings import androidx.compose.material3.CenterAlignedTopAppBar import androidx.compose.material3.ExperimentalMaterial3Api @@ -89,7 +89,7 @@ fun FilesApp(filesViewModel: FilesViewModel = viewModel(factory = FilesViewModel } }) { Icon( - imageVector = Icons.Default.ArrowBack, contentDescription = null + imageVector = Icons.AutoMirrored.Filled.ArrowBack, contentDescription = null ) } } diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index c039ee7..944af98 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -1,16 +1,15 @@ [versions] -activity-compose = "1.8.1" -agp = "8.2.0" +activity-compose = "1.8.2" +agp = "8.2.2" androidx-test-ext-junit = "1.1.5" -compose-bom = "2023.10.01" +compose-bom = "2024.01.00" core-ktx = "1.12.0" -datastore-preferences = "1.0.0" espresso-core = "3.5.1" junit = "4.13.2" -kotlin = "1.9.21" -lifecycle-runtime-ktx = "2.6.2" -msal = "4.10.0" -navigation-compose = "2.7.5" +kotlin = "1.9.22" +lifecycle-runtime-ktx = "2.7.0" +msal = "5.0.1" +navigation-compose = "2.7.6" opentelemetry-api = "1.18.0" opentelemetry-context = "1.18.0" retrofit = "2.9.0" @@ -20,7 +19,6 @@ activity-compose = { group = "androidx.activity", name = "activity-compose", ver androidx-test-ext-junit = { group = "androidx.test.ext", name = "junit", version.ref = "androidx-test-ext-junit" } compose-bom = { group = "androidx.compose", name = "compose-bom", version.ref = "compose-bom" } core-ktx = { group = "androidx.core", name = "core-ktx", version.ref = "core-ktx" } -datastore-preferences = { group = "androidx.datastore", name = "datastore-preferences", version.ref = "datastore-preferences" } espresso-core = { group = "androidx.test.espresso", name = "espresso-core", version.ref = "espresso-core" } junit = { group = "junit", name = "junit", version.ref = "junit" } lifecycle-runtime-ktx = { group = "androidx.lifecycle", name = "lifecycle-runtime-ktx", version.ref = "lifecycle-runtime-ktx" } diff --git a/gradle/wrapper/gradle-wrapper.properties b/gradle/wrapper/gradle-wrapper.properties index 48b7bb1..84f3469 100644 --- a/gradle/wrapper/gradle-wrapper.properties +++ b/gradle/wrapper/gradle-wrapper.properties @@ -1,6 +1,6 @@ -#Fri Nov 10 19:07:23 MSK 2023 +#Fri Feb 02 11:54:44 MSK 2024 distributionBase=GRADLE_USER_HOME distributionPath=wrapper/dists -distributionUrl=https\://services.gradle.org/distributions/gradle-8.4-bin.zip +distributionUrl=https\://services.gradle.org/distributions/gradle-8.5-bin.zip zipStoreBase=GRADLE_USER_HOME zipStorePath=wrapper/dists From 11f0d45b864537ab70f177fd72714045eb452a82 Mon Sep 17 00:00:00 2001 From: Andrey Slyusar Date: Fri, 2 Feb 2024 15:46:16 +0300 Subject: [PATCH 12/13] ci: Fix syntax Signed-off-by: Andrey Slyusar --- .github/workflows/android.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/android.yml b/.github/workflows/android.yml index eaab641..733cc55 100644 --- a/.github/workflows/android.yml +++ b/.github/workflows/android.yml @@ -23,11 +23,11 @@ jobs: - name: Generate auth_config_single_account.json run: | mkdir ./app/src/main/res/raw - echo "${{ secrets.AUTH_CONFIG_SINGLE_ACCOUNT }} > ./app/src/main/res/raw/auth_config_single_account.json + echo "${{ secrets.AUTH_CONFIG_SINGLE_ACCOUNT }}" > ./app/src/main/res/raw/auth_config_single_account.json - name: Generate secrets.properties run: | - echo "${{ secrets.SECRETS_PROPERTIES }} > ./secrets.properties + echo "${{ secrets.SECRETS_PROPERTIES }}" > ./secrets.properties - name: Generate keystore.properties run: | From f17c3bc1f8361ed5fa93a30e9a5cbc8642e12b6e Mon Sep 17 00:00:00 2001 From: Andrey Slyusar Date: Fri, 2 Feb 2024 16:17:26 +0300 Subject: [PATCH 13/13] ci: Update actions - Node.js 16 actions are deprecated Signed-off-by: Andrey Slyusar --- .github/workflows/android.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/android.yml b/.github/workflows/android.yml index 733cc55..06cbdf0 100644 --- a/.github/workflows/android.yml +++ b/.github/workflows/android.yml @@ -14,7 +14,7 @@ jobs: steps: - uses: actions/checkout@v4 - name: Set up JDK 17 - uses: actions/setup-java@v3 + uses: actions/setup-java@v4.0.0 with: java-version: '17' distribution: 'temurin' @@ -48,7 +48,7 @@ jobs: run: ./gradlew assembleRelease - name: Upload a Build Artifact - uses: actions/upload-artifact@v3.1.3 + uses: actions/upload-artifact@v4.3.0 with: name: files path: ./app/build/outputs/apk/release/app-release.apk