├── .gitignore ├── README.md ├── app ├── .gitignore ├── build.gradle.kts ├── proguard-rules.pro └── src │ └── main │ ├── AndroidManifest.xml │ ├── ic_launcher-playstore.png │ ├── java │ └── com │ │ └── axiel7 │ │ └── tachisync │ │ ├── App.kt │ │ ├── data │ │ ├── datastore │ │ │ ├── PreferencesDataStore.kt │ │ │ └── PreferencesRepository.kt │ │ └── model │ │ │ └── Manga.kt │ │ ├── ui │ │ ├── base │ │ │ ├── BaseViewModel.kt │ │ │ ├── UiEvent.kt │ │ │ └── UiState.kt │ │ ├── composables │ │ │ └── MessageDialog.kt │ │ ├── external │ │ │ ├── ExternalEvent.kt │ │ │ ├── ExternalUiState.kt │ │ │ ├── ExternalView.kt │ │ │ ├── ExternalViewModel.kt │ │ │ └── composables │ │ │ │ └── ExternalDeviceItemView.kt │ │ ├── files │ │ │ ├── FilesEvent.kt │ │ │ ├── FilesUiState.kt │ │ │ ├── FilesView.kt │ │ │ └── FilesViewModel.kt │ │ ├── main │ │ │ ├── MainActivity.kt │ │ │ ├── MainEvent.kt │ │ │ ├── MainNavigation.kt │ │ │ ├── MainUiState.kt │ │ │ ├── MainViewModel.kt │ │ │ └── composables │ │ │ │ ├── BottomNavBar.kt │ │ │ │ └── SyncingDialog.kt │ │ ├── settings │ │ │ └── SettingsView.kt │ │ └── theme │ │ │ ├── Color.kt │ │ │ ├── Theme.kt │ │ │ └── Type.kt │ │ └── utils │ │ ├── Extensions.kt │ │ └── FileUtils.kt │ └── res │ ├── drawable │ ├── arrow_back_24.xml │ ├── close_24.xml │ ├── code_24.xml │ ├── download_24.xml │ ├── ic_launcher_foreground.xml │ ├── person_24.xml │ ├── refresh_24.xml │ ├── select_all_24.xml │ ├── settings_24.xml │ ├── storage_24.xml │ ├── sync_24.xml │ └── usb_24.xml │ ├── mipmap-anydpi-v26 │ ├── ic_launcher.xml │ └── ic_launcher_round.xml │ ├── mipmap-hdpi │ ├── ic_launcher.png │ └── ic_launcher_round.png │ ├── mipmap-mdpi │ ├── ic_launcher.png │ └── ic_launcher_round.png │ ├── mipmap-xhdpi │ ├── ic_launcher.png │ └── ic_launcher_round.png │ ├── mipmap-xxhdpi │ ├── ic_launcher.png │ └── ic_launcher_round.png │ ├── mipmap-xxxhdpi │ ├── ic_launcher.png │ └── ic_launcher_round.png │ ├── values-es │ └── strings.xml │ ├── values │ ├── ic_launcher_background.xml │ ├── strings.xml │ └── themes.xml │ └── xml │ ├── backup_rules.xml │ └── data_extraction_rules.xml ├── build.gradle.kts ├── gradle.properties ├── gradle └── wrapper │ ├── gradle-wrapper.jar │ └── gradle-wrapper.properties ├── gradlew ├── gradlew.bat └── settings.gradle.kts /.gitignore: -------------------------------------------------------------------------------- 1 | *.iml 2 | .gradle 3 | /local.properties 4 | /.idea/caches 5 | /.idea/libraries 6 | /.idea/modules.xml 7 | /.idea/workspace.xml 8 | /.idea/navEditor.xml 9 | /.idea/assetWizardSettings.xml 10 | .DS_Store 11 | /build 12 | /captures 13 | .externalNativeBuild 14 | .cxx 15 | local.properties 16 | /.idea 17 | /app/release -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # ![app-icon](https://github.com/axiel7/Tachisync/blob/master/app/src/main/res/mipmap-mdpi/ic_launcher_round.png)Tachisync 2 | Android app to sync Tachiyomi/Mihon/Others downloads on external devices (OTG only for now). 3 | 4 | I made this app for personal use to easily transfer the Tachiyomi downloads to my Kobo eBook, but feel free to request new features or improvements! 5 | 6 | Tested on Android 13+ but it should work fine on Android 10+. 7 | Untested on older Android versions, open an issue if it doesn’t work. 8 | 9 | screenshot1 10 | -------------------------------------------------------------------------------- /app/.gitignore: -------------------------------------------------------------------------------- 1 | /build -------------------------------------------------------------------------------- /app/build.gradle.kts: -------------------------------------------------------------------------------- 1 | plugins { 2 | id("com.android.application") 3 | id("org.jetbrains.kotlin.android") 4 | id("org.jetbrains.kotlin.plugin.compose") 5 | } 6 | 7 | android { 8 | namespace = "com.axiel7.tachisync" 9 | compileSdk = 35 10 | 11 | defaultConfig { 12 | applicationId = "com.axiel7.tachisync" 13 | minSdk = 24 14 | targetSdk = 35 15 | versionCode = 11 16 | versionName = "1.0.10" 17 | setProperty("archivesBaseName", "tachisync-$versionName") 18 | 19 | testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" 20 | resourceConfigurations.addAll(arrayOf("en", "es")) 21 | vectorDrawables { 22 | useSupportLibrary = true 23 | } 24 | } 25 | 26 | buildTypes { 27 | debug { 28 | isDebuggable = true 29 | isMinifyEnabled = false 30 | isShrinkResources = false 31 | proguardFiles( 32 | getDefaultProguardFile("proguard-android-optimize.txt"), 33 | "proguard-rules.pro" 34 | ) 35 | } 36 | release { 37 | isDebuggable = false 38 | isMinifyEnabled = true 39 | isShrinkResources = true 40 | proguardFiles( 41 | getDefaultProguardFile("proguard-android-optimize.txt"), 42 | "proguard-rules.pro" 43 | ) 44 | } 45 | } 46 | compileOptions { 47 | sourceCompatibility = JavaVersion.VERSION_11 48 | targetCompatibility = JavaVersion.VERSION_11 49 | } 50 | kotlinOptions { 51 | jvmTarget = "11" 52 | } 53 | buildFeatures { 54 | compose = true 55 | buildConfig = true 56 | } 57 | packaging { 58 | resources { 59 | excludes += "/META-INF/{AL2.0,LGPL2.1}" 60 | } 61 | } 62 | } 63 | 64 | dependencies { 65 | 66 | // Android X 67 | implementation("androidx.core:core-ktx:1.13.1") 68 | implementation("androidx.datastore:datastore-preferences:1.1.1") 69 | implementation("androidx.documentfile:documentfile:1.0.1") 70 | 71 | // Compose 72 | implementation(platform("androidx.compose:compose-bom:2024.08.00")) 73 | implementation("androidx.compose.ui:ui-tooling-preview") 74 | debugImplementation("androidx.compose.ui:ui-tooling") 75 | 76 | implementation("androidx.activity:activity-compose:1.9.1") 77 | implementation("androidx.compose.material3:material3-android:1.2.1") 78 | implementation("androidx.lifecycle:lifecycle-viewmodel-compose:2.8.4") 79 | implementation("androidx.navigation:navigation-compose:2.7.7") 80 | 81 | implementation("org.jetbrains.kotlinx:kotlinx-collections-immutable:0.3.7") 82 | 83 | // Test 84 | testImplementation("junit:junit:4.13.2") 85 | androidTestImplementation("androidx.test.ext:junit:1.2.1") 86 | androidTestImplementation("androidx.test.espresso:espresso-core:3.6.1") 87 | androidTestImplementation("androidx.compose.ui:ui-test-junit4") 88 | } -------------------------------------------------------------------------------- /app/proguard-rules.pro: -------------------------------------------------------------------------------- 1 | # Add project specific ProGuard rules here. 2 | # You can control the set of applied configuration files using the 3 | # proguardFiles setting in build.gradle. 4 | # 5 | # For more details, see 6 | # http://developer.android.com/guide/developing/tools/proguard.html 7 | 8 | # If your project uses WebView with JS, uncomment the following 9 | # and specify the fully qualified class name to the JavaScript interface 10 | # class: 11 | #-keepclassmembers class fqcn.of.javascript.interface.for.webview { 12 | # public *; 13 | #} 14 | 15 | # Uncomment this to preserve the line number information for 16 | # debugging stack traces. 17 | #-keepattributes SourceFile,LineNumberTable 18 | 19 | # If you keep the line number information, uncomment this to 20 | # hide the original source file name. 21 | #-renamesourcefileattribute SourceFile -------------------------------------------------------------------------------- /app/src/main/AndroidManifest.xml: -------------------------------------------------------------------------------- 1 | 2 | 4 | 5 | 8 | 9 | 20 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | -------------------------------------------------------------------------------- /app/src/main/ic_launcher-playstore.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/axiel7/Tachisync/d8784f039594c0e93799503cabb3150bcf0096bf/app/src/main/ic_launcher-playstore.png -------------------------------------------------------------------------------- /app/src/main/java/com/axiel7/tachisync/App.kt: -------------------------------------------------------------------------------- 1 | package com.axiel7.tachisync 2 | 3 | import android.app.Application 4 | import android.content.Context 5 | import androidx.datastore.core.DataStore 6 | import androidx.datastore.preferences.core.Preferences 7 | import com.axiel7.tachisync.data.datastore.PreferencesDataStore.defaultPreferencesDataStore 8 | 9 | class App : Application() { 10 | 11 | init { 12 | INSTANCE = this 13 | } 14 | 15 | override fun onCreate() { 16 | super.onCreate() 17 | dataStore = defaultPreferencesDataStore 18 | } 19 | 20 | companion object { 21 | lateinit var INSTANCE: App 22 | private set 23 | val applicationContext: Context get() = INSTANCE.applicationContext 24 | 25 | lateinit var dataStore: DataStore 26 | } 27 | } -------------------------------------------------------------------------------- /app/src/main/java/com/axiel7/tachisync/data/datastore/PreferencesDataStore.kt: -------------------------------------------------------------------------------- 1 | package com.axiel7.tachisync.data.datastore 2 | 3 | import android.content.Context 4 | import androidx.datastore.preferences.core.booleanPreferencesKey 5 | import androidx.datastore.preferences.core.stringPreferencesKey 6 | import androidx.datastore.preferences.preferencesDataStore 7 | 8 | object PreferencesDataStore { 9 | 10 | val EXTERNAL_URI_KEY = stringPreferencesKey("external_uri") 11 | val TACHIYOMI_URI_KEY = stringPreferencesKey("tachiyomi_uri") 12 | val REMOVE_SCANLATOR_KEY = booleanPreferencesKey("remove_scanlator") 13 | 14 | val Context.defaultPreferencesDataStore by preferencesDataStore(name = "default") 15 | } -------------------------------------------------------------------------------- /app/src/main/java/com/axiel7/tachisync/data/datastore/PreferencesRepository.kt: -------------------------------------------------------------------------------- 1 | package com.axiel7.tachisync.data.datastore 2 | 3 | import androidx.compose.runtime.Composable 4 | import androidx.compose.runtime.collectAsState 5 | import androidx.datastore.preferences.core.Preferences 6 | import androidx.datastore.preferences.core.edit 7 | import com.axiel7.tachisync.App 8 | import kotlinx.coroutines.flow.first 9 | import kotlinx.coroutines.flow.map 10 | import kotlinx.coroutines.runBlocking 11 | 12 | object PreferencesRepository { 13 | 14 | private val store get() = App.dataStore 15 | 16 | suspend fun get(key: Preferences.Key) = store.data.first()[key] 17 | 18 | @Composable 19 | fun remember( 20 | key: Preferences.Key, 21 | initial: T? = null 22 | ) = store.data.map { it[key] }.collectAsState(initial) 23 | 24 | /** 25 | * Gets the value by blocking the main thread 26 | */ 27 | fun getSync(key: Preferences.Key) = runBlocking { get(key) } 28 | 29 | suspend fun set( 30 | key: Preferences.Key, 31 | value: T 32 | ) = store.edit { it[key] = value } 33 | 34 | /** 35 | * Sets the value by blocking the main thread 36 | */ 37 | fun setSync( 38 | key: Preferences.Key, 39 | value: T 40 | ) = runBlocking { set(key, value) } 41 | 42 | suspend fun remove(key: Preferences.Key) = store.edit { it.remove(key) } 43 | 44 | /** 45 | * Removes the value by blocking the main thread 46 | */ 47 | fun removeSync(key: Preferences.Key) = runBlocking { remove(key) } 48 | } -------------------------------------------------------------------------------- /app/src/main/java/com/axiel7/tachisync/data/model/Manga.kt: -------------------------------------------------------------------------------- 1 | package com.axiel7.tachisync.data.model 2 | 3 | import androidx.documentfile.provider.DocumentFile 4 | 5 | data class Manga( 6 | val name: String, 7 | val chapters: Int, 8 | val file: DocumentFile, 9 | val isSelected: Boolean = false 10 | ) 11 | -------------------------------------------------------------------------------- /app/src/main/java/com/axiel7/tachisync/ui/base/BaseViewModel.kt: -------------------------------------------------------------------------------- 1 | package com.axiel7.tachisync.ui.base 2 | 3 | import androidx.lifecycle.ViewModel 4 | import kotlinx.coroutines.flow.MutableStateFlow 5 | import kotlinx.coroutines.flow.StateFlow 6 | import kotlinx.coroutines.flow.asStateFlow 7 | import kotlinx.coroutines.flow.update 8 | 9 | abstract class BaseViewModel : ViewModel(), UiEvent { 10 | 11 | protected abstract val mutableUiState: MutableStateFlow 12 | val uiState: StateFlow get() = mutableUiState.asStateFlow() 13 | 14 | @Suppress("UNCHECKED_CAST") 15 | fun setLoading(value: Boolean) { 16 | mutableUiState.update { it.setLoading(value) as S } 17 | } 18 | 19 | @Suppress("UNCHECKED_CAST") 20 | override fun showMessage(message: String?) { 21 | mutableUiState.update { it.setMessage(message) as S } 22 | } 23 | 24 | @Suppress("UNCHECKED_CAST") 25 | override fun onMessageDisplayed() { 26 | mutableUiState.update { it.setMessage(null) as S } 27 | } 28 | } -------------------------------------------------------------------------------- /app/src/main/java/com/axiel7/tachisync/ui/base/UiEvent.kt: -------------------------------------------------------------------------------- 1 | package com.axiel7.tachisync.ui.base 2 | 3 | interface UiEvent { 4 | fun showMessage(message: String?) 5 | fun onMessageDisplayed() 6 | } -------------------------------------------------------------------------------- /app/src/main/java/com/axiel7/tachisync/ui/base/UiState.kt: -------------------------------------------------------------------------------- 1 | package com.axiel7.tachisync.ui.base 2 | 3 | abstract class UiState { 4 | abstract val isLoading: Boolean 5 | abstract val message: String? 6 | 7 | // These methods are required because we can't have an abstract data class 8 | // so we need to manually implement the copy() method 9 | 10 | /** 11 | * copy(isLoading = value) 12 | */ 13 | abstract fun setLoading(value: Boolean): UiState 14 | 15 | /** 16 | * copy(message = value) 17 | */ 18 | abstract fun setMessage(value: String?): UiState 19 | } -------------------------------------------------------------------------------- /app/src/main/java/com/axiel7/tachisync/ui/composables/MessageDialog.kt: -------------------------------------------------------------------------------- 1 | package com.axiel7.tachisync.ui.composables 2 | 3 | import androidx.compose.material3.AlertDialog 4 | import androidx.compose.material3.Text 5 | import androidx.compose.material3.TextButton 6 | import androidx.compose.runtime.Composable 7 | import androidx.compose.ui.res.stringResource 8 | 9 | @Composable 10 | fun MessageDialog( 11 | title: String?, 12 | message: String, 13 | onConfirm: () -> Unit, 14 | onDismiss: () -> Unit, 15 | ) { 16 | AlertDialog( 17 | onDismissRequest = onDismiss, 18 | title = title?.let { { Text(text = title) } }, 19 | text = { Text(text = message) }, 20 | confirmButton = { 21 | TextButton(onClick = onConfirm) { 22 | Text(text = stringResource(android.R.string.ok)) 23 | } 24 | } 25 | ) 26 | } -------------------------------------------------------------------------------- /app/src/main/java/com/axiel7/tachisync/ui/external/ExternalEvent.kt: -------------------------------------------------------------------------------- 1 | package com.axiel7.tachisync.ui.external 2 | 3 | import android.content.Context 4 | import android.os.storage.StorageVolume 5 | import com.axiel7.tachisync.ui.base.UiEvent 6 | 7 | interface ExternalEvent : UiEvent { 8 | fun getExternalStorages(context: Context) 9 | fun onDeviceSelected(device: StorageVolume?) 10 | fun setOpenIntentForDirectory(value: Boolean) 11 | fun setOpenExternalDirectoryHelpDialog(value: Boolean) 12 | fun reset() 13 | } -------------------------------------------------------------------------------- /app/src/main/java/com/axiel7/tachisync/ui/external/ExternalUiState.kt: -------------------------------------------------------------------------------- 1 | package com.axiel7.tachisync.ui.external 2 | 3 | import android.os.storage.StorageVolume 4 | import androidx.compose.runtime.Stable 5 | import androidx.compose.runtime.mutableStateListOf 6 | import androidx.compose.runtime.snapshots.SnapshotStateList 7 | import com.axiel7.tachisync.ui.base.UiState 8 | 9 | @Stable 10 | data class ExternalUiState( 11 | val externalStorages: SnapshotStateList = mutableStateListOf(), 12 | val selectedDevice: StorageVolume? = null, 13 | val openIntentForDirectory: Boolean = false, 14 | val openExternalDirectoryHelpDialog: Boolean = false, 15 | override val isLoading: Boolean = false, 16 | override val message: String? = null, 17 | ) : UiState() { 18 | override fun setLoading(value: Boolean) = copy(isLoading = value) 19 | override fun setMessage(value: String?) = copy(message = value) 20 | } 21 | -------------------------------------------------------------------------------- /app/src/main/java/com/axiel7/tachisync/ui/external/ExternalView.kt: -------------------------------------------------------------------------------- 1 | package com.axiel7.tachisync.ui.external 2 | 3 | import android.os.Build 4 | import androidx.compose.foundation.layout.Arrangement 5 | import androidx.compose.foundation.layout.Column 6 | import androidx.compose.foundation.layout.fillMaxSize 7 | import androidx.compose.foundation.layout.padding 8 | import androidx.compose.foundation.lazy.LazyColumn 9 | import androidx.compose.foundation.lazy.items 10 | import androidx.compose.material3.Button 11 | import androidx.compose.material3.Icon 12 | import androidx.compose.material3.Surface 13 | import androidx.compose.material3.Text 14 | import androidx.compose.material3.TextButton 15 | import androidx.compose.runtime.Composable 16 | import androidx.compose.runtime.LaunchedEffect 17 | import androidx.compose.runtime.collectAsState 18 | import androidx.compose.runtime.getValue 19 | import androidx.compose.ui.Alignment 20 | import androidx.compose.ui.Modifier 21 | import androidx.compose.ui.platform.LocalContext 22 | import androidx.compose.ui.res.painterResource 23 | import androidx.compose.ui.res.stringResource 24 | import androidx.compose.ui.text.style.TextAlign 25 | import androidx.compose.ui.tooling.preview.Preview 26 | import androidx.compose.ui.unit.dp 27 | import androidx.lifecycle.viewmodel.compose.viewModel 28 | import com.axiel7.tachisync.R 29 | import com.axiel7.tachisync.ui.composables.MessageDialog 30 | import com.axiel7.tachisync.ui.external.composables.ExternalDeviceItemView 31 | import com.axiel7.tachisync.ui.main.MainEvent 32 | import com.axiel7.tachisync.ui.main.MainUiState 33 | import com.axiel7.tachisync.ui.theme.TachisyncTheme 34 | import com.axiel7.tachisync.utils.FileUtils.areUriPermissionsGranted 35 | import com.axiel7.tachisync.utils.FileUtils.rememberUriLauncher 36 | 37 | const val EXTERNAL_STORAGE_DESTINATION = "external_storage" 38 | 39 | @Composable 40 | fun ExternalView( 41 | mainUiState: MainUiState, 42 | mainEvent: MainEvent?, 43 | modifier: Modifier = Modifier, 44 | ) { 45 | val viewModel: ExternalViewModel = viewModel() 46 | val uiState by viewModel.uiState.collectAsState() 47 | 48 | ExternalContent( 49 | mainUiState = mainUiState, 50 | mainEvent = mainEvent, 51 | externalUiState = uiState, 52 | externalEvent = viewModel, 53 | modifier = modifier, 54 | ) 55 | } 56 | 57 | @Composable 58 | private fun ExternalContent( 59 | mainUiState: MainUiState, 60 | mainEvent: MainEvent?, 61 | externalUiState: ExternalUiState, 62 | externalEvent: ExternalEvent?, 63 | modifier: Modifier = Modifier, 64 | ) { 65 | val context = LocalContext.current 66 | 67 | val uriLauncher = rememberUriLauncher { uri -> 68 | mainEvent?.onExternalUriChanged(uri.toString()) 69 | } 70 | 71 | LaunchedEffect(mainUiState.externalSyncUri) { 72 | if (mainUiState.externalSyncUri == null 73 | || !context.areUriPermissionsGranted(mainUiState.externalSyncUri.toString()) 74 | ) { 75 | externalEvent?.getExternalStorages(context) 76 | } 77 | } 78 | 79 | if (externalUiState.openExternalDirectoryHelpDialog) { 80 | MessageDialog( 81 | title = stringResource(R.string.external_directory), 82 | message = stringResource(R.string.external_directory_explanation), 83 | onConfirm = { 84 | externalEvent?.setOpenExternalDirectoryHelpDialog(false) 85 | externalEvent?.setOpenIntentForDirectory(true) 86 | }, 87 | onDismiss = { externalEvent?.setOpenExternalDirectoryHelpDialog(false) } 88 | ) 89 | } 90 | 91 | if (externalUiState.openIntentForDirectory && externalUiState.selectedDevice != null) { 92 | if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { 93 | uriLauncher.launch(externalUiState.selectedDevice.createOpenDocumentTreeIntent()) 94 | } else { 95 | @Suppress("DEPRECATION") 96 | externalUiState.selectedDevice.createAccessIntent(null) 97 | ?.let { uriLauncher.launch(it) } 98 | } 99 | externalEvent?.setOpenIntentForDirectory(false) 100 | } 101 | 102 | if (mainUiState.externalSyncUri != null) { 103 | Column( 104 | modifier = modifier.fillMaxSize(), 105 | verticalArrangement = Arrangement.Center, 106 | horizontalAlignment = Alignment.CenterHorizontally 107 | ) { 108 | Text( 109 | text = stringResource( 110 | R.string.contents_synced_on, 111 | mainUiState.externalSyncUri.lastPathSegment.orEmpty() 112 | ), 113 | modifier = Modifier.padding(horizontal = 16.dp), 114 | textAlign = TextAlign.Center 115 | ) 116 | 117 | Button( 118 | onClick = { 119 | externalEvent?.reset() 120 | externalEvent?.getExternalStorages(context) 121 | }, 122 | modifier = Modifier.padding(16.dp) 123 | ) { 124 | Text(text = stringResource(R.string.select_another_directory)) 125 | } 126 | } 127 | } else if (externalUiState.externalStorages.isEmpty()) { 128 | Column( 129 | modifier = modifier.fillMaxSize(), 130 | verticalArrangement = Arrangement.Center, 131 | horizontalAlignment = Alignment.CenterHorizontally 132 | ) { 133 | Text( 134 | text = stringResource(R.string.no_external_found), 135 | modifier = Modifier.padding(16.dp) 136 | ) 137 | 138 | TextButton(onClick = { externalEvent?.getExternalStorages(context) }) { 139 | Icon( 140 | painter = painterResource(R.drawable.refresh_24), 141 | contentDescription = stringResource(R.string.refresh) 142 | ) 143 | Text( 144 | text = stringResource(R.string.refresh), 145 | modifier = Modifier.padding(start = 4.dp) 146 | ) 147 | } 148 | }//:Column 149 | } else { 150 | Column(modifier = modifier) { 151 | Text( 152 | text = stringResource(R.string.select_device_to_sync), 153 | modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp) 154 | ) 155 | LazyColumn { 156 | items(externalUiState.externalStorages) { 157 | ExternalDeviceItemView( 158 | deviceName = it.getDescription(context), 159 | onClick = { 160 | externalEvent?.onDeviceSelected(it) 161 | externalEvent?.setOpenExternalDirectoryHelpDialog(true) 162 | } 163 | ) 164 | } 165 | }//:LazyColumn 166 | }//:Column 167 | } 168 | } 169 | 170 | @Preview(showBackground = true) 171 | @Composable 172 | fun ExternalPreview() { 173 | TachisyncTheme { 174 | Surface { 175 | ExternalContent( 176 | mainUiState = MainUiState(), 177 | mainEvent = null, 178 | externalUiState = ExternalUiState(), 179 | externalEvent = null, 180 | ) 181 | } 182 | } 183 | } -------------------------------------------------------------------------------- /app/src/main/java/com/axiel7/tachisync/ui/external/ExternalViewModel.kt: -------------------------------------------------------------------------------- 1 | package com.axiel7.tachisync.ui.external 2 | 3 | import android.content.Context 4 | import android.net.Uri 5 | import android.os.Environment 6 | import android.os.storage.StorageManager 7 | import android.os.storage.StorageVolume 8 | import androidx.lifecycle.viewModelScope 9 | import com.axiel7.tachisync.App 10 | import com.axiel7.tachisync.data.datastore.PreferencesDataStore.EXTERNAL_URI_KEY 11 | import com.axiel7.tachisync.data.datastore.PreferencesRepository 12 | import com.axiel7.tachisync.ui.base.BaseViewModel 13 | import com.axiel7.tachisync.utils.FileUtils.releaseUriPermissions 14 | import kotlinx.coroutines.Dispatchers 15 | import kotlinx.coroutines.flow.MutableStateFlow 16 | import kotlinx.coroutines.flow.update 17 | import kotlinx.coroutines.launch 18 | 19 | class ExternalViewModel : BaseViewModel(), ExternalEvent { 20 | 21 | override val mutableUiState = MutableStateFlow(ExternalUiState()) 22 | 23 | override fun getExternalStorages(context: Context) { 24 | viewModelScope.launch(Dispatchers.IO) { 25 | setLoading(true) 26 | val storageManager = context.getSystemService(Context.STORAGE_SERVICE) as StorageManager 27 | mutableUiState.value.run { 28 | externalStorages.clear() 29 | externalStorages.addAll( 30 | storageManager.storageVolumes 31 | .filter { it.isRemovable && it.state == Environment.MEDIA_MOUNTED } 32 | ) 33 | } 34 | setLoading(false) 35 | } 36 | } 37 | 38 | override fun onDeviceSelected(device: StorageVolume?) { 39 | mutableUiState.update { it.copy(selectedDevice = device) } 40 | } 41 | 42 | override fun setOpenIntentForDirectory(value: Boolean) { 43 | mutableUiState.update { it.copy(openIntentForDirectory = value) } 44 | } 45 | 46 | override fun setOpenExternalDirectoryHelpDialog(value: Boolean) { 47 | mutableUiState.update { it.copy(openExternalDirectoryHelpDialog = value) } 48 | } 49 | 50 | override fun reset() { 51 | viewModelScope.launch(Dispatchers.IO) { 52 | PreferencesRepository.get(EXTERNAL_URI_KEY)?.let { uri -> 53 | App.applicationContext.releaseUriPermissions(Uri.parse(uri)) 54 | PreferencesRepository.remove(EXTERNAL_URI_KEY) 55 | } 56 | mutableUiState.update { 57 | it.externalStorages.clear() 58 | it.copy(selectedDevice = null) 59 | } 60 | } 61 | } 62 | } -------------------------------------------------------------------------------- /app/src/main/java/com/axiel7/tachisync/ui/external/composables/ExternalDeviceItemView.kt: -------------------------------------------------------------------------------- 1 | package com.axiel7.tachisync.ui.external.composables 2 | 3 | import androidx.compose.foundation.clickable 4 | import androidx.compose.foundation.layout.Row 5 | import androidx.compose.foundation.layout.fillMaxWidth 6 | import androidx.compose.foundation.layout.padding 7 | import androidx.compose.material3.Icon 8 | import androidx.compose.material3.Text 9 | import androidx.compose.runtime.Composable 10 | import androidx.compose.ui.Alignment 11 | import androidx.compose.ui.Modifier 12 | import androidx.compose.ui.res.painterResource 13 | import androidx.compose.ui.res.stringResource 14 | import androidx.compose.ui.text.style.TextOverflow 15 | import androidx.compose.ui.unit.dp 16 | import com.axiel7.tachisync.R 17 | 18 | @Composable 19 | fun ExternalDeviceItemView( 20 | deviceName: String, 21 | onClick: () -> Unit 22 | ) { 23 | Row( 24 | modifier = Modifier 25 | .clickable(onClick = onClick) 26 | .padding(16.dp) 27 | .fillMaxWidth(), 28 | verticalAlignment = Alignment.CenterVertically 29 | ) { 30 | Icon( 31 | painter = painterResource(R.drawable.usb_24), 32 | contentDescription = stringResource(R.string.device) 33 | ) 34 | Text( 35 | text = deviceName, 36 | modifier = Modifier.padding(start = 8.dp), 37 | overflow = TextOverflow.Ellipsis, 38 | maxLines = 1 39 | ) 40 | } 41 | } -------------------------------------------------------------------------------- /app/src/main/java/com/axiel7/tachisync/ui/files/FilesEvent.kt: -------------------------------------------------------------------------------- 1 | package com.axiel7.tachisync.ui.files 2 | 3 | import android.content.Context 4 | import android.net.Uri 5 | import com.axiel7.tachisync.ui.base.UiEvent 6 | 7 | interface FilesEvent : UiEvent { 8 | fun onSelectedManga(index: Int, selected: Boolean) 9 | fun selectAllManga() 10 | fun deselectAllManga() 11 | fun refresh(context: Context) 12 | fun readDownloadsDir(downloadsUri: Uri, context: Context) 13 | fun setOpenIntentForDirectory(value: Boolean) 14 | fun setOpenTachiyomiDirectoryHelpDialog(value: Boolean) 15 | } -------------------------------------------------------------------------------- /app/src/main/java/com/axiel7/tachisync/ui/files/FilesUiState.kt: -------------------------------------------------------------------------------- 1 | package com.axiel7.tachisync.ui.files 2 | 3 | import androidx.compose.runtime.Stable 4 | import androidx.compose.runtime.mutableStateListOf 5 | import androidx.compose.runtime.snapshots.SnapshotStateList 6 | import com.axiel7.tachisync.data.model.Manga 7 | import com.axiel7.tachisync.ui.base.UiState 8 | 9 | @Stable 10 | data class FilesUiState( 11 | val downloadedManga: SnapshotStateList = mutableStateListOf(), 12 | val selectedMangaIndices: SnapshotStateList = mutableStateListOf(), 13 | val openIntentForDirectory: Boolean = false, 14 | val openTachiyomiDirectoryHelpDialog: Boolean = false, 15 | override val isLoading: Boolean = false, 16 | override val message: String? = null 17 | ) : UiState() { 18 | override fun setLoading(value: Boolean) = copy(isLoading = value) 19 | override fun setMessage(value: String?) = copy(message = value) 20 | } 21 | -------------------------------------------------------------------------------- /app/src/main/java/com/axiel7/tachisync/ui/files/FilesView.kt: -------------------------------------------------------------------------------- 1 | package com.axiel7.tachisync.ui.files 2 | 3 | import android.content.Intent 4 | import android.widget.Toast 5 | import androidx.compose.foundation.clickable 6 | import androidx.compose.foundation.layout.Arrangement 7 | import androidx.compose.foundation.layout.Box 8 | import androidx.compose.foundation.layout.PaddingValues 9 | import androidx.compose.foundation.layout.Row 10 | import androidx.compose.foundation.layout.fillMaxSize 11 | import androidx.compose.foundation.layout.fillMaxWidth 12 | import androidx.compose.foundation.layout.padding 13 | import androidx.compose.foundation.lazy.LazyColumn 14 | import androidx.compose.foundation.lazy.itemsIndexed 15 | import androidx.compose.material3.Checkbox 16 | import androidx.compose.material3.CircularProgressIndicator 17 | import androidx.compose.material3.MaterialTheme 18 | import androidx.compose.material3.Surface 19 | import androidx.compose.material3.Text 20 | import androidx.compose.runtime.Composable 21 | import androidx.compose.runtime.LaunchedEffect 22 | import androidx.compose.runtime.getValue 23 | import androidx.compose.runtime.mutableStateOf 24 | import androidx.compose.runtime.remember 25 | import androidx.compose.runtime.setValue 26 | import androidx.compose.ui.Alignment 27 | import androidx.compose.ui.Modifier 28 | import androidx.compose.ui.draw.clipToBounds 29 | import androidx.compose.ui.platform.LocalContext 30 | import androidx.compose.ui.res.stringResource 31 | import androidx.compose.ui.text.style.TextOverflow 32 | import androidx.compose.ui.tooling.preview.Preview 33 | import androidx.compose.ui.unit.dp 34 | import com.axiel7.tachisync.R 35 | import com.axiel7.tachisync.data.model.Manga 36 | import com.axiel7.tachisync.ui.composables.MessageDialog 37 | import com.axiel7.tachisync.ui.main.MainEvent 38 | import com.axiel7.tachisync.ui.theme.TachisyncTheme 39 | import com.axiel7.tachisync.utils.FileUtils.rememberUriLauncher 40 | 41 | const val FILES_DESTINATION = "files" 42 | 43 | @Composable 44 | fun FilesView( 45 | filesUiState: FilesUiState, 46 | filesEvent: FilesEvent?, 47 | mainEvent: MainEvent?, 48 | contentPadding: PaddingValues, 49 | ) { 50 | val context = LocalContext.current 51 | val uriLauncher = rememberUriLauncher { uri -> 52 | mainEvent?.onTachiyomiUriChanged(uri.toString()) 53 | filesEvent?.readDownloadsDir(uri, context) 54 | } 55 | 56 | if (filesUiState.openIntentForDirectory) { 57 | Intent(Intent.ACTION_OPEN_DOCUMENT_TREE).apply { 58 | uriLauncher.launch(this) 59 | } 60 | filesEvent?.setOpenIntentForDirectory(false) 61 | } 62 | 63 | if (filesUiState.openTachiyomiDirectoryHelpDialog) { 64 | MessageDialog( 65 | title = stringResource(R.string.download_directory), 66 | message = stringResource(R.string.downloads_directory_explanation), 67 | onConfirm = { 68 | filesEvent?.setOpenTachiyomiDirectoryHelpDialog(false) 69 | filesEvent?.setOpenIntentForDirectory(true) 70 | }, 71 | onDismiss = { filesEvent?.setOpenTachiyomiDirectoryHelpDialog(false) } 72 | ) 73 | } 74 | 75 | if (filesUiState.message != null) { 76 | Toast.makeText(context, filesUiState.message, Toast.LENGTH_SHORT).show() 77 | filesEvent?.onMessageDisplayed() 78 | } 79 | 80 | LaunchedEffect(context) { 81 | if (filesUiState.downloadedManga.isEmpty()) filesEvent?.refresh(context) 82 | } 83 | 84 | Box( 85 | modifier = Modifier.clipToBounds() 86 | ) { 87 | LazyColumn( 88 | modifier = Modifier.fillMaxSize(), 89 | contentPadding = contentPadding 90 | ) { 91 | itemsIndexed( 92 | items = filesUiState.downloadedManga, 93 | key = { _, manga -> manga.file.uri } 94 | ) { index, manga -> 95 | SelectableMangaItemView( 96 | manga = manga, 97 | onClick = { selected -> 98 | filesEvent?.onSelectedManga(index, selected) 99 | } 100 | ) 101 | } 102 | } 103 | 104 | if (filesUiState.isLoading) { 105 | CircularProgressIndicator( 106 | modifier = Modifier.align(Alignment.Center) 107 | ) 108 | } else if (filesUiState.downloadedManga.isEmpty()) { 109 | Text( 110 | text = stringResource(R.string.no_downloaded_content), 111 | modifier = Modifier.align(Alignment.Center) 112 | ) 113 | } 114 | } 115 | } 116 | 117 | @Composable 118 | fun SelectableMangaItemView( 119 | manga: Manga, 120 | onClick: (Boolean) -> Unit 121 | ) { 122 | var isChecked by remember { mutableStateOf(manga.isSelected) } 123 | Row( 124 | modifier = Modifier 125 | .clickable { 126 | isChecked = !manga.isSelected 127 | onClick(isChecked) 128 | } 129 | .padding(horizontal = 16.dp, vertical = 4.dp) 130 | .fillMaxWidth(), 131 | horizontalArrangement = Arrangement.SpaceBetween, 132 | verticalAlignment = Alignment.CenterVertically 133 | ) { 134 | Row( 135 | modifier = Modifier.weight(1f), 136 | verticalAlignment = Alignment.CenterVertically 137 | ) { 138 | Checkbox( 139 | checked = manga.isSelected, 140 | onCheckedChange = { 141 | isChecked = it 142 | onClick(it) 143 | } 144 | ) 145 | Text( 146 | text = manga.name, 147 | modifier = Modifier.padding(8.dp), 148 | overflow = TextOverflow.Ellipsis, 149 | maxLines = 1 150 | ) 151 | } 152 | Text(text = manga.chapters.toString(), color = MaterialTheme.colorScheme.onPrimaryContainer) 153 | } 154 | } 155 | 156 | @Preview(showBackground = true) 157 | @Composable 158 | fun FilesPreview() { 159 | TachisyncTheme { 160 | Surface { 161 | FilesView( 162 | filesUiState = FilesUiState(), 163 | filesEvent = null, 164 | mainEvent = null, 165 | contentPadding = PaddingValues() 166 | ) 167 | } 168 | } 169 | } -------------------------------------------------------------------------------- /app/src/main/java/com/axiel7/tachisync/ui/files/FilesViewModel.kt: -------------------------------------------------------------------------------- 1 | package com.axiel7.tachisync.ui.files 2 | 3 | import android.content.Context 4 | import android.net.Uri 5 | import androidx.documentfile.provider.DocumentFile 6 | import androidx.lifecycle.viewModelScope 7 | import com.axiel7.tachisync.App 8 | import com.axiel7.tachisync.data.datastore.PreferencesDataStore.TACHIYOMI_URI_KEY 9 | import com.axiel7.tachisync.data.datastore.PreferencesRepository 10 | import com.axiel7.tachisync.data.model.Manga 11 | import com.axiel7.tachisync.ui.base.BaseViewModel 12 | import com.axiel7.tachisync.utils.FileUtils.areUriPermissionsGranted 13 | import com.axiel7.tachisync.utils.FileUtils.releaseUriPermissions 14 | import kotlinx.coroutines.Dispatchers 15 | import kotlinx.coroutines.flow.MutableStateFlow 16 | import kotlinx.coroutines.flow.update 17 | import kotlinx.coroutines.launch 18 | 19 | class FilesViewModel : BaseViewModel(), FilesEvent { 20 | 21 | override val mutableUiState = MutableStateFlow(FilesUiState()) 22 | 23 | override fun onSelectedManga(index: Int, selected: Boolean) { 24 | mutableUiState.value.run { 25 | downloadedManga[index] = downloadedManga[index].copy(isSelected = selected) 26 | 27 | if (selected) selectedMangaIndices.add(index) 28 | else selectedMangaIndices.remove(index) 29 | } 30 | } 31 | 32 | override fun selectAllManga() { 33 | viewModelScope.launch(Dispatchers.IO) { 34 | mutableUiState.value.run { 35 | for (index in downloadedManga.indices) { 36 | downloadedManga[index] = downloadedManga[index].copy(isSelected = true) 37 | } 38 | selectedMangaIndices.clear() 39 | selectedMangaIndices.addAll(downloadedManga.indices) 40 | } 41 | } 42 | } 43 | 44 | override fun deselectAllManga() { 45 | viewModelScope.launch(Dispatchers.IO) { 46 | mutableUiState.value.run { 47 | for (index in downloadedManga.indices) { 48 | downloadedManga[index] = downloadedManga[index].copy(isSelected = false) 49 | } 50 | selectedMangaIndices.clear() 51 | } 52 | } 53 | } 54 | 55 | override fun refresh(context: Context) { 56 | viewModelScope.launch(Dispatchers.IO) { 57 | deselectAllManga() 58 | val tachiyomiUri = PreferencesRepository.get(TACHIYOMI_URI_KEY) 59 | if (tachiyomiUri.isNullOrEmpty() || !context.areUriPermissionsGranted(tachiyomiUri)) { 60 | setOpenTachiyomiDirectoryHelpDialog(true) 61 | } else { 62 | readDownloadsDir(Uri.parse(tachiyomiUri), context) 63 | } 64 | } 65 | } 66 | 67 | override fun readDownloadsDir(downloadsUri: Uri, context: Context) { 68 | viewModelScope.launch(Dispatchers.IO) { 69 | setLoading(true) 70 | val tempContent = mutableListOf() 71 | val sourcesDir = DocumentFile.fromTreeUri(context, downloadsUri) 72 | if (sourcesDir == null || !sourcesDir.exists()) { 73 | App.applicationContext.releaseUriPermissions(downloadsUri) 74 | PreferencesRepository.remove(TACHIYOMI_URI_KEY) 75 | } else { 76 | sourcesDir.listFiles().forEach { sourceFile -> 77 | sourceFile.listFiles().forEach { series -> 78 | val chaptersDownloaded = series.listFiles().size 79 | if (chaptersDownloaded > 0) 80 | tempContent.add( 81 | Manga( 82 | name = series.name ?: "Unknown", 83 | chapters = chaptersDownloaded, 84 | file = series 85 | ) 86 | ) 87 | } 88 | } 89 | mutableUiState.value.run { 90 | downloadedManga.clear() 91 | downloadedManga.addAll(tempContent) 92 | } 93 | } 94 | 95 | setLoading(false) 96 | } 97 | } 98 | 99 | override fun setOpenIntentForDirectory(value: Boolean) { 100 | mutableUiState.update { it.copy(openIntentForDirectory = value) } 101 | } 102 | 103 | override fun setOpenTachiyomiDirectoryHelpDialog(value: Boolean) { 104 | mutableUiState.update { it.copy(openTachiyomiDirectoryHelpDialog = value) } 105 | } 106 | } -------------------------------------------------------------------------------- /app/src/main/java/com/axiel7/tachisync/ui/main/MainActivity.kt: -------------------------------------------------------------------------------- 1 | package com.axiel7.tachisync.ui.main 2 | 3 | import android.os.Bundle 4 | import androidx.activity.ComponentActivity 5 | import androidx.activity.compose.setContent 6 | import androidx.activity.enableEdgeToEdge 7 | import androidx.compose.animation.AnimatedVisibility 8 | import androidx.compose.animation.scaleIn 9 | import androidx.compose.animation.scaleOut 10 | import androidx.compose.animation.slideInVertically 11 | import androidx.compose.animation.slideOutVertically 12 | import androidx.compose.foundation.layout.WindowInsets 13 | import androidx.compose.foundation.layout.WindowInsetsSides 14 | import androidx.compose.foundation.layout.fillMaxSize 15 | import androidx.compose.foundation.layout.only 16 | import androidx.compose.foundation.layout.padding 17 | import androidx.compose.foundation.layout.systemBars 18 | import androidx.compose.material3.ExperimentalMaterial3Api 19 | import androidx.compose.material3.ExtendedFloatingActionButton 20 | import androidx.compose.material3.Icon 21 | import androidx.compose.material3.IconButton 22 | import androidx.compose.material3.MaterialTheme 23 | import androidx.compose.material3.Scaffold 24 | import androidx.compose.material3.SnackbarHost 25 | import androidx.compose.material3.SnackbarHostState 26 | import androidx.compose.material3.Surface 27 | import androidx.compose.material3.Text 28 | import androidx.compose.material3.TopAppBar 29 | import androidx.compose.material3.TopAppBarDefaults 30 | import androidx.compose.material3.surfaceColorAtElevation 31 | import androidx.compose.runtime.Composable 32 | import androidx.compose.runtime.LaunchedEffect 33 | import androidx.compose.runtime.collectAsState 34 | import androidx.compose.runtime.derivedStateOf 35 | import androidx.compose.runtime.getValue 36 | import androidx.compose.runtime.remember 37 | import androidx.compose.ui.Modifier 38 | import androidx.compose.ui.platform.LocalContext 39 | import androidx.compose.ui.res.painterResource 40 | import androidx.compose.ui.res.stringResource 41 | import androidx.compose.ui.tooling.preview.Preview 42 | import androidx.compose.ui.unit.dp 43 | import androidx.lifecycle.viewmodel.compose.viewModel 44 | import androidx.navigation.compose.currentBackStackEntryAsState 45 | import androidx.navigation.compose.rememberNavController 46 | import com.axiel7.tachisync.R 47 | import com.axiel7.tachisync.ui.files.FILES_DESTINATION 48 | import com.axiel7.tachisync.ui.files.FilesEvent 49 | import com.axiel7.tachisync.ui.files.FilesUiState 50 | import com.axiel7.tachisync.ui.files.FilesViewModel 51 | import com.axiel7.tachisync.ui.main.composables.BottomNavBar 52 | import com.axiel7.tachisync.ui.main.composables.SyncingDialog 53 | import com.axiel7.tachisync.ui.settings.SETTINGS_DESTINATION 54 | import com.axiel7.tachisync.ui.theme.TachisyncTheme 55 | 56 | class MainActivity : ComponentActivity() { 57 | override fun onCreate(savedInstanceState: Bundle?) { 58 | enableEdgeToEdge() 59 | super.onCreate(savedInstanceState) 60 | setContent { 61 | val mainViewModel: MainViewModel = viewModel() 62 | val mainUiState by mainViewModel.uiState.collectAsState() 63 | val filesViewModel: FilesViewModel = viewModel() 64 | val filesUiState by filesViewModel.uiState.collectAsState() 65 | 66 | TachisyncTheme { 67 | Surface( 68 | modifier = Modifier.fillMaxSize() 69 | ) { 70 | MainView( 71 | mainEvent = mainViewModel, 72 | mainUiState = mainUiState, 73 | filesEvent = filesViewModel, 74 | filesUiState = filesUiState, 75 | ) 76 | } 77 | } 78 | } 79 | } 80 | } 81 | 82 | @OptIn(ExperimentalMaterial3Api::class) 83 | @Composable 84 | fun MainView( 85 | mainEvent: MainEvent?, 86 | mainUiState: MainUiState, 87 | filesEvent: FilesEvent?, 88 | filesUiState: FilesUiState, 89 | ) { 90 | val context = LocalContext.current 91 | val navController = rememberNavController() 92 | val navBackStackEntry by navController.currentBackStackEntryAsState() 93 | val snackbarState = remember { SnackbarHostState() } 94 | val isFullScreenDestination by remember { 95 | derivedStateOf { 96 | navBackStackEntry?.destination?.route == SETTINGS_DESTINATION 97 | } 98 | } 99 | val showEditToolbar by remember { 100 | derivedStateOf { 101 | navBackStackEntry?.destination?.route == FILES_DESTINATION 102 | && filesUiState.selectedMangaIndices.isNotEmpty() 103 | } 104 | } 105 | 106 | Scaffold( 107 | topBar = { 108 | AnimatedVisibility( 109 | visible = !isFullScreenDestination, 110 | enter = slideInVertically(), 111 | exit = slideOutVertically() 112 | ) { 113 | TopAppBar( 114 | title = { 115 | if (showEditToolbar) { 116 | Text( 117 | text = stringResource( 118 | R.string.num_selected, 119 | filesUiState.selectedMangaIndices.size.toString() 120 | ) 121 | ) 122 | } else { 123 | Text(text = stringResource(R.string.app_name)) 124 | } 125 | }, 126 | navigationIcon = { 127 | if (showEditToolbar) { 128 | IconButton(onClick = { filesEvent?.deselectAllManga() }) { 129 | Icon( 130 | painter = painterResource(R.drawable.close_24), 131 | contentDescription = stringResource(R.string.close) 132 | ) 133 | } 134 | } 135 | }, 136 | actions = { 137 | if (showEditToolbar) { 138 | IconButton(onClick = { filesEvent?.selectAllManga() }) { 139 | Icon( 140 | painter = painterResource(R.drawable.select_all_24), 141 | contentDescription = stringResource(R.string.select_all) 142 | ) 143 | } 144 | } else { 145 | if (navBackStackEntry?.destination?.route == FILES_DESTINATION) { 146 | IconButton(onClick = { filesEvent?.refresh(context) }) { 147 | Icon( 148 | painter = painterResource(R.drawable.refresh_24), 149 | contentDescription = stringResource(R.string.refresh) 150 | ) 151 | } 152 | } 153 | IconButton(onClick = { navController.navigate(SETTINGS_DESTINATION) }) { 154 | Icon( 155 | painter = painterResource(R.drawable.settings_24), 156 | contentDescription = stringResource(R.string.settings) 157 | ) 158 | } 159 | } 160 | }, 161 | colors = TopAppBarDefaults.topAppBarColors( 162 | containerColor = if (showEditToolbar) 163 | MaterialTheme.colorScheme.surfaceColorAtElevation(3.dp) 164 | else MaterialTheme.colorScheme.background 165 | ) 166 | ) 167 | } 168 | }, 169 | bottomBar = { 170 | AnimatedVisibility( 171 | visible = !isFullScreenDestination, 172 | enter = slideInVertically { it / 2 }, 173 | exit = slideOutVertically { it / 2 } 174 | ) { 175 | BottomNavBar(navController = navController) 176 | } 177 | }, 178 | snackbarHost = { 179 | SnackbarHost(hostState = snackbarState) 180 | }, 181 | floatingActionButton = { 182 | AnimatedVisibility( 183 | visible = !isFullScreenDestination && showEditToolbar, 184 | enter = scaleIn(), 185 | exit = scaleOut() 186 | ) { 187 | ExtendedFloatingActionButton( 188 | onClick = { 189 | mainEvent?.syncContents( 190 | context, 191 | filesUiState.downloadedManga, 192 | filesUiState.selectedMangaIndices 193 | ) 194 | } 195 | ) { 196 | Icon( 197 | painter = painterResource(R.drawable.sync_24), 198 | contentDescription = stringResource(R.string.sync) 199 | ) 200 | Text( 201 | text = stringResource(R.string.sync), 202 | modifier = Modifier.padding(start = 8.dp) 203 | ) 204 | } 205 | } 206 | }, 207 | contentWindowInsets = WindowInsets.systemBars 208 | .only(WindowInsetsSides.Horizontal) 209 | ) { padding -> 210 | MainNavigation( 211 | navController = navController, 212 | padding = padding, 213 | mainUiState = mainUiState, 214 | mainEvent = mainEvent, 215 | filesUiState = filesUiState, 216 | filesEvent = filesEvent, 217 | ) 218 | } 219 | 220 | LaunchedEffect(mainUiState.message, filesUiState.message) { 221 | if (mainUiState.message != null) { 222 | snackbarState.showSnackbar(message = mainUiState.message) 223 | mainEvent?.onMessageDisplayed() 224 | } else if (filesUiState.message != null) { 225 | snackbarState.showSnackbar(message = filesUiState.message) 226 | filesEvent?.onMessageDisplayed() 227 | } 228 | } 229 | 230 | if (mainUiState.isLoading) { 231 | SyncingDialog( 232 | progress = mainUiState.syncProgress 233 | ) 234 | } 235 | } 236 | 237 | @Preview(showBackground = true) 238 | @Composable 239 | fun DefaultPreview() { 240 | TachisyncTheme { 241 | Surface { 242 | MainView( 243 | mainEvent = null, 244 | mainUiState = MainUiState(), 245 | filesEvent = null, 246 | filesUiState = FilesUiState(), 247 | ) 248 | } 249 | } 250 | } -------------------------------------------------------------------------------- /app/src/main/java/com/axiel7/tachisync/ui/main/MainEvent.kt: -------------------------------------------------------------------------------- 1 | package com.axiel7.tachisync.ui.main 2 | 3 | import android.content.Context 4 | import com.axiel7.tachisync.data.model.Manga 5 | import com.axiel7.tachisync.ui.base.UiEvent 6 | 7 | interface MainEvent : UiEvent { 8 | fun onExternalUriChanged(value: String) 9 | fun onTachiyomiUriChanged(value: String) 10 | fun syncContents(context: Context, contents: List, selected: List) 11 | } -------------------------------------------------------------------------------- /app/src/main/java/com/axiel7/tachisync/ui/main/MainNavigation.kt: -------------------------------------------------------------------------------- 1 | package com.axiel7.tachisync.ui.main 2 | 3 | import androidx.compose.animation.core.animateDpAsState 4 | import androidx.compose.animation.core.tween 5 | import androidx.compose.animation.fadeIn 6 | import androidx.compose.animation.fadeOut 7 | import androidx.compose.foundation.layout.PaddingValues 8 | import androidx.compose.foundation.layout.calculateEndPadding 9 | import androidx.compose.foundation.layout.calculateStartPadding 10 | import androidx.compose.foundation.layout.padding 11 | import androidx.compose.runtime.Composable 12 | import androidx.compose.runtime.getValue 13 | import androidx.compose.ui.Modifier 14 | import androidx.compose.ui.platform.LocalLayoutDirection 15 | import androidx.navigation.NavHostController 16 | import androidx.navigation.compose.NavHost 17 | import androidx.navigation.compose.composable 18 | import com.axiel7.tachisync.ui.external.EXTERNAL_STORAGE_DESTINATION 19 | import com.axiel7.tachisync.ui.external.ExternalView 20 | import com.axiel7.tachisync.ui.files.FILES_DESTINATION 21 | import com.axiel7.tachisync.ui.files.FilesEvent 22 | import com.axiel7.tachisync.ui.files.FilesUiState 23 | import com.axiel7.tachisync.ui.files.FilesView 24 | import com.axiel7.tachisync.ui.settings.SETTINGS_DESTINATION 25 | import com.axiel7.tachisync.ui.settings.SettingsView 26 | 27 | @Composable 28 | fun MainNavigation( 29 | navController: NavHostController, 30 | padding: PaddingValues, 31 | mainUiState: MainUiState, 32 | mainEvent: MainEvent?, 33 | filesUiState: FilesUiState, 34 | filesEvent: FilesEvent?, 35 | ) { 36 | val topPadding by animateDpAsState( 37 | targetValue = padding.calculateTopPadding(), 38 | label = "top_bar_padding" 39 | ) 40 | val bottomPadding by animateDpAsState( 41 | targetValue = padding.calculateBottomPadding(), 42 | label = "bottom_bar_padding" 43 | ) 44 | NavHost( 45 | navController = navController, 46 | startDestination = FILES_DESTINATION, 47 | modifier = Modifier.padding( 48 | start = padding.calculateStartPadding(LocalLayoutDirection.current), 49 | end = padding.calculateEndPadding(LocalLayoutDirection.current), 50 | ), 51 | enterTransition = { fadeIn(tween(400)) }, 52 | exitTransition = { fadeOut(tween(400)) } 53 | ) { 54 | composable(FILES_DESTINATION) { 55 | FilesView( 56 | filesUiState = filesUiState, 57 | filesEvent = filesEvent, 58 | mainEvent = mainEvent, 59 | contentPadding = PaddingValues( 60 | top = topPadding, 61 | bottom = bottomPadding 62 | ) 63 | ) 64 | } 65 | 66 | composable(EXTERNAL_STORAGE_DESTINATION) { 67 | ExternalView( 68 | mainUiState = mainUiState, 69 | mainEvent = mainEvent, 70 | modifier = Modifier.padding( 71 | top = topPadding, 72 | bottom = bottomPadding 73 | ) 74 | ) 75 | } 76 | 77 | composable(SETTINGS_DESTINATION) { 78 | SettingsView( 79 | resetDownloadsDirectory = { 80 | mainEvent?.onTachiyomiUriChanged("") 81 | }, 82 | navigateBack = { 83 | navController.popBackStack() 84 | } 85 | ) 86 | } 87 | } 88 | } -------------------------------------------------------------------------------- /app/src/main/java/com/axiel7/tachisync/ui/main/MainUiState.kt: -------------------------------------------------------------------------------- 1 | package com.axiel7.tachisync.ui.main 2 | 3 | import android.net.Uri 4 | import com.axiel7.tachisync.ui.base.UiState 5 | 6 | data class MainUiState( 7 | val syncProgress: Float = 0f, 8 | val externalSyncUri: Uri? = null, 9 | override val isLoading: Boolean = false, 10 | override val message: String? = null 11 | ) : UiState() { 12 | override fun setLoading(value: Boolean) = copy(isLoading = value) 13 | override fun setMessage(value: String?) = copy(message = value) 14 | } 15 | -------------------------------------------------------------------------------- /app/src/main/java/com/axiel7/tachisync/ui/main/MainViewModel.kt: -------------------------------------------------------------------------------- 1 | package com.axiel7.tachisync.ui.main 2 | 3 | import android.content.Context 4 | import android.net.Uri 5 | import androidx.compose.runtime.mutableIntStateOf 6 | import androidx.documentfile.provider.DocumentFile 7 | import androidx.lifecycle.viewModelScope 8 | import com.axiel7.tachisync.App 9 | import com.axiel7.tachisync.data.datastore.PreferencesDataStore.EXTERNAL_URI_KEY 10 | import com.axiel7.tachisync.data.datastore.PreferencesDataStore.REMOVE_SCANLATOR_KEY 11 | import com.axiel7.tachisync.data.datastore.PreferencesDataStore.TACHIYOMI_URI_KEY 12 | import com.axiel7.tachisync.data.datastore.PreferencesRepository 13 | import com.axiel7.tachisync.data.model.Manga 14 | import com.axiel7.tachisync.ui.base.BaseViewModel 15 | import com.axiel7.tachisync.utils.FileUtils.syncDirectory 16 | import kotlinx.coroutines.Dispatchers 17 | import kotlinx.coroutines.flow.MutableStateFlow 18 | import kotlinx.coroutines.flow.launchIn 19 | import kotlinx.coroutines.flow.map 20 | import kotlinx.coroutines.flow.onEach 21 | import kotlinx.coroutines.flow.update 22 | import kotlinx.coroutines.launch 23 | 24 | class MainViewModel : BaseViewModel(), MainEvent { 25 | 26 | override val mutableUiState = MutableStateFlow(MainUiState()) 27 | 28 | override fun onExternalUriChanged(value: String) { 29 | viewModelScope.launch { 30 | PreferencesRepository.set(EXTERNAL_URI_KEY, value) 31 | } 32 | } 33 | 34 | override fun onTachiyomiUriChanged(value: String) { 35 | viewModelScope.launch { 36 | PreferencesRepository.set(TACHIYOMI_URI_KEY, value) 37 | } 38 | } 39 | 40 | override fun syncContents(context: Context, contents: List, selected: List) { 41 | viewModelScope.launch(Dispatchers.IO) { 42 | val externalUri = uiState.value.externalSyncUri 43 | if (selected.isEmpty()) { 44 | showMessage("No content selected") 45 | } else if (externalUri == null) { 46 | showMessage("No external directory selected") 47 | } else { 48 | mutableUiState.update { it.copy(isLoading = true, syncProgress = 0f) } 49 | try { 50 | val destDir = DocumentFile.fromTreeUri(context, externalUri) 51 | if (destDir == null || !destDir.isDirectory) { 52 | showMessage("Invalid external directory") 53 | } else { 54 | val shouldRemoveScanlator = PreferencesRepository.get(REMOVE_SCANLATOR_KEY) 55 | val currentFileCount = mutableIntStateOf(0) 56 | val selectedContent = 57 | contents.filterIndexed { index, _ -> selected.contains(index) } 58 | val files = selectedContent.map { it.file } 59 | val total = selectedContent.sumOf { it.chapters }.toFloat() 60 | files.forEach { file -> 61 | context.syncDirectory( 62 | sourceDir = file, 63 | destRootDir = destDir, 64 | progress = uiState.value.syncProgress, 65 | updateProgress = this@MainViewModel::updateProgress, 66 | currentFileCount = currentFileCount, 67 | total = total, 68 | shouldRemoveScanlator = shouldRemoveScanlator == true 69 | ) 70 | } 71 | } 72 | } catch (e: Exception) { 73 | mutableUiState.update { 74 | it.copy(isLoading = false, message = e.message ?: "Error syncing") 75 | } 76 | return@launch 77 | } 78 | mutableUiState.update { it.copy(isLoading = false, message = "Sync completed") } 79 | } 80 | } 81 | } 82 | 83 | private fun updateProgress(value: Float) { 84 | mutableUiState.update { it.copy(syncProgress = value) } 85 | } 86 | 87 | init { 88 | App.dataStore.data 89 | .map { it[EXTERNAL_URI_KEY]?.let { uri -> Uri.parse(uri) } } 90 | .onEach { value -> 91 | mutableUiState.update { it.copy(externalSyncUri = value) } 92 | } 93 | .launchIn(viewModelScope) 94 | } 95 | } -------------------------------------------------------------------------------- /app/src/main/java/com/axiel7/tachisync/ui/main/composables/BottomNavBar.kt: -------------------------------------------------------------------------------- 1 | package com.axiel7.tachisync.ui.main.composables 2 | 3 | import androidx.compose.material3.Icon 4 | import androidx.compose.material3.NavigationBar 5 | import androidx.compose.material3.NavigationBarItem 6 | import androidx.compose.material3.Text 7 | import androidx.compose.runtime.Composable 8 | import androidx.compose.runtime.getValue 9 | import androidx.compose.runtime.mutableIntStateOf 10 | import androidx.compose.runtime.remember 11 | import androidx.compose.runtime.setValue 12 | import androidx.compose.ui.res.painterResource 13 | import androidx.compose.ui.res.stringResource 14 | import androidx.navigation.NavController 15 | import androidx.navigation.NavGraph.Companion.findStartDestination 16 | import com.axiel7.tachisync.R 17 | import com.axiel7.tachisync.ui.external.EXTERNAL_STORAGE_DESTINATION 18 | import com.axiel7.tachisync.ui.files.FILES_DESTINATION 19 | 20 | @Composable 21 | fun BottomNavBar(navController: NavController) { 22 | var selectedItem by remember { mutableIntStateOf(0) } 23 | 24 | NavigationBar { 25 | NavigationBarItem( 26 | selected = selectedItem == 0, 27 | onClick = { 28 | selectedItem = 0 29 | navController.navigate(FILES_DESTINATION) { 30 | popUpTo(navController.graph.findStartDestination().id) { 31 | saveState = true 32 | } 33 | launchSingleTop = true 34 | restoreState = true 35 | } 36 | }, 37 | icon = { 38 | Icon( 39 | painter = painterResource(R.drawable.download_24), 40 | contentDescription = stringResource(R.string.downloads) 41 | ) 42 | }, 43 | label = { Text(text = stringResource(R.string.downloads)) } 44 | ) 45 | 46 | NavigationBarItem( 47 | selected = selectedItem == 1, 48 | onClick = { 49 | selectedItem = 1 50 | navController.navigate(EXTERNAL_STORAGE_DESTINATION) { 51 | popUpTo(navController.graph.findStartDestination().id) { 52 | saveState = true 53 | } 54 | launchSingleTop = true 55 | restoreState = true 56 | } 57 | }, 58 | icon = { 59 | Icon( 60 | painter = painterResource(R.drawable.storage_24), 61 | contentDescription = stringResource(R.string.external) 62 | ) 63 | }, 64 | label = { Text(text = stringResource(R.string.external)) } 65 | ) 66 | } 67 | } -------------------------------------------------------------------------------- /app/src/main/java/com/axiel7/tachisync/ui/main/composables/SyncingDialog.kt: -------------------------------------------------------------------------------- 1 | package com.axiel7.tachisync.ui.main.composables 2 | 3 | import androidx.compose.foundation.layout.padding 4 | import androidx.compose.material3.AlertDialog 5 | import androidx.compose.material3.LinearProgressIndicator 6 | import androidx.compose.material3.Text 7 | import androidx.compose.runtime.Composable 8 | import androidx.compose.ui.Modifier 9 | import androidx.compose.ui.res.stringResource 10 | import androidx.compose.ui.unit.dp 11 | import com.axiel7.tachisync.R 12 | 13 | @Composable 14 | fun SyncingDialog(progress: Float) { 15 | AlertDialog( 16 | onDismissRequest = { }, 17 | confirmButton = { }, 18 | title = { 19 | Text( 20 | text = stringResource(R.string.syncing), 21 | modifier = Modifier.padding(16.dp) 22 | ) 23 | }, 24 | text = { 25 | LinearProgressIndicator( 26 | progress = { progress }, 27 | modifier = Modifier.padding(8.dp) 28 | ) 29 | } 30 | ) 31 | } -------------------------------------------------------------------------------- /app/src/main/java/com/axiel7/tachisync/ui/settings/SettingsView.kt: -------------------------------------------------------------------------------- 1 | package com.axiel7.tachisync.ui.settings 2 | 3 | import androidx.annotation.DrawableRes 4 | import androidx.compose.foundation.clickable 5 | import androidx.compose.foundation.layout.Arrangement 6 | import androidx.compose.foundation.layout.Column 7 | import androidx.compose.foundation.layout.Row 8 | import androidx.compose.foundation.layout.Spacer 9 | import androidx.compose.foundation.layout.fillMaxWidth 10 | import androidx.compose.foundation.layout.padding 11 | import androidx.compose.foundation.layout.size 12 | import androidx.compose.material3.ExperimentalMaterial3Api 13 | import androidx.compose.material3.Icon 14 | import androidx.compose.material3.IconButton 15 | import androidx.compose.material3.MaterialTheme 16 | import androidx.compose.material3.Scaffold 17 | import androidx.compose.material3.Switch 18 | import androidx.compose.material3.Text 19 | import androidx.compose.material3.TopAppBar 20 | import androidx.compose.runtime.Composable 21 | import androidx.compose.runtime.getValue 22 | import androidx.compose.runtime.rememberCoroutineScope 23 | import androidx.compose.ui.Alignment 24 | import androidx.compose.ui.Modifier 25 | import androidx.compose.ui.platform.LocalContext 26 | import androidx.compose.ui.res.painterResource 27 | import androidx.compose.ui.res.stringResource 28 | import androidx.compose.ui.tooling.preview.Preview 29 | import androidx.compose.ui.unit.dp 30 | import androidx.compose.ui.unit.sp 31 | import com.axiel7.tachisync.BuildConfig 32 | import com.axiel7.tachisync.R 33 | import com.axiel7.tachisync.data.datastore.PreferencesDataStore.REMOVE_SCANLATOR_KEY 34 | import com.axiel7.tachisync.data.datastore.PreferencesRepository 35 | import com.axiel7.tachisync.ui.theme.TachisyncTheme 36 | import com.axiel7.tachisync.utils.Extensions.openAction 37 | import kotlinx.coroutines.launch 38 | 39 | const val SETTINGS_DESTINATION = "settings" 40 | 41 | @OptIn(ExperimentalMaterial3Api::class) 42 | @Composable 43 | fun SettingsView( 44 | resetDownloadsDirectory: () -> Unit, 45 | navigateBack: () -> Unit 46 | ) { 47 | val context = LocalContext.current 48 | val scope = rememberCoroutineScope() 49 | val removeScanlator by PreferencesRepository.remember(REMOVE_SCANLATOR_KEY) 50 | Scaffold( 51 | topBar = { 52 | TopAppBar( 53 | title = { Text(text = stringResource(R.string.settings)) }, 54 | navigationIcon = { 55 | IconButton(onClick = navigateBack) { 56 | Icon( 57 | painter = painterResource(R.drawable.arrow_back_24), 58 | contentDescription = stringResource(R.string.back) 59 | ) 60 | } 61 | } 62 | ) 63 | } 64 | ) { 65 | Column( 66 | modifier = Modifier.padding(it) 67 | ) { 68 | SwitchPreference( 69 | title = "Remove scanlator from filename", 70 | value = removeScanlator, 71 | onValueChange = { 72 | scope.launch { 73 | PreferencesRepository.set(REMOVE_SCANLATOR_KEY, it) 74 | } 75 | } 76 | ) 77 | AboutItem( 78 | title = stringResource(R.string.reset_downloads_directory), 79 | icon = R.drawable.sync_24, 80 | onClick = resetDownloadsDirectory 81 | ) 82 | AboutItem( 83 | title = "GitHub", 84 | subtitle = "Source code", 85 | icon = R.drawable.code_24, 86 | onClick = { context.openAction("https://github.com/axiel7/Tachisync") } 87 | ) 88 | 89 | AboutItem( 90 | title = stringResource(R.string.developer), 91 | subtitle = "axiel7", 92 | icon = R.drawable.person_24, 93 | onClick = { context.openAction("https://github.com/axiel7") } 94 | ) 95 | 96 | AboutItem( 97 | title = stringResource(R.string.version), 98 | subtitle = "${BuildConfig.VERSION_NAME} (${BuildConfig.VERSION_CODE})" 99 | ) 100 | } 101 | } 102 | } 103 | 104 | @Composable 105 | fun AboutItem( 106 | title: String, 107 | modifier: Modifier = Modifier, 108 | subtitle: String? = null, 109 | @DrawableRes icon: Int? = null, 110 | onClick: (() -> Unit)? = null, 111 | ) { 112 | Row( 113 | modifier = modifier 114 | .fillMaxWidth() 115 | .clickable { onClick?.invoke() }, 116 | horizontalArrangement = Arrangement.Start, 117 | verticalAlignment = Alignment.CenterVertically 118 | ) { 119 | if (icon != null) { 120 | Icon( 121 | painter = painterResource(icon), 122 | contentDescription = "", 123 | modifier = Modifier.padding(16.dp), 124 | tint = MaterialTheme.colorScheme.primary 125 | ) 126 | } else { 127 | Spacer( 128 | modifier = Modifier 129 | .padding(16.dp) 130 | .size(24.dp) 131 | ) 132 | } 133 | 134 | Column( 135 | modifier = if (subtitle != null) 136 | Modifier.padding(16.dp) 137 | else Modifier.padding(horizontal = 16.dp) 138 | ) { 139 | Text( 140 | text = title, 141 | color = MaterialTheme.colorScheme.onSurface 142 | ) 143 | 144 | if (subtitle != null) { 145 | Text( 146 | text = subtitle, 147 | color = MaterialTheme.colorScheme.onSurfaceVariant, 148 | fontSize = 13.sp 149 | ) 150 | } 151 | } 152 | } 153 | } 154 | 155 | @Composable 156 | fun SwitchPreference( 157 | title: String, 158 | modifier: Modifier = Modifier, 159 | subtitle: String? = null, 160 | value: Boolean?, 161 | @DrawableRes icon: Int? = null, 162 | onValueChange: (Boolean) -> Unit 163 | ) { 164 | Row( 165 | modifier = modifier 166 | .fillMaxWidth() 167 | .clickable { 168 | onValueChange(value?.not() ?: false) 169 | }, 170 | horizontalArrangement = Arrangement.SpaceBetween, 171 | verticalAlignment = Alignment.CenterVertically 172 | ) { 173 | Row( 174 | modifier = Modifier.weight(1f), 175 | horizontalArrangement = Arrangement.Start, 176 | verticalAlignment = Alignment.CenterVertically 177 | ) { 178 | if (icon != null) { 179 | Icon( 180 | painter = painterResource(icon), 181 | contentDescription = title, 182 | modifier = Modifier.padding(16.dp), 183 | tint = MaterialTheme.colorScheme.primary 184 | ) 185 | } else { 186 | Spacer( 187 | modifier = Modifier 188 | .padding(16.dp) 189 | .size(24.dp) 190 | ) 191 | } 192 | 193 | Column( 194 | modifier = if (subtitle != null) 195 | Modifier.padding(16.dp) 196 | else Modifier.padding(horizontal = 16.dp) 197 | ) { 198 | Text( 199 | text = title, 200 | color = MaterialTheme.colorScheme.onSurface 201 | ) 202 | 203 | if (subtitle != null) { 204 | Text( 205 | text = subtitle, 206 | color = MaterialTheme.colorScheme.onSurfaceVariant, 207 | fontSize = 13.sp, 208 | lineHeight = 14.sp 209 | ) 210 | } 211 | }//: Column 212 | }//: Row 213 | 214 | Switch( 215 | checked = value ?: false, 216 | onCheckedChange = onValueChange, 217 | modifier = Modifier.padding(horizontal = 16.dp) 218 | ) 219 | }//: Row 220 | } 221 | 222 | @Preview(showBackground = true) 223 | @Composable 224 | fun SettingsPreview() { 225 | TachisyncTheme { 226 | SettingsView( 227 | resetDownloadsDirectory = {}, 228 | navigateBack = {} 229 | ) 230 | } 231 | } -------------------------------------------------------------------------------- /app/src/main/java/com/axiel7/tachisync/ui/theme/Color.kt: -------------------------------------------------------------------------------- 1 | package com.axiel7.tachisync.ui.theme 2 | 3 | import androidx.compose.ui.graphics.Color 4 | 5 | val md_theme_light_primary = Color(0xFF006398) 6 | val md_theme_light_onPrimary = Color(0xFFFFFFFF) 7 | val md_theme_light_primaryContainer = Color(0xFFCDE5FF) 8 | val md_theme_light_onPrimaryContainer = Color(0xFF001D31) 9 | val md_theme_light_secondary = Color(0xFF51606F) 10 | val md_theme_light_onSecondary = Color(0xFFFFFFFF) 11 | val md_theme_light_secondaryContainer = Color(0xFFD4E4F6) 12 | val md_theme_light_onSecondaryContainer = Color(0xFF0D1D2A) 13 | val md_theme_light_tertiary = Color(0xFF006398) 14 | val md_theme_light_onTertiary = Color(0xFFFFFFFF) 15 | val md_theme_light_tertiaryContainer = Color(0xFFCDE5FF) 16 | val md_theme_light_onTertiaryContainer = Color(0xFF001D31) 17 | val md_theme_light_error = Color(0xFFBA1A1A) 18 | val md_theme_light_errorContainer = Color(0xFFFFDAD6) 19 | val md_theme_light_onError = Color(0xFFFFFFFF) 20 | val md_theme_light_onErrorContainer = Color(0xFF410002) 21 | val md_theme_light_background = Color(0xFFFCFCFF) 22 | val md_theme_light_onBackground = Color(0xFF1A1C1E) 23 | val md_theme_light_surface = Color(0xFFFCFCFF) 24 | val md_theme_light_onSurface = Color(0xFF1A1C1E) 25 | val md_theme_light_surfaceVariant = Color(0xFFDEE3EB) 26 | val md_theme_light_onSurfaceVariant = Color(0xFF42474E) 27 | val md_theme_light_outline = Color(0xFF72787E) 28 | val md_theme_light_inverseOnSurface = Color(0xFFF0F0F4) 29 | val md_theme_light_inverseSurface = Color(0xFF2F3033) 30 | val md_theme_light_inversePrimary = Color(0xFF93CCFF) 31 | val md_theme_light_shadow = Color(0xFF000000) 32 | val md_theme_light_surfaceTint = Color(0xFF006398) 33 | val md_theme_light_outlineVariant = Color(0xFFC2C7CE) 34 | val md_theme_light_scrim = Color(0xFF000000) 35 | 36 | val md_theme_dark_primary = Color(0xFF93CCFF) 37 | val md_theme_dark_onPrimary = Color(0xFF003351) 38 | val md_theme_dark_primaryContainer = Color(0xFF004B74) 39 | val md_theme_dark_onPrimaryContainer = Color(0xFFCDE5FF) 40 | val md_theme_dark_secondary = Color(0xFFB8C8DA) 41 | val md_theme_dark_onSecondary = Color(0xFF23323F) 42 | val md_theme_dark_secondaryContainer = Color(0xFF394857) 43 | val md_theme_dark_onSecondaryContainer = Color(0xFFD4E4F6) 44 | val md_theme_dark_tertiary = Color(0xFF93CCFF) 45 | val md_theme_dark_onTertiary = Color(0xFF003351) 46 | val md_theme_dark_tertiaryContainer = Color(0xFF004B74) 47 | val md_theme_dark_onTertiaryContainer = Color(0xFFCDE5FF) 48 | val md_theme_dark_error = Color(0xFFFFB4AB) 49 | val md_theme_dark_errorContainer = Color(0xFF93000A) 50 | val md_theme_dark_onError = Color(0xFF690005) 51 | val md_theme_dark_onErrorContainer = Color(0xFFFFDAD6) 52 | val md_theme_dark_background = Color(0xFF1A1C1E) 53 | val md_theme_dark_onBackground = Color(0xFFE2E2E5) 54 | val md_theme_dark_surface = Color(0xFF1A1C1E) 55 | val md_theme_dark_onSurface = Color(0xFFE2E2E5) 56 | val md_theme_dark_surfaceVariant = Color(0xFF42474E) 57 | val md_theme_dark_onSurfaceVariant = Color(0xFFC2C7CE) 58 | val md_theme_dark_outline = Color(0xFF8C9198) 59 | val md_theme_dark_inverseOnSurface = Color(0xFF1A1C1E) 60 | val md_theme_dark_inverseSurface = Color(0xFFE2E2E5) 61 | val md_theme_dark_inversePrimary = Color(0xFF006398) 62 | val md_theme_dark_shadow = Color(0xFF000000) 63 | val md_theme_dark_surfaceTint = Color(0xFF93CCFF) 64 | val md_theme_dark_outlineVariant = Color(0xFF42474E) 65 | val md_theme_dark_scrim = Color(0xFF000000) 66 | 67 | 68 | val seed = Color(0xFF2E84BF) -------------------------------------------------------------------------------- /app/src/main/java/com/axiel7/tachisync/ui/theme/Theme.kt: -------------------------------------------------------------------------------- 1 | package com.axiel7.tachisync.ui.theme 2 | 3 | import android.os.Build 4 | import androidx.compose.foundation.isSystemInDarkTheme 5 | import androidx.compose.material3.MaterialTheme 6 | import androidx.compose.material3.darkColorScheme 7 | import androidx.compose.material3.dynamicDarkColorScheme 8 | import androidx.compose.material3.dynamicLightColorScheme 9 | import androidx.compose.material3.lightColorScheme 10 | import androidx.compose.runtime.Composable 11 | import androidx.compose.ui.platform.LocalContext 12 | 13 | private val LightColorScheme = lightColorScheme( 14 | primary = md_theme_light_primary, 15 | onPrimary = md_theme_light_onPrimary, 16 | primaryContainer = md_theme_light_primaryContainer, 17 | onPrimaryContainer = md_theme_light_onPrimaryContainer, 18 | secondary = md_theme_light_secondary, 19 | onSecondary = md_theme_light_onSecondary, 20 | secondaryContainer = md_theme_light_secondaryContainer, 21 | onSecondaryContainer = md_theme_light_onSecondaryContainer, 22 | tertiary = md_theme_light_tertiary, 23 | onTertiary = md_theme_light_onTertiary, 24 | tertiaryContainer = md_theme_light_tertiaryContainer, 25 | onTertiaryContainer = md_theme_light_onTertiaryContainer, 26 | error = md_theme_light_error, 27 | errorContainer = md_theme_light_errorContainer, 28 | onError = md_theme_light_onError, 29 | onErrorContainer = md_theme_light_onErrorContainer, 30 | background = md_theme_light_background, 31 | onBackground = md_theme_light_onBackground, 32 | surface = md_theme_light_surface, 33 | onSurface = md_theme_light_onSurface, 34 | surfaceVariant = md_theme_light_surfaceVariant, 35 | onSurfaceVariant = md_theme_light_onSurfaceVariant, 36 | outline = md_theme_light_outline, 37 | inverseOnSurface = md_theme_light_inverseOnSurface, 38 | inverseSurface = md_theme_light_inverseSurface, 39 | inversePrimary = md_theme_light_inversePrimary, 40 | surfaceTint = md_theme_light_surfaceTint, 41 | outlineVariant = md_theme_light_outlineVariant, 42 | scrim = md_theme_light_scrim, 43 | ) 44 | 45 | 46 | private val DarkColorScheme = darkColorScheme( 47 | primary = md_theme_dark_primary, 48 | onPrimary = md_theme_dark_onPrimary, 49 | primaryContainer = md_theme_dark_primaryContainer, 50 | onPrimaryContainer = md_theme_dark_onPrimaryContainer, 51 | secondary = md_theme_dark_secondary, 52 | onSecondary = md_theme_dark_onSecondary, 53 | secondaryContainer = md_theme_dark_secondaryContainer, 54 | onSecondaryContainer = md_theme_dark_onSecondaryContainer, 55 | tertiary = md_theme_dark_tertiary, 56 | onTertiary = md_theme_dark_onTertiary, 57 | tertiaryContainer = md_theme_dark_tertiaryContainer, 58 | onTertiaryContainer = md_theme_dark_onTertiaryContainer, 59 | error = md_theme_dark_error, 60 | errorContainer = md_theme_dark_errorContainer, 61 | onError = md_theme_dark_onError, 62 | onErrorContainer = md_theme_dark_onErrorContainer, 63 | background = md_theme_dark_background, 64 | onBackground = md_theme_dark_onBackground, 65 | surface = md_theme_dark_surface, 66 | onSurface = md_theme_dark_onSurface, 67 | surfaceVariant = md_theme_dark_surfaceVariant, 68 | onSurfaceVariant = md_theme_dark_onSurfaceVariant, 69 | outline = md_theme_dark_outline, 70 | inverseOnSurface = md_theme_dark_inverseOnSurface, 71 | inverseSurface = md_theme_dark_inverseSurface, 72 | inversePrimary = md_theme_dark_inversePrimary, 73 | surfaceTint = md_theme_dark_surfaceTint, 74 | outlineVariant = md_theme_dark_outlineVariant, 75 | scrim = md_theme_dark_scrim, 76 | ) 77 | 78 | @Composable 79 | fun TachisyncTheme( 80 | darkTheme: Boolean = isSystemInDarkTheme(), 81 | // Dynamic color is available on Android 12+ 82 | dynamicColor: Boolean = true, 83 | content: @Composable () -> Unit 84 | ) { 85 | val colorScheme = when { 86 | dynamicColor && Build.VERSION.SDK_INT >= Build.VERSION_CODES.S -> { 87 | val context = LocalContext.current 88 | if (darkTheme) dynamicDarkColorScheme(context) else dynamicLightColorScheme(context) 89 | } 90 | darkTheme -> DarkColorScheme 91 | else -> LightColorScheme 92 | } 93 | 94 | MaterialTheme( 95 | colorScheme = colorScheme, 96 | typography = Typography, 97 | content = content 98 | ) 99 | } -------------------------------------------------------------------------------- /app/src/main/java/com/axiel7/tachisync/ui/theme/Type.kt: -------------------------------------------------------------------------------- 1 | package com.axiel7.tachisync.ui.theme 2 | 3 | import androidx.compose.material3.Typography 4 | import androidx.compose.ui.text.TextStyle 5 | import androidx.compose.ui.text.font.FontFamily 6 | import androidx.compose.ui.text.font.FontWeight 7 | import androidx.compose.ui.unit.sp 8 | 9 | // Set of Material typography styles to start with 10 | val Typography = Typography( 11 | bodyLarge = TextStyle( 12 | fontFamily = FontFamily.Default, 13 | fontWeight = FontWeight.Normal, 14 | fontSize = 16.sp, 15 | lineHeight = 24.sp, 16 | letterSpacing = 0.5.sp 17 | ) 18 | /* Other default text styles to override 19 | titleLarge = TextStyle( 20 | fontFamily = FontFamily.Default, 21 | fontWeight = FontWeight.Normal, 22 | fontSize = 22.sp, 23 | lineHeight = 28.sp, 24 | letterSpacing = 0.sp 25 | ), 26 | labelSmall = TextStyle( 27 | fontFamily = FontFamily.Default, 28 | fontWeight = FontWeight.Medium, 29 | fontSize = 11.sp, 30 | lineHeight = 16.sp, 31 | letterSpacing = 0.5.sp 32 | ) 33 | */ 34 | ) -------------------------------------------------------------------------------- /app/src/main/java/com/axiel7/tachisync/utils/Extensions.kt: -------------------------------------------------------------------------------- 1 | package com.axiel7.tachisync.utils 2 | 3 | import android.content.Context 4 | import android.content.Intent 5 | import android.net.Uri 6 | 7 | object Extensions { 8 | 9 | fun Context.openAction(uri: String) { 10 | Intent(Intent.ACTION_VIEW, Uri.parse(uri)).apply { 11 | startActivity(this) 12 | } 13 | } 14 | } -------------------------------------------------------------------------------- /app/src/main/java/com/axiel7/tachisync/utils/FileUtils.kt: -------------------------------------------------------------------------------- 1 | package com.axiel7.tachisync.utils 2 | 3 | import android.content.Context 4 | import android.content.Intent 5 | import android.net.Uri 6 | import androidx.activity.compose.rememberLauncherForActivityResult 7 | import androidx.activity.result.contract.ActivityResultContracts 8 | import androidx.compose.runtime.Composable 9 | import androidx.compose.runtime.MutableIntState 10 | import androidx.documentfile.provider.DocumentFile 11 | import com.axiel7.tachisync.App 12 | 13 | object FileUtils { 14 | 15 | private val chapterRegex = Regex("(#\\d*)") 16 | 17 | @Composable 18 | fun rememberUriLauncher(onUriReceived: (Uri) -> Unit) = rememberLauncherForActivityResult( 19 | ActivityResultContracts.StartActivityForResult() 20 | ) { 21 | it.data?.data?.let { uri -> 22 | App.applicationContext.contentResolver.takePersistableUriPermission( 23 | uri, Intent.FLAG_GRANT_READ_URI_PERMISSION or 24 | Intent.FLAG_GRANT_WRITE_URI_PERMISSION 25 | ) 26 | onUriReceived(uri) 27 | } 28 | } 29 | 30 | fun Context.areUriPermissionsGranted(uriString: String): Boolean { 31 | val list = contentResolver.persistedUriPermissions 32 | for (index in list.indices) { 33 | val persistedUriString = list[index].uri.toString() 34 | if (persistedUriString == uriString && list[index].isWritePermission && list[index].isReadPermission) { 35 | return true 36 | } 37 | } 38 | return false 39 | } 40 | 41 | fun Context.releaseUriPermissions(uri: Uri) { 42 | val flags: Int = Intent.FLAG_GRANT_READ_URI_PERMISSION or 43 | Intent.FLAG_GRANT_WRITE_URI_PERMISSION 44 | contentResolver.releasePersistableUriPermission(uri, flags) 45 | } 46 | 47 | fun Context.syncDirectory( 48 | sourceDir: DocumentFile, 49 | destRootDir: DocumentFile, 50 | progress: Float, 51 | updateProgress: (Float) -> Unit, 52 | currentFileCount: MutableIntState, 53 | total: Float, 54 | shouldRemoveScanlator: Boolean, 55 | ) { 56 | if (sourceDir.isDirectory && destRootDir.isDirectory && sourceDir.name != null) { 57 | if (sourceDir.name!!.endsWith("_tmp")) return 58 | // Check if the directory already exist 59 | var destDir = destRootDir.findFile(sourceDir.name!!) 60 | if (destDir == null) { 61 | // Create the destination directory if it doesn't exist 62 | destDir = destRootDir.createDirectory(sourceDir.name!!) 63 | } 64 | 65 | // Copy each file or directory to the destination directory 66 | sourceDir.listFiles().forEach { file -> 67 | file.name?.let { filename -> 68 | if (file.isDirectory) { 69 | // If the file is a directory, recursively call this function 70 | val childSourceDir = sourceDir.findFile(file.name!!) 71 | val childDestDir = destDir?.findFile(file.name!!) 72 | if (childSourceDir != null && childDestDir != null) 73 | syncDirectory( 74 | sourceDir = childSourceDir, 75 | destRootDir = childDestDir, 76 | progress = progress, 77 | updateProgress = updateProgress, 78 | currentFileCount = currentFileCount, 79 | total = total, 80 | shouldRemoveScanlator = shouldRemoveScanlator 81 | ) 82 | } else { 83 | currentFileCount.intValue += 1 84 | // If the file is a regular file, copy its contents to the destination file 85 | // Check if the file already exist 86 | var destFile = destDir?.findFile(filename) 87 | if (destFile == null && file.type != null) { 88 | destFile = destDir?.createFile(file.type!!, file.name!!) 89 | } 90 | if (shouldRemoveScanlator && filename.contains('_')) { 91 | val chapterNumber = chapterRegex.find(filename)?.value 92 | val nameWithoutScanlator = filename 93 | .replaceBefore('_', "") 94 | .drop(1) 95 | val finalName = if (chapterNumber != null 96 | && !nameWithoutScanlator.contains(chapterNumber) 97 | ) { 98 | "$chapterNumber $nameWithoutScanlator" 99 | } else { 100 | nameWithoutScanlator 101 | } 102 | destFile?.renameTo(finalName) 103 | } 104 | if (destFile?.uri != null) { 105 | val inputStream = contentResolver.openInputStream(file.uri) 106 | val outputStream = contentResolver.openOutputStream(destFile.uri) 107 | if (outputStream != null) inputStream?.copyTo(outputStream) 108 | inputStream?.close() 109 | outputStream?.close() 110 | } 111 | updateProgress(currentFileCount.intValue.div(total)) 112 | } 113 | } 114 | } 115 | } 116 | } 117 | 118 | } -------------------------------------------------------------------------------- /app/src/main/res/drawable/arrow_back_24.xml: -------------------------------------------------------------------------------- 1 | 4 | 5 | 6 | -------------------------------------------------------------------------------- /app/src/main/res/drawable/close_24.xml: -------------------------------------------------------------------------------- 1 | 4 | 5 | 6 | -------------------------------------------------------------------------------- /app/src/main/res/drawable/code_24.xml: -------------------------------------------------------------------------------- 1 | 4 | 5 | 6 | -------------------------------------------------------------------------------- /app/src/main/res/drawable/download_24.xml: -------------------------------------------------------------------------------- 1 | 4 | 5 | 6 | -------------------------------------------------------------------------------- /app/src/main/res/drawable/ic_launcher_foreground.xml: -------------------------------------------------------------------------------- 1 | 2 | 7 | 11 | 12 | 14 | 16 | 17 | 18 | -------------------------------------------------------------------------------- /app/src/main/res/drawable/person_24.xml: -------------------------------------------------------------------------------- 1 | 4 | 5 | 6 | -------------------------------------------------------------------------------- /app/src/main/res/drawable/refresh_24.xml: -------------------------------------------------------------------------------- 1 | 4 | 5 | 6 | -------------------------------------------------------------------------------- /app/src/main/res/drawable/select_all_24.xml: -------------------------------------------------------------------------------- 1 | 4 | 5 | 6 | -------------------------------------------------------------------------------- /app/src/main/res/drawable/settings_24.xml: -------------------------------------------------------------------------------- 1 | 7 | 10 | 11 | -------------------------------------------------------------------------------- /app/src/main/res/drawable/storage_24.xml: -------------------------------------------------------------------------------- 1 | 4 | 5 | 6 | -------------------------------------------------------------------------------- /app/src/main/res/drawable/sync_24.xml: -------------------------------------------------------------------------------- 1 | 4 | 5 | 6 | -------------------------------------------------------------------------------- /app/src/main/res/drawable/usb_24.xml: -------------------------------------------------------------------------------- 1 | 4 | 5 | 6 | -------------------------------------------------------------------------------- /app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | -------------------------------------------------------------------------------- /app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | -------------------------------------------------------------------------------- /app/src/main/res/mipmap-hdpi/ic_launcher.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/axiel7/Tachisync/d8784f039594c0e93799503cabb3150bcf0096bf/app/src/main/res/mipmap-hdpi/ic_launcher.png -------------------------------------------------------------------------------- /app/src/main/res/mipmap-hdpi/ic_launcher_round.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/axiel7/Tachisync/d8784f039594c0e93799503cabb3150bcf0096bf/app/src/main/res/mipmap-hdpi/ic_launcher_round.png -------------------------------------------------------------------------------- /app/src/main/res/mipmap-mdpi/ic_launcher.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/axiel7/Tachisync/d8784f039594c0e93799503cabb3150bcf0096bf/app/src/main/res/mipmap-mdpi/ic_launcher.png -------------------------------------------------------------------------------- /app/src/main/res/mipmap-mdpi/ic_launcher_round.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/axiel7/Tachisync/d8784f039594c0e93799503cabb3150bcf0096bf/app/src/main/res/mipmap-mdpi/ic_launcher_round.png -------------------------------------------------------------------------------- /app/src/main/res/mipmap-xhdpi/ic_launcher.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/axiel7/Tachisync/d8784f039594c0e93799503cabb3150bcf0096bf/app/src/main/res/mipmap-xhdpi/ic_launcher.png -------------------------------------------------------------------------------- /app/src/main/res/mipmap-xhdpi/ic_launcher_round.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/axiel7/Tachisync/d8784f039594c0e93799503cabb3150bcf0096bf/app/src/main/res/mipmap-xhdpi/ic_launcher_round.png -------------------------------------------------------------------------------- /app/src/main/res/mipmap-xxhdpi/ic_launcher.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/axiel7/Tachisync/d8784f039594c0e93799503cabb3150bcf0096bf/app/src/main/res/mipmap-xxhdpi/ic_launcher.png -------------------------------------------------------------------------------- /app/src/main/res/mipmap-xxhdpi/ic_launcher_round.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/axiel7/Tachisync/d8784f039594c0e93799503cabb3150bcf0096bf/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.png -------------------------------------------------------------------------------- /app/src/main/res/mipmap-xxxhdpi/ic_launcher.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/axiel7/Tachisync/d8784f039594c0e93799503cabb3150bcf0096bf/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png -------------------------------------------------------------------------------- /app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/axiel7/Tachisync/d8784f039594c0e93799503cabb3150bcf0096bf/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.png -------------------------------------------------------------------------------- /app/src/main/res/values-es/strings.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | %s seleccionados 4 | Cerrar 5 | Seleccionar Todo 6 | Sincronizar 7 | Sincronizando… 8 | Descargas 9 | Externo 10 | Directorio de descargas 11 | En la siguiente pantalla, dirígete a la carpeta contenedora de tus descargas de Tachiyomi/Mihon (normalmente /Tachiyomi/downloads/) y pulsa \'Usar esta carpeta\' 12 | Los contenidos serán sincronizados en: %s 13 | Seleccionar otro directorio 14 | No se han encontrado dispositivos externos 15 | Refrescar 16 | Selecciona el dispositivo donde sincronizar 17 | Dispositivo 18 | Directorio externo 19 | En la siguiente pantalla, dirígete a la carpeta donde quieras sincronizar tu contenido descargado y pulsa \'Usar esta carpeta\' 20 | Ajustes 21 | Atrás 22 | Desarrollador 23 | Versión 24 | Resetear directorio de descargas 25 | Sin contenido descargado 26 | -------------------------------------------------------------------------------- /app/src/main/res/values/ic_launcher_background.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | #2E84BF 4 | -------------------------------------------------------------------------------- /app/src/main/res/values/strings.xml: -------------------------------------------------------------------------------- 1 | 2 | Tachisync 3 | %s selected 4 | Close 5 | Select All 6 | Sync now 7 | Syncing… 8 | Downloads 9 | External 10 | Downloads directory 11 | On the next screen, navigate to the folder containing your Tachiyomi/Mihon downloads (usually /Tachiyomi/downloads/) and select \'Use this folder\' 12 | Contents will be synced on: %s 13 | Select another directory 14 | No external devices found 15 | Refresh 16 | Select the device you want to sync on 17 | Device 18 | External directory 19 | On the next screen, navigate to the folder you want to sync your downloaded content and select \'Use this folder\' 20 | Settings 21 | Back 22 | Developer 23 | Version 24 | Reset downloads directory 25 | No downloaded content 26 | -------------------------------------------------------------------------------- /app/src/main/res/values/themes.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 |