diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 8d4c6e1..b5e5eeb 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.2" + versionName = "0.1.3" manifestPlaceholders["YANDEX_CLIENT_ID"] = secretsProperties["YANDEX_CLIENT_ID"] as String 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 4bfe0b6..857ccb6 100644 --- a/app/src/main/java/com/reysand/files/data/AppContainer.kt +++ b/app/src/main/java/com/reysand/files/data/AppContainer.kt @@ -18,9 +18,11 @@ 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.remote.FileYandexDiskDataSource import com.reysand.files.data.remote.YandexUserDataSource import com.reysand.files.data.repository.FileRepository import com.reysand.files.data.repository.OneDriveRepository +import com.reysand.files.data.repository.YandexDiskRepository import com.reysand.files.data.repository.YandexUserRepository import com.reysand.files.data.util.MicrosoftService import com.reysand.files.data.util.YandexService @@ -31,6 +33,8 @@ interface AppContainer { val oneDriveRepository: OneDriveRepository + val yandexDiskRepository: YandexDiskRepository + val yandexUserRepository: YandexUserRepository val microsoftService: MicrosoftService @@ -48,6 +52,10 @@ class DefaultAppContainer(context: Context) : AppContainer { FileOneDriveDataSource(microsoftService) } + override val yandexDiskRepository: YandexDiskRepository by lazy { + FileYandexDiskDataSource(yandexService) + } + override val yandexUserRepository: YandexUserRepository by lazy { YandexUserDataSource() } diff --git a/app/src/main/java/com/reysand/files/data/model/YandexDisk.kt b/app/src/main/java/com/reysand/files/data/model/YandexDisk.kt new file mode 100644 index 0000000..6692e87 --- /dev/null +++ b/app/src/main/java/com/reysand/files/data/model/YandexDisk.kt @@ -0,0 +1,54 @@ +/* + * 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 + +import com.google.gson.annotations.SerializedName + +/** + * Data class representing a Yandex.Disk object. + * + * @property embedded The embedded files. + */ +data class YandexDisk( + @SerializedName("_embedded") + val embedded: Embedded +) + +/** + * Data class representing the embedded files from Yandex.Disk. + * + * @property items The list of files. + */ +data class Embedded( + val items: List +) + +/** + * Data class representing a file from Yandex.Disk. + * + * @property name The name of the file. + * @property type The type of the file. + * @property path The path of the file. + * @property size The size of the file in bytes. + * @property modified The timestamp of the last modification in ISO8601. + */ +data class YandexDiskFile( + val name: String, + val type: String, + val path: String, + val size: Long, + val modified: String +) diff --git a/app/src/main/java/com/reysand/files/data/model/YandexDiskQuota.kt b/app/src/main/java/com/reysand/files/data/model/YandexDiskQuota.kt new file mode 100644 index 0000000..bc4f0e3 --- /dev/null +++ b/app/src/main/java/com/reysand/files/data/model/YandexDiskQuota.kt @@ -0,0 +1,31 @@ +/* + * 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 + +import com.google.gson.annotations.SerializedName + +/** + * Data class representing the quota of a Yandex.Disk account. + * + * @property total The total quota of the Yandex.Disk account. + * @property used The used quota of the Yandex.Disk account. + */ +data class YandexDiskQuota( + @SerializedName("total_space") + val total: Long, + @SerializedName("used_space") + val used: Long +) diff --git a/app/src/main/java/com/reysand/files/data/remote/FileYandexDiskDataSource.kt b/app/src/main/java/com/reysand/files/data/remote/FileYandexDiskDataSource.kt new file mode 100644 index 0000000..5e7871e --- /dev/null +++ b/app/src/main/java/com/reysand/files/data/remote/FileYandexDiskDataSource.kt @@ -0,0 +1,145 @@ +/* + * 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.YandexDiskFile +import com.reysand.files.data.repository.YandexDiskRepository +import com.reysand.files.data.util.FileDateFormatter +import com.reysand.files.data.util.FileSizeFormatter +import com.reysand.files.data.util.YandexDiskService +import com.reysand.files.data.util.YandexService +import retrofit2.Retrofit +import retrofit2.converter.gson.GsonConverterFactory + +private const val TAG = "FileYandexDiskDataSource" + +class FileYandexDiskDataSource(private val yandexService: YandexService) : YandexDiskRepository { + + private val yandexAPIService: YandexDiskService by lazy { + Retrofit.Builder() + .baseUrl("https://cloud-api.yandex.net/v1/") + .addConverterFactory(GsonConverterFactory.create()) + .build() + .create(YandexDiskService::class.java) + } + + override suspend fun getStorageFreeSpace(): String { + var freeSpace = 0L + + try { + val response = yandexAPIService.getDisk(yandexService.token?.value ?: "") + Log.d(TAG, "YandexService: $response") + + if (response.isSuccessful) { + freeSpace = (response.body()?.total ?: 0) - (response.body()?.used ?: 0) + } + } catch (e: Exception) { + e.printStackTrace() + } + return FileSizeFormatter.getFormattedSize(freeSpace) + } + + override suspend fun getFiles(path: String): List { + val files = mutableListOf() + + try { + val response = yandexAPIService.getFiles(yandexService.token?.value ?: "", path) + Log.d(TAG, "YandexService: $response") + + if (response.isSuccessful) { + for (item in response.body()?.embedded?.items!!) { + files.add(createFileModel(item)) + } + } + } catch (e: Exception) { + e.printStackTrace() + } + + return files + } + + override suspend fun moveFile(source: String, destination: String): Boolean { + val response = yandexAPIService.moveFile( + yandexService.token?.value ?: "", + source, + destination + ) + Log.d(TAG, "YandexService: $response") + + return response.isSuccessful + } + + override suspend fun copyFile(source: String, destination: String): Boolean { + val response = yandexAPIService.copyFile( + yandexService.token?.value ?: "", + source, + destination + ) + Log.d(TAG, "YandexService: $response") + + return response.isSuccessful + } + + override suspend fun renameFile(from: String, to: String): Boolean { + val response = yandexAPIService.moveFile( + yandexService.token?.value ?: "", + from, + if (from.contains('/')) { + from.substringBeforeLast('/') + "/" + to + } else { + to + } + ) + Log.d(TAG, "YandexService: $response") + + return response.isSuccessful + } + + override suspend fun deleteFile(path: String): Boolean { + val response = yandexAPIService.deleteFile(yandexService.token?.value ?: "", path) + Log.d(TAG, "YandexService: $response") + + return response.isSuccessful + } + + /** + * Creates a [FileModel] object from a [YandexDiskFile] instance. + * + * @param file The [YandexDiskFile] instance to create a [FileModel] from. + * @return A [FileModel] object representing the given file. + */ + private fun createFileModel(file: YandexDiskFile): FileModel { + return FileModel( + name = file.name, + path = getPath(file.path), + fileType = if (file.type == "dir") FileModel.FileType.DIRECTORY else FileModel.FileType.OTHER, + size = file.size, + lastModified = FileDateFormatter.convertToUnixTimestamp(file.modified) + ) + } + + /** + * Generate a new path for a file. + * Removes the "disk:/" part of the path. + * + * @param path The path of the file. + */ + private fun getPath(path: String): String { + return path.substringAfterLast(":") + } +} \ No newline at end of file diff --git a/app/src/main/java/com/reysand/files/data/repository/YandexDiskRepository.kt b/app/src/main/java/com/reysand/files/data/repository/YandexDiskRepository.kt new file mode 100644 index 0000000..fc0ed5b --- /dev/null +++ b/app/src/main/java/com/reysand/files/data/repository/YandexDiskRepository.kt @@ -0,0 +1,71 @@ +/* + * 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 YandexDiskRepository { + + /** + * 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 index acecebe..ac781e5 100644 --- a/app/src/main/java/com/reysand/files/data/util/FileDateFormatter.kt +++ b/app/src/main/java/com/reysand/files/data/util/FileDateFormatter.kt @@ -39,6 +39,8 @@ object FileDateFormatter { formattedDate = date.replace(".$fractionalSeconds", ".$fractionalSeconds" .padEnd(4, '0')) } + } else if (date.contains("+")) { + formattedDate = date.replace("+00:00", ".000Z") } else { formattedDate = date.replace("Z", ".000Z") } diff --git a/app/src/main/java/com/reysand/files/data/util/YandexDiskService.kt b/app/src/main/java/com/reysand/files/data/util/YandexDiskService.kt new file mode 100644 index 0000000..e286fe2 --- /dev/null +++ b/app/src/main/java/com/reysand/files/data/util/YandexDiskService.kt @@ -0,0 +1,67 @@ +/* + * 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.RenameRequest +import com.reysand.files.data.model.YandexDisk +import com.reysand.files.data.model.YandexDiskQuota +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.Query + +interface YandexDiskService { + + @GET("disk") + suspend fun getDisk(@Header("Authorization") oAuthToken: String): Response + + @GET("disk/resources") + suspend fun getFiles( + @Header("Authorization") accessToken: String, + @Query("path") path: String + ): Response + + @POST("disk/resources/move") + suspend fun moveFile( + @Header("Authorization") accessToken: String, + @Query("from") from: String, + @Query("path") path: String + ): Response + + @POST("disk/resources/copy") + suspend fun copyFile( + @Header("Authorization") accessToken: String, + @Query("from") from: String, + @Query("path") path: String + ): Response + + @PATCH("disk/resources") + suspend fun renameFile( + @Header("Authorization") accessToken: String, + @Query("path") path: String, + @Body renameRequest: RenameRequest + ): Response + + @DELETE("disk/resources") + suspend fun deleteFile( + @Header("Authorization") accessToken: String, + @Query("path") path: String + ): Response +} \ 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 302c330..222ab5b 100644 --- a/app/src/main/java/com/reysand/files/ui/FilesApp.kt +++ b/app/src/main/java/com/reysand/files/ui/FilesApp.kt @@ -62,7 +62,8 @@ fun FilesApp(filesViewModel: FilesViewModel = viewModel(factory = FilesViewModel val currentStorage = filesViewModel.currentStorage.collectAsState() val storageTitle = when (currentStorage.value) { "Local" -> R.string.internal_storage - else -> R.string.onedrive_storage + "OneDrive" -> R.string.onedrive_storage + else -> R.string.yandex_disk_storage } // Determine the title for the top app bar based on the current route @@ -89,7 +90,8 @@ fun FilesApp(filesViewModel: FilesViewModel = viewModel(factory = FilesViewModel } }) { Icon( - imageVector = Icons.AutoMirrored.Filled.ArrowBack, contentDescription = null + imageVector = Icons.AutoMirrored.Filled.ArrowBack, + contentDescription = null ) } } @@ -119,12 +121,13 @@ fun FilesApp(filesViewModel: FilesViewModel = viewModel(factory = FilesViewModel PathTabs( filesViewModel.homeDirectory, filesViewModel.currentDirectory.value ) { newPath -> - val oneDrivePath = + val fixedPath = if (newPath != "/") newPath.dropWhile { it == '/' } else newPath when (currentStorage.value) { "Local" -> filesViewModel.getFiles(newPath) - "OneDrive" -> filesViewModel.getFiles(oneDrivePath) + "OneDrive" -> filesViewModel.getFiles(fixedPath) + "YandexDisk" -> filesViewModel.getFiles(fixedPath) } } } 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 9ae9664..08617a0 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 @@ -49,6 +49,9 @@ fun HomeScreen( val oneDriveAccount by remember { filesViewModel.oneDriveAccount } val oneDriveStorageFreeSpace = remember { mutableStateOf("Not signed in") } + val yandexDiskAccount by remember { filesViewModel.yandexDiskAccount } + val yandexDiskStorageFreeSpace = remember { mutableStateOf("Not signed in") } + Column( modifier = modifier.fillMaxSize(), verticalArrangement = Arrangement.spacedBy( @@ -80,5 +83,21 @@ fun HomeScreen( filesViewModel.setCurrentStorage("OneDrive") navController.navigate(Destinations.FILE_LIST) } + + LaunchedEffect(yandexDiskAccount) { + yandexDiskAccount?.let { + yandexDiskStorageFreeSpace.value = filesViewModel.getYandexDiskStorageFreeSpace() + } + } + + StorageCard( + title = stringResource(id = R.string.yandex_disk_storage), + enabled = yandexDiskAccount != null, + leadingIcon = R.drawable.ic_cloud_storage, + info = yandexDiskStorageFreeSpace.value + ) { + filesViewModel.setCurrentStorage("YandexDisk") + navController.navigate(Destinations.FILE_LIST) + } } } \ 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 9e07455..a176ef7 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 @@ -30,6 +30,7 @@ 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.repository.YandexDiskRepository import com.reysand.files.data.repository.YandexUserRepository import com.reysand.files.data.util.MicrosoftService import com.reysand.files.data.util.YandexService @@ -52,6 +53,7 @@ private const val TAG = "FilesViewModel" class FilesViewModel( private val fileRepository: FileRepository, private val oneDriveRepository: OneDriveRepository, + private val yandexDiskRepository: YandexDiskRepository, private val yandexUserRepository: YandexUserRepository, private val contextWrapper: ContextWrapper, private val microsoftService: MicrosoftService, @@ -86,7 +88,6 @@ class FilesViewModel( oneDriveAccount.value = username Log.d("FilesViewModel", "oneDriveAccount: ${oneDriveAccount.value}") } -// yandexDiskAccount.value = yandexUserRepository.getInfo(yandexService.token?.value) } } @@ -95,6 +96,7 @@ class FilesViewModel( when (storage) { "Local" -> homeDirectory = Environment.getExternalStorageDirectory().path "OneDrive" -> homeDirectory = "/" + "YandexDisk" -> homeDirectory = "/" } currentDirectory.value = homeDirectory getFiles(homeDirectory) @@ -122,12 +124,8 @@ class FilesViewModel( suspend fun getInfo() { if (yandexService.token != null) { - Log.d(TAG, "token: ${yandexService.token!!.value}") - Log.d(TAG, "getInfo: ${yandexUserRepository.getInfo(yandexService.token!!.value)}") yandexDiskAccount.value = yandexUserRepository.getInfo(yandexService.token!!.value) } - -// Log.d(TAG, "getInfo: ${yandexUserRepository.getInfo(yandexService.token!!)}") } /** @@ -140,6 +138,7 @@ class FilesViewModel( when (currentStorage.value) { "Local" -> _files.value = fileRepository.getFiles(path) "OneDrive" -> _files.value = oneDriveRepository.getFiles(path) + "YandexDisk" -> _files.value = yandexDiskRepository.getFiles(path) } currentDirectory.value = path } @@ -156,6 +155,11 @@ class FilesViewModel( } else { currentDirectory.value.substringBeforeLast('/') } + "YandexDisk" -> if (!currentDirectory.value.contains('/')) { + "/" + } else { + currentDirectory.value.substringBeforeLast('/') + } else -> null } @@ -191,6 +195,20 @@ class FilesViewModel( }.await() } + /** + * Gets the free space of the OneDrive storage. + * + * @return A string representing the free space of the OneDrive storage. + */ + suspend fun getYandexDiskStorageFreeSpace(): String { + return viewModelScope.async { + contextWrapper.getContext().getString( + R.string.storage_free_space, + yandexDiskRepository.getStorageFreeSpace() + ) + }.await() + } + /** * Move a file from one path to another. * @@ -201,10 +219,14 @@ class FilesViewModel( viewModelScope.launch { val result = when (currentStorage.value) { "Local" -> fileRepository.moveFile(file.path, homeDirectory.plus(destination)) - else -> oneDriveRepository.moveFile( + "OneDrive" -> oneDriveRepository.moveFile( file.path, homeDirectory.plus(destination.substringBeforeLast('/')) ) + else -> yandexDiskRepository.moveFile( + file.path, + homeDirectory.plus(destination) + ) } if (result) { @@ -223,10 +245,14 @@ class FilesViewModel( viewModelScope.launch { val result = when (currentStorage.value) { "Local" -> fileRepository.copyFile(file.path, homeDirectory.plus(destination)) - else -> oneDriveRepository.copyFile( + "OneDrive" -> oneDriveRepository.copyFile( file.path, homeDirectory.plus(destination.substringBeforeLast('/')) ) + else -> yandexDiskRepository.copyFile( + file.path, + homeDirectory.plus(destination) + ) } Log.d(TAG, "copyFile: ${file.path}") @@ -250,8 +276,8 @@ class FilesViewModel( file.path, file.path.removeSuffix(file.name).plus(newName) ) - - else -> oneDriveRepository.renameFile(file.path, newName) + "OneDrive" -> oneDriveRepository.renameFile(file.path, newName) + else -> yandexDiskRepository.renameFile(file.path, newName) } if (result) { @@ -269,7 +295,8 @@ class FilesViewModel( viewModelScope.launch { val result = when (currentStorage.value) { "Local" -> fileRepository.deleteFile(path) - else -> oneDriveRepository.deleteFile(path) + "OneDrive" -> oneDriveRepository.deleteFile(path) + else -> yandexDiskRepository.deleteFile(path) } if (result) { @@ -285,6 +312,7 @@ class FilesViewModel( val application = (this[APPLICATION_KEY] as FilesApplication) val fileRepository = application.container.fileRepository val oneDriveRepository = application.container.oneDriveRepository + val yandexDiskRepository = application.container.yandexDiskRepository val yandexUserRepository = application.container.yandexUserRepository val contextWrapper = ContextWrapper(application.applicationContext) val microsoftService = application.container.microsoftService @@ -292,6 +320,7 @@ class FilesViewModel( FilesViewModel( fileRepository = fileRepository, oneDriveRepository = oneDriveRepository, + yandexDiskRepository = yandexDiskRepository, yandexUserRepository = yandexUserRepository, contextWrapper = contextWrapper, microsoftService = microsoftService,