├── .github ├── ISSUE_TEMPLATE │ ├── bug.yml │ ├── config.yml │ └── feature-request.yml ├── PULL_REQUEST_TEMPLATE.md └── workflows │ └── build.yml ├── .gitignore ├── .idea ├── .gitignore ├── .name ├── codeStyles │ ├── Project.xml │ └── codeStyleConfig.xml ├── compiler.xml ├── deploymentTargetDropDown.xml ├── discord.xml ├── gradle.xml ├── inspectionProfiles │ └── Project_Default.xml ├── kotlinc.xml ├── ktfmt.xml ├── misc.xml └── vcs.xml ├── app ├── .gitignore ├── build.gradle ├── proguard-rules.pro ├── release │ ├── Chouten_1.0.apk │ └── output-metadata.json └── src │ ├── androidTest │ └── java │ │ └── com │ │ └── chouten │ │ └── app │ │ └── ExampleInstrumentedTest.kt │ ├── main │ ├── AndroidManifest.xml │ ├── ic_chouten_launcher-playstore.png │ ├── java │ │ └── com │ │ │ └── chouten │ │ │ └── app │ │ │ ├── ChoutenApp.kt │ │ │ ├── Functions.kt │ │ │ ├── MainActivity.kt │ │ │ ├── Network.kt │ │ │ ├── PreferenceManager.kt │ │ │ ├── data │ │ │ ├── AlertData.kt │ │ │ ├── Constants.kt │ │ │ ├── DataLayer.kt │ │ │ ├── LogDataLayer.kt │ │ │ ├── ModuleDataLayer.kt │ │ │ ├── ModuleModel.kt │ │ │ ├── SettingsModel.kt │ │ │ ├── SnackbarData.kt │ │ │ └── WebviewHandler.kt │ │ │ └── ui │ │ │ ├── Navigation.kt │ │ │ ├── Screen.kt │ │ │ ├── components │ │ │ ├── Alert.kt │ │ │ ├── Banners.kt │ │ │ ├── Divider.kt │ │ │ ├── EpisodeItem.kt │ │ │ ├── ListAnimation.kt │ │ │ ├── ModuleSelector.kt │ │ │ ├── SegmentedControl.kt │ │ │ ├── SettingsComponents.kt │ │ │ ├── Shimmer.kt │ │ │ ├── Slider.kt │ │ │ ├── Snackbar.kt │ │ │ └── SwipeAction.kt │ │ │ ├── theme │ │ │ ├── Colors.kt │ │ │ ├── Modifiers.kt │ │ │ ├── Shapes.kt │ │ │ ├── Theme.kt │ │ │ └── Type.kt │ │ │ └── views │ │ │ ├── home │ │ │ ├── HomePage.kt │ │ │ └── HomePageViewModel.kt │ │ │ ├── info │ │ │ ├── InfoPage.kt │ │ │ └── InfoPageViewModel.kt │ │ │ ├── search │ │ │ ├── SearchPage.kt │ │ │ └── SearchPageViewModel.kt │ │ │ ├── settings │ │ │ ├── MorePage.kt │ │ │ ├── MorePageViewModel.kt │ │ │ └── screens │ │ │ │ ├── LogPage.kt │ │ │ │ ├── NetworkPage.kt │ │ │ │ └── appearance │ │ │ │ ├── AppearancePage.kt │ │ │ │ └── AppearanceViewModel.kt │ │ │ └── watch │ │ │ ├── PlayerActivity.kt │ │ │ ├── WatchPage.kt │ │ │ └── WatchPageViewModel.kt │ └── res │ │ ├── drawable-v24 │ │ └── ic_launcher_foreground.xml │ │ ├── drawable │ │ └── ic_launcher_background.xml │ │ ├── mipmap-anydpi-v26 │ │ ├── ic_chouten_launcher.xml │ │ ├── ic_chouten_launcher_round.xml │ │ ├── ic_launcher.xml │ │ └── ic_launcher_round.xml │ │ ├── mipmap-hdpi │ │ ├── ic_chouten_launcher.png │ │ ├── ic_chouten_launcher_foreground.png │ │ ├── ic_chouten_launcher_round.png │ │ ├── ic_launcher.webp │ │ └── ic_launcher_round.webp │ │ ├── mipmap-mdpi │ │ ├── ic_chouten_launcher.png │ │ ├── ic_chouten_launcher_foreground.png │ │ ├── ic_chouten_launcher_round.png │ │ ├── ic_launcher.webp │ │ └── ic_launcher_round.webp │ │ ├── mipmap-xhdpi │ │ ├── ic_chouten_launcher.png │ │ ├── ic_chouten_launcher_foreground.png │ │ ├── ic_chouten_launcher_round.png │ │ ├── ic_launcher.webp │ │ └── ic_launcher_round.webp │ │ ├── mipmap-xxhdpi │ │ ├── ic_chouten_launcher.png │ │ ├── ic_chouten_launcher_foreground.png │ │ ├── ic_chouten_launcher_round.png │ │ ├── ic_launcher.webp │ │ └── ic_launcher_round.webp │ │ ├── mipmap-xxxhdpi │ │ ├── ic_chouten_launcher.png │ │ ├── ic_chouten_launcher_foreground.png │ │ ├── ic_chouten_launcher_round.png │ │ ├── ic_launcher.webp │ │ └── ic_launcher_round.webp │ │ ├── values-night │ │ └── splashscreen_dark.xml │ │ ├── values-v31 │ │ └── colors.xml │ │ ├── values │ │ ├── colors.xml │ │ ├── ic_chouten_launcher_background.xml │ │ ├── splashscreen.xml │ │ ├── strings.xml │ │ └── themes.xml │ │ └── xml │ │ ├── backup_rules.xml │ │ └── data_extraction_rules.xml │ └── test │ └── java │ └── com │ └── chouten │ └── app │ └── ExampleUnitTest.kt ├── build.gradle ├── gradle.properties ├── gradle └── wrapper │ ├── gradle-wrapper.jar │ └── gradle-wrapper.properties ├── gradlew ├── gradlew.bat ├── lint.xml └── settings.gradle /.github/ISSUE_TEMPLATE/bug.yml: -------------------------------------------------------------------------------- 1 | name: Bug Report 2 | description: File a bug report 3 | labels: [bug] 4 | title: "[BUG] - " 5 | body: 6 | - type: markdown 7 | attributes: 8 | value: | 9 | Thanks for taking the time to fill out this bug report! 10 | 11 | Please do not submit feature requests. 12 | - type: textarea 13 | id: reproduce 14 | attributes: 15 | label: Steps To Reproduce 16 | description: How can we reproduce the behavior. 17 | value: | 18 | 1. Go to '...' 19 | 2. Click on '....' 20 | 3. Scroll down to '....' 21 | 4. Click on '...' 22 | validations: 23 | required: true 24 | - type: textarea 25 | id: expected 26 | attributes: 27 | label: Expected Result 28 | description: A clear and concise description of what you expected to happen. 29 | validations: 30 | required: true 31 | - type: textarea 32 | id: actual 33 | attributes: 34 | label: Actual Result 35 | description: A clear and concise description of what is happening. 36 | validations: 37 | required: true 38 | - type: textarea 39 | id: screenshots 40 | attributes: 41 | label: Screenshots or Videos 42 | description: If applicable, add screenshots and/or a short video to help explain your problem. 43 | - type: textarea 44 | id: additional-context 45 | attributes: 46 | label: Additional Context 47 | description: Add any other context about the problem here. 48 | - type: input 49 | id: os-version 50 | attributes: 51 | label: Operating System Version 52 | description: What version of the operating system(s) are you seeing the problem on? 53 | - type: input 54 | id: device 55 | attributes: 56 | label: Device 57 | description: Which device are you seeing the problem on? 58 | placeholder: Samsung Galaxy S10, Google Pixel 7 59 | # - type: input 60 | # id: version 61 | # attributes: 62 | # label: Build Version 63 | # description: What version of our software are you running? (go to "Settings" → "About" in the app) 64 | # validations: 65 | # required: true 66 | - type: checkboxes 67 | id: beta 68 | attributes: 69 | label: Beta 70 | options: 71 | - label: Using a pre-release version of the application. -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/config.yml: -------------------------------------------------------------------------------- 1 | blank_issues_enabled: false -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/feature-request.yml: -------------------------------------------------------------------------------- 1 | name: Feature Request 2 | description: Request a new feature or functionality 3 | labels: [feature request] 4 | title: "[FEATURE REQUEST] - " 5 | body: 6 | 7 | - type: textarea 8 | id: feature-description 9 | attributes: 10 | label: Describe your suggested feature 11 | description: How can Chouten be improved? 12 | placeholder: | 13 | Example: 14 | "It should work like this..." 15 | validations: 16 | required: true 17 | 18 | - type: textarea 19 | id: examples 20 | attributes: 21 | label: Examples 22 | placeholder: | 23 | Found an open source app that already implements this or documentation on it? 24 | Put a link to it or if it's an idea for the ui a screenshot or screen recording here 25 | 26 | - type: textarea 27 | id: other-details 28 | attributes: 29 | label: Other details 30 | placeholder: | 31 | Additional details and attachments. 32 | 33 | - type: checkboxes 34 | id: acknowledgements 35 | attributes: 36 | label: Acknowledgements 37 | description: Read this carefully, we will close and ignore your issue if you skimmed through this. 38 | options: 39 | - label: I have searched the existing issues and this is a new ticket, **NOT** a duplicate or related to another open or closed issue. 40 | required: true 41 | - label: I have written a short but informative title. 42 | required: true 43 | - label: I have updated the app to the **latest version**. 44 | required: true -------------------------------------------------------------------------------- /.github/PULL_REQUEST_TEMPLATE.md: -------------------------------------------------------------------------------- 1 | ## Type of change 2 | - [ ] Bug fix 3 | - [ ] New feature development 4 | - [ ] Tech debt (refactoring, code cleanup, dependency upgrades, etc) 5 | - [ ] Build/deploy pipeline (DevOps) 6 | - [ ] Other 7 | 8 | ## Objective 9 | 10 | 11 | 12 | 13 | ## Code changes 14 | 15 | 16 | * **file.ext:** Description of what was changed and why 17 | 18 | ## Screenshots 19 | 20 | 21 | 22 | 23 | ## Before you submit 24 | - Please check for errors (`gradlew lint`) (required) 25 | - Please format and remove unused imports (required) 26 | - Please add **unit tests** where it makes sense to do so (encouraged but not required) 27 | - If this change requires a **documentation update** - please notify us 28 | - If this change has particular **deployment requirements** - please notify us -------------------------------------------------------------------------------- /.github/workflows/build.yml: -------------------------------------------------------------------------------- 1 | name: Android CI 2 | 3 | on: 4 | push: 5 | branches: [ "main", "dev" ] 6 | pull_request: 7 | branches: [ "main", "dev" ] 8 | 9 | jobs: 10 | build: 11 | runs-on: ubuntu-latest 12 | steps: 13 | - uses: actions/checkout@v3 14 | - name: set up JDK 11 15 | uses: actions/setup-java@v3 16 | with: 17 | distribution: 'temurin' 18 | java-version: '17' 19 | cache: 'gradle' 20 | 21 | - name: Grant execute permission for gradlew 22 | run: chmod +x gradlew 23 | # Here we need to decode keystore.jks from base64 string and place it 24 | # in the folder specified in the release signing configuration 25 | - name: Decode Keystore 26 | id: decode_keystore 27 | uses: timheuer/base64-to-file@v1.2 28 | with: 29 | fileName: 'android_keystore.jks' 30 | fileDir: '/home/runner/work/Chouten-Android/Chouten-Android/app/keystore/' 31 | encodedString: ${{ secrets.KEYSTORE }} 32 | 33 | # Build and sign APK ("-x test" argument is used to skip tests) 34 | - name: Build APK 35 | run: ./gradlew :app:assembleRelease -x test 36 | env: 37 | SIGNING_KEY_ALIAS: ${{ secrets.SIGNING_KEY_ALIAS }} 38 | SIGNING_KEY_PASSWORD: ${{ secrets.SIGNING_KEY_PASSWORD }} 39 | SIGNING_STORE_PASSWORD: ${{ secrets.SIGNING_STORE_PASSWORD }} 40 | 41 | # Show information about the APK's signing certificates 42 | - name: Verify Signature 43 | run: $ANDROID_SDK_ROOT/build-tools/33.0.1/apksigner verify --print-certs app/build/outputs/apk/release/Chouten.apk 44 | 45 | - name: Upload a Build Artifact 46 | uses: actions/upload-artifact@v3.0.0 47 | with: 48 | name: Chouten 49 | path: "app/build/outputs/apk/release/Chouten.apk" 50 | 51 | - name: Upload the APK to Discord 52 | shell: bash 53 | run: | 54 | curl -F "debug=@app/build/outputs/apk/release/Chouten.apk" ${{ secrets.DISCORD_WEBHOOK_URL }} 55 | -------------------------------------------------------------------------------- /.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/.gitignore: -------------------------------------------------------------------------------- 1 | # Default ignored files 2 | /shelf/ 3 | /workspace.xml 4 | -------------------------------------------------------------------------------- /.idea/.name: -------------------------------------------------------------------------------- 1 | Chouten -------------------------------------------------------------------------------- /.idea/codeStyles/Project.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 6 | 7 | 119 | 120 | 122 | 123 | -------------------------------------------------------------------------------- /.idea/codeStyles/codeStyleConfig.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 6 | -------------------------------------------------------------------------------- /.idea/compiler.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | -------------------------------------------------------------------------------- /.idea/deploymentTargetDropDown.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | -------------------------------------------------------------------------------- /.idea/discord.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 7 | -------------------------------------------------------------------------------- /.idea/gradle.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 18 | 19 | -------------------------------------------------------------------------------- /.idea/inspectionProfiles/Project_Default.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 41 | -------------------------------------------------------------------------------- /.idea/kotlinc.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 6 | -------------------------------------------------------------------------------- /.idea/ktfmt.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 6 | -------------------------------------------------------------------------------- /.idea/misc.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 9 | -------------------------------------------------------------------------------- /.idea/vcs.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | -------------------------------------------------------------------------------- /app/.gitignore: -------------------------------------------------------------------------------- 1 | /build -------------------------------------------------------------------------------- /app/build.gradle: -------------------------------------------------------------------------------- 1 | plugins { 2 | id 'com.android.application' 3 | id 'org.jetbrains.kotlin.android' 4 | id 'org.jetbrains.kotlin.plugin.serialization' version '1.8.10' 5 | } 6 | 7 | android { 8 | namespace 'com.chouten.app' 9 | compileSdk 33 10 | 11 | defaultConfig { 12 | applicationId "com.chouten.app" 13 | minSdk 23 14 | targetSdk 33 15 | versionCode 1 16 | versionName "0.1.3-alpha" 17 | minSdkVersion 23 18 | 19 | testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner" 20 | vectorDrawables { 21 | useSupportLibrary true 22 | } 23 | } 24 | 25 | lintOptions { 26 | lintConfig = file("$rootDir/lint.xml") 27 | } 28 | 29 | signingConfigs { 30 | release { 31 | storeFile = file("keystore/android_keystore.jks") 32 | storePassword System.getenv("SIGNING_STORE_PASSWORD") 33 | keyAlias System.getenv("SIGNING_KEY_ALIAS") 34 | keyPassword System.getenv("SIGNING_KEY_PASSWORD") 35 | } 36 | } 37 | 38 | buildTypes { 39 | release { 40 | minifyEnabled false 41 | proguardFiles getDefaultProguardFile('proguard-android-optimize.txt'), 'proguard-rules.pro' 42 | 43 | applicationVariants.all { variant -> 44 | variant.outputs.all { 45 | outputFileName = "Chouten.apk" 46 | } 47 | } 48 | signingConfig signingConfigs.release 49 | } 50 | } 51 | compileOptions { 52 | sourceCompatibility JavaVersion.VERSION_1_8 53 | targetCompatibility JavaVersion.VERSION_1_8 54 | } 55 | kotlinOptions { 56 | jvmTarget = '1.8' 57 | } 58 | buildFeatures { 59 | compose true 60 | } 61 | composeOptions { 62 | kotlinCompilerExtensionVersion '1.4.3' 63 | } 64 | packagingOptions { 65 | resources { 66 | excludes += '/META-INF/{AL2.0,LGPL2.1}' 67 | } 68 | } 69 | } 70 | 71 | dependencies { 72 | 73 | implementation 'org.jetbrains.kotlinx:kotlinx-serialization-json:1.5.0' 74 | 75 | implementation "com.github.skydoves:landscapist-glide:2.1.9" 76 | 77 | def lifecycle_version = "2.6.1" 78 | def nav_version = "2.6.0" 79 | def exo_version = "1.0.2" 80 | 81 | implementation 'androidx.core:core-ktx:1.10.1' 82 | implementation "androidx.lifecycle:lifecycle-runtime-ktx:$lifecycle_version" 83 | implementation "androidx.lifecycle:lifecycle-viewmodel-ktx:$lifecycle_version" 84 | implementation "androidx.lifecycle:lifecycle-viewmodel-compose:$lifecycle_version" 85 | implementation 'androidx.activity:activity-compose:1.7.2' 86 | implementation "androidx.compose.ui:ui:$compose_version" 87 | implementation "androidx.compose.ui:ui-tooling-preview:$compose_version" 88 | implementation "androidx.navigation:navigation-compose:$nav_version" 89 | implementation "androidx.compose.ui:ui-util:$compose_version" 90 | 91 | // Accompanist 92 | implementation "com.google.accompanist:accompanist-systemuicontroller:$accompanist_version" 93 | 94 | // Exoplayer 95 | implementation "androidx.media3:media3-exoplayer:$exo_version" 96 | implementation "androidx.media3:media3-exoplayer-hls:$exo_version" 97 | implementation "androidx.media3:media3-exoplayer-dash:$exo_version" 98 | implementation "androidx.media3:media3-datasource-okhttp:$exo_version" 99 | implementation "androidx.media3:media3-ui:$exo_version" 100 | implementation "androidx.media3:media3-cast:$exo_version" 101 | implementation "androidx.media3:media3-transformer:$exo_version" 102 | implementation "androidx.media3:media3-decoder:$exo_version" 103 | implementation "androidx.media3:media3-datasource:$exo_version" 104 | implementation "androidx.media3:media3-common:$exo_version" 105 | implementation "androidx.media3:media3-database:$exo_version" 106 | 107 | // Compose Testing 108 | testImplementation 'junit:junit:4.13.2' 109 | androidTestImplementation 'androidx.test.ext:junit:1.1.5' 110 | androidTestImplementation 'androidx.test.espresso:espresso-core:3.5.1' 111 | androidTestImplementation "androidx.compose.ui:ui-test-junit4:$compose_version" 112 | debugImplementation "androidx.compose.ui:ui-tooling:$compose_version" 113 | debugImplementation "androidx.compose.ui:ui-test-manifest:$compose_version" 114 | 115 | implementation 'androidx.core:core-splashscreen:1.0.1' 116 | 117 | // Material UI 118 | implementation 'androidx.compose.material3:material3:1.2.0-alpha02' 119 | implementation "androidx.compose.material:material-icons-extended:$compose_version" 120 | implementation 'androidx.compose.material:material:1.4.3' 121 | 122 | // Network 123 | implementation 'com.github.brahmkshatriya:NiceHttp:1.1.0' 124 | implementation "org.jetbrains.kotlin:kotlin-reflect:1.8.10" 125 | 126 | implementation 'com.valentinilk.shimmer:compose-shimmer:1.0.4' 127 | 128 | implementation "com.google.guava:guava:31.1-android" 129 | } 130 | -------------------------------------------------------------------------------- /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/release/Chouten_1.0.apk: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Chouten-App/Chouten-Android/310d6d428f596e92cdfc2d8936b2c428b3af74f0/app/release/Chouten_1.0.apk -------------------------------------------------------------------------------- /app/release/output-metadata.json: -------------------------------------------------------------------------------- 1 | { 2 | "version": 3, 3 | "artifactType": { 4 | "type": "APK", 5 | "kind": "Directory" 6 | }, 7 | "applicationId": "com.chouten.app", 8 | "variantName": "release", 9 | "elements": [ 10 | { 11 | "type": "SINGLE", 12 | "filters": [], 13 | "attributes": [], 14 | "versionCode": 1, 15 | "versionName": "1.0", 16 | "outputFile": "Chouten_1.0.apk" 17 | } 18 | ], 19 | "elementType": "File" 20 | } -------------------------------------------------------------------------------- /app/src/androidTest/java/com/chouten/app/ExampleInstrumentedTest.kt: -------------------------------------------------------------------------------- 1 | package com.chouten.app 2 | 3 | import androidx.test.platform.app.InstrumentationRegistry 4 | import androidx.test.ext.junit.runners.AndroidJUnit4 5 | 6 | import org.junit.Test 7 | import org.junit.runner.RunWith 8 | 9 | import org.junit.Assert.* 10 | 11 | /** 12 | * Instrumented test, which will execute on an Android device. 13 | * 14 | * See [testing documentation](http://d.android.com/tools/testing). 15 | */ 16 | @RunWith(AndroidJUnit4::class) 17 | class ExampleInstrumentedTest { 18 | @Test 19 | fun useAppContext() { 20 | // Context of the app under test. 21 | val appContext = 22 | InstrumentationRegistry.getInstrumentation().targetContext 23 | assertEquals("com.chouten.app", appContext.packageName) 24 | } 25 | } -------------------------------------------------------------------------------- /app/src/main/AndroidManifest.xml: -------------------------------------------------------------------------------- 1 | 2 | 4 | 5 | 6 | 9 | 10 | 13 | 14 | 27 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | 38 | 39 | 40 | 41 | 42 | 43 | 44 | 45 | 46 | 47 | 48 | 49 | 52 | 55 | 56 | 61 | 62 | -------------------------------------------------------------------------------- /app/src/main/ic_chouten_launcher-playstore.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Chouten-App/Chouten-Android/310d6d428f596e92cdfc2d8936b2c428b3af74f0/app/src/main/ic_chouten_launcher-playstore.png -------------------------------------------------------------------------------- /app/src/main/java/com/chouten/app/ChoutenApp.kt: -------------------------------------------------------------------------------- 1 | package com.chouten.app 2 | 3 | import androidx.compose.animation.AnimatedVisibility 4 | import androidx.compose.animation.expandVertically 5 | import androidx.compose.animation.shrinkVertically 6 | import androidx.compose.foundation.layout.Box 7 | import androidx.compose.foundation.layout.WindowInsets 8 | import androidx.compose.foundation.layout.WindowInsetsSides 9 | import androidx.compose.foundation.layout.fillMaxSize 10 | import androidx.compose.foundation.layout.navigationBars 11 | import androidx.compose.foundation.layout.only 12 | import androidx.compose.foundation.layout.padding 13 | import androidx.compose.foundation.layout.windowInsetsPadding 14 | import androidx.compose.material3.Scaffold 15 | import androidx.compose.runtime.Composable 16 | import androidx.compose.runtime.collectAsState 17 | import androidx.compose.ui.Modifier 18 | import androidx.navigation.compose.rememberNavController 19 | import com.chouten.app.data.DataLayer 20 | import com.chouten.app.data.LogDataLayer 21 | import com.chouten.app.data.ModuleDataLayer 22 | import com.chouten.app.data.NavigationItems 23 | import com.chouten.app.ui.BottomNavigationBar 24 | import com.chouten.app.ui.Navigation 25 | import com.chouten.app.ui.components.AppStateBanners 26 | import com.chouten.app.ui.components.ChoutenAlert 27 | import com.chouten.app.ui.components.Snackbar 28 | import kotlinx.coroutines.flow.asStateFlow 29 | 30 | lateinit var ModuleLayer: ModuleDataLayer 31 | lateinit var LogLayer: LogDataLayer 32 | val PrimaryDataLayer = DataLayer() 33 | 34 | fun initializeRepositories() { 35 | ModuleLayer = ModuleDataLayer() 36 | LogLayer = LogDataLayer() 37 | } 38 | 39 | @Composable 40 | fun ChoutenApp() { 41 | val scaffoldInsets = WindowInsets.navigationBars.only(WindowInsetsSides.Horizontal) 42 | 43 | val navController = rememberNavController() 44 | val alerts = PrimaryDataLayer.alertQueue.asStateFlow() 45 | 46 | Scaffold(modifier = Modifier.fillMaxSize(), topBar = { 47 | AppStateBanners( 48 | downloadedOnlyMode = preferenceHandler.isOfflineMode, 49 | incognitoMode = preferenceHandler.isIncognito, 50 | indexing = false, 51 | modifier = Modifier.windowInsetsPadding(scaffoldInsets), 52 | ) 53 | }, 54 | contentWindowInsets = scaffoldInsets, 55 | bottomBar = { 56 | AnimatedVisibility( 57 | visible = PrimaryDataLayer.isNavigationShown, 58 | enter = expandVertically(), 59 | exit = shrinkVertically() 60 | ) { 61 | BottomNavigationBar(navController = navController, items = listOf( 62 | NavigationItems.HomePage, 63 | NavigationItems.SearchPage, 64 | NavigationItems.MorePage, 65 | ), onItemClick = { 66 | navController.navigate(route = it.route) 67 | }) 68 | } 69 | }, snackbarHost = { Snackbar() }, content = { padding -> 70 | Box( 71 | modifier = Modifier 72 | .padding(padding) 73 | .fillMaxSize() 74 | ) { 75 | Box( 76 | modifier = Modifier.fillMaxSize() 77 | ) { 78 | alerts.collectAsState().value.forEach { 79 | ChoutenAlert(alert = it) 80 | } 81 | } 82 | 83 | Navigation(navController) 84 | } 85 | }) 86 | } -------------------------------------------------------------------------------- /app/src/main/java/com/chouten/app/MainActivity.kt: -------------------------------------------------------------------------------- 1 | package com.chouten.app 2 | 3 | import android.content.ClipData 4 | import android.content.Intent 5 | import android.content.Intent.ACTION_VIEW 6 | import android.os.Bundle 7 | import android.util.Log 8 | import androidx.activity.ComponentActivity 9 | import androidx.activity.compose.setContent 10 | import androidx.compose.material3.MaterialTheme 11 | import androidx.compose.runtime.LaunchedEffect 12 | import androidx.compose.ui.graphics.Color 13 | import androidx.compose.ui.graphics.luminance 14 | import androidx.core.net.toUri 15 | import androidx.core.splashscreen.SplashScreen.Companion.installSplashScreen 16 | import androidx.core.view.WindowCompat 17 | import androidx.lifecycle.lifecycleScope 18 | import com.chouten.app.ui.components.DownloadedOnlyBannerBackgroundColor 19 | import com.chouten.app.ui.components.IncognitoModeBannerBackgroundColor 20 | import com.chouten.app.ui.theme.ChoutenTheme 21 | import com.google.accompanist.systemuicontroller.rememberSystemUiController 22 | import kotlinx.coroutines.Dispatchers 23 | import kotlinx.coroutines.launch 24 | 25 | const val PREFERENCE_FILE = "CHOUTEN_PREFS" 26 | 27 | lateinit var preferenceHandler: PreferenceManager 28 | 29 | lateinit var App: MainActivity 30 | 31 | class MainActivity : ComponentActivity() { 32 | override fun onCreate(savedInstanceState: Bundle?) { 33 | installSplashScreen() 34 | 35 | super.onCreate(savedInstanceState) 36 | App = this@MainActivity 37 | 38 | actionBar?.hide() 39 | WindowCompat.setDecorFitsSystemWindows(window, false) 40 | 41 | preferenceHandler = PreferenceManager(this) 42 | 43 | checkPermissions() 44 | createAppDirectory() 45 | 46 | initializeNetwork(applicationContext) 47 | initializeRepositories() 48 | 49 | lifecycleScope.launch(Dispatchers.IO) { 50 | ModuleLayer.loadModules() 51 | } 52 | 53 | if (intent != null) handleSharedIntent(intent) 54 | setContent { 55 | val incognito = preferenceHandler.isIncognito 56 | val downloadOnly = preferenceHandler.isOfflineMode 57 | // Set statusbar color considering the top app state banner 58 | val systemUiController = rememberSystemUiController() 59 | ChoutenTheme { 60 | val statusBarBackgroundColor = when { 61 | downloadOnly -> DownloadedOnlyBannerBackgroundColor 62 | incognito -> IncognitoModeBannerBackgroundColor 63 | else -> MaterialTheme.colorScheme.surface 64 | } 65 | LaunchedEffect(systemUiController, statusBarBackgroundColor) { 66 | val luminance = statusBarBackgroundColor.luminance() 67 | val darkIcons = luminance > 0.5 68 | 69 | systemUiController.setStatusBarColor( 70 | color = Color.Transparent, 71 | darkIcons = darkIcons, 72 | transformColorForLightContent = { Color.Black } 73 | ) 74 | } 75 | ChoutenApp() 76 | } 77 | } 78 | } 79 | 80 | private fun handleSharedIntent(intent: Intent?) { 81 | Log.d("INTENT", "$intent") 82 | // Enqueue the Resource 83 | lifecycleScope.launch { 84 | when (intent?.type) { 85 | "text/plain" -> ModuleLayer.enqueueRemoteInstall( 86 | this@MainActivity, intent 87 | ) 88 | 89 | "application/octet-stream" -> 90 | ModuleLayer.enqueueFileInstall( 91 | intent, this@MainActivity 92 | ) 93 | 94 | else -> Log.d( 95 | "IMPORT", 96 | "Import type `${intent?.type}` not yet implemented" 97 | ) 98 | } 99 | 100 | // If the file is opened in a file manager, the intent will be null 101 | // so we need to check for that 102 | if (intent != null && intent.action == ACTION_VIEW) { 103 | val contentUrl = intent.dataString 104 | // Set the clipdata to the content url 105 | val clipData = ClipData.newRawUri("Content URL", contentUrl?.toUri()) 106 | intent.clipData = clipData 107 | ModuleLayer.enqueueFileInstall( 108 | intent, this@MainActivity 109 | ) 110 | } 111 | } 112 | } 113 | 114 | override fun onNewIntent(intent: Intent?) { 115 | super.onNewIntent(intent) 116 | handleSharedIntent(intent) 117 | } 118 | 119 | override fun onDestroy() { 120 | super.onDestroy() 121 | println("Destroying Activity") 122 | ModuleLayer.webviewHandler.destroy() 123 | } 124 | } 125 | -------------------------------------------------------------------------------- /app/src/main/java/com/chouten/app/Network.kt: -------------------------------------------------------------------------------- 1 | package com.chouten.app 2 | 3 | import android.content.Context 4 | import android.os.Build 5 | import android.util.Log 6 | import com.chouten.app.data.CustomDNS 7 | import dev.brahmkshatriya.nicehttp.Requests 8 | import dev.brahmkshatriya.nicehttp.ResponseParser 9 | import dev.brahmkshatriya.nicehttp.addGenericDns 10 | import kotlinx.coroutines.CancellationException 11 | import kotlinx.coroutines.async 12 | import kotlinx.coroutines.runBlocking 13 | import kotlinx.serialization.ExperimentalSerializationApi 14 | import kotlinx.serialization.InternalSerializationApi 15 | import kotlinx.serialization.decodeFromString 16 | import kotlinx.serialization.json.Json 17 | import kotlinx.serialization.serializer 18 | import okhttp3.Cache 19 | import okhttp3.OkHttpClient 20 | import java.io.File 21 | import java.io.PrintWriter 22 | import java.io.Serializable 23 | import java.io.StringWriter 24 | import java.util.concurrent.TimeUnit 25 | import kotlin.reflect.KClass 26 | import kotlin.reflect.KFunction 27 | 28 | // Borrowed from https://github.com/saikou-app/saikou/blob/main/app/src/main/java/ani/saikou/Network.kt 29 | //TODO: properly implement functions, this is a half-assed copy 30 | 31 | val defaultHeaders = mapOf( 32 | "User-Agent" to 33 | "Mozilla/5.0 (Linux; Android %s; %s) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/100.0.4896.127 Mobile Safari/537.36" 34 | .format(Build.VERSION.RELEASE, Build.MODEL) 35 | ) 36 | lateinit var cache: Cache 37 | 38 | lateinit var okHttpClient: OkHttpClient 39 | lateinit var client: Requests 40 | 41 | fun initializeNetwork(context: Context) { 42 | val dns = preferenceHandler.dns 43 | cache = Cache( 44 | File(context.cacheDir, "http_cache"), 45 | 5 * 1024L * 1024L // 5 MiB 46 | ) 47 | okHttpClient = OkHttpClient.Builder() 48 | .followRedirects(true) 49 | .followSslRedirects(true) 50 | .cache(cache) 51 | .apply { 52 | when (dns) { 53 | CustomDNS.GOOGLE -> addGoogleDns() 54 | CustomDNS.CLOUDFLARE -> addCloudFlareDns() 55 | CustomDNS.ADGUARD -> addAdGuardDns() 56 | else -> {} 57 | } 58 | } 59 | .build() 60 | client = Requests( 61 | okHttpClient, 62 | defaultHeaders, 63 | defaultCacheTime = 6, 64 | defaultCacheTimeUnit = TimeUnit.HOURS, 65 | responseParser = Mapper 66 | ) 67 | } 68 | 69 | object Mapper : ResponseParser { 70 | 71 | @OptIn(ExperimentalSerializationApi::class) 72 | val json = Json { 73 | isLenient = true 74 | ignoreUnknownKeys = true 75 | explicitNulls = false 76 | } 77 | 78 | @OptIn(InternalSerializationApi::class) 79 | override fun parse(text: String, kClass: KClass): T { 80 | return json.decodeFromString(kClass.serializer(), text) 81 | } 82 | 83 | override fun parseSafe(text: String, kClass: KClass): T? { 84 | return try { 85 | parse(text, kClass) 86 | } catch (e: Exception) { 87 | null 88 | } 89 | } 90 | 91 | inline fun parse(text: String): T { 92 | return json.decodeFromString(text) 93 | } 94 | } 95 | 96 | fun Collection.asyncMap(f: suspend (A) -> B): List = runBlocking { 97 | map { async { f(it) } }.map { it.await() } 98 | } 99 | 100 | fun Collection.asyncMapNotNull(f: suspend (A) -> B?): List = 101 | runBlocking { 102 | map { async { f(it) } }.mapNotNull { it.await() } 103 | } 104 | 105 | fun logError(e: Exception, post: Boolean = true/*, snackbar: Boolean = true*/) { 106 | val sw = StringWriter() 107 | val pw = PrintWriter(sw) 108 | e.printStackTrace(pw) 109 | val stackTrace: String = sw.toString() 110 | if (post) { 111 | //if (snackbar) 112 | //snackString(e.localizedMessage, null, stackTrace) 113 | //else 114 | //toast(e.localizedMessage) 115 | Log.d("Error", stackTrace) 116 | } 117 | e.printStackTrace() 118 | } 119 | 120 | fun tryWith( 121 | post: Boolean = false/*, snackbar: Boolean = true*/, 122 | call: () -> T 123 | ): T? { 124 | return try { 125 | call.invoke() 126 | } catch (e: Exception) { 127 | logError(e, post/*, snackbar*/) 128 | null 129 | } 130 | } 131 | 132 | suspend fun tryWithSuspend( 133 | post: Boolean = false/*, snackbar: Boolean = true*/, 134 | call: suspend () -> T 135 | ): T? { 136 | return try { 137 | call.invoke() 138 | } catch (e: Exception) { 139 | logError(e, post/*, snackbar*/) 140 | null 141 | } catch (e: CancellationException) { 142 | null 143 | } 144 | } 145 | 146 | /** 147 | * A url, which can also have headers 148 | * **/ 149 | data class FileUrl( 150 | val url: String, 151 | val headers: Map = mapOf() 152 | ) : Serializable { 153 | companion object { 154 | operator fun get( 155 | url: String?, 156 | headers: Map = mapOf() 157 | ): FileUrl? { 158 | return FileUrl(url ?: return null, headers) 159 | } 160 | } 161 | } 162 | 163 | //Credits to leg 164 | data class Lazier( 165 | val lClass: KFunction, 166 | val name: String 167 | ) { 168 | val get = lazy { lClass.call() } 169 | } 170 | 171 | fun lazyList(vararg objects: Pair>): List> { 172 | return objects.map { 173 | Lazier(it.second, it.first) 174 | } 175 | } 176 | 177 | fun T.printIt(pre: String = ""): T { 178 | println("$pre$this") 179 | return this 180 | } 181 | 182 | 183 | fun OkHttpClient.Builder.addGoogleDns() = ( 184 | addGenericDns( 185 | "https://dns.google/dns-query", 186 | listOf( 187 | "8.8.4.4", 188 | "8.8.8.8" 189 | ) 190 | )) 191 | 192 | fun OkHttpClient.Builder.addCloudFlareDns() = ( 193 | addGenericDns( 194 | "https://cloudflare-dns.com/dns-query", 195 | listOf( 196 | "1.1.1.1", 197 | "1.0.0.1", 198 | "2606:4700:4700::1111", 199 | "2606:4700:4700::1001" 200 | ) 201 | )) 202 | 203 | fun OkHttpClient.Builder.addAdGuardDns() = ( 204 | addGenericDns( 205 | "https://dns.adguard.com/dns-query", 206 | listOf( 207 | // "Non-filtering" 208 | "94.140.14.140", 209 | "94.140.14.141", 210 | ) 211 | )) 212 | -------------------------------------------------------------------------------- /app/src/main/java/com/chouten/app/PreferenceManager.kt: -------------------------------------------------------------------------------- 1 | package com.chouten.app 2 | 3 | import android.content.Context 4 | import android.content.SharedPreferences 5 | import android.os.Build 6 | import androidx.compose.runtime.getValue 7 | import androidx.compose.runtime.mutableStateOf 8 | import androidx.compose.runtime.setValue 9 | import androidx.core.content.edit 10 | import com.chouten.app.data.AppThemeType 11 | import com.chouten.app.data.CustomDNS 12 | import com.chouten.app.data.Preferences 13 | import kotlin.reflect.KProperty 14 | 15 | class PreferenceManager(context: Context) : 16 | BasePreferenceManager( 17 | context.applicationContext.getSharedPreferences( 18 | PREFERENCE_FILE, 19 | Context.MODE_PRIVATE 20 | ) 21 | ) { 22 | var isOledTheme: Boolean by booleanPreference( 23 | Preferences.Settings.oledTheme.preference.first, 24 | false 25 | ) 26 | var isDynamicColor: Boolean by booleanPreference( 27 | Preferences.Settings.dynamicColor.preference.first, 28 | Build.VERSION.SDK_INT >= Build.VERSION_CODES.S 29 | ) 30 | 31 | var isOfflineMode: Boolean by booleanPreference( 32 | Preferences.Settings.downloadedOnly.preference.first, 33 | false 34 | ) 35 | 36 | var isIncognito: Boolean by booleanPreference( 37 | Preferences.Settings.incognito.preference.first, 38 | false 39 | ) 40 | 41 | var isDevMode: Boolean by booleanPreference( 42 | Preferences.Settings.devMode.preference.first, 43 | false 44 | ) 45 | 46 | var selectedModule by stringPreference( 47 | Preferences.SelectedModule 48 | ) 49 | 50 | var themeType by enumPreference( 51 | Preferences.Settings.themeType.preference.first, 52 | AppThemeType.SYSTEM 53 | ) 54 | 55 | var dns by enumPreference( 56 | Preferences.Settings.dns.preference.first, 57 | CustomDNS.NONE 58 | ) 59 | } 60 | 61 | abstract class BasePreferenceManager(private val handler: SharedPreferences) { 62 | 63 | fun getBoolean(key: String, defaultValue: Boolean): Boolean = 64 | handler.getBoolean(key, defaultValue) 65 | 66 | fun putBoolean(key: String, value: Boolean) = 67 | handler.edit { putBoolean(key, value) } 68 | 69 | fun getString(key: String, defaultValue: String?): String? = 70 | handler.getString(key, defaultValue) 71 | 72 | fun putString(key: String, value: String?): Unit = 73 | handler.edit { putString(key, value) } 74 | 75 | fun getInt(key: String, defaultValue: Int): Int = 76 | handler.getInt(key, defaultValue) 77 | 78 | fun putInt(key: String, value: Int): Unit = 79 | handler.edit { putInt(key, value) } 80 | 81 | fun getFloat(key: String, defaultValue: Float): Float = 82 | handler.getFloat(key, defaultValue) 83 | 84 | fun putFloat(key: String, value: Float): Unit = 85 | handler.edit { putFloat(key, value) } 86 | 87 | fun getLong(key: String, defaultValue: Long): Long = 88 | handler.getLong(key, defaultValue) 89 | 90 | fun putLong(key: String, value: Long): Unit = 91 | handler.edit { putLong(key, value) } 92 | 93 | inline fun > getEnum(key: String, defaultValue: T): T = 94 | enumValueOf(getString(key, defaultValue.name)!!) 95 | 96 | inline fun > putEnum(key: String, value: T) = 97 | putString(key, value.name) 98 | 99 | protected class Preference( 100 | private val key: String, 101 | defaultValue: T, 102 | getter: (key: String, defaultValue: T) -> T, 103 | private val setter: (key: String, value: T) -> Unit 104 | ) { 105 | var value by mutableStateOf(getter(key, defaultValue)) 106 | private set 107 | 108 | operator fun getValue(thisRef: Any?, property: KProperty<*>) = value 109 | operator fun setValue( 110 | thisRef: Any?, 111 | property: KProperty<*>, 112 | updatedValue: T 113 | ) { 114 | value = updatedValue 115 | setter(key, updatedValue) 116 | } 117 | } 118 | 119 | protected fun booleanPreference( 120 | key: String, 121 | defaultValue: Boolean 122 | ) = Preference( 123 | key = key, 124 | defaultValue = defaultValue, 125 | getter = { _, _ -> getBoolean(key, defaultValue) }, 126 | setter = { _, value -> putBoolean(key, value) } 127 | ) 128 | 129 | protected fun stringPreference( 130 | key: String, 131 | defaultValue: String = "" 132 | ) = Preference( 133 | key = key, 134 | defaultValue = defaultValue, 135 | getter = ::getString, 136 | setter = ::putString 137 | ) 138 | 139 | protected fun intPreference( 140 | key: String, 141 | defaultValue: Int 142 | ) = Preference( 143 | key = key, 144 | defaultValue = defaultValue, 145 | getter = ::getInt, 146 | setter = ::putInt 147 | ) 148 | 149 | protected fun floatPreference( 150 | key: String, 151 | defaultValue: Float 152 | ) = Preference( 153 | key = key, 154 | defaultValue = defaultValue, 155 | getter = ::getFloat, 156 | setter = ::putFloat 157 | ) 158 | 159 | protected fun longPreference( 160 | key: String, 161 | defaultValue: Long 162 | ) = Preference( 163 | key = key, 164 | defaultValue = defaultValue, 165 | getter = ::getLong, 166 | setter = ::putLong 167 | ) 168 | 169 | protected inline fun > enumPreference( 170 | key: String, 171 | defaultValue: T 172 | ) = Preference( 173 | key = key, 174 | defaultValue = defaultValue, 175 | getter = ::getEnum, 176 | setter = ::putEnum 177 | ) 178 | } -------------------------------------------------------------------------------- /app/src/main/java/com/chouten/app/data/AlertData.kt: -------------------------------------------------------------------------------- 1 | package com.chouten.app.data 2 | 3 | import androidx.compose.ui.graphics.vector.ImageVector 4 | 5 | data class AlertData( 6 | val title: String = "Notice", 7 | val message: String, 8 | val confirmButtonText: String? = null, 9 | val confirmButtonAction: (() -> Unit)? = null, 10 | val cancelButtonText: String? = null, 11 | val cancelButtonAction: (() -> Unit)? = null, 12 | val icon: ImageVector? = null, 13 | ) -------------------------------------------------------------------------------- /app/src/main/java/com/chouten/app/data/Constants.kt: -------------------------------------------------------------------------------- 1 | package com.chouten.app.data 2 | 3 | import android.os.Build 4 | import android.os.Environment 5 | import androidx.compose.material.icons.Icons 6 | import androidx.compose.material.icons.filled.Home 7 | import androidx.compose.material.icons.filled.MoreHoriz 8 | import androidx.compose.material.icons.filled.Search 9 | import androidx.compose.material.icons.outlined.Home 10 | import androidx.compose.material.icons.outlined.MoreHoriz 11 | import androidx.compose.material.icons.outlined.Search 12 | import com.chouten.app.R 13 | import com.chouten.app.ui.BottomNavItem 14 | import java.io.File 15 | 16 | object AppPaths { 17 | val baseDir = 18 | Environment.getExternalStoragePublicDirectory("${Environment.DIRECTORY_DOCUMENTS}/Chouten/") 19 | val _toCreate = listOf("Modules", "Themes") 20 | val addedDirs = mutableMapOf() 21 | } 22 | 23 | object RequestCodes { 24 | const val allowAllFiles = 1 25 | } 26 | 27 | object Preferences { 28 | const val SelectedModule = "SelectedModule" 29 | 30 | object Settings { 31 | val oledTheme = ChoutenSetting( 32 | R.string.oled_theme_toggle__title, 33 | R.string.oled_theme_toggle__desc, 34 | preference = Pair("oledMode", Boolean) 35 | ) 36 | val downloadedOnly = ChoutenSetting( 37 | R.string.downloaded_only_toggle__title, 38 | R.string.downloaded_only_toggle__desc, 39 | preference = Pair("offlineMode", Boolean) 40 | ) 41 | 42 | val incognito = ChoutenSetting( 43 | R.string.incognito_toggle__title, 44 | R.string.incognito_toggle__desc, 45 | preference = Pair("incognito", Boolean) 46 | ) 47 | 48 | val devMode = ChoutenSetting( 49 | R.string.dev_mode_toggle__title, 50 | R.string.dev_mode_toggle__desc, 51 | preference = Pair("devMode", Boolean) 52 | ) 53 | 54 | val dynamicColor = ChoutenSetting( 55 | R.string.dynamic_colour_toggle__title, 56 | R.string.dynamic_colour_toggle__desc, 57 | preference = Pair("dynamicColor", Boolean), 58 | constraints = { Build.VERSION.SDK_INT >= 31 } // Disable if not on Android 12+ 59 | ) 60 | val themeType = ChoutenSetting( 61 | R.string.appearance__title, 62 | R.string.appearance__desc, 63 | preference = Pair("themeType", Enum), 64 | ) 65 | val dns = ChoutenSetting( 66 | R.string.dns__title, 67 | R.string.dns__desc, 68 | preference = Pair("dns", String), 69 | ) 70 | } 71 | } 72 | 73 | object NavigationItems { 74 | val HomePage = BottomNavItem( 75 | name = R.string.navbar_home, 76 | route = "home/", 77 | activeIcon = Icons.Filled.Home, 78 | inactiveIcon = Icons.Outlined.Home, 79 | ) 80 | val SearchPage = BottomNavItem( 81 | name = R.string.navbar_search, 82 | route = "search/", 83 | activeIcon = Icons.Filled.Search, 84 | inactiveIcon = Icons.Outlined.Search, 85 | ) 86 | val MorePage = BottomNavItem( 87 | name = R.string.navbar_more, 88 | route = "more/", 89 | activeIcon = Icons.Filled.MoreHoriz, 90 | inactiveIcon = Icons.Outlined.MoreHoriz 91 | ) 92 | val AppearancePage = BottomNavItem( 93 | name = R.string.appearance__title, 94 | route = "more/appearance", 95 | ) 96 | val NetworkPage = BottomNavItem( 97 | name = R.string.network__title, 98 | route = "more/network", 99 | ) 100 | val LogPage = BottomNavItem( 101 | name = R.string.settings_submenu_log, 102 | route = "more/log", 103 | ) 104 | } 105 | 106 | enum class AppThemeType(val printable: String) { 107 | LIGHT("Light"), 108 | DARK("Dark"), 109 | SYSTEM("System"); 110 | 111 | override fun toString(): String { 112 | return this.printable 113 | } 114 | } 115 | 116 | enum class CustomDNS(val printable: String) { 117 | NONE("None"), 118 | CLOUDFLARE("Cloudflare"), 119 | GOOGLE("Google"), 120 | ADGUARD("AdGuard"); 121 | 122 | override fun toString(): String { 123 | return this.printable 124 | } 125 | } -------------------------------------------------------------------------------- /app/src/main/java/com/chouten/app/data/DataLayer.kt: -------------------------------------------------------------------------------- 1 | package com.chouten.app.data 2 | 3 | import android.util.Log 4 | import androidx.compose.runtime.getValue 5 | import androidx.compose.runtime.mutableStateOf 6 | import androidx.compose.runtime.setValue 7 | import androidx.lifecycle.MutableLiveData 8 | import kotlinx.coroutines.flow.MutableStateFlow 9 | 10 | class DataLayer { 11 | var snackbarQueue: MutableLiveData> = 12 | MutableLiveData(listOf()) 13 | private set 14 | private var _snackbarDeleteBuffer = 0 15 | 16 | // Alert Queue uses Flows because we want to be able 17 | // to easily modify it and keep the state in composables 18 | var alertQueue: MutableStateFlow> = 19 | MutableStateFlow(listOf()) 20 | private set 21 | 22 | var isNavigationShown by mutableStateOf(true) 23 | 24 | fun enqueueSnackbar(content: SnackbarVisualsWithError) { 25 | if (_snackbarDeleteBuffer > 0) { 26 | // Force it to pop however many need to be 27 | popSnackbarQueue(true) 28 | } 29 | 30 | snackbarQueue.postValue(snackbarQueue.value?.plus(content)) 31 | } 32 | 33 | fun popSnackbarQueue(force: Boolean = false) { 34 | if (!force && _snackbarDeleteBuffer < snackbarDeleteBufferMax) { 35 | _snackbarDeleteBuffer += 1; return 36 | } 37 | 38 | val amountToRemove = 39 | if (force) _snackbarDeleteBuffer else snackbarDeleteBufferMax 40 | 41 | if (snackbarQueue.value == null || snackbarQueue.value?.size == 0) return 42 | 43 | // TODO: Fix snackbar.value 44 | // Using postValue causes some weird issues where the snackbar 45 | // doesn't ever actually clear the values off meaning that the messages 46 | // from before are displayed along with the new one. 47 | // Using `.value = <....>` can be an issue because when the app is 48 | // in the background there may not be a state to alter and the 49 | // operation will error out. 50 | try { 51 | snackbarQueue.value = 52 | snackbarQueue.value?.takeLast(snackbarQueue.value!!.size - amountToRemove) 53 | } catch (e: Exception) { 54 | Log.d("CHOUTEN/SNACKBAR", e.localizedMessage ?: "Snackbar Error") 55 | } 56 | 57 | _snackbarDeleteBuffer = 0 58 | } 59 | 60 | fun enqueueAlert(content: AlertData) { 61 | alertQueue.value = alertQueue.value.plus(content) 62 | } 63 | 64 | fun popAlertQueue() { 65 | try { 66 | alertQueue.value = alertQueue.value.drop(1) 67 | } catch (e: Exception) { 68 | Log.d("CHOUTEN/ALERT", e.localizedMessage ?: "Alert Error") 69 | } 70 | } 71 | 72 | companion object { 73 | const val snackbarDeleteBufferMax = 5 74 | } 75 | } -------------------------------------------------------------------------------- /app/src/main/java/com/chouten/app/data/LogDataLayer.kt: -------------------------------------------------------------------------------- 1 | package com.chouten.app.data 2 | 3 | import android.util.Log 4 | import androidx.compose.runtime.mutableStateListOf 5 | import com.chouten.app.ModuleLayer 6 | import java.text.SimpleDateFormat 7 | import java.util.Date 8 | import java.util.Locale 9 | 10 | class LogDataLayer { 11 | private var _logEntries = mutableStateListOf() 12 | val logEntries: List 13 | get() = _logEntries 14 | 15 | fun addLogEntry(entry: LogEntry) { 16 | Log.d("CHOUTEN/LOG", entry.message) 17 | _logEntries.add(entry) 18 | } 19 | 20 | fun clearLogs() { 21 | _logEntries.clear() 22 | } 23 | } 24 | 25 | data class LogEntry( 26 | val timestamp: String = SimpleDateFormat( 27 | "HH:mm:ss", 28 | Locale.getDefault() 29 | ).format(Date()), 30 | val title: String, 31 | val module: ModuleModel = ModuleLayer.selectedModule 32 | ?: throw Exception("No module selected"), 33 | val message: String, 34 | val isError: Boolean, 35 | ) -------------------------------------------------------------------------------- /app/src/main/java/com/chouten/app/data/ModuleModel.kt: -------------------------------------------------------------------------------- 1 | package com.chouten.app.data 2 | 3 | import com.chouten.app.Mapper 4 | import kotlinx.serialization.SerialName 5 | import kotlinx.serialization.Serializable 6 | import org.json.* 7 | 8 | @Serializable 9 | data class ModuleModel( 10 | var id: String, // The ID provided by the module 11 | // something that needs to be defined within the JSON 12 | val type: String, 13 | val subtypes: List, 14 | var name: String, 15 | val version: String, 16 | val formatVersion: Int, 17 | val updateUrl: String, 18 | @SerialName("general") val meta: ModuleMetaData, 19 | var code: Map? 20 | ) { 21 | @Serializable 22 | data class ModuleMetaData( 23 | val author: String, 24 | val description: String, 25 | var icon: String?, 26 | val lang: List, 27 | @SerialName("baseURL") val baseUrl: String, 28 | @SerialName("bgColor") val backgroundColor: String, 29 | @SerialName("fgColor") val foregroundColor: String, 30 | ) { 31 | override fun toString(): String { 32 | return "{\"author\": \"$author\", \"description\": \"$description\", \"icon\": \"$icon\", \"lang\": ${ 33 | lang.map { 34 | "\"$it\"" 35 | } 36 | }, \"baseURL\": \"$baseUrl\", \"bgColor\": \"$backgroundColor\", \"fgColor\": \"$foregroundColor\"}" 37 | } 38 | } 39 | 40 | override fun toString(): String { 41 | return "{\"id\": \"$id\", \"type\": \"${type}\", \"subtypes\": ${ 42 | subtypes.map { 43 | "\"${it}\"" 44 | } 45 | }, \"name\": \"$name\", \"version\": \"$version\", \"formatVersion\": $formatVersion, \"updateUrl\": \"$updateUrl\", \"general\": $meta}" 46 | } 47 | 48 | @Serializable 49 | data class ModuleCode( 50 | val home: List = listOf(), 51 | val search: List = listOf(), 52 | val info: List = listOf(), 53 | val mediaConsume: List = listOf(), 54 | ) { 55 | @Serializable 56 | data class ModuleCodeblock( 57 | val imports: List? = listOf(), 58 | // not set within the JSON 59 | var code: String, 60 | ) 61 | 62 | override fun toString(): String { 63 | return "{\"home\": $home, \"search\": $search, \"info\": $info, \"mediaConsume\": $mediaConsume}" 64 | } 65 | } 66 | 67 | override fun hashCode(): Int { 68 | return ( 69 | this.type.hashCode() 70 | + this.subtypes.hashCode() 71 | + this.name.hashCode() 72 | + this.version.hashCode() 73 | + this.updateUrl.hashCode() 74 | + this.meta.hashCode() 75 | ) 76 | } 77 | 78 | override fun equals(other: Any?): Boolean { 79 | if (this === other) return true 80 | if (javaClass != other?.javaClass) return false 81 | 82 | other as ModuleModel 83 | 84 | if (id != other.id) return false 85 | if (type != other.type) return false 86 | if (subtypes != other.subtypes) return false 87 | if (name != other.name) return false 88 | if (version != other.version) return false 89 | if (updateUrl != other.updateUrl) return false 90 | return meta == other.meta 91 | } 92 | } 93 | 94 | @Serializable 95 | data class ModuleResponse( 96 | val result: T, val action: String? = "" 97 | ) 98 | 99 | // TODO: make the action field static (should be action = "error") 100 | // it doesn't stringify correctly when that's the case 101 | @Serializable 102 | data class ErrorAction( 103 | val action: String, 104 | val result: String 105 | ) 106 | 107 | @Serializable 108 | data class HTTPAction( 109 | val reqId: String, 110 | val responseText: String 111 | ) 112 | 113 | @Serializable 114 | data class ModuleAction( 115 | val action: String? = "" 116 | ) 117 | 118 | @Serializable 119 | data class HomeResult( 120 | val type: String, // "Carousel", "list", "grid_2x", "..." 121 | val title: String, // "Spotlight", "Recently released", "..." 122 | val data: List 123 | ) { 124 | @Serializable 125 | data class HomeItem( 126 | val url: String, 127 | val image: String, 128 | val titles: Map, 129 | val indicator: String?, 130 | val current: Int?, 131 | val total: Int?, 132 | val subtitle: String?, 133 | val subtitleValue: List, 134 | val buttonText: String?, 135 | val showIcon: Boolean? = false, 136 | val iconText: String?, 137 | ) 138 | } 139 | 140 | @Serializable 141 | data class SearchResult( 142 | val url: String, 143 | val img: String, 144 | val title: String, 145 | val indicatorText: String?, 146 | val currentCount: Int?, 147 | val totalCount: Int?, 148 | ) 149 | 150 | @Serializable 151 | data class WebviewPayload( 152 | val query: String, 153 | val action: String 154 | ) 155 | 156 | @Serializable 157 | data class BasePayload( 158 | val reqId: String?, 159 | val action: String, 160 | val payload: String 161 | ) 162 | 163 | @Serializable 164 | data class HomepagePayload( 165 | val action: String, 166 | ) 167 | 168 | @Serializable 169 | data class InfoResult( 170 | val id: String?, 171 | val titles: Titles, 172 | val epListURLs: List, 173 | val altTitles: List?, 174 | val description: String, 175 | val poster: String, 176 | val banner: String?, 177 | val status: String?, 178 | val totalMediaCount: Int?, 179 | val mediaType: String, 180 | val seasons: List?, 181 | val mediaList: List?, 182 | ) { 183 | @Serializable 184 | data class MediaListItem( 185 | val title: String, 186 | val list: List 187 | ) 188 | 189 | @Serializable 190 | data class Titles( 191 | val primary: String, 192 | val secondary: String? 193 | ) 194 | 195 | @Serializable 196 | data class MediaItem( 197 | val url: String, 198 | val number: Float?, 199 | val title: String?, 200 | val description: String?, 201 | val image: String?, 202 | ) { 203 | override fun toString(): String{ 204 | // TODO: refactor 205 | val map = MediaItem( 206 | url = url, 207 | number = number, 208 | title = title, 209 | description = description, 210 | image = image 211 | ) 212 | 213 | return Mapper.json.encodeToString(MediaItem.serializer(), map); 214 | } 215 | } 216 | 217 | @Serializable 218 | data class Season( 219 | val name: String, 220 | val url: String, 221 | ) 222 | } 223 | 224 | @Serializable 225 | data class WatchResult( 226 | val sources: List, 227 | val subtitles: List, 228 | val skips: List, 229 | val headers: Map, 230 | ) { 231 | 232 | @Serializable 233 | data class ServerData( 234 | val title: String, 235 | val list: List 236 | ) 237 | 238 | @Serializable 239 | data class Server( 240 | val name: String, 241 | val url: String, 242 | ) 243 | 244 | @Serializable 245 | data class Source( 246 | val file: String, 247 | val type: String, 248 | ) 249 | 250 | @Serializable 251 | data class Subtitles( 252 | val url: String, 253 | val language: String, 254 | ) 255 | 256 | @Serializable 257 | data class SkipTimes( 258 | val start: Double, 259 | val end: Double, 260 | val type: String 261 | ) 262 | } -------------------------------------------------------------------------------- /app/src/main/java/com/chouten/app/data/SettingsModel.kt: -------------------------------------------------------------------------------- 1 | package com.chouten.app.data 2 | 3 | import androidx.annotation.StringRes 4 | import androidx.compose.ui.graphics.vector.ImageVector 5 | 6 | data class ChoutenSetting( 7 | @StringRes val text: Int, 8 | @StringRes val secondaryText: Int? = null, 9 | val icon: ImageVector? = null, 10 | val preference: Pair, 11 | val constraints: (() -> Boolean)? = null, 12 | val onToggle: ((Boolean) -> Unit)? = null 13 | ) 14 | -------------------------------------------------------------------------------- /app/src/main/java/com/chouten/app/data/SnackbarData.kt: -------------------------------------------------------------------------------- 1 | package com.chouten.app.data 2 | 3 | import androidx.compose.material3.SnackbarDuration 4 | import androidx.compose.material3.SnackbarVisuals 5 | 6 | data class SnackbarAction( 7 | val actionText: String? = null, 8 | val action: () -> Unit 9 | ) 10 | 11 | class SnackbarVisualsWithError( 12 | override val message: String, 13 | val isError: Boolean, 14 | val shouldShowButton: Boolean = false, 15 | val buttonText: String? = null, 16 | val customButton: SnackbarAction? = null 17 | ) : SnackbarVisuals { 18 | override val actionLabel: String 19 | get() = customButton?.actionText ?: if (isError) "Error" else "OK" 20 | override val withDismissAction: Boolean 21 | get() = false 22 | override val duration: SnackbarDuration 23 | get() = if (!shouldShowButton) SnackbarDuration.Indefinite else SnackbarDuration.Short 24 | } -------------------------------------------------------------------------------- /app/src/main/java/com/chouten/app/ui/Screen.kt: -------------------------------------------------------------------------------- 1 | package com.chouten.app.ui 2 | 3 | import com.chouten.app.data.NavigationItems 4 | 5 | sealed class Screen(val route: String) { 6 | object HomePage : Screen(NavigationItems.HomePage.route) 7 | object SearchPage : Screen(NavigationItems.SearchPage.route) 8 | object MorePage : Screen(NavigationItems.MorePage.route) 9 | object AppearancePage : Screen(NavigationItems.AppearancePage.route) 10 | object NetworkPage : Screen(NavigationItems.NetworkPage.route) 11 | object LogPage : Screen(NavigationItems.LogPage.route) 12 | } 13 | -------------------------------------------------------------------------------- /app/src/main/java/com/chouten/app/ui/components/Alert.kt: -------------------------------------------------------------------------------- 1 | package com.chouten.app.ui.components 2 | 3 | import androidx.compose.animation.AnimatedVisibility 4 | import androidx.compose.material3.AlertDialog 5 | import androidx.compose.material3.Button 6 | import androidx.compose.material3.Icon 7 | import androidx.compose.material3.Text 8 | import androidx.compose.material3.TextButton 9 | import androidx.compose.runtime.Composable 10 | import androidx.compose.runtime.mutableStateOf 11 | import androidx.compose.runtime.saveable.rememberSaveable 12 | import androidx.compose.ui.platform.LocalLifecycleOwner 13 | import androidx.compose.ui.res.stringResource 14 | import androidx.lifecycle.lifecycleScope 15 | import com.chouten.app.PrimaryDataLayer 16 | import com.chouten.app.R 17 | import com.chouten.app.data.AlertData 18 | import kotlinx.coroutines.launch 19 | 20 | @Composable 21 | fun ChoutenAlert(alert: AlertData) { 22 | 23 | val isShown = rememberSaveable { 24 | mutableStateOf(true) 25 | } 26 | 27 | val defaultDismiss = { 28 | PrimaryDataLayer.popAlertQueue() 29 | isShown.value = false 30 | } 31 | 32 | val owner = LocalLifecycleOwner.current 33 | 34 | AnimatedVisibility(visible = isShown.value) { 35 | AlertDialog(onDismissRequest = { 36 | alert.cancelButtonAction?.invoke() 37 | defaultDismiss() 38 | }, icon = { 39 | if (alert.icon != null) { 40 | Icon(imageVector = alert.icon, contentDescription = null) 41 | } 42 | }, 43 | title = { 44 | Text(text = alert.title) 45 | }, text = { 46 | Text(text = alert.message) 47 | }, confirmButton = { 48 | Button( 49 | onClick = { 50 | alert.confirmButtonAction?.invoke() 51 | defaultDismiss() 52 | }, 53 | ) { 54 | Text( 55 | text = alert.confirmButtonText 56 | ?: stringResource(id = R.string.ok) 57 | ) 58 | } 59 | }, dismissButton = { 60 | if (alert.cancelButtonText != null && alert.cancelButtonAction != null) { 61 | TextButton( 62 | onClick = { 63 | owner.lifecycleScope.launch { 64 | alert.cancelButtonAction.invoke() 65 | defaultDismiss() 66 | } 67 | }, 68 | ) { 69 | Text(text = alert.cancelButtonText) 70 | } 71 | } 72 | }) 73 | } 74 | } 75 | -------------------------------------------------------------------------------- /app/src/main/java/com/chouten/app/ui/components/Banners.kt: -------------------------------------------------------------------------------- 1 | package com.chouten.app.ui.components 2 | 3 | import androidx.annotation.StringRes 4 | import androidx.compose.animation.AnimatedVisibility 5 | import androidx.compose.animation.expandVertically 6 | import androidx.compose.animation.shrinkVertically 7 | import androidx.compose.foundation.background 8 | import androidx.compose.foundation.layout.Arrangement 9 | import androidx.compose.foundation.layout.Row 10 | import androidx.compose.foundation.layout.Spacer 11 | import androidx.compose.foundation.layout.WindowInsets 12 | import androidx.compose.foundation.layout.fillMaxWidth 13 | import androidx.compose.foundation.layout.padding 14 | import androidx.compose.foundation.layout.requiredSize 15 | import androidx.compose.foundation.layout.statusBars 16 | import androidx.compose.foundation.layout.width 17 | import androidx.compose.foundation.layout.windowInsetsPadding 18 | import androidx.compose.material3.CircularProgressIndicator 19 | import androidx.compose.material3.MaterialTheme 20 | import androidx.compose.material3.Text 21 | import androidx.compose.runtime.Composable 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.Modifier 27 | import androidx.compose.ui.layout.SubcomposeLayout 28 | import androidx.compose.ui.platform.LocalDensity 29 | import androidx.compose.ui.res.stringResource 30 | import androidx.compose.ui.text.style.TextAlign 31 | import androidx.compose.ui.unit.Dp 32 | import androidx.compose.ui.unit.dp 33 | 34 | import androidx.compose.ui.util.fastForEach 35 | import androidx.compose.ui.util.fastMap 36 | import androidx.compose.ui.util.fastMaxBy 37 | 38 | val DownloadedOnlyBannerBackgroundColor 39 | @Composable get() = MaterialTheme.colorScheme.tertiary 40 | val IncognitoModeBannerBackgroundColor 41 | @Composable get() = MaterialTheme.colorScheme.primary 42 | val IndexingBannerBackgroundColor 43 | @Composable get() = MaterialTheme.colorScheme.secondary 44 | 45 | @Composable 46 | fun WarningBanner( 47 | @StringRes textRes: Int, 48 | modifier: Modifier = Modifier, 49 | topPadding: Dp = 0.dp 50 | ) { 51 | Text( 52 | text = stringResource(textRes), 53 | modifier = modifier 54 | .fillMaxWidth() 55 | .background(MaterialTheme.colorScheme.error) 56 | .padding(top = topPadding + 8.dp, bottom = 8.dp), 57 | color = MaterialTheme.colorScheme.onError, 58 | style = MaterialTheme.typography.bodyMedium, 59 | textAlign = TextAlign.Center, 60 | ) 61 | } 62 | 63 | 64 | @Composable 65 | fun AppStateBanners( 66 | downloadedOnlyMode: Boolean, 67 | incognitoMode: Boolean, 68 | indexing: Boolean, 69 | modifier: Modifier = Modifier, 70 | ) { 71 | val density = LocalDensity.current 72 | val mainInsets = WindowInsets.statusBars 73 | val mainInsetsTop = mainInsets.getTop(density) 74 | SubcomposeLayout(modifier = modifier) { constraints -> 75 | val indexingPlaceable = subcompose(0) { 76 | AnimatedVisibility( 77 | visible = indexing, 78 | enter = expandVertically(), 79 | exit = shrinkVertically(), 80 | ) { 81 | IndexingDownloadBanner( 82 | modifier = Modifier.windowInsetsPadding(mainInsets), 83 | ) 84 | } 85 | }.fastMap { it.measure(constraints) } 86 | val indexingHeight = indexingPlaceable.fastMaxBy { it.height }?.height ?: 0 87 | 88 | val downloadedOnlyPlaceable = subcompose(1) { 89 | AnimatedVisibility( 90 | visible = downloadedOnlyMode, 91 | enter = expandVertically(), 92 | exit = shrinkVertically(), 93 | ) { 94 | val top = (mainInsetsTop - indexingHeight).coerceAtLeast(0) 95 | DownloadedOnlyModeBanner( 96 | modifier = Modifier.windowInsetsPadding(WindowInsets(top = top)), 97 | ) 98 | } 99 | }.fastMap { it.measure(constraints) } 100 | val downloadedOnlyHeight = downloadedOnlyPlaceable.fastMaxBy { it.height }?.height ?: 0 101 | 102 | val incognitoPlaceable = subcompose(2) { 103 | AnimatedVisibility( 104 | visible = incognitoMode, 105 | enter = expandVertically(), 106 | exit = shrinkVertically(), 107 | ) { 108 | val top = (mainInsetsTop - indexingHeight - downloadedOnlyHeight).coerceAtLeast(0) 109 | IncognitoModeBanner( 110 | modifier = Modifier.windowInsetsPadding(WindowInsets(top = top)), 111 | ) 112 | } 113 | }.fastMap { it.measure(constraints) } 114 | val incognitoHeight = incognitoPlaceable.fastMaxBy { it.height }?.height ?: 0 115 | 116 | layout(constraints.maxWidth, indexingHeight + downloadedOnlyHeight + incognitoHeight) { 117 | indexingPlaceable.fastForEach { 118 | it.place(0, 0) 119 | } 120 | downloadedOnlyPlaceable.fastForEach { 121 | it.place(0, indexingHeight) 122 | } 123 | incognitoPlaceable.fastForEach { 124 | it.place(0, indexingHeight + downloadedOnlyHeight) 125 | } 126 | } 127 | } 128 | } 129 | 130 | 131 | @Composable 132 | private fun DownloadedOnlyModeBanner(modifier: Modifier = Modifier) { 133 | Text( 134 | text = "Downloaded only mode", 135 | modifier = Modifier 136 | .background(DownloadedOnlyBannerBackgroundColor) 137 | .fillMaxWidth() 138 | .padding(4.dp) 139 | .then(modifier), 140 | color = MaterialTheme.colorScheme.onTertiary, 141 | textAlign = TextAlign.Center, 142 | style = MaterialTheme.typography.labelMedium, 143 | ) 144 | } 145 | 146 | @Composable 147 | private fun IncognitoModeBanner(modifier: Modifier = Modifier) { 148 | Text( 149 | text = "Incognito mode", 150 | modifier = Modifier 151 | .background(IncognitoModeBannerBackgroundColor) 152 | .fillMaxWidth() 153 | .padding(4.dp) 154 | .then(modifier), 155 | color = MaterialTheme.colorScheme.onPrimary, 156 | textAlign = TextAlign.Center, 157 | style = MaterialTheme.typography.labelMedium, 158 | ) 159 | } 160 | 161 | @Composable 162 | private fun IndexingDownloadBanner(modifier: Modifier = Modifier) { 163 | val density = LocalDensity.current 164 | Row( 165 | modifier = Modifier 166 | .background(color = IndexingBannerBackgroundColor) 167 | .fillMaxWidth() 168 | .padding(8.dp) 169 | .then(modifier), 170 | horizontalArrangement = Arrangement.Center, 171 | ) { 172 | var textHeight by remember { mutableStateOf(0.dp) } 173 | CircularProgressIndicator( 174 | modifier = Modifier.requiredSize(textHeight), 175 | color = MaterialTheme.colorScheme.onSecondary, 176 | strokeWidth = textHeight / 8, 177 | ) 178 | Spacer(modifier = Modifier.width(8.dp)) 179 | Text( 180 | text = "Indexing...", 181 | color = MaterialTheme.colorScheme.onSecondary, 182 | textAlign = TextAlign.Center, 183 | style = MaterialTheme.typography.labelMedium, 184 | onTextLayout = { 185 | with(density) { 186 | textHeight = it.size.height.toDp() 187 | } 188 | }, 189 | ) 190 | } 191 | } -------------------------------------------------------------------------------- /app/src/main/java/com/chouten/app/ui/components/Divider.kt: -------------------------------------------------------------------------------- 1 | package com.chouten.app.ui.components 2 | 3 | import androidx.compose.foundation.background 4 | import androidx.compose.foundation.layout.Box 5 | import androidx.compose.foundation.layout.fillMaxHeight 6 | import androidx.compose.foundation.layout.heightIn 7 | import androidx.compose.foundation.layout.width 8 | import androidx.compose.material3.DividerDefaults 9 | import androidx.compose.runtime.Composable 10 | import androidx.compose.ui.Modifier 11 | import androidx.compose.ui.graphics.Color 12 | import androidx.compose.ui.platform.LocalDensity 13 | import androidx.compose.ui.unit.Dp 14 | import androidx.compose.ui.unit.dp 15 | 16 | /** 17 | * A vertical variation of the Material Design divider. 18 | * 19 | * A divider is a thin line that groups content in lists and layouts. 20 | * 21 | * ![Divider image](https://developer.android.com/images/reference/androidx/compose/material3/divider.png) 22 | * 23 | * @param modifier the [Modifier] to be applied to this divider line. 24 | * @param thickness thickness of this divider line. Using [Dp.Hairline] will produce a single pixel 25 | * divider regardless of screen density. 26 | * @param color color of this divider line. 27 | * @param height the maximum height of this divider. 28 | */ 29 | @Composable 30 | fun VerticalDivider( 31 | modifier: Modifier = Modifier, 32 | thickness: Dp = DividerDefaults.Thickness, 33 | color: Color = DividerDefaults.color, 34 | height: Dp = Dp.Infinity 35 | ) { 36 | val targetThickness = if (thickness == Dp.Hairline) { 37 | (1f / LocalDensity.current.density).dp 38 | } else { 39 | thickness 40 | } 41 | Box( 42 | modifier 43 | .heightIn(max = height) 44 | .fillMaxHeight() 45 | .width(targetThickness) 46 | .background(color = color) 47 | ) 48 | } -------------------------------------------------------------------------------- /app/src/main/java/com/chouten/app/ui/components/EpisodeItem.kt: -------------------------------------------------------------------------------- 1 | package com.chouten.app.ui.components 2 | 3 | import androidx.compose.foundation.background 4 | import androidx.compose.foundation.layout.Arrangement 5 | import androidx.compose.foundation.layout.Box 6 | import androidx.compose.foundation.layout.Column 7 | import androidx.compose.foundation.layout.Row 8 | import androidx.compose.foundation.layout.fillMaxHeight 9 | import androidx.compose.foundation.layout.fillMaxWidth 10 | import androidx.compose.foundation.layout.height 11 | import androidx.compose.foundation.layout.heightIn 12 | import androidx.compose.foundation.layout.padding 13 | import androidx.compose.foundation.layout.width 14 | import androidx.compose.material3.MaterialTheme 15 | import androidx.compose.material3.Text 16 | import androidx.compose.material3.surfaceColorAtElevation 17 | import androidx.compose.runtime.Composable 18 | import androidx.compose.ui.Alignment 19 | import androidx.compose.ui.Modifier 20 | import androidx.compose.ui.draw.clip 21 | import androidx.compose.ui.layout.ContentScale 22 | import androidx.compose.ui.text.font.FontWeight 23 | import androidx.compose.ui.text.style.TextOverflow 24 | import androidx.compose.ui.unit.dp 25 | import androidx.compose.ui.unit.sp 26 | import com.chouten.app.data.InfoResult 27 | import com.skydoves.landscapist.ImageOptions 28 | import com.skydoves.landscapist.glide.GlideImage 29 | import com.valentinilk.shimmer.shimmer 30 | 31 | @Composable 32 | fun EpisodeItem( 33 | item: InfoResult.MediaItem, 34 | imageAlternative: String, 35 | modifier: Modifier = Modifier 36 | ) { 37 | Column( 38 | Modifier 39 | .fillMaxWidth() 40 | .clip(MaterialTheme.shapes.medium) 41 | .background( 42 | MaterialTheme.colorScheme.surfaceColorAtElevation( 43 | 3.dp 44 | ) 45 | ) 46 | .then(modifier) 47 | ) { 48 | Row( 49 | horizontalArrangement = Arrangement.spacedBy( 50 | 12.dp 51 | ) 52 | ) { 53 | GlideImage( 54 | modifier = Modifier 55 | .width(160.dp) 56 | .height(90.dp) 57 | .clip(MaterialTheme.shapes.medium), 58 | imageModel = { 59 | item.image ?: imageAlternative 60 | }, 61 | imageOptions = ImageOptions( 62 | contentScale = ContentScale.Crop, 63 | alignment = Alignment.Center, 64 | contentDescription = "${item.title} Thumbnail", 65 | ), 66 | loading = { 67 | Box( 68 | Modifier 69 | .shimmer() 70 | .matchParentSize() 71 | .background(MaterialTheme.colorScheme.onSurface) 72 | ) 73 | }, 74 | ) 75 | Column(Modifier.heightIn(max = 90.dp)) { 76 | Column( 77 | modifier = Modifier.fillMaxHeight( 78 | 0.7F 79 | ), 80 | verticalArrangement = Arrangement.SpaceAround 81 | ) { 82 | Text( 83 | item.title ?: "Episode 1", 84 | color = MaterialTheme.colorScheme.onSurface, 85 | fontSize = 15.sp, 86 | lineHeight = 16.sp, 87 | fontWeight = FontWeight.SemiBold, 88 | maxLines = 2, 89 | overflow = TextOverflow.Ellipsis 90 | ) 91 | } 92 | Row( 93 | horizontalArrangement = Arrangement.SpaceBetween, 94 | modifier = Modifier 95 | .fillMaxWidth() 96 | .padding( 97 | bottom = 6.dp, end = 6.dp 98 | ) 99 | ) { 100 | Text( 101 | "Episode ${ 102 | item.number.toString() 103 | .substringBefore(".0") 104 | }", 105 | color = MaterialTheme.colorScheme.onSurface.copy( 106 | 0.7F 107 | ), 108 | fontSize = 12.sp, 109 | fontWeight = FontWeight.SemiBold 110 | ) 111 | 112 | Text( 113 | "24 mins", 114 | color = MaterialTheme.colorScheme.onSurface.copy( 115 | 0.7F 116 | ), 117 | fontSize = 12.sp, 118 | fontWeight = FontWeight.SemiBold 119 | ) 120 | } 121 | } 122 | } 123 | if (item.description?.isNotBlank() == true) { 124 | Text( 125 | "${item.description}", 126 | color = MaterialTheme.colorScheme.onSurface.copy( 127 | 0.7F 128 | ), 129 | fontSize = 12.sp, 130 | lineHeight = MaterialTheme.typography.bodySmall.lineHeight, 131 | maxLines = 4, 132 | overflow = TextOverflow.Ellipsis, 133 | modifier = Modifier.padding(all = 12.dp) 134 | ) 135 | } 136 | } 137 | } -------------------------------------------------------------------------------- /app/src/main/java/com/chouten/app/ui/components/ListAnimation.kt: -------------------------------------------------------------------------------- 1 | package com.chouten.app.ui.components 2 | 3 | import android.annotation.SuppressLint 4 | import androidx.compose.animation.AnimatedVisibility 5 | import androidx.compose.animation.EnterTransition 6 | import androidx.compose.animation.ExitTransition 7 | import androidx.compose.animation.core.MutableTransitionState 8 | import androidx.compose.animation.expandVertically 9 | import androidx.compose.animation.shrinkVertically 10 | import androidx.compose.foundation.lazy.LazyItemScope 11 | import androidx.compose.foundation.lazy.LazyListScope 12 | import androidx.compose.runtime.Composable 13 | import androidx.compose.runtime.LaunchedEffect 14 | import androidx.compose.runtime.State 15 | import androidx.compose.runtime.mutableStateOf 16 | import androidx.compose.runtime.remember 17 | import androidx.recyclerview.widget.DiffUtil 18 | import androidx.recyclerview.widget.ListUpdateCallback 19 | import kotlinx.coroutines.Dispatchers 20 | import kotlinx.coroutines.withContext 21 | 22 | @SuppressLint("ComposableNaming", "UnusedTransitionTargetStateParameter") 23 | /** 24 | * @param state Use [updateAnimatedItemsState]. 25 | */ 26 | inline fun LazyListScope.animatedItemsIndexed( 27 | state: List>, 28 | enterTransition: EnterTransition = expandVertically(), 29 | exitTransition: ExitTransition = shrinkVertically(), 30 | noinline key: ((item: T) -> Any)? = null, 31 | crossinline itemContent: @Composable LazyItemScope.(index: Int, item: T) -> Unit 32 | ) { 33 | items( 34 | state.size, 35 | if (key != null) { keyIndex: Int -> key(state[keyIndex].item) } else null 36 | ) { index -> 37 | val item = state[index] 38 | val visibility = item.visibility 39 | androidx.compose.runtime.key(key?.invoke(item.item)) { 40 | AnimatedVisibility( 41 | visibleState = visibility, 42 | enter = enterTransition, 43 | exit = exitTransition 44 | ) { 45 | itemContent(index, item.item) 46 | } 47 | } 48 | } 49 | } 50 | 51 | @Composable 52 | fun updateAnimatedItemsState( 53 | newList: List 54 | ): State>> { 55 | val state = remember { mutableStateOf(emptyList>()) } 56 | LaunchedEffect(newList) { 57 | if (state.value == newList) { 58 | return@LaunchedEffect 59 | } 60 | val oldList = state.value.toList() 61 | val diffCb = object : DiffUtil.Callback() { 62 | override fun getOldListSize(): Int = oldList.size 63 | override fun getNewListSize(): Int = newList.size 64 | override fun areItemsTheSame(oldItemPosition: Int, newItemPosition: Int): Boolean = 65 | oldList[oldItemPosition].item == newList[newItemPosition] 66 | 67 | override fun areContentsTheSame(oldItemPosition: Int, newItemPosition: Int): Boolean = 68 | oldList[oldItemPosition].item == newList[newItemPosition] 69 | } 70 | val diffResult = calculateDiff(false, diffCb) 71 | val compositeList = oldList.toMutableList() 72 | diffResult.dispatchUpdatesTo(object : ListUpdateCallback { 73 | override fun onInserted(position: Int, count: Int) { 74 | for (i in 0 until count) { 75 | val newItem = AnimatedItem( 76 | visibility = MutableTransitionState(false), 77 | newList[position + i] 78 | ) 79 | newItem.visibility.targetState = true 80 | compositeList.add(position + i, newItem) 81 | } 82 | } 83 | 84 | override fun onRemoved(position: Int, count: Int) { 85 | for (i in 0 until count) { 86 | compositeList[position + i].visibility.targetState = false 87 | } 88 | } 89 | 90 | override fun onMoved(fromPosition: Int, toPosition: Int) { 91 | // not detecting moves. 92 | } 93 | 94 | override fun onChanged(position: Int, count: Int, payload: Any?) { 95 | // irrelevant with compose. 96 | } 97 | }) 98 | if (state.value != compositeList) state.value = compositeList 99 | 100 | val initialAnimation = androidx.compose.animation.core.Animatable(1.0f) 101 | initialAnimation.animateTo(0f) 102 | state.value = state.value.filter { it.visibility.targetState } 103 | } 104 | return state 105 | } 106 | 107 | data class AnimatedItem( 108 | val visibility: MutableTransitionState, 109 | val item: T, 110 | ) { 111 | override fun hashCode(): Int { 112 | return item?.hashCode() ?: 0 113 | } 114 | 115 | override fun equals(other: Any?): Boolean { 116 | if (this === other) return true 117 | if (javaClass != other?.javaClass) return false 118 | other as AnimatedItem<*> 119 | return item == other.item 120 | } 121 | } 122 | 123 | suspend fun calculateDiff( 124 | detectMoves: Boolean = true, 125 | diffCb: DiffUtil.Callback 126 | ): DiffUtil.DiffResult { 127 | return withContext(Dispatchers.Unconfined) { 128 | DiffUtil.calculateDiff(diffCb, detectMoves) 129 | } 130 | } -------------------------------------------------------------------------------- /app/src/main/java/com/chouten/app/ui/components/SegmentedControl.kt: -------------------------------------------------------------------------------- 1 | package com.chouten.app.ui.components 2 | 3 | import androidx.compose.animation.animateContentSize 4 | import androidx.compose.foundation.BorderStroke 5 | import androidx.compose.foundation.layout.Arrangement 6 | import androidx.compose.foundation.layout.Row 7 | import androidx.compose.foundation.layout.fillMaxWidth 8 | import androidx.compose.foundation.layout.offset 9 | import androidx.compose.foundation.layout.padding 10 | import androidx.compose.foundation.layout.width 11 | import androidx.compose.foundation.shape.RoundedCornerShape 12 | import androidx.compose.material.icons.Icons 13 | import androidx.compose.material.icons.filled.Check 14 | import androidx.compose.material3.ButtonDefaults 15 | import androidx.compose.material3.Icon 16 | import androidx.compose.material3.MaterialTheme 17 | import androidx.compose.material3.OutlinedButton 18 | import androidx.compose.material3.Text 19 | import androidx.compose.runtime.Composable 20 | import androidx.compose.runtime.getValue 21 | import androidx.compose.runtime.mutableStateOf 22 | import androidx.compose.runtime.remember 23 | import androidx.compose.runtime.setValue 24 | import androidx.compose.ui.Alignment 25 | import androidx.compose.ui.Modifier 26 | import androidx.compose.ui.graphics.Color 27 | import androidx.compose.ui.layout.onGloballyPositioned 28 | import androidx.compose.ui.platform.LocalDensity 29 | import androidx.compose.ui.text.font.FontWeight 30 | import androidx.compose.ui.unit.Dp 31 | import androidx.compose.ui.unit.dp 32 | import androidx.compose.ui.zIndex 33 | 34 | /** 35 | * - `modifier` : modifier to be applied to the control (Optional) 36 | * - `items` : list of items to be rendered 37 | * - `defaultSelectedItemIndex` : to highlight item by default (Optional) 38 | * - `useFixedWidth` : set true if you want to set fix width to item (Optional) 39 | * - `itemWidth` : Provide item width if useFixedWidth is set to true (Optional) 40 | * - `cornerRadius` : To make control as rounded (Optional) 41 | * - `color` : Set color to control (Optional) 42 | * - `onItemSelection` : Get selected item index 43 | */ 44 | @Composable 45 | fun SegmentedControl( 46 | modifier: Modifier = Modifier, 47 | items: List, 48 | defaultSelectedItemIndex: Int = 0, 49 | useFixedWidth: Boolean = false, 50 | itemWidth: Dp = 120.dp, 51 | cornerRadius: Int = 10, 52 | color: Color = MaterialTheme.colorScheme.onPrimaryContainer, 53 | onItemSelection: (selectedItemIndex: Int) -> Unit 54 | ) { 55 | val selectedIndex = remember { mutableStateOf(defaultSelectedItemIndex) } 56 | val localDensity = LocalDensity.current 57 | // Create element height in dp state 58 | var rowWidthDp by remember { 59 | mutableStateOf(0.dp) 60 | } 61 | 62 | Row( 63 | modifier = modifier 64 | .onGloballyPositioned { coordinates -> 65 | // Set column height using the LayoutCoordinates 66 | rowWidthDp = with(localDensity) { coordinates.size.width.toDp() } 67 | } 68 | .padding(horizontal = 5.dp) 69 | .fillMaxWidth(), 70 | ) { 71 | items.forEachIndexed { index, item -> 72 | OutlinedButton( 73 | modifier = when (index) { 74 | 0 -> { 75 | if (useFixedWidth) { 76 | Modifier 77 | .width(itemWidth) 78 | .offset(0.dp, 0.dp) 79 | .zIndex(if (selectedIndex.value == index) 1f else 0f) 80 | } else { 81 | Modifier 82 | .width(rowWidthDp / items.size) 83 | .offset(0.dp, 0.dp) 84 | .zIndex(if (selectedIndex.value == index) 1f else 0f) 85 | } 86 | } 87 | 88 | else -> { 89 | if (useFixedWidth) Modifier 90 | .width(itemWidth) 91 | .offset((-1 * index).dp, 0.dp) 92 | .zIndex(if (selectedIndex.value == index) 1f else 0f) 93 | else Modifier 94 | .width(rowWidthDp / items.size) 95 | .offset((-1 * index).dp, 0.dp) 96 | .zIndex(if (selectedIndex.value == index) 1f else 0f) 97 | } 98 | }, 99 | onClick = { 100 | selectedIndex.value = index 101 | onItemSelection(selectedIndex.value) 102 | }, 103 | shape = when (index) { 104 | /** 105 | * left outer button 106 | */ 107 | 0 -> RoundedCornerShape( 108 | topStartPercent = cornerRadius, 109 | topEndPercent = 0, 110 | bottomStartPercent = cornerRadius, 111 | bottomEndPercent = 0 112 | ) 113 | /** 114 | * right outer button 115 | */ 116 | items.size - 1 -> RoundedCornerShape( 117 | topStartPercent = 0, 118 | topEndPercent = cornerRadius, 119 | bottomStartPercent = 0, 120 | bottomEndPercent = cornerRadius 121 | ) 122 | /** 123 | * middle button 124 | */ 125 | else -> RoundedCornerShape( 126 | topStartPercent = 0, 127 | topEndPercent = 0, 128 | bottomStartPercent = 0, 129 | bottomEndPercent = 0 130 | ) 131 | }, 132 | border = BorderStroke( 133 | 1.dp, if (selectedIndex.value == index) color else color.copy(alpha = 0.75f) 134 | ), 135 | colors = if (selectedIndex.value == index) { 136 | /** 137 | * selected colors 138 | */ 139 | ButtonDefaults.outlinedButtonColors( 140 | containerColor = color 141 | ) 142 | } else { 143 | /** 144 | * not selected colors 145 | */ 146 | ButtonDefaults.outlinedButtonColors(containerColor = Color.Transparent) 147 | }, 148 | ) { 149 | Row( 150 | modifier = Modifier 151 | .fillMaxWidth() 152 | .animateContentSize(), 153 | verticalAlignment = Alignment.CenterVertically, 154 | horizontalArrangement = Arrangement.SpaceEvenly 155 | ) { 156 | if (selectedIndex.value == index) { 157 | Icon( 158 | Icons.Default.Check, 159 | contentDescription = "Use $item as selected item", 160 | tint = MaterialTheme.colorScheme.inverseOnSurface 161 | ) 162 | } 163 | Text( 164 | text = item, 165 | fontWeight = FontWeight.Normal, 166 | color = if (selectedIndex.value == index) MaterialTheme.colorScheme.inverseOnSurface else color.copy( 167 | alpha = 0.9f 168 | ), 169 | ) 170 | } 171 | } 172 | } 173 | } 174 | } -------------------------------------------------------------------------------- /app/src/main/java/com/chouten/app/ui/components/SettingsComponents.kt: -------------------------------------------------------------------------------- 1 | package com.chouten.app.ui.components 2 | 3 | import androidx.compose.animation.AnimatedVisibility 4 | import androidx.compose.foundation.LocalIndication 5 | import androidx.compose.foundation.clickable 6 | import androidx.compose.foundation.interaction.MutableInteractionSource 7 | import androidx.compose.foundation.layout.Box 8 | import androidx.compose.foundation.layout.Column 9 | import androidx.compose.foundation.layout.Row 10 | import androidx.compose.foundation.layout.Spacer 11 | import androidx.compose.foundation.layout.fillMaxSize 12 | import androidx.compose.foundation.layout.fillMaxWidth 13 | import androidx.compose.foundation.layout.padding 14 | import androidx.compose.foundation.layout.width 15 | import androidx.compose.material.icons.Icons 16 | import androidx.compose.material.icons.filled.ArrowBack 17 | import androidx.compose.material3.AlertDialog 18 | import androidx.compose.material3.ExperimentalMaterial3Api 19 | import androidx.compose.material3.Icon 20 | import androidx.compose.material3.IconButton 21 | import androidx.compose.material3.ListItem 22 | import androidx.compose.material3.MaterialTheme 23 | import androidx.compose.material3.RadioButton 24 | import androidx.compose.material3.Scaffold 25 | import androidx.compose.material3.Switch 26 | import androidx.compose.material3.Text 27 | import androidx.compose.material3.TextButton 28 | import androidx.compose.material3.TopAppBar 29 | import androidx.compose.runtime.Composable 30 | import androidx.compose.runtime.getValue 31 | import androidx.compose.runtime.mutableStateOf 32 | import androidx.compose.runtime.remember 33 | import androidx.compose.runtime.saveable.rememberSaveable 34 | import androidx.compose.runtime.setValue 35 | import androidx.compose.ui.Alignment 36 | import androidx.compose.ui.Modifier 37 | import androidx.compose.ui.res.stringResource 38 | import androidx.compose.ui.unit.dp 39 | import androidx.navigation.NavController 40 | import com.chouten.app.R 41 | import com.chouten.app.data.ChoutenSetting 42 | 43 | @Composable 44 | fun SettingsToggle( 45 | preference: ChoutenSetting, 46 | modifier: Modifier = Modifier, 47 | onCheckedChange: ((Boolean) -> Unit), 48 | defaultValue: Boolean = false 49 | ) { 50 | var toggleState by rememberSaveable { mutableStateOf(defaultValue) } 51 | val interactionSource = remember { MutableInteractionSource() } 52 | 53 | SettingsItem( 54 | modifier = modifier.clickable( 55 | interactionSource = interactionSource, 56 | indication = LocalIndication.current 57 | ) { 58 | preference.onToggle?.invoke(!toggleState) 59 | onCheckedChange.invoke(!toggleState) 60 | toggleState = !toggleState 61 | }, 62 | icon = { preference.icon?.let { Icon(it, stringResource(preference.text)) } }, 63 | text = { Text(stringResource(preference.text)) }, 64 | secondaryText = { preference.secondaryText?.let { Text(stringResource(it)) } } 65 | ) { 66 | Switch( 67 | enabled = preference.constraints?.let { it() } != false, 68 | checked = toggleState, 69 | onCheckedChange = { 70 | preference.onToggle?.invoke(toggleState) 71 | onCheckedChange.invoke(it) 72 | toggleState = it 73 | }, 74 | interactionSource = interactionSource 75 | ) 76 | } 77 | } 78 | 79 | @Composable 80 | inline fun > SettingsChoice( 81 | preference: ChoutenSetting, 82 | modifier: Modifier = Modifier, 83 | crossinline onPreferenceChange: (T) -> Unit, 84 | crossinline onPreviewSelectionChange: (T) -> Unit = {}, 85 | defaultValue: T 86 | ) { 87 | var isOpen by rememberSaveable { 88 | mutableStateOf(false) 89 | } 90 | 91 | SettingsItem(modifier.clickable { 92 | isOpen = true 93 | }, 94 | { preference.icon?.let { Icon(it, stringResource(preference.text)) } }, 95 | { Text(stringResource(preference.text)) }, 96 | { preference.secondaryText?.let { Text(stringResource(it)) } }) { 97 | SettingsChoicePopup(visible = isOpen, 98 | title = { Text(text = stringResource(preference.text)) }, 99 | defaultValue = defaultValue, 100 | onClose = { isOpen = false }, 101 | onSelection = { onPreferenceChange(it); isOpen = false }, 102 | onPreviewSelection = { onPreviewSelectionChange(it) }) 103 | } 104 | } 105 | 106 | @Composable 107 | inline fun > SettingsChoicePopup( 108 | visible: Boolean, 109 | noinline title: @Composable () -> Unit, 110 | defaultValue: T, 111 | noinline onClose: () -> Unit, 112 | noinline onSelection: (T) -> Unit, 113 | noinline onPreviewSelection: (T) -> Unit = {}, 114 | ) { 115 | var selected by rememberSaveable { mutableStateOf(defaultValue) } 116 | AnimatedVisibility(visible = visible) { 117 | AlertDialog(onDismissRequest = { 118 | onClose() 119 | if (selected != defaultValue) { 120 | selected = defaultValue 121 | onSelection(defaultValue) 122 | } 123 | }, title = title, text = { 124 | Column { 125 | enumValues().forEach { e -> 126 | Row( 127 | Modifier 128 | .fillMaxWidth() 129 | .clickable { 130 | selected = e 131 | onPreviewSelection(e) 132 | }, verticalAlignment = Alignment.CenterVertically 133 | ) { 134 | RadioButton(selected = e == selected, onClick = { 135 | selected = e 136 | onPreviewSelection(e) 137 | }) 138 | Spacer(Modifier.width(8.dp)) 139 | Text( 140 | e.toString(), 141 | style = MaterialTheme.typography.bodyLarge 142 | ) 143 | } 144 | } 145 | } 146 | }, confirmButton = { 147 | TextButton(onClick = { onClose(); onSelection(selected) }) { 148 | Text(stringResource(R.string.confirm)) 149 | } 150 | }) 151 | } 152 | } 153 | 154 | @Composable 155 | fun SettingsItem( 156 | modifier: Modifier = Modifier, 157 | icon: @Composable (() -> Unit)? = null, 158 | text: @Composable () -> Unit, 159 | secondaryText: @Composable (() -> Unit) = { }, 160 | trailing: @Composable (() -> Unit) = { }, 161 | ) { 162 | ListItem( 163 | modifier = modifier, 164 | leadingContent = icon, 165 | headlineContent = text, 166 | supportingContent = secondaryText, 167 | trailingContent = trailing, 168 | ) 169 | } 170 | 171 | @OptIn(ExperimentalMaterial3Api::class) 172 | @Composable 173 | fun Subpage( 174 | title: String, 175 | navController: NavController, 176 | content: @Composable () -> Unit 177 | ) { 178 | Scaffold(topBar = { 179 | TopAppBar(title = { 180 | Row( 181 | verticalAlignment = Alignment.CenterVertically, 182 | ) { 183 | IconButton(onClick = { 184 | navController.popBackStack() 185 | }) { 186 | Icon( 187 | imageVector = Icons.Filled.ArrowBack, 188 | contentDescription = stringResource(R.string.back) 189 | ) 190 | } 191 | Text(title) 192 | } 193 | }) 194 | }) { scaffoldPadding -> 195 | Box( 196 | Modifier 197 | .padding(scaffoldPadding) 198 | .fillMaxSize() 199 | ) { 200 | content() 201 | } 202 | } 203 | } -------------------------------------------------------------------------------- /app/src/main/java/com/chouten/app/ui/components/Snackbar.kt: -------------------------------------------------------------------------------- 1 | package com.chouten.app.ui.components 2 | 3 | import androidx.compose.foundation.layout.padding 4 | import androidx.compose.material.icons.Icons 5 | import androidx.compose.material.icons.filled.Close 6 | import androidx.compose.material3.ButtonDefaults 7 | import androidx.compose.material3.Icon 8 | import androidx.compose.material3.MaterialTheme 9 | import androidx.compose.material3.SnackbarHost 10 | import androidx.compose.material3.SnackbarHostState 11 | import androidx.compose.material3.Text 12 | import androidx.compose.material3.TextButton 13 | import androidx.compose.material3.surfaceColorAtElevation 14 | import androidx.compose.runtime.Composable 15 | import androidx.compose.ui.Modifier 16 | import androidx.compose.ui.graphics.Color 17 | import androidx.compose.ui.platform.LocalLifecycleOwner 18 | import androidx.compose.ui.unit.dp 19 | import androidx.lifecycle.lifecycleScope 20 | import com.chouten.app.PrimaryDataLayer 21 | import com.chouten.app.data.SnackbarVisualsWithError 22 | import com.chouten.app.ui.theme.shapes 23 | import kotlinx.coroutines.launch 24 | import androidx.compose.material3.Snackbar as MaterialSnackbar 25 | 26 | @Composable 27 | fun Snackbar() { 28 | val snackbarState = SnackbarHostState() 29 | 30 | // Observe the Flow of the Snackbar Queue 31 | val owner = LocalLifecycleOwner.current 32 | PrimaryDataLayer.snackbarQueue.observe(owner) { 33 | it.iterator().forEach { snackbarItem -> 34 | owner.lifecycleScope.launch { 35 | snackbarState.showSnackbar( 36 | snackbarItem 37 | ) 38 | } 39 | PrimaryDataLayer.popSnackbarQueue() 40 | } 41 | } 42 | 43 | SnackbarHost(hostState = snackbarState) { data -> 44 | val extendedVisuals = data.visuals as? SnackbarVisualsWithError 45 | val isError = 46 | extendedVisuals?.isError 47 | ?: false 48 | val buttonColor = if (isError) { 49 | ButtonDefaults.buttonColors( 50 | containerColor = MaterialTheme.colorScheme.error, 51 | contentColor = MaterialTheme.colorScheme.onError 52 | ) 53 | } else { 54 | ButtonDefaults.buttonColors( 55 | containerColor = Color.Transparent, 56 | contentColor = MaterialTheme.colorScheme.primary 57 | ) 58 | } 59 | 60 | MaterialSnackbar( 61 | modifier = Modifier.padding(12.dp), 62 | containerColor = if (!isError) { 63 | MaterialTheme.colorScheme.surfaceColorAtElevation( 64 | 10.dp 65 | ) 66 | } else { 67 | MaterialTheme.colorScheme.error 68 | }, 69 | contentColor = if (!isError) { 70 | MaterialTheme.colorScheme.onSurface 71 | } else { 72 | MaterialTheme.colorScheme.onError 73 | }, 74 | action = { 75 | TextButton( 76 | onClick = { 77 | extendedVisuals?.customButton?.action?.invoke() 78 | ?: if (isError) data.dismiss() else data.performAction() 79 | }, 80 | shape = shapes.extraSmall, 81 | colors = buttonColor 82 | ) { 83 | extendedVisuals?.customButton?.actionText?.let { 84 | Text( 85 | it 86 | ) 87 | } ?: extendedVisuals?.buttonText?.let { 88 | Text( 89 | it 90 | ) 91 | } ?: Icon(Icons.Default.Close, "Dismiss") 92 | } 93 | } 94 | ) { 95 | Text( 96 | text = data.visuals.message, 97 | maxLines = 8 98 | ) 99 | } 100 | } 101 | } -------------------------------------------------------------------------------- /app/src/main/java/com/chouten/app/ui/theme/Colors.kt: -------------------------------------------------------------------------------- 1 | package com.chouten.app.ui.theme 2 | 3 | import androidx.compose.ui.graphics.Color 4 | 5 | val md_light_primary = Color(0xFF31628D) 6 | val md_light_onPrimary = Color(0xFFFFFFFF) 7 | val md_light_primaryContainer = Color(0xFFCFE5FF) 8 | val md_light_onPrimaryContainer = Color(0xFF001D34) 9 | val md_light_inversePrimary = Color(0xFF99CBFF) 10 | val md_light_secondary = Color(0xFF515E7D) 11 | val md_light_onSecondary = Color(0xFFFFFFFF) 12 | val md_light_secondaryContainer = Color(0xFFD5E4F7) 13 | val md_light_onSecondaryContainer = Color(0xFF0E1D2A) 14 | val md_light_tertiary = Color(0xFF695779) 15 | val md_light_onTertiary = Color(0xFFFFFFFF) 16 | val md_light_tertiaryContainer = Color(0xFFF0DBFF) 17 | val md_light_onTertiaryContainer = Color(0xFF231533) 18 | val md_light_background = Color(0xFFFCFCFF) 19 | val md_light_onBackground = Color(0xFF1A1C1E) 20 | val md_light_surface = Color(0xFFFCFCFF) 21 | val md_light_onSurface = Color(0xFF1A1C1E) 22 | val md_light_surfaceVariant = Color(0xFFDEE3EB) 23 | val md_light_onSurfaceVariant = Color(0xFF42474E) 24 | val md_light_surfaceTint = Color(0xFF31628D) 25 | val md_light_inverseSurface = Color(0xFF2F3033) 26 | val md_light_inverseOnSurface = Color(0xFFF1F0F4) 27 | val md_light_error = Color(0xFFB3261E) 28 | val md_light_onError = Color(0xFFFFFFFF) 29 | val md_light_errorContainer = Color(0xFFF9DEDC) 30 | val md_light_onErrorContainer = Color(0xFF410E0B) 31 | val md_light_outline = Color(0xFF71767E) 32 | val md_light_outlineVariant = Color(0xFFCAC4D0) 33 | val md_light_scrim = Color(0xFF000000) 34 | 35 | val md_dark_primary = Color(0xFF9DCBFB) 36 | val md_dark_onPrimary = Color(0xFF003354) 37 | val md_dark_primaryContainer = Color(0xFF124A73) 38 | val md_dark_onPrimaryContainer = Color(0xFFCFE5FF) 39 | val md_dark_inversePrimary = Color(0xFF31628D) 40 | val md_dark_secondary = Color(0xFFB9C8DA) 41 | val md_dark_onSecondary = Color(0xFF243240) 42 | val md_dark_secondaryContainer = Color(0xFF3A4857) 43 | val md_dark_onSecondaryContainer = Color(0xFFD5E4F7) 44 | val md_dark_tertiary = Color(0xFFD4BEE6) 45 | val md_dark_onTertiary = Color(0xFF392A49) 46 | val md_dark_tertiaryContainer = Color(0xFF504060) 47 | val md_dark_onTertiaryContainer = Color(0xFFF0DBFF) 48 | val md_dark_background = Color(0xFF1A1C1E) 49 | val md_dark_onBackground = Color(0xFFE2E2E5) 50 | val md_dark_surface = Color(0xFF1A1C1E) 51 | val md_dark_onSurface = Color(0xFFE2E2E5) 52 | val md_dark_surfaceVariant = Color(0xFF42474E) 53 | val md_dark_onSurfaceVariant = Color(0xFFC2C7CF) 54 | val md_dark_surfaceTint = Color(0xFF9DCBFB) 55 | val md_dark_inverseSurface = Color(0xFFE2E2E5) 56 | val md_dark_inverseOnSurface = Color(0xFF2F3033) 57 | val md_dark_error = Color(0xFFF2B8B5) 58 | val md_dark_onError = Color(0xFF601410) 59 | val md_dark_errorContainer = Color(0xFF8C1D18) 60 | val md_dark_onErrorContainer = Color(0xFFF9DEDC) 61 | val md_dark_outline = Color(0xFF8C9199) 62 | val md_dark_outlineVariant = Color(0xFF49454F) 63 | val md_dark_scrim = Color(0xFF000000) -------------------------------------------------------------------------------- /app/src/main/java/com/chouten/app/ui/theme/Modifiers.kt: -------------------------------------------------------------------------------- 1 | package com.chouten.app.ui.theme 2 | 3 | import androidx.compose.ui.Modifier 4 | import androidx.compose.ui.composed 5 | import androidx.compose.ui.draw.drawWithCache 6 | import androidx.compose.ui.geometry.CornerRadius 7 | import androidx.compose.ui.graphics.Color 8 | import androidx.compose.ui.graphics.PathEffect 9 | import androidx.compose.ui.graphics.drawscope.Stroke 10 | import androidx.compose.ui.platform.LocalDensity 11 | import androidx.compose.ui.unit.Dp 12 | 13 | // https://stackoverflow.com/questions/66427587/how-to-have-dashed-border-in-jetpack-compose 14 | fun Modifier.dashedBorder( 15 | strokeWidth: Dp, 16 | color: Color, 17 | cornerRadius: Dp, 18 | interval: Float = 5f 19 | ) = 20 | composed { 21 | val density = LocalDensity.current 22 | val strokeWidthPx = density.run { strokeWidth.toPx() } 23 | val cornerRadiusPx = density.run { cornerRadius.toPx() } 24 | 25 | this.then( 26 | Modifier.drawWithCache { 27 | onDrawBehind { 28 | val stroke = 29 | Stroke( 30 | width = strokeWidthPx, 31 | pathEffect = 32 | PathEffect.dashPathEffect( 33 | floatArrayOf(100 / interval, 100 / interval), 0f 34 | ) 35 | ) 36 | 37 | drawRoundRect( 38 | style = stroke, 39 | color = color, 40 | cornerRadius = CornerRadius(cornerRadiusPx) 41 | ) 42 | } 43 | }) 44 | } 45 | -------------------------------------------------------------------------------- /app/src/main/java/com/chouten/app/ui/theme/Shapes.kt: -------------------------------------------------------------------------------- 1 | package com.chouten.app.ui.theme 2 | 3 | import androidx.compose.foundation.shape.RoundedCornerShape 4 | import androidx.compose.material3.Shapes 5 | import androidx.compose.ui.unit.dp 6 | 7 | val shapes = Shapes( 8 | extraSmall = RoundedCornerShape(4.dp), 9 | small = RoundedCornerShape(8.dp), 10 | medium = RoundedCornerShape(16.dp), 11 | large = RoundedCornerShape(24.dp), 12 | extraLarge = RoundedCornerShape(32.dp) 13 | ) -------------------------------------------------------------------------------- /app/src/main/java/com/chouten/app/ui/theme/Theme.kt: -------------------------------------------------------------------------------- 1 | package com.chouten.app.ui.theme 2 | 3 | import android.app.Activity 4 | import android.content.Context 5 | import android.content.res.Configuration 6 | import android.os.Build 7 | import androidx.compose.animation.animateColorAsState 8 | import androidx.compose.animation.core.CubicBezierEasing 9 | import androidx.compose.animation.core.tween 10 | import androidx.compose.material3.ColorScheme 11 | import androidx.compose.material3.MaterialTheme 12 | import androidx.compose.material3.darkColorScheme 13 | import androidx.compose.material3.dynamicDarkColorScheme 14 | import androidx.compose.material3.dynamicLightColorScheme 15 | import androidx.compose.material3.lightColorScheme 16 | import androidx.compose.runtime.Composable 17 | import androidx.compose.runtime.SideEffect 18 | import androidx.compose.runtime.remember 19 | import androidx.compose.ui.graphics.Color 20 | import androidx.compose.ui.graphics.luminance 21 | import androidx.compose.ui.graphics.toArgb 22 | import androidx.compose.ui.platform.LocalContext 23 | import androidx.compose.ui.platform.LocalView 24 | import androidx.core.view.ViewCompat 25 | import com.chouten.app.data.AppThemeType 26 | import com.chouten.app.preferenceHandler 27 | 28 | private val darkColorScheme = 29 | darkColorScheme( 30 | primary = md_dark_primary, 31 | onPrimary = md_dark_onPrimary, 32 | primaryContainer = md_dark_primaryContainer, 33 | onPrimaryContainer = md_dark_onPrimaryContainer, 34 | inversePrimary = md_dark_inversePrimary, 35 | secondary = md_dark_secondary, 36 | onSecondary = md_dark_onSecondary, 37 | secondaryContainer = md_dark_secondaryContainer, 38 | onSecondaryContainer = md_dark_onSecondaryContainer, 39 | tertiary = md_dark_tertiary, 40 | onTertiary = md_dark_onTertiary, 41 | tertiaryContainer = md_dark_tertiaryContainer, 42 | onTertiaryContainer = md_dark_onTertiaryContainer, 43 | background = md_dark_background, 44 | onBackground = md_dark_onBackground, 45 | surface = md_dark_surface, 46 | onSurface = md_dark_onSurface, 47 | surfaceVariant = md_dark_surfaceVariant, 48 | onSurfaceVariant = md_dark_onSurfaceVariant, 49 | surfaceTint = md_dark_surfaceTint, 50 | inverseSurface = md_dark_inverseSurface, 51 | inverseOnSurface = md_dark_inverseOnSurface, 52 | error = md_dark_error, 53 | onError = md_dark_onError, 54 | errorContainer = md_dark_errorContainer, 55 | onErrorContainer = md_dark_onErrorContainer, 56 | outline = md_dark_outline, 57 | outlineVariant = md_dark_outlineVariant, 58 | scrim = md_dark_scrim, 59 | ) 60 | 61 | private val lightColorScheme = 62 | lightColorScheme( 63 | primary = md_light_primary, 64 | onPrimary = md_light_onPrimary, 65 | primaryContainer = md_light_primaryContainer, 66 | onPrimaryContainer = md_light_onPrimaryContainer, 67 | inversePrimary = md_light_inversePrimary, 68 | secondary = md_light_secondary, 69 | onSecondary = md_light_onSecondary, 70 | secondaryContainer = md_light_secondaryContainer, 71 | onSecondaryContainer = md_light_onSecondaryContainer, 72 | tertiary = md_light_tertiary, 73 | onTertiary = md_light_onTertiary, 74 | tertiaryContainer = md_light_tertiaryContainer, 75 | onTertiaryContainer = md_light_onTertiaryContainer, 76 | background = md_light_background, 77 | onBackground = md_light_onBackground, 78 | surface = md_light_surface, 79 | onSurface = md_light_onSurface, 80 | surfaceVariant = md_light_surfaceVariant, 81 | onSurfaceVariant = md_light_onSurfaceVariant, 82 | surfaceTint = md_light_surfaceTint, 83 | inverseSurface = md_light_inverseSurface, 84 | inverseOnSurface = md_light_inverseOnSurface, 85 | error = md_light_error, 86 | onError = md_light_onError, 87 | errorContainer = md_light_errorContainer, 88 | onErrorContainer = md_light_onErrorContainer, 89 | outline = md_light_outline, 90 | outlineVariant = md_light_outlineVariant, 91 | scrim = md_light_scrim, 92 | ) 93 | 94 | fun isDarkTheme(context: Context): Boolean { 95 | val uiMode = context.applicationContext.resources.configuration.uiMode 96 | val uiDark = Configuration.UI_MODE_NIGHT_YES 97 | return when (preferenceHandler.themeType) { 98 | AppThemeType.SYSTEM -> uiMode.and(Configuration.UI_MODE_NIGHT_MASK) == uiDark 99 | AppThemeType.DARK -> true 100 | else -> false 101 | } 102 | } 103 | 104 | @Composable 105 | fun ChoutenTheme( 106 | darkTheme: Boolean = isDarkTheme(LocalContext.current), 107 | oledTheme: Boolean = preferenceHandler.isOledTheme, 108 | // Dynamic color is available on Android 12+ 109 | dynamicColor: Boolean = Build.VERSION.SDK_INT >= Build.VERSION_CODES.S, 110 | content: @Composable () -> Unit 111 | ) { 112 | val colorScheme = 113 | when { 114 | preferenceHandler.isDynamicColor && dynamicColor && Build.VERSION.SDK_INT >= Build.VERSION_CODES.S -> { 115 | val context = LocalContext.current 116 | if (darkTheme) dynamicDarkColorScheme(context) else dynamicLightColorScheme( 117 | context 118 | ) 119 | } 120 | 121 | darkTheme -> darkColorScheme 122 | else -> lightColorScheme 123 | }.let { 124 | if (darkTheme && oledTheme) it.toOled() else it 125 | } 126 | val view = LocalView.current 127 | if (!view.isInEditMode) { 128 | SideEffect { 129 | val activity = view.context as? Activity 130 | activity?.window?.apply { 131 | navigationBarColor = Color.Transparent.toArgb() 132 | statusBarColor = Color.Transparent.toArgb() 133 | } 134 | // Set statusbar icons color considering the top app state banner 135 | val isIncognito = preferenceHandler.isIncognito 136 | val isOfflineMode = preferenceHandler.isOfflineMode 137 | val statusBarBackgroundColor = when { 138 | isOfflineMode -> colorScheme.tertiary 139 | isIncognito -> colorScheme.primary 140 | else -> colorScheme.surface 141 | } 142 | 143 | val isLightStatusBar = statusBarBackgroundColor.luminance() > 0.5 144 | ViewCompat.getWindowInsetsController(view)?.isAppearanceLightStatusBars = 145 | isLightStatusBar 146 | } 147 | } 148 | 149 | MaterialTheme( 150 | colorScheme = animate(colorScheme), 151 | typography = Typography, 152 | content = content 153 | ) 154 | } 155 | 156 | @Composable 157 | private fun animate(colors: ColorScheme): ColorScheme { 158 | val animSpec = remember { 159 | tween(durationMillis = 300, easing = CubicBezierEasing(0.2f, 0f, 0f, 1f)) 160 | } 161 | 162 | @Composable 163 | fun animateColor(color: Color): Color = 164 | animateColorAsState( 165 | targetValue = color, 166 | animationSpec = animSpec, 167 | label = "Theme Color Transition" 168 | ).value 169 | 170 | return ColorScheme( 171 | primary = animateColor(colors.primary), 172 | onPrimary = animateColor(colors.onPrimary), 173 | primaryContainer = animateColor(colors.primaryContainer), 174 | onPrimaryContainer = animateColor(colors.onPrimaryContainer), 175 | inversePrimary = animateColor(colors.inversePrimary), 176 | secondary = animateColor(colors.secondary), 177 | onSecondary = animateColor(colors.onSecondary), 178 | secondaryContainer = animateColor(colors.secondaryContainer), 179 | onSecondaryContainer = animateColor(colors.onSecondaryContainer), 180 | tertiary = animateColor(colors.tertiary), 181 | onTertiary = animateColor(colors.onTertiary), 182 | tertiaryContainer = animateColor(colors.tertiaryContainer), 183 | onTertiaryContainer = animateColor(colors.onTertiaryContainer), 184 | background = animateColor(colors.background), 185 | onBackground = animateColor(colors.onBackground), 186 | surface = animateColor(colors.surface), 187 | onSurface = animateColor(colors.onSurface), 188 | surfaceVariant = animateColor(colors.surfaceVariant), 189 | onSurfaceVariant = animateColor(colors.onSurfaceVariant), 190 | surfaceTint = animateColor(colors.surfaceTint), 191 | inverseSurface = animateColor(colors.inverseSurface), 192 | inverseOnSurface = animateColor(colors.inverseOnSurface), 193 | error = animateColor(colors.error), 194 | onError = animateColor(colors.onError), 195 | errorContainer = animateColor(colors.errorContainer), 196 | onErrorContainer = animateColor(colors.onErrorContainer), 197 | outline = animateColor(colors.outline), 198 | outlineVariant = animateColor(colors.outlineVariant), 199 | scrim = animateColor(colors.scrim) 200 | ) 201 | } 202 | 203 | private fun ColorScheme.toOled() = this.copy( 204 | surface = Color.Black, 205 | background = Color.Black 206 | ) 207 | -------------------------------------------------------------------------------- /app/src/main/java/com/chouten/app/ui/theme/Type.kt: -------------------------------------------------------------------------------- 1 | package com.chouten.app.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/chouten/app/ui/views/home/HomePageViewModel.kt: -------------------------------------------------------------------------------- 1 | package com.chouten.app.ui.views.home 2 | 3 | import android.content.Context 4 | import android.os.Handler 5 | import android.os.Looper 6 | import android.util.Log 7 | import androidx.compose.runtime.getValue 8 | import androidx.compose.runtime.mutableStateListOf 9 | import androidx.compose.runtime.mutableStateOf 10 | import androidx.compose.runtime.setValue 11 | import androidx.lifecycle.ViewModel 12 | import androidx.lifecycle.viewModelScope 13 | import com.chouten.app.Mapper 14 | import com.chouten.app.ModuleLayer 15 | import com.chouten.app.PrimaryDataLayer 16 | import com.chouten.app.data.HomeResult 17 | import com.chouten.app.data.ModuleModel 18 | import com.chouten.app.data.ModuleResponse 19 | import com.chouten.app.data.SnackbarVisualsWithError 20 | import com.chouten.app.data.WebviewHandler 21 | import com.chouten.app.data.ErrorAction 22 | import com.chouten.app.data.ModuleAction 23 | import kotlinx.coroutines.launch 24 | import com.chouten.app.data.HomepagePayload 25 | 26 | class HomePageViewModel(context: Context, private val webview: WebviewHandler = WebviewHandler()) : 27 | ViewModel() { 28 | 29 | var isLoading by mutableStateOf(false) 30 | private set 31 | 32 | private val _homeResults = mutableStateListOf() 33 | val homeResults: List 34 | get() = _homeResults 35 | 36 | private val _loadedModule = mutableStateOf(null) 37 | val loadedModule: ModuleModel? 38 | get() = _loadedModule.value 39 | 40 | var errors = 0 41 | 42 | init { 43 | webview.initialize(context) 44 | webview.dontCloseOnError() 45 | webview.setCallback(this::callback) 46 | } 47 | 48 | // This is called by the webview 49 | // when it's done calling the logic function 50 | fun callback(message: String) { 51 | 52 | val action = Mapper.parse(message).action 53 | 54 | try { 55 | if(action == "error"){ 56 | val error = Mapper.parse(message) 57 | throw Exception(error.result) 58 | } 59 | 60 | val results = Mapper.parse>>(message) 61 | 62 | _homeResults.clear() 63 | _homeResults.addAll(results.result) 64 | } catch (e: Exception) { 65 | errors += 1 66 | PrimaryDataLayer.enqueueSnackbar( 67 | SnackbarVisualsWithError( 68 | e.localizedMessage ?: "Error parsing home page results.", 69 | isError = true 70 | ) 71 | ) 72 | e.printStackTrace() 73 | } 74 | 75 | isLoading = false 76 | } 77 | 78 | private val handler = Handler(Looper.getMainLooper()) 79 | 80 | // TODO: unify with init 81 | fun initialize(shouldReset: Boolean = false) { 82 | if(shouldReset){ 83 | errors = 0 84 | } 85 | 86 | _homeResults.clear() 87 | viewModelScope.launch { loadHomePage() } 88 | } 89 | 90 | fun getCode(): String{ 91 | val currentModule = ModuleLayer.selectedModule ?: throw Exception("No module selected") 92 | val subtype = currentModule.subtypes.getOrNull(0) ?: throw Exception("Subtype not found") 93 | return currentModule.code?.get(subtype)?.home?.getOrNull(0)?.code ?: throw Exception("Code not found") 94 | } 95 | 96 | private suspend fun loadHomePage() { 97 | _loadedModule.value = ModuleLayer.selectedModule 98 | 99 | isLoading = true 100 | val code = this.getCode() 101 | 102 | val webviewPayload = HomepagePayload( 103 | action = "homepage" 104 | ) 105 | 106 | webview.load( 107 | code, 108 | Mapper.json.encodeToString(HomepagePayload.serializer(), webviewPayload) 109 | ) 110 | 111 | } 112 | } 113 | -------------------------------------------------------------------------------- /app/src/main/java/com/chouten/app/ui/views/info/InfoPageViewModel.kt: -------------------------------------------------------------------------------- 1 | package com.chouten.app.ui.views.info 2 | 3 | import android.content.Context 4 | import androidx.compose.runtime.getValue 5 | import androidx.compose.runtime.mutableIntStateOf 6 | import androidx.compose.runtime.mutableStateOf 7 | import androidx.compose.runtime.setValue 8 | import androidx.lifecycle.ViewModel 9 | import androidx.lifecycle.viewModelScope 10 | import com.chouten.app.Mapper 11 | import com.chouten.app.ModuleLayer 12 | import com.chouten.app.PrimaryDataLayer 13 | import com.chouten.app.data.ErrorAction 14 | import com.chouten.app.data.InfoResult 15 | import com.chouten.app.data.ModuleAction 16 | import com.chouten.app.data.ModuleResponse 17 | import com.chouten.app.data.SnackbarVisualsWithError 18 | import com.chouten.app.data.WebviewHandler 19 | import com.chouten.app.data.WebviewPayload 20 | import kotlinx.coroutines.launch 21 | import kotlinx.coroutines.sync.Mutex 22 | import java.net.URLDecoder 23 | 24 | class InfoPageViewModel(context: Context, private val url: String, private var title: String) : 25 | ViewModel() { 26 | 27 | private val webview = WebviewHandler() 28 | 29 | private val syncLock = Mutex(false) 30 | 31 | private var hasLoadedInfo by mutableStateOf(false) 32 | val hasLoadedInfoText: Boolean 33 | get() = hasLoadedInfo 34 | 35 | private var hasLoadedMediaEpisodes by mutableStateOf(false) 36 | val hasLoadedEpisodes: Boolean 37 | get() = hasLoadedMediaEpisodes 38 | 39 | private var altTitles by mutableStateOf(listOf()) 40 | val altTitlesText: List 41 | get() = altTitles 42 | 43 | private var description by mutableStateOf("") 44 | val descriptionText: String 45 | get() = description 46 | 47 | private var thumbnail by mutableStateOf("") 48 | val thumbnailUrl: String 49 | get() = thumbnail 50 | 51 | private var banner by mutableStateOf("") 52 | val bannerUrl: String 53 | get() = banner 54 | 55 | private var status by mutableStateOf("") 56 | val statusText: String 57 | get() = status 58 | 59 | private var mediaCount by mutableIntStateOf(0) 60 | val mediaCountText: Int 61 | get() = mediaCount 62 | 63 | private var mediaType by mutableStateOf("") 64 | val mediaTypeText: String 65 | get() = mediaType 66 | 67 | private var infoResult by mutableStateOf(listOf()) 68 | val infoResults: List 69 | get() = infoResult 70 | 71 | private var _seasons by mutableStateOf(listOf()) 72 | val seasons: List 73 | get() = _seasons 74 | 75 | private var selectedSeason by mutableIntStateOf(0) 76 | val selectedSeasonText: Int 77 | get() = selectedSeason 78 | 79 | fun getCode(): String{ 80 | val currentModule = ModuleLayer.selectedModule ?: throw Exception("No module selected") 81 | val subtype = currentModule.subtypes.getOrNull(0) ?: throw Exception("Subtype not found") 82 | return currentModule.code?.get(subtype)?.info?.getOrNull(0)?.code ?: throw Exception("Code not found") 83 | } 84 | 85 | fun callback(message: String) { 86 | val title = "" 87 | 88 | if (message.isBlank()) { 89 | PrimaryDataLayer.enqueueSnackbar( 90 | SnackbarVisualsWithError("No results found for $title", false) 91 | ) 92 | } 93 | 94 | val action = Mapper.parse(message).action 95 | 96 | try{ 97 | if(action == "error"){ 98 | val error = Mapper.parse(message) 99 | throw Exception(error.result) 100 | } 101 | 102 | when(action){ 103 | "metadata" -> { 104 | try { 105 | val results = Mapper.parse>(message) 106 | 107 | val result = results.result 108 | altTitles = result.altTitles ?: listOf() 109 | description = result.description 110 | thumbnail = result.poster 111 | banner = result.banner ?: "" 112 | status = result.status ?: "" 113 | mediaCount = result.totalMediaCount ?: 0 114 | mediaType = result.mediaType 115 | hasLoadedInfo = true 116 | 117 | 118 | val epListURL = result.seasons?.getOrNull( 119 | selectedSeason 120 | )?.url ?: result.epListURLs.firstOrNull() ?: "" 121 | 122 | val webviewPayload = WebviewPayload( 123 | query = epListURL, 124 | action = "eplist" 125 | ) 126 | 127 | val code = getCode() 128 | 129 | viewModelScope.launch { 130 | webview.load( 131 | code, 132 | Mapper.json.encodeToString(WebviewPayload.serializer(), webviewPayload) 133 | ) 134 | } 135 | 136 | } catch (e: Exception) { 137 | e.printStackTrace() 138 | PrimaryDataLayer.enqueueSnackbar( 139 | SnackbarVisualsWithError("Error parsing results for $title", false) 140 | ) 141 | } 142 | } 143 | "eplist" -> { 144 | try { 145 | val results = Mapper.parse>>(message) 146 | infoResult = results.result 147 | _seasons = infoResult.map { 148 | InfoResult.Season( 149 | it.title, 150 | it.list.firstOrNull()?.url ?: "" 151 | ) 152 | }.distinctBy { it.url } 153 | hasLoadedMediaEpisodes = true 154 | } catch (e: Exception) { 155 | PrimaryDataLayer.enqueueSnackbar( 156 | SnackbarVisualsWithError( 157 | "Error parsing second results for $title", 158 | false 159 | ) 160 | ) 161 | println("Parsing error: $e") 162 | throw Exception("Error parsing results for $title") 163 | } 164 | } 165 | else -> { 166 | throw Exception("Action not found. The action must be either set to 'eplist' or 'metadata'") 167 | } 168 | } 169 | }catch(e: Exception){ 170 | PrimaryDataLayer.enqueueSnackbar( 171 | SnackbarVisualsWithError( 172 | e.localizedMessage ?: "Error parsing home page results.", 173 | isError = true 174 | ) 175 | ) 176 | e.printStackTrace() 177 | } 178 | } 179 | 180 | init { 181 | // Both title and url are url-encoded. 182 | title = URLDecoder.decode(title, "UTF-8") 183 | val decodedUrl = URLDecoder.decode(url, "UTF-8") 184 | 185 | // We want to get the info code from the webview handler 186 | // and then load the page with that code. 187 | webview.initialize(context) 188 | webview.setCallback(this::callback) 189 | 190 | val code = getCode() 191 | val webviewPayload = WebviewPayload( 192 | query = decodedUrl, 193 | action = "metadata" 194 | ) 195 | 196 | viewModelScope.launch { 197 | webview.load(code, Mapper.json.encodeToString(WebviewPayload.serializer(), webviewPayload)) 198 | } 199 | 200 | } 201 | 202 | suspend fun changeSeason(context: Context, season: InfoResult.Season) { 203 | hasLoadedInfo = false 204 | hasLoadedMediaEpisodes = false 205 | selectedSeason = seasons.indexOfFirst { it == season } 206 | webview.destroy() 207 | webview.initialize(context) 208 | webview.setCallback(::callback) 209 | 210 | val code = getCode() 211 | val metadataPayload = WebviewPayload( 212 | query = url, 213 | action = "metadata" 214 | ) 215 | 216 | viewModelScope.launch { 217 | webview.load(code, Mapper.json.encodeToString(WebviewPayload.serializer(), metadataPayload)) 218 | } 219 | } 220 | 221 | fun getTitle(): String { 222 | return title 223 | } 224 | 225 | fun getUrl(): String { 226 | return url 227 | } 228 | 229 | override fun onCleared() { 230 | webview.destroy() 231 | super.onCleared() 232 | } 233 | } 234 | -------------------------------------------------------------------------------- /app/src/main/java/com/chouten/app/ui/views/search/SearchPageViewModel.kt: -------------------------------------------------------------------------------- 1 | package com.chouten.app.ui.views.search 2 | 3 | import android.content.Context 4 | import android.util.Log 5 | import androidx.compose.runtime.getValue 6 | import androidx.compose.runtime.mutableStateListOf 7 | import androidx.compose.runtime.mutableStateOf 8 | import androidx.compose.runtime.setValue 9 | import androidx.lifecycle.ViewModel 10 | import androidx.lifecycle.viewModelScope 11 | import com.chouten.app.LogLayer 12 | import com.chouten.app.Mapper 13 | import com.chouten.app.ModuleLayer 14 | import com.chouten.app.PrimaryDataLayer 15 | import com.chouten.app.data.LogEntry 16 | import com.chouten.app.data.ModuleResponse 17 | import com.chouten.app.data.SearchResult 18 | import com.chouten.app.data.SnackbarVisualsWithError 19 | import com.chouten.app.data.WebviewHandler 20 | import com.chouten.app.data.ModuleAction 21 | import com.chouten.app.data.ErrorAction 22 | import com.chouten.app.data.WebviewPayload 23 | import kotlinx.coroutines.FlowPreview 24 | import kotlinx.coroutines.Job 25 | import kotlinx.coroutines.flow.MutableStateFlow 26 | import kotlinx.coroutines.flow.collectLatest 27 | import kotlinx.coroutines.flow.debounce 28 | import kotlinx.coroutines.flow.distinctUntilChanged 29 | import kotlinx.coroutines.launch 30 | 31 | class SearchPageViewModel( 32 | context: Context, 33 | private val webview: WebviewHandler = WebviewHandler() 34 | ) : ViewModel() { 35 | var isSearching by mutableStateOf(false) 36 | private set 37 | 38 | // We want to keep track of the search job so that 39 | // we can cancel it if the search query changes. 40 | private var searchJob: Job? = null 41 | 42 | // We want to use a flow for this so that we 43 | // can have a debounce on the search. 44 | // Every time the search query changes, we want to 45 | // wait 500ms before actually searching. 46 | private var _searchQuery = MutableStateFlow("") 47 | 48 | @OptIn(FlowPreview::class) 49 | var searchQuery: String = _searchQuery.value 50 | set(value) { 51 | field = value 52 | _searchQuery.value = value 53 | searchJob?.cancel() 54 | searchJob = 55 | viewModelScope.launch { 56 | _searchQuery.debounce(500).distinctUntilChanged().collectLatest { 57 | isSearching = true 58 | if (it.isBlank()) { 59 | _searchResults.clear() 60 | isSearching = false 61 | return@collectLatest 62 | } 63 | println("Searching for $it") 64 | _searchQuery.value = it 65 | search(it) 66 | } 67 | } 68 | } 69 | 70 | var previousSearchQuery by mutableStateOf("") 71 | private set // We don't want to be able to set this from outside the class. 72 | 73 | init { 74 | webview.initialize(context) 75 | webview.dontCloseOnError() 76 | webview.setCallback(this::callback) 77 | } 78 | 79 | fun getCode(): String{ 80 | val currentModule = ModuleLayer.selectedModule ?: throw Exception("No module selected") 81 | val subtype = currentModule.subtypes.getOrNull(0) ?: throw Exception("Subtype not found") 82 | return currentModule.code?.get(subtype)?.search?.getOrNull(0)?.code ?: throw Exception("Code not found") 83 | } 84 | 85 | // This is called by the webview 86 | // when it's done calling the logic function 87 | fun callback(message: String) { 88 | 89 | if (message.isBlank()) { 90 | PrimaryDataLayer.enqueueSnackbar( 91 | SnackbarVisualsWithError("No results found ", false) 92 | ) 93 | isSearching = false 94 | } 95 | 96 | _searchResults.clear() 97 | 98 | val action = Mapper.parse(message).action 99 | 100 | try { 101 | if(action == "error"){ 102 | val error = Mapper.parse(message) 103 | throw Exception(error.result) 104 | } 105 | 106 | val results = Mapper.parse>>(message) 107 | _searchResults.addAll(results.result) 108 | isSearching = false 109 | } catch (e: Exception) { 110 | PrimaryDataLayer.enqueueSnackbar( 111 | SnackbarVisualsWithError("Error parsing search results: " + e.message, true) 112 | ) 113 | e.printStackTrace() 114 | e.localizedMessage 115 | ?.let { 116 | LogEntry( 117 | title = "Error parsing search results", 118 | message = it, 119 | isError = true, 120 | ) 121 | } 122 | ?.let { LogLayer.addLogEntry(it) } 123 | isSearching = false 124 | } 125 | } 126 | 127 | private val _searchResults = mutableStateListOf() 128 | val searchResults: List 129 | get() = _searchResults 130 | 131 | private suspend fun search(query: String) { 132 | // Update the previous search query. 133 | // By setting it to the searchQuery value, 134 | // we can ensure that it is always up to date. 135 | previousSearchQuery = _searchQuery.value 136 | 137 | // We want to get the currently selected module and then 138 | // search for the query within that module. 139 | isSearching = true 140 | 141 | val code = getCode() 142 | 143 | if(!code.isEmpty()){ 144 | val webviewPayload = WebviewPayload( 145 | query = query, 146 | action = "search" 147 | ) 148 | 149 | webview.load(code, Mapper.json.encodeToString(WebviewPayload.serializer(), webviewPayload)) 150 | } 151 | } 152 | } 153 | -------------------------------------------------------------------------------- /app/src/main/java/com/chouten/app/ui/views/settings/MorePage.kt: -------------------------------------------------------------------------------- 1 | package com.chouten.app.ui.views.settings 2 | 3 | import android.annotation.SuppressLint 4 | import androidx.compose.foundation.clickable 5 | import androidx.compose.foundation.layout.Column 6 | import androidx.compose.foundation.layout.fillMaxSize 7 | import androidx.compose.foundation.layout.fillMaxWidth 8 | import androidx.compose.foundation.layout.padding 9 | import androidx.compose.material.icons.Icons 10 | import androidx.compose.material.icons.filled.ChevronRight 11 | import androidx.compose.material3.* 12 | import androidx.compose.runtime.Composable 13 | import androidx.compose.ui.Modifier 14 | import androidx.compose.ui.res.stringResource 15 | import androidx.compose.ui.unit.dp 16 | import androidx.navigation.NavController 17 | import com.chouten.app.R 18 | import com.chouten.app.data.Preferences 19 | import com.chouten.app.preferenceHandler 20 | import com.chouten.app.ui.Screen 21 | import com.chouten.app.ui.components.SettingsItem 22 | import com.chouten.app.ui.components.SettingsToggle 23 | import kotlinx.serialization.json.Json 24 | 25 | val json = Json { prettyPrint = true } 26 | 27 | @SuppressLint("UnusedMaterial3ScaffoldPaddingParameter") 28 | @OptIn(ExperimentalMaterial3Api::class) 29 | @Composable 30 | fun MorePage( 31 | navHost: NavController 32 | ) { 33 | Scaffold(topBar = { 34 | CenterAlignedTopAppBar( 35 | title = { 36 | Text( 37 | text = stringResource(R.string.navbar_more_header), 38 | style = MaterialTheme.typography.headlineMedium 39 | ) 40 | }, modifier = Modifier.fillMaxWidth() 41 | ) 42 | }) { 43 | Column( 44 | modifier = Modifier 45 | .fillMaxSize() 46 | .padding(it), 47 | ) { 48 | SettingsToggle( 49 | preference = Preferences.Settings.downloadedOnly, 50 | defaultValue = preferenceHandler.getBoolean( 51 | Preferences.Settings.downloadedOnly.preference.first, 52 | false 53 | ), 54 | onCheckedChange = { toggle -> 55 | preferenceHandler.isOfflineMode = toggle 56 | } 57 | ) 58 | SettingsToggle( 59 | preference = Preferences.Settings.incognito, 60 | defaultValue = preferenceHandler.getBoolean( 61 | Preferences.Settings.incognito.preference.first, 62 | false 63 | ), 64 | onCheckedChange = { toggle -> 65 | preferenceHandler.isIncognito = toggle 66 | } 67 | ) 68 | Divider(Modifier.padding(16.dp)) 69 | SettingsItem( 70 | modifier = Modifier.clickable { 71 | navHost.navigate(Screen.AppearancePage.route) 72 | }, 73 | icon = {}, 74 | text = { Text(stringResource(R.string.appearance__title)) }, 75 | secondaryText = { Text(stringResource(R.string.appearance__desc)) }, 76 | trailing = { 77 | Icon( 78 | Icons.Default.ChevronRight, 79 | stringResource(R.string.log__title) 80 | ) 81 | }, 82 | ) 83 | SettingsItem( 84 | modifier = Modifier.clickable { 85 | navHost.navigate(Screen.NetworkPage.route) 86 | }, 87 | icon = {}, 88 | text = { Text(stringResource(R.string.network__title)) }, 89 | secondaryText = { Text(stringResource(R.string.network__desc)) }, 90 | trailing = { 91 | Icon( 92 | Icons.Default.ChevronRight, 93 | stringResource(R.string.network__title) 94 | ) 95 | }, 96 | ) 97 | SettingsItem( 98 | modifier = Modifier.clickable { 99 | navHost.navigate(Screen.LogPage.route) 100 | }, 101 | icon = {}, 102 | text = { Text(stringResource(R.string.log__title)) }, 103 | secondaryText = { Text(stringResource(R.string.log__desc)) }, 104 | trailing = { 105 | Icon( 106 | Icons.Default.ChevronRight, 107 | stringResource(R.string.log__title) 108 | ) 109 | }, 110 | ) 111 | SettingsToggle( 112 | preference = Preferences.Settings.devMode, 113 | defaultValue = preferenceHandler.getBoolean( 114 | Preferences.Settings.devMode.preference.first, 115 | false 116 | ), 117 | onCheckedChange = { toggle -> 118 | preferenceHandler.isDevMode = toggle 119 | } 120 | ) 121 | } 122 | } 123 | } -------------------------------------------------------------------------------- /app/src/main/java/com/chouten/app/ui/views/settings/MorePageViewModel.kt: -------------------------------------------------------------------------------- 1 | package com.chouten.app.ui.views.settings 2 | 3 | import androidx.lifecycle.ViewModel 4 | 5 | class SettingsPageViewModel : ViewModel() -------------------------------------------------------------------------------- /app/src/main/java/com/chouten/app/ui/views/settings/screens/LogPage.kt: -------------------------------------------------------------------------------- 1 | package com.chouten.app.ui.views.settings.screens 2 | 3 | import androidx.compose.animation.animateContentSize 4 | import androidx.compose.foundation.clickable 5 | import androidx.compose.foundation.layout.Arrangement 6 | import androidx.compose.foundation.layout.Box 7 | import androidx.compose.foundation.layout.BoxWithConstraints 8 | import androidx.compose.foundation.layout.Column 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.layout.size 14 | import androidx.compose.foundation.lazy.LazyColumn 15 | import androidx.compose.foundation.lazy.items 16 | import androidx.compose.foundation.shape.CircleShape 17 | import androidx.compose.material.icons.Icons 18 | import androidx.compose.material.icons.filled.Clear 19 | import androidx.compose.material3.Card 20 | import androidx.compose.material3.CardDefaults 21 | import androidx.compose.material3.CircularProgressIndicator 22 | import androidx.compose.material3.FilledTonalButton 23 | import androidx.compose.material3.Icon 24 | import androidx.compose.material3.MaterialTheme 25 | import androidx.compose.material3.Text 26 | import androidx.compose.runtime.Composable 27 | import androidx.compose.runtime.getValue 28 | import androidx.compose.runtime.mutableStateOf 29 | import androidx.compose.runtime.remember 30 | import androidx.compose.runtime.setValue 31 | import androidx.compose.ui.Alignment 32 | import androidx.compose.ui.Modifier 33 | import androidx.compose.ui.draw.clip 34 | import androidx.compose.ui.layout.ContentScale 35 | import androidx.compose.ui.res.stringResource 36 | import androidx.compose.ui.text.font.FontWeight 37 | import androidx.compose.ui.unit.IntSize 38 | import androidx.compose.ui.unit.dp 39 | import com.chouten.app.R 40 | import com.chouten.app.data.LogDataLayer 41 | import com.chouten.app.data.LogEntry 42 | import com.skydoves.landscapist.ImageOptions 43 | import com.skydoves.landscapist.glide.GlideImage 44 | 45 | @Composable 46 | fun LogPage( 47 | provider: LogDataLayer 48 | ) { 49 | 50 | val logs = remember { 51 | provider.logEntries.asReversed() 52 | } 53 | 54 | Column( 55 | modifier = Modifier.animateContentSize(), 56 | horizontalAlignment = Alignment.CenterHorizontally, 57 | ) { 58 | // TODO: Not cut off the results horribly - floating bar? 59 | Row( 60 | modifier = Modifier 61 | .fillMaxWidth() 62 | .padding(16.dp, 16.dp), 63 | horizontalArrangement = Arrangement.spacedBy(8.dp), 64 | ) { 65 | FilledTonalButton( 66 | onClick = { provider.clearLogs() }, 67 | enabled = logs.isNotEmpty() 68 | ) { 69 | Row( 70 | horizontalArrangement = Arrangement.spacedBy(8.dp), 71 | verticalAlignment = Alignment.CenterVertically, 72 | ) { 73 | Icon( 74 | imageVector = Icons.Filled.Clear, 75 | contentDescription = stringResource(R.string.log__action_clear), 76 | ) 77 | Text( 78 | text = stringResource(R.string.log__action_clear), 79 | fontWeight = FontWeight.Bold 80 | ) 81 | } 82 | } 83 | } 84 | 85 | if (logs.isEmpty()) { 86 | Column( 87 | modifier = Modifier 88 | .fillMaxSize() 89 | .padding(16.dp, 0.dp), 90 | horizontalAlignment = Alignment.CenterHorizontally, 91 | verticalArrangement = Arrangement.Center 92 | ) { 93 | Text( 94 | text = stringResource(R.string.log__placeholder) 95 | ) 96 | } 97 | } else LazyColumn( 98 | modifier = Modifier 99 | .fillMaxSize() 100 | .padding(bottom = 8.dp), 101 | horizontalAlignment = Alignment.CenterHorizontally, 102 | verticalArrangement = Arrangement.spacedBy(8.dp) 103 | ) { 104 | items( 105 | logs 106 | ) { 107 | BoxWithConstraints( 108 | modifier = Modifier.padding(16.dp, 0.dp) 109 | ) { 110 | LogEntryCard(it) 111 | } 112 | } 113 | } 114 | } 115 | } 116 | 117 | @Composable 118 | fun LogEntryCard( 119 | entry: LogEntry 120 | ) { 121 | var isMessageExpanded by remember { 122 | mutableStateOf(false) 123 | } 124 | 125 | Card( 126 | colors = if (entry.isError) { 127 | CardDefaults.cardColors( 128 | containerColor = MaterialTheme.colorScheme.error 129 | ) 130 | } else CardDefaults.cardColors(), modifier = Modifier.fillMaxWidth() 131 | ) { 132 | Column( 133 | modifier = Modifier.padding(8.dp), 134 | verticalArrangement = Arrangement.SpaceBetween 135 | ) { 136 | Row( 137 | horizontalArrangement = Arrangement.SpaceBetween, 138 | verticalAlignment = Alignment.CenterVertically, 139 | modifier = Modifier 140 | .fillMaxWidth() 141 | .padding(bottom = 8.dp) 142 | ) { 143 | Row( 144 | horizontalArrangement = Arrangement.spacedBy(8.dp), 145 | verticalAlignment = Alignment.CenterVertically, 146 | ) { 147 | GlideImage( 148 | imageModel = { entry.module.meta.icon }, 149 | imageOptions = ImageOptions( 150 | contentScale = ContentScale.Fit, 151 | alignment = Alignment.Center, 152 | contentDescription = "Favicon for the ${entry.module.name} module", 153 | requestSize = IntSize(128, 128) 154 | ), 155 | loading = { 156 | Box(Modifier.matchParentSize()) { 157 | CircularProgressIndicator( 158 | Modifier.align( 159 | Alignment.Center 160 | ) 161 | ) 162 | } 163 | }, 164 | modifier = Modifier 165 | .size(36.dp) 166 | .clip(CircleShape), 167 | ) 168 | 169 | Column { 170 | Text( 171 | text = entry.title, 172 | style = MaterialTheme.typography.headlineSmall 173 | ) 174 | 175 | Text( 176 | text = entry.module.name, 177 | style = MaterialTheme.typography.labelMedium 178 | ) 179 | } 180 | } 181 | 182 | Text( 183 | text = entry.timestamp, 184 | style = MaterialTheme.typography.labelMedium 185 | ) 186 | } 187 | 188 | Text(modifier = Modifier 189 | .padding(8.dp) 190 | .clickable { 191 | isMessageExpanded = !isMessageExpanded 192 | } 193 | .animateContentSize(), 194 | text = entry.message, 195 | style = MaterialTheme.typography.bodyMedium, 196 | maxLines = if (isMessageExpanded) Int.MAX_VALUE else 4) 197 | } 198 | } 199 | } -------------------------------------------------------------------------------- /app/src/main/java/com/chouten/app/ui/views/settings/screens/NetworkPage.kt: -------------------------------------------------------------------------------- 1 | package com.chouten.app.ui.views.settings.screens 2 | 3 | import androidx.compose.foundation.layout.Column 4 | import androidx.compose.foundation.layout.fillMaxSize 5 | import androidx.compose.runtime.Composable 6 | import androidx.compose.ui.Modifier 7 | import com.chouten.app.App 8 | import com.chouten.app.data.CustomDNS 9 | import com.chouten.app.data.Preferences 10 | import com.chouten.app.initializeNetwork 11 | import com.chouten.app.preferenceHandler 12 | import com.chouten.app.ui.components.SettingsChoice 13 | 14 | @Composable 15 | fun NetworkPage() { 16 | Column( 17 | modifier = Modifier.fillMaxSize() 18 | ) { 19 | SettingsChoice( 20 | preference = Preferences.Settings.dns, 21 | onPreferenceChange = { updated -> 22 | preferenceHandler.dns = updated 23 | // We need to remake the client 24 | initializeNetwork(App.applicationContext) 25 | }, 26 | defaultValue = preferenceHandler.getEnum( 27 | Preferences.Settings.dns.preference.first, 28 | CustomDNS.NONE 29 | ), 30 | ) 31 | } 32 | } -------------------------------------------------------------------------------- /app/src/main/java/com/chouten/app/ui/views/settings/screens/appearance/AppearancePage.kt: -------------------------------------------------------------------------------- 1 | package com.chouten.app.ui.views.settings.screens.appearance 2 | 3 | import androidx.compose.animation.AnimatedVisibility 4 | import androidx.compose.foundation.clickable 5 | import androidx.compose.foundation.layout.Column 6 | import androidx.compose.foundation.text.KeyboardActions 7 | import androidx.compose.foundation.text.KeyboardOptions 8 | import androidx.compose.material.icons.Icons 9 | import androidx.compose.material.icons.filled.CopyAll 10 | import androidx.compose.material3.AlertDialog 11 | import androidx.compose.material3.Icon 12 | import androidx.compose.material3.MaterialTheme 13 | import androidx.compose.material3.OutlinedTextField 14 | import androidx.compose.material3.Text 15 | import androidx.compose.material3.TextButton 16 | import androidx.compose.runtime.Composable 17 | import androidx.compose.runtime.mutableStateOf 18 | import androidx.compose.runtime.saveable.rememberSaveable 19 | import androidx.compose.ui.Modifier 20 | import androidx.compose.ui.platform.LocalContext 21 | import androidx.compose.ui.platform.LocalFocusManager 22 | import androidx.compose.ui.res.stringResource 23 | import androidx.compose.ui.text.input.ImeAction 24 | import com.chouten.app.PrimaryDataLayer 25 | import com.chouten.app.R 26 | import com.chouten.app.data.AppThemeType 27 | import com.chouten.app.data.Preferences 28 | import com.chouten.app.data.SnackbarVisualsWithError 29 | import com.chouten.app.preferenceHandler 30 | import com.chouten.app.ui.components.SettingsChoice 31 | import com.chouten.app.ui.components.SettingsItem 32 | import com.chouten.app.ui.components.SettingsToggle 33 | 34 | 35 | @Composable 36 | fun AppearancePage() { 37 | val context = LocalContext.current 38 | val vm = AppearanceViewModel(context, MaterialTheme.colorScheme) 39 | 40 | val isNamePromptVisible = rememberSaveable { mutableStateOf(false) } 41 | val themeName = rememberSaveable { mutableStateOf("") } 42 | val labelText = rememberSaveable { mutableStateOf(R.string.export_theme__label) } 43 | 44 | val successMessage = stringResource(R.string.export_theme__success) 45 | 46 | val exportTheme = { 47 | labelText.value = R.string.export_theme__label 48 | if (vm.exportTheme(themeName.value)) { 49 | isNamePromptVisible.value = false 50 | PrimaryDataLayer.enqueueSnackbar( 51 | SnackbarVisualsWithError( 52 | message = successMessage, 53 | isError = false, 54 | // TODO: Add custom button 55 | // which opens the folder where the theme was exported 56 | ) 57 | ) 58 | } else { 59 | labelText.value = R.string.export_theme__error 60 | } 61 | } 62 | 63 | val focusManager = LocalFocusManager.current 64 | 65 | Column { 66 | 67 | AnimatedVisibility(visible = isNamePromptVisible.value) { 68 | // We want an Alert with a text field for the user to enter the name of the theme 69 | AlertDialog(onDismissRequest = { 70 | isNamePromptVisible.value = false 71 | }, title = { 72 | Text(text = stringResource(R.string.export_theme__title)) 73 | }, text = { 74 | OutlinedTextField( 75 | value = themeName.value, 76 | onValueChange = { themeName.value = it }, 77 | label = { 78 | Text( 79 | stringResource(R.string.export_theme__label), 80 | color = if (labelText.value == R.string.export_theme__error) { 81 | MaterialTheme.colorScheme.error 82 | } else { 83 | MaterialTheme.colorScheme.onSurface 84 | } 85 | ) 86 | }, 87 | shape = MaterialTheme.shapes.medium, 88 | singleLine = true, 89 | keyboardOptions = KeyboardOptions(imeAction = ImeAction.Done), 90 | keyboardActions = KeyboardActions(onDone = { 91 | exportTheme() 92 | focusManager.clearFocus() 93 | }) 94 | ) 95 | }, confirmButton = { 96 | TextButton(onClick = { 97 | exportTheme() 98 | }) { 99 | Text(text = stringResource(R.string.export_theme__title)) 100 | } 101 | }, dismissButton = { 102 | TextButton(onClick = { 103 | isNamePromptVisible.value = false 104 | }) { 105 | Text(text = stringResource(R.string.cancel)) 106 | } 107 | }) 108 | } 109 | 110 | SettingsToggle(preference = Preferences.Settings.dynamicColor, 111 | defaultValue = preferenceHandler.getBoolean( 112 | Preferences.Settings.dynamicColor.preference.first, true 113 | ), 114 | onCheckedChange = { toggle -> 115 | preferenceHandler.isDynamicColor = toggle 116 | }) 117 | SettingsToggle(preference = Preferences.Settings.oledTheme, 118 | defaultValue = preferenceHandler.getBoolean( 119 | Preferences.Settings.oledTheme.preference.first, false 120 | ), 121 | onCheckedChange = { toggle -> 122 | preferenceHandler.isOledTheme = toggle 123 | }) 124 | SettingsItem(modifier = Modifier.clickable { 125 | isNamePromptVisible.value = true 126 | }, 127 | { }, 128 | { Text(text = stringResource(R.string.export_theme__title)) }, 129 | { Text(text = stringResource(R.string.export_theme__desc)) }) { 130 | Icon( 131 | Icons.Default.CopyAll, stringResource(R.string.export_theme__desc) 132 | ) 133 | } 134 | SettingsChoice(preference = Preferences.Settings.themeType, 135 | onPreferenceChange = { updated -> 136 | preferenceHandler.themeType = updated 137 | }, 138 | defaultValue = preferenceHandler.getEnum( 139 | Preferences.Settings.themeType.preference.first, AppThemeType.SYSTEM 140 | ), 141 | onPreviewSelectionChange = { theme -> 142 | preferenceHandler.themeType = theme 143 | }) 144 | } 145 | } -------------------------------------------------------------------------------- /app/src/main/java/com/chouten/app/ui/views/settings/screens/appearance/AppearanceViewModel.kt: -------------------------------------------------------------------------------- 1 | package com.chouten.app.ui.views.settings.screens.appearance 2 | 3 | import android.content.Context 4 | import android.os.Build 5 | import androidx.compose.material3.ColorScheme 6 | import androidx.compose.material3.dynamicDarkColorScheme 7 | import androidx.compose.material3.dynamicLightColorScheme 8 | import androidx.compose.material3.surfaceColorAtElevation 9 | import androidx.compose.ui.graphics.Color 10 | import androidx.compose.ui.graphics.toArgb 11 | import androidx.compose.ui.unit.dp 12 | import androidx.lifecycle.ViewModel 13 | import com.chouten.app.PrimaryDataLayer 14 | import com.chouten.app.data.AppPaths 15 | import com.chouten.app.data.SnackbarVisualsWithError 16 | import com.chouten.app.ui.views.settings.json 17 | import kotlinx.serialization.encodeToString 18 | import kotlin.reflect.full.declaredMemberProperties 19 | 20 | class AppearanceViewModel(context: Context, colorScheme: ColorScheme) : ViewModel() { 21 | 22 | private val themes: Pair? 23 | private val surfaceContainer: Color 24 | 25 | init { 26 | this.surfaceContainer = colorScheme.surfaceColorAtElevation(3.dp) 27 | if (Build.VERSION.SDK_INT >= 31) { 28 | this.themes = Pair( 29 | dynamicLightColorScheme(context), dynamicDarkColorScheme(context) 30 | ) 31 | } else { 32 | this.themes = null 33 | } 34 | } 35 | 36 | fun exportTheme(themeName: String): Boolean { 37 | if (themes == null) { 38 | PrimaryDataLayer.enqueueSnackbar( 39 | SnackbarVisualsWithError( 40 | "Android 12 or higher is required to export themes", false 41 | ) 42 | ) 43 | return false 44 | } 45 | val getColours: ((ColorScheme) -> Map) = { theme -> 46 | theme::class.declaredMemberProperties.associate { member -> 47 | val hexColor = 48 | Integer.toHexString((member.getter.call(theme) as Color).toArgb()).drop(2) 49 | if (member.name.startsWith("on")) { 50 | member.name to "#$hexColor" 51 | } else { 52 | member.name.replaceFirstChar { it.uppercase() } to "#$hexColor" 53 | } 54 | } 55 | } 56 | 57 | val lightPairs = getColours(themes.first).toMutableMap().apply { 58 | this["SurfaceContainer"] = Integer.toHexString(surfaceContainer.toArgb()).drop(2) 59 | } 60 | val darkPairs = getColours(themes.second).toMutableMap().apply { 61 | this["SurfaceContainer"] = Integer.toHexString(surfaceContainer.toArgb()).drop(2) 62 | } 63 | 64 | val exportedJson = json.encodeToString( 65 | mapOf( 66 | "light" to lightPairs, "dark" to darkPairs 67 | ) 68 | ) 69 | 70 | val themeDir = AppPaths.addedDirs.getOrElse("Themes") { 71 | throw Exception("Themes directory not found") 72 | } 73 | 74 | val export = { 75 | val themeFile = themeDir.resolve("${themeName.trim()}.theme") 76 | themeFile.createNewFile() 77 | themeFile.writeText(exportedJson) 78 | true 79 | } 80 | 81 | return try { 82 | export() 83 | } catch (e: Exception) { 84 | // Create the app folders 85 | themeDir.mkdir() 86 | try { 87 | export() 88 | } catch (e: Exception) { 89 | e.printStackTrace() 90 | false 91 | } 92 | } 93 | } 94 | } -------------------------------------------------------------------------------- /app/src/main/java/com/chouten/app/ui/views/watch/WatchPage.kt: -------------------------------------------------------------------------------- 1 | package com.chouten.app.ui.views.watch 2 | 3 | import android.app.Activity 4 | import android.content.pm.ActivityInfo 5 | import android.net.Uri 6 | import android.view.ViewGroup 7 | import androidx.compose.runtime.Composable 8 | import androidx.compose.runtime.DisposableEffect 9 | import androidx.compose.runtime.LaunchedEffect 10 | import androidx.compose.runtime.remember 11 | import androidx.compose.runtime.saveable.rememberSaveable 12 | import androidx.compose.ui.platform.LocalContext 13 | import androidx.compose.ui.viewinterop.AndroidView 14 | import androidx.media3.common.C 15 | import androidx.media3.common.MediaItem 16 | import androidx.media3.exoplayer.ExoPlayer 17 | import androidx.media3.ui.AspectRatioFrameLayout 18 | import androidx.media3.ui.PlayerView 19 | import com.chouten.app.findActivity 20 | 21 | @Composable 22 | @androidx.annotation.OptIn(androidx.media3.common.util.UnstableApi::class) 23 | fun WatchPage( 24 | provider: WatchPageViewModel 25 | ) { 26 | // Force the user's screen to 27 | // be oriented to landscape. 28 | //LockScreenOrientation(ActivityInfo.SCREEN_ORIENTATION_LANDSCAPE) 29 | 30 | val context = LocalContext.current 31 | val orientationOnLoad = rememberSaveable { 32 | (context as Activity).requestedOrientation 33 | } 34 | 35 | if (provider.sources.isNotEmpty()) { 36 | val _player = remember { 37 | ExoPlayer.Builder( 38 | context 39 | ).build() 40 | .apply { 41 | setMediaItem( 42 | MediaItem.fromUri( 43 | Uri.parse( 44 | provider.sources.first().file 45 | ) 46 | ) 47 | ) 48 | prepare() 49 | } 50 | } 51 | _player.playWhenReady = true 52 | _player.videoScalingMode = 53 | C.VIDEO_SCALING_MODE_SCALE_TO_FIT 54 | _player.repeatMode = ExoPlayer.REPEAT_MODE_OFF 55 | 56 | 57 | LaunchedEffect((context as Activity).requestedOrientation) { 58 | context.requestedOrientation = ActivityInfo.SCREEN_ORIENTATION_LANDSCAPE 59 | } 60 | 61 | DisposableEffect( 62 | AndroidView( 63 | factory = { 64 | PlayerView(context).apply { 65 | resizeMode = AspectRatioFrameLayout.RESIZE_MODE_FILL 66 | player = _player 67 | layoutParams = ViewGroup.LayoutParams( 68 | ViewGroup.LayoutParams.MATCH_PARENT, 69 | ViewGroup.LayoutParams.MATCH_PARENT 70 | ) 71 | } 72 | } 73 | ) 74 | ) { 75 | // Restore the user's screen to 76 | // its original orientation. 77 | context.requestedOrientation = orientationOnLoad 78 | 79 | onDispose { 80 | _player.release() 81 | } 82 | } 83 | } 84 | } 85 | 86 | @Composable 87 | fun LockScreenOrientation(orientation: Int) { 88 | val context = LocalContext.current 89 | DisposableEffect(orientation) { 90 | val activity = context.findActivity() ?: return@DisposableEffect onDispose {} 91 | val originalOrientation = activity.requestedOrientation 92 | activity.requestedOrientation = orientation 93 | onDispose { 94 | // restore original orientation when view disappears 95 | activity.requestedOrientation = originalOrientation 96 | } 97 | } 98 | } 99 | -------------------------------------------------------------------------------- /app/src/main/java/com/chouten/app/ui/views/watch/WatchPageViewModel.kt: -------------------------------------------------------------------------------- 1 | package com.chouten.app.ui.views.watch 2 | 3 | import android.content.Context 4 | import android.util.Log 5 | import androidx.compose.runtime.getValue 6 | import androidx.compose.runtime.mutableStateOf 7 | import androidx.compose.runtime.setValue 8 | import androidx.lifecycle.ViewModel 9 | import androidx.lifecycle.viewModelScope 10 | import com.chouten.app.Mapper 11 | import com.chouten.app.ModuleLayer 12 | import com.chouten.app.PrimaryDataLayer 13 | import com.chouten.app.data.ModuleResponse 14 | import com.chouten.app.data.SnackbarVisualsWithError 15 | import com.chouten.app.data.WatchResult 16 | import com.chouten.app.data.WebviewHandler 17 | import java.net.URLDecoder 18 | import kotlinx.coroutines.launch 19 | import kotlinx.coroutines.sync.Mutex 20 | 21 | class WatchPageViewModel( 22 | context: Context, 23 | private var title: String? = "", 24 | private var name: String? = "", 25 | _url: String 26 | ) : ViewModel() { 27 | var url: String = "" 28 | private set 29 | 30 | val webview = WebviewHandler() 31 | 32 | private val syncLock = Mutex(false) 33 | 34 | private var _mediaUrl by mutableStateOf("") 35 | val mediaUrl: String 36 | get() = _mediaUrl 37 | 38 | private var _servers by mutableStateOf(listOf()) 39 | val servers: List 40 | get() = _servers 41 | 42 | private var _sources by mutableStateOf(listOf()) 43 | val sources: List 44 | get() = _sources 45 | 46 | private var _subtitles by mutableStateOf(listOf()) 47 | val subtitles: List 48 | get() = _subtitles 49 | 50 | private var _skips by mutableStateOf(listOf()) 51 | val skips: List 52 | get() = _skips 53 | 54 | 55 | init { 56 | 57 | } 58 | } 59 | -------------------------------------------------------------------------------- /app/src/main/res/drawable-v24/ic_launcher_foreground.xml: -------------------------------------------------------------------------------- 1 | 7 | 8 | 9 | 15 | 18 | 21 | 22 | 23 | 24 | 30 | -------------------------------------------------------------------------------- /app/src/main/res/drawable/ic_launcher_background.xml: -------------------------------------------------------------------------------- 1 | 2 | 7 | 10 | 15 | 20 | 25 | 30 | 35 | 40 | 45 | 50 | 55 | 60 | 65 | 70 | 75 | 80 | 85 | 90 | 95 | 100 | 105 | 110 | 115 | 120 | 125 | 130 | 135 | 140 | 145 | 150 | 155 | 160 | 165 | 170 | 171 | -------------------------------------------------------------------------------- /app/src/main/res/mipmap-anydpi-v26/ic_chouten_launcher.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | -------------------------------------------------------------------------------- /app/src/main/res/mipmap-anydpi-v26/ic_chouten_launcher_round.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | -------------------------------------------------------------------------------- /app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | -------------------------------------------------------------------------------- /app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | -------------------------------------------------------------------------------- /app/src/main/res/mipmap-hdpi/ic_chouten_launcher.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Chouten-App/Chouten-Android/310d6d428f596e92cdfc2d8936b2c428b3af74f0/app/src/main/res/mipmap-hdpi/ic_chouten_launcher.png -------------------------------------------------------------------------------- /app/src/main/res/mipmap-hdpi/ic_chouten_launcher_foreground.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Chouten-App/Chouten-Android/310d6d428f596e92cdfc2d8936b2c428b3af74f0/app/src/main/res/mipmap-hdpi/ic_chouten_launcher_foreground.png -------------------------------------------------------------------------------- /app/src/main/res/mipmap-hdpi/ic_chouten_launcher_round.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Chouten-App/Chouten-Android/310d6d428f596e92cdfc2d8936b2c428b3af74f0/app/src/main/res/mipmap-hdpi/ic_chouten_launcher_round.png -------------------------------------------------------------------------------- /app/src/main/res/mipmap-hdpi/ic_launcher.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Chouten-App/Chouten-Android/310d6d428f596e92cdfc2d8936b2c428b3af74f0/app/src/main/res/mipmap-hdpi/ic_launcher.webp -------------------------------------------------------------------------------- /app/src/main/res/mipmap-hdpi/ic_launcher_round.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Chouten-App/Chouten-Android/310d6d428f596e92cdfc2d8936b2c428b3af74f0/app/src/main/res/mipmap-hdpi/ic_launcher_round.webp -------------------------------------------------------------------------------- /app/src/main/res/mipmap-mdpi/ic_chouten_launcher.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Chouten-App/Chouten-Android/310d6d428f596e92cdfc2d8936b2c428b3af74f0/app/src/main/res/mipmap-mdpi/ic_chouten_launcher.png -------------------------------------------------------------------------------- /app/src/main/res/mipmap-mdpi/ic_chouten_launcher_foreground.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Chouten-App/Chouten-Android/310d6d428f596e92cdfc2d8936b2c428b3af74f0/app/src/main/res/mipmap-mdpi/ic_chouten_launcher_foreground.png -------------------------------------------------------------------------------- /app/src/main/res/mipmap-mdpi/ic_chouten_launcher_round.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Chouten-App/Chouten-Android/310d6d428f596e92cdfc2d8936b2c428b3af74f0/app/src/main/res/mipmap-mdpi/ic_chouten_launcher_round.png -------------------------------------------------------------------------------- /app/src/main/res/mipmap-mdpi/ic_launcher.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Chouten-App/Chouten-Android/310d6d428f596e92cdfc2d8936b2c428b3af74f0/app/src/main/res/mipmap-mdpi/ic_launcher.webp -------------------------------------------------------------------------------- /app/src/main/res/mipmap-mdpi/ic_launcher_round.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Chouten-App/Chouten-Android/310d6d428f596e92cdfc2d8936b2c428b3af74f0/app/src/main/res/mipmap-mdpi/ic_launcher_round.webp -------------------------------------------------------------------------------- /app/src/main/res/mipmap-xhdpi/ic_chouten_launcher.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Chouten-App/Chouten-Android/310d6d428f596e92cdfc2d8936b2c428b3af74f0/app/src/main/res/mipmap-xhdpi/ic_chouten_launcher.png -------------------------------------------------------------------------------- /app/src/main/res/mipmap-xhdpi/ic_chouten_launcher_foreground.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Chouten-App/Chouten-Android/310d6d428f596e92cdfc2d8936b2c428b3af74f0/app/src/main/res/mipmap-xhdpi/ic_chouten_launcher_foreground.png -------------------------------------------------------------------------------- /app/src/main/res/mipmap-xhdpi/ic_chouten_launcher_round.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Chouten-App/Chouten-Android/310d6d428f596e92cdfc2d8936b2c428b3af74f0/app/src/main/res/mipmap-xhdpi/ic_chouten_launcher_round.png -------------------------------------------------------------------------------- /app/src/main/res/mipmap-xhdpi/ic_launcher.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Chouten-App/Chouten-Android/310d6d428f596e92cdfc2d8936b2c428b3af74f0/app/src/main/res/mipmap-xhdpi/ic_launcher.webp -------------------------------------------------------------------------------- /app/src/main/res/mipmap-xhdpi/ic_launcher_round.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Chouten-App/Chouten-Android/310d6d428f596e92cdfc2d8936b2c428b3af74f0/app/src/main/res/mipmap-xhdpi/ic_launcher_round.webp -------------------------------------------------------------------------------- /app/src/main/res/mipmap-xxhdpi/ic_chouten_launcher.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Chouten-App/Chouten-Android/310d6d428f596e92cdfc2d8936b2c428b3af74f0/app/src/main/res/mipmap-xxhdpi/ic_chouten_launcher.png -------------------------------------------------------------------------------- /app/src/main/res/mipmap-xxhdpi/ic_chouten_launcher_foreground.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Chouten-App/Chouten-Android/310d6d428f596e92cdfc2d8936b2c428b3af74f0/app/src/main/res/mipmap-xxhdpi/ic_chouten_launcher_foreground.png -------------------------------------------------------------------------------- /app/src/main/res/mipmap-xxhdpi/ic_chouten_launcher_round.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Chouten-App/Chouten-Android/310d6d428f596e92cdfc2d8936b2c428b3af74f0/app/src/main/res/mipmap-xxhdpi/ic_chouten_launcher_round.png -------------------------------------------------------------------------------- /app/src/main/res/mipmap-xxhdpi/ic_launcher.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Chouten-App/Chouten-Android/310d6d428f596e92cdfc2d8936b2c428b3af74f0/app/src/main/res/mipmap-xxhdpi/ic_launcher.webp -------------------------------------------------------------------------------- /app/src/main/res/mipmap-xxhdpi/ic_launcher_round.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Chouten-App/Chouten-Android/310d6d428f596e92cdfc2d8936b2c428b3af74f0/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.webp -------------------------------------------------------------------------------- /app/src/main/res/mipmap-xxxhdpi/ic_chouten_launcher.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Chouten-App/Chouten-Android/310d6d428f596e92cdfc2d8936b2c428b3af74f0/app/src/main/res/mipmap-xxxhdpi/ic_chouten_launcher.png -------------------------------------------------------------------------------- /app/src/main/res/mipmap-xxxhdpi/ic_chouten_launcher_foreground.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Chouten-App/Chouten-Android/310d6d428f596e92cdfc2d8936b2c428b3af74f0/app/src/main/res/mipmap-xxxhdpi/ic_chouten_launcher_foreground.png -------------------------------------------------------------------------------- /app/src/main/res/mipmap-xxxhdpi/ic_chouten_launcher_round.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Chouten-App/Chouten-Android/310d6d428f596e92cdfc2d8936b2c428b3af74f0/app/src/main/res/mipmap-xxxhdpi/ic_chouten_launcher_round.png -------------------------------------------------------------------------------- /app/src/main/res/mipmap-xxxhdpi/ic_launcher.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Chouten-App/Chouten-Android/310d6d428f596e92cdfc2d8936b2c428b3af74f0/app/src/main/res/mipmap-xxxhdpi/ic_launcher.webp -------------------------------------------------------------------------------- /app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Chouten-App/Chouten-Android/310d6d428f596e92cdfc2d8936b2c428b3af74f0/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.webp -------------------------------------------------------------------------------- /app/src/main/res/values-night/splashscreen_dark.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 8 | -------------------------------------------------------------------------------- /app/src/main/res/values-v31/colors.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | @android:color/system_accent1_0 6 | @android:color/system_accent1_10 7 | @android:color/system_accent1_50 8 | @android:color/system_accent1_100 9 | @android:color/system_accent1_200 10 | @android:color/system_accent1_300 11 | @android:color/system_accent1_400 12 | @android:color/system_accent1_500 13 | @android:color/system_accent1_600 14 | @android:color/system_accent1_700 15 | @android:color/system_accent1_800 16 | @android:color/system_accent1_900 17 | @android:color/system_accent1_1000 18 | 19 | 20 | @android:color/system_accent2_0 21 | @android:color/system_accent2_10 22 | @android:color/system_accent2_50 23 | @android:color/system_accent2_100 24 | @android:color/system_accent2_200 25 | @android:color/system_accent2_300 26 | @android:color/system_accent2_400 27 | @android:color/system_accent2_500 28 | @android:color/system_accent2_600 29 | @android:color/system_accent2_700 30 | @android:color/system_accent2_800 31 | @android:color/system_accent2_900 32 | @android:color/system_accent2_1000 33 | 34 | 35 | @android:color/system_accent2_0 36 | @android:color/system_accent2_10 37 | @android:color/system_accent2_50 38 | @android:color/system_accent2_100 39 | @android:color/system_accent2_200 40 | @android:color/system_accent2_300 41 | @android:color/system_accent2_400 42 | @android:color/system_accent2_500 43 | @android:color/system_accent2_600 44 | @android:color/system_accent2_700 45 | @android:color/system_accent2_800 46 | @android:color/system_accent2_900 47 | @android:color/system_accent2_1000 48 | 49 | 50 | @android:color/system_accent2_0 51 | @android:color/system_accent2_10 52 | @android:color/system_accent2_50 53 | @android:color/system_accent2_100 54 | @android:color/system_accent2_200 55 | @android:color/system_accent2_300 56 | @android:color/system_accent2_400 57 | @android:color/system_accent2_500 58 | @android:color/system_accent2_600 59 | @android:color/system_accent2_700 60 | @android:color/system_accent2_800 61 | @android:color/system_accent2_900 62 | @android:color/system_accent2_1000 63 | 64 | 65 | @android:color/system_neutral1_0 66 | @android:color/system_neutral1_10 67 | @android:color/system_neutral1_50 68 | @android:color/system_neutral1_100 69 | 70 | @android:color/system_neutral1_200 71 | 72 | @android:color/system_neutral1_300 73 | 74 | @android:color/system_neutral1_400 75 | 76 | @android:color/system_neutral1_500 77 | 78 | @android:color/system_neutral1_600 79 | 80 | @android:color/system_neutral1_700 81 | 82 | @android:color/system_neutral1_800 83 | 84 | @android:color/system_neutral1_900 85 | 86 | @android:color/system_neutral1_1000 87 | 88 | 89 | @android:color/system_neutral2_0 90 | @android:color/system_neutral2_10 91 | @android:color/system_neutral2_50 92 | @android:color/system_neutral2_100 93 | 94 | @android:color/system_neutral2_200 95 | 96 | @android:color/system_neutral2_300 97 | 98 | @android:color/system_neutral2_400 99 | 100 | @android:color/system_neutral2_500 101 | 102 | @android:color/system_neutral2_600 103 | 104 | @android:color/system_neutral2_700 105 | 106 | @android:color/system_neutral2_800 107 | 108 | @android:color/system_neutral2_900 109 | 110 | @android:color/system_neutral2_1000 111 | 112 | 113 | 114 | -------------------------------------------------------------------------------- /app/src/main/res/values/colors.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | -------------------------------------------------------------------------------- /app/src/main/res/values/ic_chouten_launcher_background.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | #000000 4 | -------------------------------------------------------------------------------- /app/src/main/res/values/splashscreen.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 8 | -------------------------------------------------------------------------------- /app/src/main/res/values/strings.xml: -------------------------------------------------------------------------------- 1 | 2 | Chouten 3 | Module Selection 4 | Select one of the modules below to provide this app with data: 5 | No Module 6 | Import Module 7 | Import Theme 8 | Import Module from URL 9 | Import Module 10 | Import Module or a Repo from a URL. Please make sure you trust the URL. 11 | Import Theme or a Repo from a URL. Please make sure you trust the URL. 12 | 13 | Search for Content 14 | Search with 15 | No Module Selected 16 | 17 | Your Profile 18 | 19 | Ok 20 | Confirm 21 | Confirm Selection 22 | Cancel 23 | Import 24 | 25 | 26 | Home 27 | Search 28 | 頂点 29 | More 30 | 31 | App Logs 32 | 33 | Back 34 | 35 | 36 | Dynamic Theming 37 | Use Material You Dynamic Theming 38 | 39 | Toggle Downloaded Only 40 | Go offline and stuff you know 41 | 42 | Toggle Incognito 43 | For the naughty naughty 44 | 45 | Toggle dev mode 46 | Do not toggle, unless you know what you are doing 47 | 48 | Export Theme 49 | Export Theme as File 50 | Theme Exported Successfully 51 | Theme Name 52 | Could not export Theme 53 | 54 | Appearance 55 | Light/Dark/System 56 | 57 | Network 58 | Network Settings 59 | DNS 60 | Change DNS 61 | 62 | View Logs 63 | View module logs 64 | Clear Logs 65 | No logs… Yay? 66 | OLED Theme 67 | Enable a darker theme to save battery on certain devices 68 | -------------------------------------------------------------------------------- /app/src/main/res/values/themes.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 |