├── .github ├── FUNDING.yml ├── ISSUE_TEMPLATE │ ├── bug-report.md │ └── feature-request.md ├── pull_request_template.md └── workflows │ └── android-build.yml ├── .gitignore ├── LICENSE ├── README.md ├── app ├── .gitignore ├── build.gradle.kts ├── proguard-rules.pro └── src │ ├── androidTest │ └── java │ │ └── com │ │ └── vishnu │ │ └── whatsappcleaner │ │ └── ExampleInstrumentedTest.kt │ ├── main │ ├── AndroidManifest.xml │ ├── java │ │ └── com │ │ │ └── vishnu │ │ │ └── whatsappcleaner │ │ │ ├── Constants.kt │ │ │ ├── MainActivity.kt │ │ │ ├── MainViewModel.kt │ │ │ ├── data │ │ │ ├── FileRepository.kt │ │ │ └── StoreData.kt │ │ │ ├── model │ │ │ ├── ListDirectory.kt │ │ │ └── ListFile.kt │ │ │ └── ui │ │ │ ├── Components.kt │ │ │ ├── CustomTabLayout.kt │ │ │ ├── DetailsScreen.kt │ │ │ ├── HomeScreen.kt │ │ │ ├── PermissionScreen.kt │ │ │ └── theme │ │ │ ├── Color.kt │ │ │ ├── Theme.kt │ │ │ └── Type.kt │ └── res │ │ ├── drawable │ │ ├── audio.xml │ │ ├── check_circle.xml │ │ ├── check_circle_filled.xml │ │ ├── clean.xml │ │ ├── document.xml │ │ ├── empty.xml │ │ ├── error.xml │ │ ├── gif.xml │ │ ├── ic_grid_view.xml │ │ ├── ic_select_all.xml │ │ ├── ic_sort.xml │ │ ├── ic_view_list.xml │ │ ├── image.xml │ │ ├── open_in.xml │ │ ├── permission_hint.webp │ │ ├── profile.xml │ │ ├── recycling.xml │ │ ├── recycling_round.xml │ │ ├── status.xml │ │ ├── sticker.xml │ │ ├── unknown.xml │ │ ├── video.xml │ │ ├── video_notes.xml │ │ ├── voice.xml │ │ └── wallpaper.xml │ │ ├── font │ │ └── geist.ttf │ │ ├── values │ │ ├── colors.xml │ │ ├── strings.xml │ │ └── themes.xml │ │ └── xml │ │ ├── backup_rules.xml │ │ ├── data_extraction_rules.xml │ │ └── provider_paths.xml │ └── test │ └── java │ └── com │ └── vishnu │ └── whatsappcleaner │ └── ExampleUnitTest.kt ├── build.gradle.kts ├── fastlane └── metadata │ └── android │ └── en-US │ ├── changelogs │ └── 8.txt │ ├── full_description.txt │ ├── images │ ├── featureGraphic.png │ ├── icon.png │ └── phoneScreenshots │ │ ├── 1.png │ │ ├── 2.png │ │ └── 3.png │ ├── short_description.txt │ └── title.txt ├── gradle.properties ├── gradle ├── libs.versions.toml └── wrapper │ ├── gradle-wrapper.jar │ └── gradle-wrapper.properties ├── gradlew ├── gradlew.bat ├── settings.gradle.kts └── spotless-header /.github/FUNDING.yml: -------------------------------------------------------------------------------- 1 | buy_me_a_coffee: "VishnuSanal" 2 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/bug-report.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Bug Report 3 | about: Report bugs to help us improve 4 | title: '' 5 | labels: '' 6 | assignees: '' 7 | 8 | --- 9 | 10 | **Describe the bug** 11 | A clear and concise description of what the bug is. 12 | 13 | **To Reproduce** 14 | Steps to reproduce the behavior: 15 | 1. Go to '...' 16 | 2. Click on '....' 17 | 3. Scroll down to '....' 18 | 4. See error 19 | 20 | **Expected behavior** 21 | A clear and concise description of what you expected to happen. 22 | 23 | **Screenshots** 24 | If applicable, add screenshots to help explain your problem. 25 | 26 | **Crash logs** 27 | If possible, add crash logs to help us figure out the problem. 28 | 29 | **Smartphone (please complete the following information):** 30 | - Device: [e.g. OnePlus 8 Pro] 31 | - Andorid Version: [e.g. Android 10] 32 | - App Version: [e.g. v2.6.1] 33 | 34 | **Additional context** 35 | Add any other context about the problem here. 36 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/feature-request.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Feature Request 3 | about: 'Suggest a Feature ' 4 | title: '' 5 | labels: '' 6 | assignees: '' 7 | 8 | --- 9 | 10 | **Is your feature request related to a problem? Please describe.** 11 | A clear and concise description of what the problem is. Ex. I'm always frustrated when [...] 12 | 13 | **Describe the solution you'd like** 14 | A clear and concise description of what you want to happen. 15 | 16 | **Describe alternatives you've considered** 17 | A clear and concise description of any alternative solutions or features you've considered. 18 | 19 | **Additional context** 20 | Add any other context or screenshots about the feature request here. 21 | -------------------------------------------------------------------------------- /.github/pull_request_template.md: -------------------------------------------------------------------------------- 1 | # PR Info 2 | 3 | ## Issue Details 4 | 5 | 6 | 7 | - Fixes #---- 8 | - **Addresses** #---- 9 | 10 | ## Tests 11 | 12 | - [ ] `./gradlew spotlessCheck` 13 | - [ ] `./gradlew testDebug` 14 | 15 | ## Type of change 16 | 17 | 18 | 19 | - **Bug Fix** 20 | - **New Feature** 21 | - **Breaking Change** 22 | - **Other** 23 | 24 | ## Additional Info 25 | 26 | 27 | -------------------------------------------------------------------------------- /.github/workflows/android-build.yml: -------------------------------------------------------------------------------- 1 | name: Android Build 2 | 3 | on: [push, pull_request] 4 | 5 | jobs: 6 | build: 7 | runs-on: ubuntu-latest 8 | steps: 9 | - name: Checkout 10 | uses: actions/checkout@v4 11 | 12 | - name: Set Up JDK Environment 13 | uses: actions/setup-java@v4 14 | with: 15 | distribution: "adopt" 16 | java-version: 21 17 | 18 | - name: Spotless Test 19 | run: ./gradlew spotlessCheck --stacktrace 20 | 21 | - name: Gradle Build 22 | run: ./gradlew testDebug --stacktrace -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | 2 | # Created by https://www.gitignore.io/api/android,androidstudio 3 | # Edit at https://www.gitignore.io/?templates=android,androidstudio 4 | 5 | ### Android ### 6 | # Built application files 7 | *.apk 8 | *.ap_ 9 | *.aab 10 | 11 | # Files for the ART/Dalvik VM 12 | *.dex 13 | 14 | # Java class files 15 | *.class 16 | 17 | # Generated files 18 | bin/ 19 | gen/ 20 | out/ 21 | 22 | # Gradle files 23 | .gradle/ 24 | build/ 25 | 26 | # Local configuration file (sdk path, etc) 27 | local.properties 28 | 29 | # Proguard folder generated by Eclipse 30 | proguard/ 31 | 32 | # Log Files 33 | *.log 34 | 35 | # Android Studio Navigation editor temp files 36 | .navigation/ 37 | 38 | # Android Studio captures folder 39 | captures/ 40 | 41 | # IntelliJ 42 | *.iml 43 | .idea/ 44 | .idea/workspace.xml 45 | .idea/tasks.xml 46 | .idea/gradle.xml 47 | .idea/assetWizardSettings.xml 48 | .idea/dictionaries 49 | .idea/libraries 50 | .idea/caches 51 | # Android Studio 3 in .gitignore file. 52 | .idea/caches/build_file_checksums.ser 53 | .idea/modules.xml 54 | 55 | # Keystore files 56 | # Uncomment the following lines if you do not want to check your keystore files in. 57 | #*.jks 58 | #*.keystore 59 | 60 | # External native build folder generated in Android Studio 2.2 and later 61 | .externalNativeBuild 62 | 63 | # Google Services (e.g. APIs or Firebase) 64 | google-services.json 65 | 66 | # Freeline 67 | freeline.py 68 | freeline/ 69 | freeline_project_description.json 70 | 71 | # fastlane 72 | fastlane/report.xml 73 | fastlane/Preview.html 74 | fastlane/screenshots 75 | fastlane/test_output 76 | fastlane/readme.md 77 | 78 | # Version control 79 | vcs.xml 80 | 81 | # lint 82 | lint/intermediates/ 83 | lint/generated/ 84 | lint/outputs/ 85 | lint/tmp/ 86 | # lint/reports/ 87 | 88 | ### Android Patch ### 89 | gen-external-apklibs 90 | output.json 91 | 92 | ### AndroidStudio ### 93 | # Covers files to be ignored for android development using Android Studio. 94 | 95 | # Built application files 96 | 97 | # Files for the ART/Dalvik VM 98 | 99 | # Java class files 100 | 101 | # Generated files 102 | 103 | # Gradle files 104 | .gradle 105 | 106 | # Signing files 107 | .signing/ 108 | 109 | # Local configuration file (sdk path, etc) 110 | 111 | # Proguard folder generated by Eclipse 112 | 113 | # Log Files 114 | 115 | # Android Studio 116 | /*/build/ 117 | /*/local.properties 118 | /*/out 119 | /*/*/build 120 | /*/*/production 121 | *.ipr 122 | *~ 123 | *.swp 124 | 125 | # Android Patch 126 | 127 | # External native build folder generated in Android Studio 2.2 and later 128 | 129 | # NDK 130 | obj/ 131 | 132 | # IntelliJ IDEA 133 | *.iws 134 | /out/ 135 | 136 | # User-specific configurations 137 | .idea/caches/ 138 | .idea/libraries/ 139 | .idea/shelf/ 140 | .idea/.name 141 | .idea/compiler.xml 142 | .idea/copyright/profiles_settings.xml 143 | .idea/encodings.xml 144 | .idea/misc.xml 145 | .idea/scopes/scope_settings.xml 146 | .idea/vcs.xml 147 | .idea/jsLibraryMappings.xml 148 | .idea/datasources.xml 149 | .idea/dataSources.ids 150 | .idea/sqlDataSources.xml 151 | .idea/dynamic.xml 152 | .idea/uiDesigner.xml 153 | 154 | # OS-specific files 155 | .DS_Store 156 | .DS_Store? 157 | ._* 158 | .Spotlight-V100 159 | .Trashes 160 | ehthumbs.db 161 | Thumbs.db 162 | 163 | # Legacy Eclipse project files 164 | .classpath 165 | .project 166 | .cproject 167 | .settings/ 168 | 169 | # Mobile Tools for Java (J2ME) 170 | .mtj.tmp/ 171 | 172 | # Package Files # 173 | *.war 174 | *.ear 175 | 176 | # virtual machine crash logs (Reference: http://www.java.com/en/download/help/error_hotspot.xml) 177 | hs_err_pid* 178 | 179 | ## Plugin-specific files: 180 | 181 | # mpeltonen/sbt-idea plugin 182 | .idea_modules/ 183 | 184 | # JIRA plugin 185 | atlassian-ide-plugin.xml 186 | 187 | # Mongo Explorer plugin 188 | .idea/mongoSettings.xml 189 | 190 | # Crashlytics plugin (for Android Studio and IntelliJ) 191 | com_crashlytics_export_strings.xml 192 | crashlytics.properties 193 | crashlytics-build.properties 194 | fabric.properties 195 | 196 | ### AndroidStudio Patch ### 197 | 198 | !/gradle/wrapper/gradle-wrapper.jar 199 | 200 | # End of https://www.gitignore.io/api/android,androidstudio -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 |

2 | 3 |

4 | 5 |

6 | WhatsAppCleaner 7 |

8 | 9 |

10 | Cleaner for WhatsApp - Clean Redundant Media and Files from Storage 11 |

12 | 13 |

14 | 15 | 16 | Get it on F-Droid 17 | 18 | 19 | 20 | Get it on Github 21 | 22 | 23 |

24 | 25 |

26 | 27 | 28 | WhatsApp Cleaner - Clean WhatsApp's Redundant Media and Files from Storage | Product Hunt 29 | 30 | 31 |

32 | 33 | 38 | 39 |
40 | 41 | ![WhatsAppCleaner](https://github.com/VishnuSanal/WhatsAppCleaner/blob/main/fastlane/metadata/android/en-US/images/featureGraphic.png?raw=true) 42 | 43 | ## Enjoying WhatsAppCleaner? Consider Supporting! 44 | Consider donating to support the development! It requires a lot of time and effort to develop the 45 | copylefted libre software app, gratis and ad-free. :D 46 | 47 |

48 | 49 | Buy Me A Coffee 50 | 51 |

52 | 53 | ## Join Telegram Group 54 | If you enjoy the app, wish to contribute or simply wish to be with like-minded people, come join us. It is fun :) 55 | 56 |

57 | 58 | Join us on Telegram 59 | 60 |

61 | 62 | ## Why? 63 | 64 | Hi, I have been developing open-source native Android products for a while now. I have seen many Android devices with storage running out; most of the time all they needed were to clean-up the storage consumed by WhatsApp. I had searched for an application for the same, but I couldn't find any actually working ones on Google Play nor FDroid, but none seemed to work fine, mostly limited due to Android's varying permission requirements for different versions. And I decided to build one! :) 65 | 66 | 70 | 71 |
72 | 73 | 87 | 88 | ## Screenshots 89 | 90 |

91 | 92 | 93 | 94 | 95 | 96 |

97 | 98 | ## Participate in the community 99 | 100 | **Communities keep FOSS projects alive.** Without them, projects usually fade into obscurity when the primary developer loses interest or becomes busy in other parts of their life. That's where you come in! You can: 101 | - Read through the [issues](https://github.com/VishnuSanal/WhatsAppCleaner/issues) and give a star to the ones you care about most. 102 | - Open [new issues](https://github.com/VishnuSanal/WhatsAppCleaner/issues/new/choose) with feedback, feature requests, or bug reports. 103 | - Come chat at [Telegram](https://t.me/QuotesStatusCreator). 104 | 105 | 106 | ## License 107 | [![GNU GPLv3](https://www.gnu.org/graphics/gplv3-127x51.png)](https://www.gnu.org/licenses/gpl-3.0.en.html) 108 | 109 | Note: WhatsAppCleaner is a third-party application that helps in cleaning files generated by WhatsApp, and is not affiliated to or endorsed by WhatsApp itself. 110 | -------------------------------------------------------------------------------- /app/.gitignore: -------------------------------------------------------------------------------- 1 | /build -------------------------------------------------------------------------------- /app/build.gradle.kts: -------------------------------------------------------------------------------- 1 | plugins { 2 | alias(libs.plugins.android.application) 3 | alias(libs.plugins.jetbrains.kotlin.android) 4 | alias(libs.plugins.compose.compiler) 5 | } 6 | 7 | android { 8 | namespace = "com.vishnu.whatsappcleaner" 9 | compileSdk = 35 10 | 11 | defaultConfig { 12 | applicationId = "com.vishnu.whatsappcleaner" 13 | minSdk = 24 14 | targetSdk = 35 15 | versionCode = 8 16 | versionName = "v1.2.0" 17 | 18 | testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" 19 | vectorDrawables { 20 | useSupportLibrary = true 21 | } 22 | } 23 | 24 | // doesn't make much of a difference, can be enabled when it does 25 | // splits { 26 | // abi { 27 | // isEnable = true 28 | // reset() 29 | // include("x86", "x86_64", "armeabi-v7a", "arm64-v8a") 30 | // isUniversalApk = true 31 | // } 32 | // } 33 | 34 | buildTypes { 35 | release { 36 | isMinifyEnabled = true 37 | isShrinkResources = true 38 | proguardFiles( 39 | getDefaultProguardFile("proguard-android-optimize.txt"), 40 | "proguard-rules.pro" 41 | ) 42 | signingConfig = signingConfigs.getByName("debug") 43 | } 44 | debug { 45 | applicationIdSuffix = ".debug" 46 | } 47 | } 48 | compileOptions { 49 | sourceCompatibility = JavaVersion.VERSION_1_8 50 | targetCompatibility = JavaVersion.VERSION_1_8 51 | } 52 | kotlinOptions { 53 | jvmTarget = "1.8" 54 | } 55 | buildFeatures { 56 | compose = true 57 | } 58 | composeOptions { 59 | kotlinCompilerExtensionVersion = "1.5.1" 60 | } 61 | packaging { 62 | resources { 63 | excludes += "/META-INF/{AL2.0,LGPL2.1}" 64 | } 65 | } 66 | // https://gitlab.com/fdroid/fdroidserver/-/issues/1056 67 | dependenciesInfo { 68 | // Disables dependency metadata when building APKs. 69 | includeInApk = false 70 | // Disables dependency metadata when building Android App Bundles. 71 | includeInBundle = false 72 | } 73 | } 74 | 75 | dependencies { 76 | 77 | implementation(libs.androidx.core.ktx) 78 | implementation(libs.androidx.lifecycle.runtime.ktx) 79 | implementation(libs.androidx.activity.compose) 80 | implementation(platform(libs.androidx.compose.bom)) 81 | implementation(libs.androidx.ui) 82 | implementation(libs.androidx.ui.graphics) 83 | implementation(libs.androidx.ui.tooling.preview) 84 | implementation(libs.androidx.material3) 85 | 86 | implementation(libs.androidx.navigation.compose) 87 | 88 | implementation(libs.androidx.datastore.core) 89 | implementation(libs.androidx.datastore.preferences) 90 | 91 | implementation(libs.compose.shimmer) 92 | implementation(libs.glide.compose) 93 | 94 | implementation(libs.androidx.runtime) 95 | implementation(libs.androidx.runtime.livedata) 96 | 97 | testImplementation(libs.junit) 98 | 99 | androidTestImplementation(libs.androidx.junit) 100 | androidTestImplementation(libs.androidx.espresso.core) 101 | androidTestImplementation(platform(libs.androidx.compose.bom)) 102 | androidTestImplementation(libs.androidx.ui.test.junit4) 103 | 104 | debugImplementation(libs.androidx.ui.tooling) 105 | debugImplementation(libs.androidx.ui.test.manifest) 106 | } -------------------------------------------------------------------------------- /app/proguard-rules.pro: -------------------------------------------------------------------------------- 1 | # Add project specific ProGuard rules here. 2 | # You can control the set of applied configuration files using the 3 | # proguardFiles setting in build.gradle. 4 | # 5 | # For more details, see 6 | # http://developer.android.com/guide/developing/tools/proguard.html 7 | 8 | # If your project uses WebView with JS, uncomment the following 9 | # and specify the fully qualified class name to the JavaScript interface 10 | # class: 11 | #-keepclassmembers class fqcn.of.javascript.interface.for.webview { 12 | # public *; 13 | #} 14 | 15 | # Uncomment this to preserve the line number information for 16 | # debugging stack traces. 17 | #-keepattributes SourceFile,LineNumberTable 18 | 19 | # If you keep the line number information, uncomment this to 20 | # hide the original source file name. 21 | #-renamesourcefileattribute SourceFile -------------------------------------------------------------------------------- /app/src/androidTest/java/com/vishnu/whatsappcleaner/ExampleInstrumentedTest.kt: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright (C) 2025 Vishnu Sanal T 3 | * 4 | * This file is part of WhatsAppCleaner. 5 | * 6 | * Quotes Status Creator is free software: you can redistribute it and/or modify 7 | * it under the terms of the GNU General Public License as published by 8 | * the Free Software Foundation, either version 3 of the License, or 9 | * (at your option) any later version. 10 | * 11 | * This program is distributed in the hope that it will be useful, 12 | * but WITHOUT ANY WARRANTY; without even the implied warranty of 13 | * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 14 | * GNU General Public License for more details. 15 | * 16 | * You should have received a copy of the GNU General Public License 17 | * along with this program. If not, see . 18 | */ 19 | 20 | package com.vishnu.whatsappcleaner 21 | 22 | import androidx.test.ext.junit.runners.AndroidJUnit4 23 | import androidx.test.platform.app.InstrumentationRegistry 24 | import org.junit.Assert.assertEquals 25 | import org.junit.Test 26 | import org.junit.runner.RunWith 27 | 28 | /** 29 | * Instrumented test, which will execute on an Android device. 30 | * 31 | * See [testing documentation](http://d.android.com/tools/testing). 32 | */ 33 | @RunWith(AndroidJUnit4::class) 34 | class ExampleInstrumentedTest { 35 | @Test 36 | fun useAppContext() { 37 | // Context of the app under test. 38 | val appContext = InstrumentationRegistry.getInstrumentation().targetContext 39 | assertEquals("com.vishnu.whatsappcleaner", appContext.packageName) 40 | } 41 | } 42 | -------------------------------------------------------------------------------- /app/src/main/AndroidManifest.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 22 | 23 | 25 | 26 | 29 | 30 | 33 | 34 | 37 | 38 | 49 | 55 | 56 | 57 | 58 | 59 | 60 | 61 | 62 | 67 | 70 | 71 | 72 | 73 | -------------------------------------------------------------------------------- /app/src/main/java/com/vishnu/whatsappcleaner/Constants.kt: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright (C) 2025 Vishnu Sanal T 3 | * 4 | * This file is part of WhatsAppCleaner. 5 | * 6 | * Quotes Status Creator is free software: you can redistribute it and/or modify 7 | * it under the terms of the GNU General Public License as published by 8 | * the Free Software Foundation, either version 3 of the License, or 9 | * (at your option) any later version. 10 | * 11 | * This program is distributed in the hope that it will be useful, 12 | * but WITHOUT ANY WARRANTY; without even the implied warranty of 13 | * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 14 | * GNU General Public License for more details. 15 | * 16 | * You should have received a copy of the GNU General Public License 17 | * along with this program. If not, see . 18 | */ 19 | 20 | package com.vishnu.whatsappcleaner 21 | 22 | object Constants { 23 | const val WHATSAPP_HOME_URI = "whatsapp_home_uri" 24 | 25 | const val SCREEN_PERMISSION = "permission" 26 | const val SCREEN_HOME = "home" 27 | const val SCREEN_DETAILS = "details" 28 | 29 | const val DETAILS_LIST_ITEM = "details_list_item" 30 | const val FORCE_RELOAD_FILE_LIST = "force_reload_file_list" 31 | 32 | const val REQUEST_PERMISSIONS_CODE_WRITE_STORAGE = 2 33 | 34 | const val LIST_LOADING_INDICATION: String = "com.vishnu.whatsappcleaner.loading" 35 | 36 | final val EXTENSIONS_IMAGE = listOf( 37 | "jpg", 38 | "jpeg", 39 | "bmp", 40 | "raw", 41 | "png_a", 42 | "png", 43 | "webp_a", 44 | "webp", 45 | "animated_webp", 46 | "avif", 47 | "animated_avif", 48 | ) 49 | 50 | final val EXTENSIONS_VIDEO = listOf( 51 | "mp4", 52 | "mpeg4", 53 | "webm", 54 | "avi", 55 | "gif", 56 | "3gp", 57 | "avi", 58 | ) 59 | 60 | final val EXTENSIONS_DOCS = listOf( 61 | "txt", 62 | "pdf", 63 | "doc", 64 | "odt", 65 | "ppt", 66 | "pptx", 67 | "odp", 68 | "xls", 69 | "xlsx", 70 | "ods", 71 | ) 72 | 73 | final val EXTENSIONS_AUDIO = listOf( 74 | "aac", 75 | "mp3", 76 | "flac", 77 | "opus", 78 | "midi", 79 | "wav", 80 | "ogg", 81 | ) 82 | } 83 | -------------------------------------------------------------------------------- /app/src/main/java/com/vishnu/whatsappcleaner/MainActivity.kt: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright (C) 2025 Vishnu Sanal T 3 | * 4 | * This file is part of WhatsAppCleaner. 5 | * 6 | * Quotes Status Creator is free software: you can redistribute it and/or modify 7 | * it under the terms of the GNU General Public License as published by 8 | * the Free Software Foundation, either version 3 of the License, or 9 | * (at your option) any later version. 10 | * 11 | * This program is distributed in the hope that it will be useful, 12 | * but WITHOUT ANY WARRANTY; without even the implied warranty of 13 | * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 14 | * GNU General Public License for more details. 15 | * 16 | * You should have received a copy of the GNU General Public License 17 | * along with this program. If not, see . 18 | */ 19 | 20 | package com.vishnu.whatsappcleaner 21 | 22 | import android.Manifest 23 | import android.content.Intent 24 | import android.content.pm.PackageManager 25 | import android.net.Uri 26 | import android.os.Build 27 | import android.os.Build.VERSION_CODES 28 | import android.os.Bundle 29 | import android.os.Environment 30 | import android.provider.DocumentsContract 31 | import android.provider.Settings 32 | import android.widget.Toast 33 | import androidx.activity.ComponentActivity 34 | import androidx.activity.compose.setContent 35 | import androidx.activity.enableEdgeToEdge 36 | import androidx.activity.result.contract.ActivityResultContracts 37 | import androidx.compose.runtime.MutableState 38 | import androidx.compose.runtime.mutableStateOf 39 | import androidx.compose.runtime.remember 40 | import androidx.compose.ui.Modifier 41 | import androidx.core.app.ActivityCompat 42 | import androidx.lifecycle.Lifecycle 43 | import androidx.lifecycle.ViewModelProvider 44 | import androidx.lifecycle.lifecycleScope 45 | import androidx.lifecycle.repeatOnLifecycle 46 | import androidx.navigation.compose.NavHost 47 | import androidx.navigation.compose.composable 48 | import androidx.navigation.compose.rememberNavController 49 | import com.vishnu.whatsappcleaner.ui.DetailsScreen 50 | import com.vishnu.whatsappcleaner.ui.HomeScreen 51 | import com.vishnu.whatsappcleaner.ui.PermissionScreen 52 | import com.vishnu.whatsappcleaner.ui.theme.WhatsAppCleanerTheme 53 | import kotlinx.coroutines.launch 54 | import java.io.File 55 | 56 | class MainActivity : ComponentActivity() { 57 | 58 | private lateinit var viewModel: MainViewModel 59 | 60 | private lateinit var storagePermissionGranted: MutableState 61 | 62 | override fun onCreate(savedInstanceState: Bundle?) { 63 | super.onCreate(savedInstanceState) 64 | enableEdgeToEdge() 65 | 66 | val resultLauncher = 67 | registerForActivityResult(ActivityResultContracts.StartActivityForResult()) { result -> 68 | if (result.resultCode == RESULT_OK && result.data?.data?.path != null) { 69 | val relativePath = result.data!!.data!!.path!!.split(":")[1] 70 | val absolutePath = 71 | Environment.getExternalStorageDirectory().absolutePath + File.separator + relativePath 72 | 73 | viewModel.listDirectories(absolutePath) 74 | 75 | lifecycleScope.launch { 76 | repeatOnLifecycle(Lifecycle.State.STARTED) { 77 | viewModel.directories.collect { dirList -> 78 | if (dirList.toString().contains("/Media")) { 79 | contentResolver.takePersistableUriPermission( 80 | result.data!!.data!!, 81 | Intent.FLAG_GRANT_READ_URI_PERMISSION or Intent.FLAG_GRANT_WRITE_URI_PERMISSION 82 | ) 83 | 84 | viewModel.saveHomeUri(absolutePath) 85 | restartActivity() 86 | } else { 87 | Toast.makeText( 88 | this@MainActivity, 89 | "Wrong directory selected, please select the right directory...", 90 | Toast.LENGTH_SHORT 91 | ).show() 92 | } 93 | } 94 | } 95 | } 96 | } else { 97 | Toast.makeText(this, "Please grant permissions...", Toast.LENGTH_SHORT).show() 98 | } 99 | } 100 | 101 | val storagePermissionResultLauncher = 102 | registerForActivityResult(ActivityResultContracts.StartActivityForResult()) { 103 | if (Build.VERSION.SDK_INT >= VERSION_CODES.R && !Environment.isExternalStorageManager()) { 104 | Toast.makeText( 105 | this, 106 | "Please grant permissions...", 107 | Toast.LENGTH_SHORT 108 | ).show() 109 | } 110 | } 111 | 112 | viewModel = ViewModelProvider( 113 | this, 114 | MainViewModelFactory(application) 115 | ).get(MainViewModel::class.java) 116 | 117 | setContent { 118 | WhatsAppCleanerTheme { 119 | storagePermissionGranted = 120 | remember { 121 | mutableStateOf( 122 | (Build.VERSION.SDK_INT >= VERSION_CODES.R && Environment.isExternalStorageManager()) || 123 | ActivityCompat.checkSelfPermission( 124 | this@MainActivity, 125 | Manifest.permission.WRITE_EXTERNAL_STORAGE 126 | ) == PackageManager.PERMISSION_GRANTED 127 | ) 128 | } 129 | 130 | var startDestination = 131 | if (Build.VERSION.SDK_INT >= VERSION_CODES.R && 132 | Environment.isExternalStorageManager() && 133 | contentResolver.persistedUriPermissions.isNotEmpty() 134 | ) Constants.SCREEN_HOME 135 | else if (ActivityCompat.checkSelfPermission( 136 | this@MainActivity, 137 | Manifest.permission.WRITE_EXTERNAL_STORAGE 138 | ) == PackageManager.PERMISSION_GRANTED && 139 | contentResolver.persistedUriPermissions.isNotEmpty() 140 | ) Constants.SCREEN_HOME 141 | else { 142 | Toast.makeText( 143 | this, 144 | "Please grant all permissions...", 145 | Toast.LENGTH_SHORT 146 | ).show() 147 | Constants.SCREEN_PERMISSION 148 | } 149 | 150 | val navController = rememberNavController() 151 | 152 | NavHost( 153 | modifier = Modifier, 154 | navController = navController, 155 | startDestination = startDestination 156 | ) { 157 | composable(route = Constants.SCREEN_PERMISSION) { 158 | PermissionScreen( 159 | navController = navController, 160 | permissionsGranted = Pair( 161 | storagePermissionGranted.value, 162 | contentResolver.persistedUriPermissions.isNotEmpty() 163 | ), 164 | requestPermission = { 165 | if (Build.VERSION.SDK_INT >= VERSION_CODES.R) { 166 | storagePermissionResultLauncher.launch( 167 | Intent( 168 | Settings.ACTION_MANAGE_APP_ALL_FILES_ACCESS_PERMISSION, 169 | Uri.parse("package:" + packageName) 170 | ) 171 | ) 172 | } else { 173 | if (ActivityCompat.shouldShowRequestPermissionRationale( 174 | this@MainActivity, 175 | Manifest.permission.WRITE_EXTERNAL_STORAGE 176 | ) 177 | ) { 178 | Toast.makeText( 179 | this@MainActivity, 180 | "Storage permission required for the app to work", 181 | Toast.LENGTH_SHORT 182 | ).show() 183 | } 184 | 185 | requestPermissions( 186 | arrayOf( 187 | Manifest.permission.WRITE_EXTERNAL_STORAGE, 188 | Manifest.permission.READ_EXTERNAL_STORAGE 189 | ), 190 | Constants.REQUEST_PERMISSIONS_CODE_WRITE_STORAGE 191 | ) 192 | } 193 | }, 194 | chooseDirectory = { 195 | resultLauncher.launch( 196 | Intent(Intent.ACTION_OPEN_DOCUMENT_TREE).apply { 197 | if (Build.VERSION.SDK_INT >= VERSION_CODES.O) putExtra( 198 | DocumentsContract.EXTRA_INITIAL_URI, 199 | Uri.parse(Constants.WHATSAPP_HOME_URI) 200 | ) 201 | } 202 | ) 203 | }, 204 | ) 205 | } 206 | 207 | composable(route = Constants.SCREEN_HOME) { 208 | HomeScreen(navController, viewModel) 209 | } 210 | 211 | composable(route = Constants.SCREEN_DETAILS) { 212 | DetailsScreen(navController, viewModel) 213 | } 214 | } 215 | } 216 | } 217 | } 218 | 219 | @Deprecated("Deprecated callback") 220 | public override fun onRequestPermissionsResult( 221 | requestCode: Int, 222 | permissions: Array, 223 | grantResults: IntArray 224 | ) { 225 | super.onRequestPermissionsResult(requestCode, permissions, grantResults) 226 | 227 | if (requestCode == Constants.REQUEST_PERMISSIONS_CODE_WRITE_STORAGE) { 228 | for (i in permissions.indices) { 229 | val permission = permissions[i] 230 | val grantResult = grantResults[i] 231 | 232 | if (permission == Manifest.permission.WRITE_EXTERNAL_STORAGE) { 233 | if (grantResult == PackageManager.PERMISSION_GRANTED) { 234 | storagePermissionGranted.value = true 235 | } else { 236 | requestPermissions( 237 | arrayOf( 238 | Manifest.permission.WRITE_EXTERNAL_STORAGE, 239 | Manifest.permission.READ_EXTERNAL_STORAGE 240 | ), 241 | Constants.REQUEST_PERMISSIONS_CODE_WRITE_STORAGE 242 | ) 243 | } 244 | } 245 | } 246 | } 247 | } 248 | 249 | override fun onResume() { 250 | super.onResume() 251 | if (Build.VERSION.SDK_INT >= VERSION_CODES.R && ::storagePermissionGranted.isInitialized) 252 | storagePermissionGranted.value = Environment.isExternalStorageManager() 253 | } 254 | 255 | private fun restartActivity() { 256 | // terrible hack! 257 | val intent = intent 258 | finish() 259 | startActivity(intent) 260 | } 261 | } 262 | -------------------------------------------------------------------------------- /app/src/main/java/com/vishnu/whatsappcleaner/MainViewModel.kt: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright (C) 2025 Vishnu Sanal T 3 | * 4 | * This file is part of WhatsAppCleaner. 5 | * 6 | * Quotes Status Creator is free software: you can redistribute it and/or modify 7 | * it under the terms of the GNU General Public License as published by 8 | * the Free Software Foundation, either version 3 of the License, or 9 | * (at your option) any later version. 10 | * 11 | * This program is distributed in the hope that it will be useful, 12 | * but WITHOUT ANY WARRANTY; without even the implied warranty of 13 | * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 14 | * GNU General Public License for more details. 15 | * 16 | * You should have received a copy of the GNU General Public License 17 | * along with this program. If not, see . 18 | */ 19 | 20 | package com.vishnu.whatsappcleaner 21 | 22 | import android.app.Application 23 | import android.util.Log 24 | import androidx.lifecycle.AndroidViewModel 25 | import androidx.lifecycle.ViewModel 26 | import androidx.lifecycle.ViewModelProvider 27 | import androidx.lifecycle.viewModelScope 28 | import com.vishnu.whatsappcleaner.data.FileRepository 29 | import com.vishnu.whatsappcleaner.data.StoreData 30 | import com.vishnu.whatsappcleaner.model.ListDirectory 31 | import com.vishnu.whatsappcleaner.model.ListFile 32 | import kotlinx.coroutines.Dispatchers 33 | import kotlinx.coroutines.flow.MutableStateFlow 34 | import kotlinx.coroutines.flow.StateFlow 35 | import kotlinx.coroutines.flow.asStateFlow 36 | import kotlinx.coroutines.flow.first 37 | import kotlinx.coroutines.launch 38 | import java.util.Date 39 | 40 | class MainViewModel(private val application: Application) : AndroidViewModel(application) { 41 | private val _fileList = MutableStateFlow>(emptyList()) 42 | val fileList: StateFlow> = _fileList.asStateFlow() 43 | 44 | private val _sentList = MutableStateFlow>(emptyList()) 45 | val sentList: StateFlow> = _sentList.asStateFlow() 46 | 47 | private val _privateList = MutableStateFlow>(emptyList()) 48 | val privateList: StateFlow> = _privateList.asStateFlow() 49 | 50 | private val _isInProgress = MutableStateFlow(false) 51 | val isInProgress: StateFlow = _isInProgress.asStateFlow() 52 | 53 | private val _directories = MutableStateFlow>(emptyList()) 54 | val directories: StateFlow> = _directories 55 | 56 | private val _homeUri = MutableStateFlow("") 57 | val homeUri: StateFlow = _homeUri.asStateFlow() 58 | 59 | private val _fileReloadTrigger = MutableStateFlow(false) 60 | val fileReloadTrigger: StateFlow = _fileReloadTrigger.asStateFlow() 61 | 62 | private val storeData = StoreData(application.applicationContext) 63 | 64 | private val _isGridView = MutableStateFlow(false) 65 | val isGridView: StateFlow = _isGridView.asStateFlow() 66 | 67 | private val _directoryItem = 68 | MutableStateFlow>>>(ViewState.Loading) 69 | val directoryItem: StateFlow>>> = 70 | _directoryItem.asStateFlow() 71 | 72 | init { 73 | viewModelScope.launch(Dispatchers.Default) { 74 | storeData.isGridViewFlow.collect { 75 | _isGridView.value = it 76 | } 77 | getDirectoryList() 78 | } 79 | } 80 | 81 | fun toggleViewType() { 82 | viewModelScope.launch { 83 | val current = storeData.isGridViewFlow.first() 84 | val toggled = !current 85 | storeData.setGridViewPreference(toggled) 86 | } 87 | } 88 | 89 | fun saveHomeUri(homePath: String) { 90 | Log.i("vishnu", "saveHomeUri: $homePath") 91 | viewModelScope.launch(Dispatchers.Default) { 92 | storeData.set( 93 | Constants.WHATSAPP_HOME_URI, 94 | homePath 95 | ) 96 | } 97 | } 98 | 99 | fun getHomeUri() { 100 | viewModelScope.launch(Dispatchers.Default) { 101 | _homeUri.value = storeData.get(Constants.WHATSAPP_HOME_URI) 102 | } 103 | } 104 | 105 | fun getDirectoryList() { 106 | Log.i("vishnu", "getDirectoryList() called") 107 | 108 | viewModelScope.launch(Dispatchers.Default) { 109 | storeData.get(Constants.WHATSAPP_HOME_URI) 110 | ?.let { homeUri -> 111 | val pair = FileRepository.getDirectoryList( 112 | application, 113 | homeUri 114 | ) 115 | Log.e("vishnu", "getDirectoryList: $pair") 116 | _directoryItem.value = ViewState.Success(pair) 117 | } 118 | } 119 | } 120 | 121 | fun getFileList( 122 | target: Target, 123 | path: String, 124 | sortBy: String, 125 | isSortDescending: Boolean, 126 | filterStartDate: Long?, 127 | filterEndDate: Long? 128 | ) { 129 | Log.i("vishnu", "getFileList: $path") 130 | 131 | _isInProgress.value = true 132 | viewModelScope.launch(Dispatchers.Default) { 133 | val fileList = FileRepository.getFileList(application, path) 134 | _isInProgress.value = false 135 | 136 | fileList.sortWith( 137 | when { 138 | sortBy.contains("Name") -> compareBy { it.name } 139 | sortBy.contains("Size") -> compareBy { it.length() } 140 | else -> compareBy { it.lastModified() } 141 | } 142 | ) 143 | 144 | if ( 145 | sortBy.contains("Date") && 146 | filterStartDate != null && 147 | filterEndDate != null 148 | ) { 149 | val filteredList = fileList.filter { 150 | val lastModified = Date(it.lastModified()) 151 | lastModified.after(Date(filterStartDate)) && 152 | lastModified.before( 153 | Date( 154 | filterEndDate 155 | ) 156 | ) 157 | } 158 | fileList.clear() 159 | fileList.addAll(filteredList) 160 | } 161 | 162 | if (isSortDescending) fileList.reverse() 163 | 164 | when (target) { 165 | Target.Received -> _fileList.value = fileList 166 | Target.Sent -> _sentList.value = fileList 167 | Target.Private -> _privateList.value = fileList 168 | } 169 | } 170 | } 171 | 172 | fun listDirectories(path: String) { 173 | Log.i("vishnu", "listDirectories: $path") 174 | 175 | viewModelScope.launch(Dispatchers.Default) { 176 | val dirList = FileRepository.getDirectoryList(path) 177 | _directories.value = dirList 178 | } 179 | } 180 | 181 | fun delete(fileList: List) { 182 | Log.i("vishnu", "delete() called with: fileList = $fileList") 183 | 184 | _isInProgress.value = true 185 | viewModelScope.launch(Dispatchers.IO) { 186 | FileRepository.deleteFiles(fileList) 187 | _isInProgress.value = false 188 | _fileReloadTrigger.value = !_fileReloadTrigger.value 189 | } 190 | } 191 | } 192 | 193 | sealed class Target { 194 | data object Received : Target() 195 | data object Sent : Target() 196 | data object Private : Target() 197 | } 198 | 199 | sealed class ViewState { 200 | data object Loading : ViewState() 201 | data class Success(val data: T) : ViewState() 202 | data class Error(val message: String) : ViewState() 203 | } 204 | 205 | class MainViewModelFactory(private val application: Application) : ViewModelProvider.Factory { 206 | override fun create(modelClass: Class): T { 207 | if (modelClass.isAssignableFrom(MainViewModel::class.java)) { 208 | @Suppress("UNCHECKED_CAST") 209 | return MainViewModel(application) as T 210 | } 211 | throw IllegalArgumentException("Unknown ViewModel class") 212 | } 213 | } 214 | -------------------------------------------------------------------------------- /app/src/main/java/com/vishnu/whatsappcleaner/data/FileRepository.kt: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright (C) 2025 Vishnu Sanal T 3 | * 4 | * This file is part of WhatsAppCleaner. 5 | * 6 | * Quotes Status Creator is free software: you can redistribute it and/or modify 7 | * it under the terms of the GNU General Public License as published by 8 | * the Free Software Foundation, either version 3 of the License, or 9 | * (at your option) any later version. 10 | * 11 | * This program is distributed in the hope that it will be useful, 12 | * but WITHOUT ANY WARRANTY; without even the implied warranty of 13 | * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 14 | * GNU General Public License for more details. 15 | * 16 | * You should have received a copy of the GNU General Public License 17 | * along with this program. If not, see . 18 | */ 19 | 20 | package com.vishnu.whatsappcleaner.data 21 | 22 | import android.content.Context 23 | import android.text.format.Formatter.formatFileSize 24 | import android.util.Log 25 | import com.vishnu.whatsappcleaner.Constants 26 | import com.vishnu.whatsappcleaner.model.ListDirectory 27 | import com.vishnu.whatsappcleaner.model.ListFile 28 | import java.io.File 29 | 30 | class FileRepository { 31 | companion object { 32 | 33 | @JvmStatic 34 | public suspend fun getDirectoryList( 35 | context: Context, 36 | homePath: String 37 | ): Pair> { 38 | Log.i( 39 | "vishnu", 40 | "FileRepository#getDirectoryList: $homePath" 41 | ) 42 | 43 | var totalSize = 0L 44 | 45 | val directoryList = ListDirectory.getDirectoryList(homePath) 46 | 47 | directoryList.forEach { directoryItem -> 48 | 49 | // since this contains expensive operations :) 50 | var size = File(directoryItem.path) 51 | .walkBottomUp() 52 | .filter { f -> f.isFile() } 53 | .map { 54 | if (it.name == ".nomedia") 55 | 0 56 | else 57 | it.length() 58 | }.sum() 59 | 60 | directoryItem.size = formatFileSize(context, size) 61 | 62 | totalSize += size 63 | } 64 | 65 | return Pair( 66 | formatFileSize(context, totalSize), 67 | directoryList 68 | ) 69 | } 70 | 71 | @JvmStatic 72 | public suspend fun getFileList(context: Context, path: String): ArrayList { 73 | Log.i("vishnu", "FileRepository#getFileList: $path") 74 | 75 | val list = ArrayList() 76 | 77 | // flattening... 78 | if (path.contains("Media/WhatsApp Voice Notes") or path.contains("Media/WhatsApp Video Notes")) File( 79 | path 80 | ).walkTopDown().forEach { f -> 81 | if (!f.isDirectory && f.name != ".nomedia") list.add( 82 | ListFile( 83 | f.path, 84 | formatFileSize(context, getSize(f.path)) 85 | ) 86 | ) 87 | } 88 | else File(path).listFiles { dir, name -> 89 | 90 | val f = File("$dir/$name") 91 | 92 | if (!f.isDirectory && f.name != ".nomedia") list.add( 93 | ListFile( 94 | f.path, 95 | formatFileSize(context, getSize(f.path)) 96 | ) 97 | ) 98 | 99 | true 100 | } 101 | 102 | return list 103 | } 104 | 105 | @JvmStatic 106 | public suspend fun getDirectoryList(path: String): ArrayList { 107 | Log.i("vishnu", "FileRepository#getDirectoryList: $path") 108 | 109 | val list = ArrayList() 110 | 111 | File(path).listFiles { dir, name -> 112 | 113 | val f = File("$dir/$name") 114 | 115 | if (f.isDirectory) list.add(f.path) 116 | 117 | true 118 | } 119 | 120 | return list 121 | } 122 | 123 | @JvmStatic 124 | public fun getLoadingList(): ArrayList { 125 | val loadingList = ArrayList() 126 | 127 | for (i in 0 until 10) { 128 | loadingList.add( 129 | ListFile( 130 | Constants.LIST_LOADING_INDICATION, 131 | "0 B" 132 | ) 133 | ) 134 | } 135 | 136 | return loadingList 137 | } 138 | 139 | @JvmStatic 140 | public fun deleteFiles(fileList: List): Boolean { 141 | Log.i("vishnu", "FileRepository#deleteFiles: $fileList") 142 | 143 | fileList.forEach { file -> 144 | file.delete() 145 | } 146 | 147 | return false 148 | } 149 | 150 | private fun getSize(path: String): Long { 151 | // Log.i("vishnu", "getSize() called with: path = $path") 152 | return File(path).walkTopDown().map { it.length() }.sum() 153 | } 154 | } 155 | } 156 | -------------------------------------------------------------------------------- /app/src/main/java/com/vishnu/whatsappcleaner/data/StoreData.kt: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright (C) 2025 Vishnu Sanal T 3 | * 4 | * This file is part of WhatsAppCleaner. 5 | * 6 | * Quotes Status Creator is free software: you can redistribute it and/or modify 7 | * it under the terms of the GNU General Public License as published by 8 | * the Free Software Foundation, either version 3 of the License, or 9 | * (at your option) any later version. 10 | * 11 | * This program is distributed in the hope that it will be useful, 12 | * but WITHOUT ANY WARRANTY; without even the implied warranty of 13 | * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 14 | * GNU General Public License for more details. 15 | * 16 | * You should have received a copy of the GNU General Public License 17 | * along with this program. If not, see . 18 | */ 19 | 20 | package com.vishnu.whatsappcleaner.data 21 | 22 | import android.content.Context 23 | import androidx.datastore.preferences.core.booleanPreferencesKey 24 | import androidx.datastore.preferences.core.edit 25 | import androidx.datastore.preferences.core.stringPreferencesKey 26 | import androidx.datastore.preferences.preferencesDataStore 27 | import kotlinx.coroutines.flow.Flow 28 | import kotlinx.coroutines.flow.first 29 | import kotlinx.coroutines.flow.map 30 | 31 | class StoreData(val context: Context) { 32 | 33 | companion object { 34 | private val Context.dataStore by preferencesDataStore(name = "store_data") 35 | val IS_GRID_VIEW_KEY = booleanPreferencesKey("is_grid_view") 36 | } 37 | 38 | suspend fun set(key: String, value: String) { 39 | context.dataStore.edit { preferences -> 40 | preferences[stringPreferencesKey(key)] = value 41 | } 42 | } 43 | 44 | suspend fun get(key: String): String? = context.dataStore.data.first().get( 45 | stringPreferencesKey(key) 46 | ) 47 | 48 | suspend fun setGridViewPreference(isGridView: Boolean) { 49 | context.dataStore.edit { preferences -> 50 | preferences[IS_GRID_VIEW_KEY] = isGridView 51 | } 52 | } 53 | 54 | val isGridViewFlow: Flow = context.dataStore.data 55 | .map { preferences -> 56 | preferences[IS_GRID_VIEW_KEY] ?: true 57 | } 58 | } 59 | -------------------------------------------------------------------------------- /app/src/main/java/com/vishnu/whatsappcleaner/model/ListDirectory.kt: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright (C) 2025 Vishnu Sanal T 3 | * 4 | * This file is part of WhatsAppCleaner. 5 | * 6 | * Quotes Status Creator is free software: you can redistribute it and/or modify 7 | * it under the terms of the GNU General Public License as published by 8 | * the Free Software Foundation, either version 3 of the License, or 9 | * (at your option) any later version. 10 | * 11 | * This program is distributed in the hope that it will be useful, 12 | * but WITHOUT ANY WARRANTY; without even the implied warranty of 13 | * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 14 | * GNU General Public License for more details. 15 | * 16 | * You should have received a copy of the GNU General Public License 17 | * along with this program. If not, see . 18 | */ 19 | 20 | package com.vishnu.whatsappcleaner.model 21 | 22 | import com.vishnu.whatsappcleaner.R 23 | import java.io.Serializable 24 | 25 | data class ListDirectory( 26 | val name: String, 27 | val path: String, 28 | val icon: Int, 29 | val hasSent: Boolean = true, 30 | val hasPrivate: Boolean = true, 31 | var size: String = "0 B" 32 | ) : Serializable { 33 | companion object { 34 | private const val serialVersionUID: Long = -5435756175248173106L 35 | 36 | fun getDirectoryList(homePath: String): List = listOf( 37 | ListDirectory( 38 | "Images", 39 | "$homePath/Media/WhatsApp Images", 40 | R.drawable.image 41 | ), 42 | ListDirectory( 43 | "Videos", 44 | "$homePath/Media/WhatsApp Video", 45 | R.drawable.video 46 | ), 47 | ListDirectory( 48 | "Documents", 49 | "$homePath/Media/WhatsApp Documents", 50 | R.drawable.document 51 | ), 52 | 53 | ListDirectory( 54 | "Audios", 55 | "$homePath/Media/WhatsApp Audio", 56 | R.drawable.audio 57 | ), 58 | ListDirectory( 59 | "Statuses", 60 | "$homePath/Media/.Statuses", 61 | R.drawable.status, 62 | false, 63 | false 64 | ), 65 | 66 | ListDirectory( 67 | "Voice Notes", 68 | "$homePath/Media/WhatsApp Voice Notes", 69 | R.drawable.voice, 70 | false, 71 | false 72 | ), 73 | ListDirectory( 74 | "Video Notes", 75 | "$homePath/Media/WhatsApp Video Notes", 76 | R.drawable.video_notes, 77 | false, 78 | false 79 | ), 80 | 81 | ListDirectory( 82 | "GIFs", 83 | "$homePath/Media/WhatsApp Animated Gifs", 84 | R.drawable.gif 85 | ), 86 | ListDirectory( 87 | "Wallpapers", 88 | "$homePath/Media/WallPaper", 89 | R.drawable.wallpaper, 90 | false, 91 | false 92 | ), 93 | ListDirectory( 94 | "Stickers", 95 | "$homePath/Media/WhatsApp Stickers", 96 | R.drawable.sticker, 97 | false, 98 | false 99 | ), 100 | ListDirectory( 101 | "Profile Photos", 102 | "$homePath/Media/WhatsApp Profile Photos", 103 | R.drawable.profile, 104 | false, 105 | false 106 | ), 107 | ) 108 | } 109 | } 110 | -------------------------------------------------------------------------------- /app/src/main/java/com/vishnu/whatsappcleaner/model/ListFile.kt: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright (C) 2025 Vishnu Sanal T 3 | * 4 | * This file is part of WhatsAppCleaner. 5 | * 6 | * Quotes Status Creator is free software: you can redistribute it and/or modify 7 | * it under the terms of the GNU General Public License as published by 8 | * the Free Software Foundation, either version 3 of the License, or 9 | * (at your option) any later version. 10 | * 11 | * This program is distributed in the hope that it will be useful, 12 | * but WITHOUT ANY WARRANTY; without even the implied warranty of 13 | * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 14 | * GNU General Public License for more details. 15 | * 16 | * You should have received a copy of the GNU General Public License 17 | * along with this program. If not, see . 18 | */ 19 | 20 | package com.vishnu.whatsappcleaner.model 21 | 22 | import java.io.File 23 | 24 | data class ListFile( 25 | val filePath: String, 26 | var size: String = "0 B", 27 | var isSelected: Boolean = false, 28 | ) : File(filePath) { 29 | companion object { 30 | private const val serialVersionUID: Long = 8425722975465458623L 31 | } 32 | 33 | override fun equals(other: Any?): Boolean { 34 | if (javaClass != other?.javaClass) return false 35 | if (!super.equals(other)) return false 36 | 37 | other as ListFile 38 | 39 | if (filePath != other.filePath) return false 40 | if (size != other.size) return false 41 | if (isSelected != other.isSelected) return false 42 | 43 | return true 44 | } 45 | } 46 | -------------------------------------------------------------------------------- /app/src/main/java/com/vishnu/whatsappcleaner/ui/Components.kt: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright (C) 2025 Vishnu Sanal T 3 | * 4 | * This file is part of WhatsAppCleaner. 5 | * 6 | * Quotes Status Creator is free software: you can redistribute it and/or modify 7 | * it under the terms of the GNU General Public License as published by 8 | * the Free Software Foundation, either version 3 of the License, or 9 | * (at your option) any later version. 10 | * 11 | * This program is distributed in the hope that it will be useful, 12 | * but WITHOUT ANY WARRANTY; without even the implied warranty of 13 | * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 14 | * GNU General Public License for more details. 15 | * 16 | * You should have received a copy of the GNU General Public License 17 | * along with this program. If not, see . 18 | */ 19 | 20 | package com.vishnu.whatsappcleaner.ui 21 | 22 | import android.content.ActivityNotFoundException 23 | import android.content.Context 24 | import android.content.Intent 25 | import android.widget.Toast 26 | import androidx.compose.animation.animateColorAsState 27 | import androidx.compose.foundation.BorderStroke 28 | import androidx.compose.foundation.background 29 | import androidx.compose.foundation.basicMarquee 30 | import androidx.compose.foundation.border 31 | import androidx.compose.foundation.clickable 32 | import androidx.compose.foundation.gestures.detectTapGestures 33 | import androidx.compose.foundation.layout.Arrangement 34 | import androidx.compose.foundation.layout.Box 35 | import androidx.compose.foundation.layout.Column 36 | import androidx.compose.foundation.layout.PaddingValues 37 | import androidx.compose.foundation.layout.Row 38 | import androidx.compose.foundation.layout.Spacer 39 | import androidx.compose.foundation.layout.aspectRatio 40 | import androidx.compose.foundation.layout.fillMaxHeight 41 | import androidx.compose.foundation.layout.fillMaxSize 42 | import androidx.compose.foundation.layout.fillMaxWidth 43 | import androidx.compose.foundation.layout.padding 44 | import androidx.compose.foundation.layout.size 45 | import androidx.compose.foundation.layout.width 46 | import androidx.compose.foundation.layout.wrapContentHeight 47 | import androidx.compose.foundation.shape.CircleShape 48 | import androidx.compose.foundation.shape.RoundedCornerShape 49 | import androidx.compose.material3.ButtonDefaults 50 | import androidx.compose.material3.Card 51 | import androidx.compose.material3.CardDefaults 52 | import androidx.compose.material3.Icon 53 | import androidx.compose.material3.MaterialTheme 54 | import androidx.compose.material3.Text 55 | import androidx.compose.material3.TextButton 56 | import androidx.compose.material3.VerticalDivider 57 | import androidx.compose.runtime.Composable 58 | import androidx.compose.runtime.LaunchedEffect 59 | import androidx.compose.runtime.getValue 60 | import androidx.compose.runtime.key 61 | import androidx.compose.runtime.mutableStateOf 62 | import androidx.compose.runtime.remember 63 | import androidx.compose.runtime.setValue 64 | import androidx.compose.ui.Alignment 65 | import androidx.compose.ui.Modifier 66 | import androidx.compose.ui.draw.clip 67 | import androidx.compose.ui.draw.shadow 68 | import androidx.compose.ui.graphics.Color 69 | import androidx.compose.ui.graphics.vector.ImageVector 70 | import androidx.compose.ui.input.pointer.pointerInput 71 | import androidx.compose.ui.layout.ContentScale 72 | import androidx.compose.ui.res.painterResource 73 | import androidx.compose.ui.res.vectorResource 74 | import androidx.compose.ui.text.AnnotatedString 75 | import androidx.compose.ui.text.SpanStyle 76 | import androidx.compose.ui.text.buildAnnotatedString 77 | import androidx.compose.ui.text.font.FontWeight 78 | import androidx.compose.ui.text.style.TextAlign 79 | import androidx.compose.ui.text.style.TextOverflow 80 | import androidx.compose.ui.text.withStyle 81 | import androidx.compose.ui.unit.dp 82 | import androidx.compose.ui.unit.sp 83 | import androidx.compose.ui.zIndex 84 | import androidx.core.content.ContextCompat.startActivity 85 | import androidx.core.content.FileProvider 86 | import androidx.navigation.NavHostController 87 | import com.bumptech.glide.integration.compose.ExperimentalGlideComposeApi 88 | import com.bumptech.glide.integration.compose.GlideImage 89 | import com.bumptech.glide.integration.compose.placeholder 90 | import com.valentinilk.shimmer.shimmer 91 | import com.vishnu.whatsappcleaner.Constants 92 | import com.vishnu.whatsappcleaner.R 93 | import com.vishnu.whatsappcleaner.ViewState 94 | import com.vishnu.whatsappcleaner.model.ListDirectory 95 | import com.vishnu.whatsappcleaner.model.ListFile 96 | import java.text.DateFormat 97 | 98 | @Composable 99 | fun Title(modifier: Modifier, text: String) { 100 | Text( 101 | modifier = modifier.padding(8.dp), 102 | text = text, 103 | fontSize = 24.sp, 104 | textAlign = TextAlign.Start, 105 | fontWeight = FontWeight.Bold, 106 | ) 107 | } 108 | 109 | @Composable 110 | fun Banner(modifier: Modifier, directoryItem: ViewState>>) { 111 | val bgColor = MaterialTheme.colorScheme.primaryContainer 112 | val textColor = MaterialTheme.colorScheme.onPrimaryContainer 113 | 114 | Column( 115 | modifier = modifier, 116 | verticalArrangement = Arrangement.Center, 117 | horizontalAlignment = Alignment.CenterHorizontally 118 | ) { 119 | Box( 120 | modifier 121 | .padding(12.dp) 122 | .fillMaxWidth(0.4f) 123 | .aspectRatio(1f) 124 | .shadow(elevation = 16.dp, shape = CircleShape) 125 | .background(bgColor, shape = CircleShape), 126 | contentAlignment = Alignment.Center 127 | ) { 128 | Text( 129 | text = buildAnnotatedString { 130 | when (directoryItem) { 131 | is ViewState.Success -> { 132 | var size = directoryItem.data.first 133 | 134 | if (size.contains(" ")) { 135 | val split = size.split(" ") 136 | withStyle(SpanStyle(fontSize = 32.sp)) { 137 | append(split.get(0)) 138 | } 139 | withStyle(SpanStyle(fontSize = 18.sp)) { 140 | append(" ${split.get(1)}") 141 | } 142 | } else { 143 | withStyle(SpanStyle(fontSize = 28.sp)) { 144 | append(size) 145 | } 146 | } 147 | } 148 | 149 | is ViewState.Loading -> withStyle(SpanStyle(fontSize = 18.sp)) { 150 | append("Loading...") 151 | } 152 | 153 | is ViewState.Error -> withStyle(SpanStyle(fontSize = 18.sp)) { 154 | append("Error") 155 | } 156 | } 157 | }, 158 | style = MaterialTheme.typography.titleLarge, 159 | color = textColor, 160 | fontWeight = FontWeight.Bold, 161 | textAlign = TextAlign.Center, 162 | ) 163 | } 164 | } 165 | } 166 | 167 | @Composable 168 | fun Banner(modifier: Modifier, text: AnnotatedString) { 169 | val bgColor = MaterialTheme.colorScheme.primaryContainer 170 | val textColor = MaterialTheme.colorScheme.onPrimaryContainer 171 | 172 | Column( 173 | modifier = modifier, 174 | verticalArrangement = Arrangement.Center, 175 | horizontalAlignment = Alignment.CenterHorizontally 176 | ) { 177 | Box( 178 | Modifier 179 | .padding(12.dp) 180 | .fillMaxWidth(0.4f) 181 | .aspectRatio(1f) 182 | .shadow(elevation = 16.dp, shape = CircleShape) 183 | .background(bgColor, shape = CircleShape), 184 | contentAlignment = Alignment.Center 185 | ) { 186 | Text( 187 | text = text, 188 | style = MaterialTheme.typography.titleLarge, 189 | color = textColor, 190 | fontWeight = FontWeight.Bold, 191 | textAlign = TextAlign.Center, 192 | ) 193 | } 194 | } 195 | } 196 | 197 | @Composable 198 | fun SingleCard( 199 | listDirectory: ListDirectory, 200 | navController: NavHostController, 201 | ) { 202 | val bgColor = MaterialTheme.colorScheme.secondaryContainer 203 | val textColor = MaterialTheme.colorScheme.onSecondaryContainer 204 | 205 | var onClick: () -> Unit 206 | var modifier: Modifier 207 | 208 | if (listDirectory.path.contains(Constants.LIST_LOADING_INDICATION)) { 209 | modifier = Modifier.shimmer() 210 | onClick = { } 211 | } else { 212 | modifier = Modifier 213 | onClick = { 214 | navController.currentBackStackEntry?.savedStateHandle?.apply { 215 | set(Constants.DETAILS_LIST_ITEM, listDirectory) 216 | } 217 | navController.navigate(Constants.SCREEN_DETAILS) 218 | } 219 | } 220 | 221 | Card( 222 | modifier = modifier 223 | .fillMaxWidth() 224 | .padding(horizontal = 16.dp, vertical = 8.dp), 225 | colors = CardDefaults.cardColors(containerColor = bgColor), 226 | onClick = onClick 227 | ) { 228 | Row(verticalAlignment = Alignment.CenterVertically) { 229 | Box( 230 | Modifier 231 | .padding(16.dp) 232 | .fillMaxWidth(0.2f) 233 | .aspectRatio(1f) 234 | .shadow(elevation = 8.dp, shape = CircleShape) 235 | .background(bgColor, shape = CircleShape), 236 | contentAlignment = Alignment.Center 237 | ) { 238 | Icon( 239 | modifier = Modifier.padding(8.dp), 240 | imageVector = ImageVector.vectorResource(id = listDirectory.icon), 241 | contentDescription = "icon", 242 | tint = textColor 243 | ) 244 | } 245 | 246 | Column( 247 | Modifier 248 | .align(Alignment.CenterVertically) 249 | .fillMaxWidth(0.75f), 250 | verticalArrangement = Arrangement.SpaceEvenly 251 | ) { 252 | Text( 253 | modifier = Modifier 254 | .fillMaxWidth() 255 | .align(Alignment.Start) 256 | .padding(2.dp), 257 | text = listDirectory.name, 258 | fontWeight = FontWeight.Medium, 259 | style = MaterialTheme.typography.titleLarge, 260 | color = textColor, 261 | ) 262 | 263 | Text( 264 | modifier = Modifier 265 | .fillMaxWidth() 266 | .align(Alignment.Start) 267 | .padding(2.dp), 268 | text = listDirectory.size, 269 | fontWeight = FontWeight.Medium, 270 | style = MaterialTheme.typography.titleSmall, 271 | color = textColor, 272 | ) 273 | } 274 | } 275 | } 276 | } 277 | 278 | @OptIn(ExperimentalGlideComposeApi::class) 279 | @Composable 280 | fun ItemGridCard( 281 | listFile: ListFile, 282 | navController: NavHostController, 283 | isSelected: Boolean = false, 284 | selectionEnabled: Boolean = true, 285 | toggleSelection: () -> Unit, 286 | ) { 287 | key(listFile) { 288 | // only for keeping track of the UI 289 | var selected by remember { mutableStateOf(isSelected) } 290 | 291 | var modifier = if (listFile.filePath.toString() 292 | .contains(Constants.LIST_LOADING_INDICATION) 293 | ) Modifier.shimmer() 294 | else Modifier 295 | 296 | LaunchedEffect(isSelected) { 297 | selected = isSelected 298 | } 299 | 300 | Card( 301 | modifier = modifier 302 | .fillMaxWidth() 303 | .aspectRatio(1f) 304 | .padding( 305 | if (selected) 16.dp else 8.dp 306 | ), 307 | ) { 308 | Box( 309 | Modifier 310 | .fillMaxSize() 311 | .clip(shape = RoundedCornerShape(8.dp)) 312 | .pointerInput(Unit) { 313 | detectTapGestures(onLongPress = { 314 | if (!selectionEnabled) return@detectTapGestures 315 | 316 | selected = !selected 317 | 318 | if (!listFile.filePath.toString() 319 | .contains(Constants.LIST_LOADING_INDICATION) 320 | ) toggleSelection() 321 | }, onTap = { 322 | if (selectionEnabled && 323 | !listFile.filePath.toString() 324 | .contains(Constants.LIST_LOADING_INDICATION) 325 | ) openFile( 326 | navController.context, 327 | listFile 328 | ) 329 | }) 330 | } 331 | ) { 332 | if (selectionEnabled) Box( 333 | Modifier 334 | .padding(8.dp) 335 | .size(24.dp) 336 | .align(Alignment.TopStart) 337 | .clip(CircleShape) 338 | .border( 339 | BorderStroke( 340 | 2.dp, 341 | if (selected) Color.Unspecified else Color.White, 342 | ), 343 | CircleShape 344 | ) 345 | .aspectRatio(1f) 346 | .zIndex(4f) 347 | .clickable { 348 | selected = !selected 349 | 350 | if (!listFile.filePath.toString() 351 | .contains(Constants.LIST_LOADING_INDICATION) 352 | ) toggleSelection() 353 | } 354 | ) { 355 | if (selected) { 356 | CheckedIcon( 357 | modifier = Modifier 358 | .align(Alignment.Center) 359 | .size(24.dp), 360 | ) 361 | } 362 | } 363 | 364 | if (listFile.extension.lowercase() in Constants.EXTENSIONS_IMAGE) GlideImage( 365 | model = listFile, 366 | contentScale = ContentScale.Crop, 367 | loading = placeholder(R.drawable.image), 368 | failure = placeholder(R.drawable.error), 369 | contentDescription = "details list item" 370 | ) 371 | else if (listFile.extension.lowercase() in Constants.EXTENSIONS_VIDEO) { 372 | GlideImage( 373 | model = listFile, 374 | contentScale = ContentScale.Crop, 375 | loading = placeholder(R.drawable.image), 376 | failure = placeholder(R.drawable.error), 377 | contentDescription = "details list item" 378 | ) 379 | 380 | Icon( 381 | modifier = Modifier 382 | .size(32.dp) 383 | .align(Alignment.Center) 384 | .clip(CircleShape) 385 | .background(MaterialTheme.colorScheme.background.copy(alpha = 0.6f)) 386 | .padding(8.dp) 387 | .aspectRatio(1f) 388 | .zIndex(2f), 389 | painter = painterResource(id = R.drawable.video), 390 | contentDescription = "video", 391 | ) 392 | } else if (listFile.extension.lowercase() in Constants.EXTENSIONS_DOCS) { 393 | Column { 394 | Icon( 395 | modifier = Modifier 396 | .weight(1f) 397 | .fillMaxWidth() 398 | .fillMaxHeight() 399 | .background(MaterialTheme.colorScheme.background.copy(alpha = 0.6f)) 400 | .padding(8.dp), 401 | painter = painterResource(id = R.drawable.document), 402 | contentDescription = "doc", 403 | ) 404 | 405 | Text( 406 | modifier = Modifier 407 | .fillMaxWidth() 408 | .wrapContentHeight() 409 | .padding(8.dp), 410 | text = listFile.name, 411 | textAlign = TextAlign.Center, 412 | maxLines = 2, 413 | minLines = 2, 414 | overflow = TextOverflow.Ellipsis 415 | ) 416 | } 417 | } else if (listFile.extension.lowercase() in Constants.EXTENSIONS_AUDIO) { 418 | Column { 419 | Icon( 420 | modifier = Modifier 421 | .weight(1f) 422 | .fillMaxWidth() 423 | .fillMaxHeight() 424 | .background(MaterialTheme.colorScheme.background.copy(alpha = 0.6f)) 425 | .padding(8.dp), 426 | painter = painterResource(id = R.drawable.audio), 427 | contentDescription = "audio", 428 | ) 429 | 430 | Text( 431 | modifier = Modifier 432 | .fillMaxWidth() 433 | .wrapContentHeight() 434 | .padding(8.dp), 435 | text = listFile.name, 436 | textAlign = TextAlign.Center, 437 | maxLines = 2, 438 | minLines = 2, 439 | overflow = TextOverflow.Ellipsis 440 | ) 441 | } 442 | } else { 443 | Column { 444 | Icon( 445 | modifier = Modifier 446 | .weight(1f) 447 | .fillMaxWidth() 448 | .fillMaxHeight() 449 | .background(MaterialTheme.colorScheme.background.copy(alpha = 0.6f)) 450 | .padding(8.dp), 451 | painter = painterResource(id = R.drawable.unknown), 452 | contentDescription = "unknown", 453 | ) 454 | 455 | Text( 456 | modifier = Modifier 457 | .fillMaxWidth() 458 | .wrapContentHeight() 459 | .padding(8.dp), 460 | text = listFile.name, 461 | textAlign = TextAlign.Center, 462 | maxLines = 2, 463 | minLines = 2, 464 | overflow = TextOverflow.Ellipsis 465 | ) 466 | } 467 | } 468 | } 469 | } 470 | } 471 | } 472 | 473 | @OptIn(ExperimentalGlideComposeApi::class) 474 | @Composable 475 | fun ItemListCard( 476 | listFile: ListFile, 477 | navController: NavHostController, 478 | isSelected: Boolean = false, 479 | selectionEnabled: Boolean = true, 480 | toggleSelection: () -> Unit, 481 | ) { 482 | var selected by remember { mutableStateOf(isSelected) } 483 | 484 | val modifier = if (listFile.filePath.toString().contains(Constants.LIST_LOADING_INDICATION)) 485 | Modifier.shimmer() else Modifier 486 | 487 | LaunchedEffect(isSelected) { 488 | selected = isSelected 489 | } 490 | 491 | Card( 492 | modifier = modifier 493 | .fillMaxWidth() 494 | .padding(vertical = 4.dp, horizontal = 8.dp), 495 | shape = RoundedCornerShape(8.dp), 496 | elevation = CardDefaults.cardElevation(defaultElevation = 4.dp) 497 | ) { 498 | Row( 499 | modifier = Modifier 500 | .fillMaxWidth() 501 | .padding(16.dp) 502 | .pointerInput(Unit) { 503 | detectTapGestures(onLongPress = { 504 | if (!selectionEnabled) return@detectTapGestures 505 | 506 | selected = !selected 507 | 508 | if (!listFile.filePath.toString() 509 | .contains(Constants.LIST_LOADING_INDICATION) 510 | ) toggleSelection() 511 | }, onTap = { 512 | if (selectionEnabled && 513 | !listFile.filePath.toString() 514 | .contains(Constants.LIST_LOADING_INDICATION) 515 | ) openFile( 516 | navController.context, 517 | listFile 518 | ) 519 | }) 520 | }, 521 | verticalAlignment = Alignment.CenterVertically 522 | ) { 523 | if (selectionEnabled) { 524 | Box( 525 | Modifier 526 | .size(48.dp) 527 | .clip(CircleShape) 528 | .clickable { 529 | selected = !selected 530 | if (!listFile.filePath.toString() 531 | .contains(Constants.LIST_LOADING_INDICATION) 532 | ) { 533 | toggleSelection() 534 | } 535 | } 536 | ) { 537 | when { 538 | listFile.extension.lowercase() in Constants.EXTENSIONS_IMAGE -> { 539 | GlideImage( 540 | model = listFile, 541 | contentScale = ContentScale.Crop, 542 | loading = placeholder(R.drawable.image), 543 | failure = placeholder(R.drawable.error), 544 | contentDescription = "image preview", 545 | modifier = Modifier.size(48.dp) 546 | ) 547 | } 548 | 549 | listFile.extension.lowercase() in Constants.EXTENSIONS_VIDEO -> { 550 | Icon( 551 | modifier = Modifier 552 | .size(32.dp) 553 | .align(Alignment.Center) 554 | .clip(CircleShape) 555 | .background(MaterialTheme.colorScheme.background.copy(alpha = 0.6f)) 556 | .padding(8.dp) 557 | .aspectRatio(1f) 558 | .zIndex(2f), 559 | painter = painterResource(id = R.drawable.video), 560 | contentDescription = "video", 561 | ) 562 | 563 | GlideImage( 564 | model = listFile, 565 | contentScale = ContentScale.Crop, 566 | loading = placeholder(R.drawable.image), 567 | failure = placeholder(R.drawable.error), 568 | contentDescription = "details list item" 569 | ) 570 | } 571 | 572 | listFile.extension.lowercase() in Constants.EXTENSIONS_DOCS -> { 573 | Icon( 574 | painter = painterResource(id = R.drawable.document), 575 | contentDescription = "document", 576 | modifier = Modifier.size(48.dp), 577 | tint = MaterialTheme.colorScheme.onSurface 578 | ) 579 | } 580 | 581 | listFile.extension.lowercase() in Constants.EXTENSIONS_AUDIO -> { 582 | Icon( 583 | painter = painterResource(id = R.drawable.audio), 584 | contentDescription = "audio file", 585 | modifier = Modifier.size(48.dp), 586 | tint = MaterialTheme.colorScheme.onSurface 587 | ) 588 | } 589 | 590 | else -> { 591 | Icon( 592 | painter = painterResource(id = R.drawable.unknown), 593 | contentDescription = "unknown file", 594 | modifier = Modifier.size(48.dp), 595 | tint = MaterialTheme.colorScheme.onSurface 596 | ) 597 | } 598 | } 599 | 600 | if (selected) { 601 | CheckedIcon( 602 | modifier = Modifier 603 | .align(Alignment.Center) 604 | .size(24.dp) 605 | .zIndex(3f), 606 | ) 607 | } 608 | } 609 | } 610 | 611 | Spacer(modifier = Modifier.width(8.dp)) 612 | 613 | Column( 614 | modifier = Modifier.weight(1f) 615 | ) { 616 | Text( 617 | modifier = Modifier 618 | .padding(vertical = 4.dp, horizontal = 8.dp) 619 | .fillMaxWidth() 620 | .basicMarquee(), 621 | text = listFile.name, 622 | style = MaterialTheme.typography.bodyLarge, 623 | maxLines = 1, 624 | overflow = TextOverflow.Clip 625 | ) 626 | Row(Modifier.padding(vertical = 4.dp, horizontal = 8.dp)) { 627 | Text( 628 | text = listFile.extension.uppercase(), 629 | style = MaterialTheme.typography.bodySmall, 630 | color = MaterialTheme.colorScheme.onSurfaceVariant 631 | ) 632 | 633 | VerticalDivider( 634 | modifier = Modifier.padding(2.dp), 635 | thickness = 1.dp, 636 | color = MaterialTheme.colorScheme.onSurfaceVariant 637 | ) 638 | 639 | Text( 640 | text = listFile.size, 641 | style = MaterialTheme.typography.bodySmall, 642 | color = MaterialTheme.colorScheme.onSurfaceVariant 643 | ) 644 | 645 | VerticalDivider( 646 | modifier = Modifier.padding(2.dp), 647 | thickness = 1.dp, 648 | color = MaterialTheme.colorScheme.onSurfaceVariant 649 | ) 650 | 651 | Text( 652 | text = DateFormat.getDateInstance().format(listFile.lastModified()), 653 | style = MaterialTheme.typography.bodySmall, 654 | color = MaterialTheme.colorScheme.onSurfaceVariant 655 | ) 656 | } 657 | } 658 | } 659 | } 660 | } 661 | 662 | @Composable 663 | fun CheckedIcon(modifier: Modifier = Modifier) { 664 | Box( 665 | modifier = modifier 666 | .background(MaterialTheme.colorScheme.onPrimary, shape = CircleShape), 667 | contentAlignment = Alignment.Center 668 | ) { 669 | Icon( 670 | modifier = Modifier.matchParentSize(), 671 | painter = painterResource(id = R.drawable.check_circle_filled), 672 | tint = MaterialTheme.colorScheme.primary, 673 | contentDescription = "checkbox" 674 | ) 675 | } 676 | } 677 | 678 | @Composable 679 | fun CleanUpButton( 680 | modifier: Modifier = Modifier, 681 | selectedItems: List, 682 | onShowDialog: () -> Unit 683 | ) { 684 | val isEnabled = selectedItems.isNotEmpty() 685 | 686 | val containerColor by animateColorAsState( 687 | targetValue = if (isEnabled) 688 | MaterialTheme.colorScheme.primary 689 | else 690 | MaterialTheme.colorScheme.surfaceVariant, 691 | label = "ContainerColorAnimation" 692 | ) 693 | 694 | val contentColor by animateColorAsState( 695 | targetValue = if (isEnabled) 696 | MaterialTheme.colorScheme.onPrimary 697 | else 698 | MaterialTheme.colorScheme.onSurfaceVariant, 699 | label = "ContentColorAnimation" 700 | ) 701 | 702 | TextButton( 703 | modifier = modifier.padding(2.dp), 704 | colors = ButtonDefaults.textButtonColors( 705 | containerColor = containerColor, 706 | contentColor = contentColor, 707 | disabledContainerColor = containerColor, 708 | disabledContentColor = contentColor 709 | ), 710 | shape = RoundedCornerShape(64.dp), 711 | contentPadding = PaddingValues(8.dp), 712 | enabled = isEnabled, 713 | onClick = { 714 | if (selectedItems.isNotEmpty()) onShowDialog() 715 | } 716 | ) { 717 | Text( 718 | text = "Cleanup", 719 | fontWeight = FontWeight.Medium, 720 | style = MaterialTheme.typography.titleLarge, 721 | fontSize = 18.sp, 722 | letterSpacing = 1.sp 723 | ) 724 | } 725 | } 726 | 727 | fun openFile(context: Context, listFile: ListFile) { 728 | try { 729 | startActivity( 730 | context, 731 | Intent( 732 | Intent.ACTION_VIEW, 733 | FileProvider.getUriForFile( 734 | context, 735 | context.packageName + ".provider", 736 | listFile 737 | ) 738 | ).addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION), 739 | null 740 | ) 741 | } catch (e: ActivityNotFoundException) { 742 | e.printStackTrace() 743 | Toast.makeText( 744 | context, 745 | "No application found to open this file.", 746 | Toast.LENGTH_SHORT 747 | ).show() 748 | } catch (e: IllegalArgumentException) { 749 | e.printStackTrace() 750 | Toast.makeText( 751 | context, 752 | "Something went wrong...", 753 | Toast.LENGTH_SHORT 754 | ).show() 755 | } 756 | } 757 | -------------------------------------------------------------------------------- /app/src/main/java/com/vishnu/whatsappcleaner/ui/CustomTabLayout.kt: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright (C) 2025 Vishnu Sanal T 3 | * 4 | * This file is part of WhatsAppCleaner. 5 | * 6 | * Quotes Status Creator is free software: you can redistribute it and/or modify 7 | * it under the terms of the GNU General Public License as published by 8 | * the Free Software Foundation, either version 3 of the License, or 9 | * (at your option) any later version. 10 | * 11 | * This program is distributed in the hope that it will be useful, 12 | * but WITHOUT ANY WARRANTY; without even the implied warranty of 13 | * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 14 | * GNU General Public License for more details. 15 | * 16 | * You should have received a copy of the GNU General Public License 17 | * along with this program. If not, see . 18 | */ 19 | 20 | package com.vishnu.whatsappcleaner.ui 21 | 22 | import androidx.compose.animation.animateColorAsState 23 | import androidx.compose.animation.core.LinearEasing 24 | import androidx.compose.animation.core.animateDpAsState 25 | import androidx.compose.animation.core.tween 26 | import androidx.compose.foundation.background 27 | import androidx.compose.foundation.clickable 28 | import androidx.compose.foundation.layout.Arrangement 29 | import androidx.compose.foundation.layout.Box 30 | import androidx.compose.foundation.layout.IntrinsicSize 31 | import androidx.compose.foundation.layout.Row 32 | import androidx.compose.foundation.layout.fillMaxHeight 33 | import androidx.compose.foundation.layout.height 34 | import androidx.compose.foundation.layout.offset 35 | import androidx.compose.foundation.layout.padding 36 | import androidx.compose.foundation.layout.width 37 | import androidx.compose.foundation.shape.CircleShape 38 | import androidx.compose.material3.MaterialTheme 39 | import androidx.compose.material3.Text 40 | import androidx.compose.runtime.Composable 41 | import androidx.compose.runtime.getValue 42 | import androidx.compose.ui.Modifier 43 | import androidx.compose.ui.draw.clip 44 | import androidx.compose.ui.graphics.Color 45 | import androidx.compose.ui.platform.LocalConfiguration 46 | import androidx.compose.ui.text.style.TextAlign 47 | import androidx.compose.ui.tooling.preview.Preview 48 | import androidx.compose.ui.unit.Dp 49 | import androidx.compose.ui.unit.dp 50 | import com.vishnu.whatsappcleaner.ui.theme.WhatsAppCleanerTheme 51 | 52 | @Composable 53 | fun CustomTabLayout( 54 | modifier: Modifier = Modifier, 55 | selectedItemIndex: Int, 56 | items: List, 57 | onTabSelected: (index: Int) -> Unit, 58 | ) { 59 | val screenWidth = LocalConfiguration.current.screenWidthDp.dp 60 | val tabWidth = (screenWidth - 32.dp) / items.size 61 | 62 | val indicatorOffset: Dp by animateDpAsState( 63 | targetValue = tabWidth * selectedItemIndex, 64 | animationSpec = tween(easing = LinearEasing), 65 | label = "indicator-offset" 66 | ) 67 | 68 | Box( 69 | modifier = modifier 70 | .clip(CircleShape) 71 | .height(intrinsicSize = IntrinsicSize.Min), 72 | ) { 73 | TabIndicator( 74 | indicatorWidth = tabWidth, 75 | indicatorOffset = indicatorOffset, 76 | indicatorColor = MaterialTheme.colorScheme.primary, 77 | ) 78 | Row( 79 | modifier = Modifier.clip(CircleShape), 80 | horizontalArrangement = Arrangement.Center, 81 | ) { 82 | items.mapIndexed { index, text -> 83 | val isSelected = index == selectedItemIndex 84 | 85 | TabItem( 86 | isSelected = isSelected, 87 | onClick = { 88 | onTabSelected(index) 89 | }, 90 | tabWidth = tabWidth, 91 | text = text, 92 | ) 93 | } 94 | } 95 | } 96 | } 97 | 98 | @Composable 99 | private fun TabItem( 100 | isSelected: Boolean, 101 | onClick: () -> Unit, 102 | tabWidth: Dp, 103 | text: String, 104 | ) { 105 | val tabTextColor: Color by animateColorAsState( 106 | targetValue = if (isSelected) { 107 | MaterialTheme.colorScheme.onPrimary 108 | } else { 109 | MaterialTheme.colorScheme.onSurfaceVariant 110 | }, 111 | animationSpec = tween(easing = LinearEasing), 112 | ) 113 | 114 | Text( 115 | modifier = Modifier 116 | .clip(CircleShape) 117 | .clickable { 118 | onClick() 119 | } 120 | .width(tabWidth) 121 | .padding(12.dp), 122 | text = text, 123 | color = tabTextColor, 124 | textAlign = TextAlign.Center, 125 | ) 126 | } 127 | 128 | @Composable 129 | private fun TabIndicator( 130 | indicatorWidth: Dp, 131 | indicatorOffset: Dp, 132 | indicatorColor: Color, 133 | ) { 134 | Box( 135 | modifier = Modifier 136 | .fillMaxHeight() 137 | .width( 138 | width = indicatorWidth, 139 | ) 140 | .offset( 141 | x = indicatorOffset, 142 | ) 143 | .clip( 144 | shape = CircleShape, 145 | ) 146 | .background( 147 | color = indicatorColor, 148 | ), 149 | ) 150 | } 151 | 152 | @Preview(showBackground = true, widthDp = 400) 153 | @Composable 154 | private fun CustomTabPreview() { 155 | WhatsAppCleanerTheme { 156 | CustomTabLayout( 157 | modifier = Modifier.padding(12.dp), 158 | items = listOf("Tab 1", "Tab 2", "Tab 3"), 159 | selectedItemIndex = 0, 160 | onTabSelected = {}, 161 | ) 162 | } 163 | } 164 | -------------------------------------------------------------------------------- /app/src/main/java/com/vishnu/whatsappcleaner/ui/DetailsScreen.kt: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright (C) 2025 Vishnu Sanal T 3 | * 4 | * This file is part of WhatsAppCleaner. 5 | * 6 | * Quotes Status Creator is free software: you can redistribute it and/or modify 7 | * it under the terms of the GNU General Public License as published by 8 | * the Free Software Foundation, either version 3 of the License, or 9 | * (at your option) any later version. 10 | * 11 | * This program is distributed in the hope that it will be useful, 12 | * but WITHOUT ANY WARRANTY; without even the implied warranty of 13 | * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 14 | * GNU General Public License for more details. 15 | * 16 | * You should have received a copy of the GNU General Public License 17 | * along with this program. If not, see . 18 | */ 19 | 20 | package com.vishnu.whatsappcleaner.ui 21 | 22 | import androidx.compose.animation.AnimatedVisibility 23 | import androidx.compose.foundation.background 24 | import androidx.compose.foundation.clickable 25 | import androidx.compose.foundation.gestures.awaitEachGesture 26 | import androidx.compose.foundation.gestures.awaitFirstDown 27 | import androidx.compose.foundation.gestures.waitForUpOrCancellation 28 | import androidx.compose.foundation.layout.Arrangement 29 | import androidx.compose.foundation.layout.Column 30 | import androidx.compose.foundation.layout.PaddingValues 31 | import androidx.compose.foundation.layout.Row 32 | import androidx.compose.foundation.layout.Spacer 33 | import androidx.compose.foundation.layout.fillMaxSize 34 | import androidx.compose.foundation.layout.fillMaxWidth 35 | import androidx.compose.foundation.layout.padding 36 | import androidx.compose.foundation.layout.size 37 | import androidx.compose.foundation.layout.width 38 | import androidx.compose.foundation.layout.wrapContentHeight 39 | import androidx.compose.foundation.lazy.LazyColumn 40 | import androidx.compose.foundation.lazy.LazyListState 41 | import androidx.compose.foundation.lazy.grid.GridCells 42 | import androidx.compose.foundation.lazy.grid.LazyGridState 43 | import androidx.compose.foundation.lazy.grid.LazyVerticalGrid 44 | import androidx.compose.foundation.lazy.grid.items 45 | import androidx.compose.foundation.lazy.items 46 | import androidx.compose.foundation.pager.HorizontalPager 47 | import androidx.compose.foundation.pager.rememberPagerState 48 | import androidx.compose.foundation.shape.RoundedCornerShape 49 | import androidx.compose.material3.ButtonDefaults 50 | import androidx.compose.material3.Card 51 | import androidx.compose.material3.CardDefaults 52 | import androidx.compose.material3.DatePickerDialog 53 | import androidx.compose.material3.DateRangePicker 54 | import androidx.compose.material3.DateRangePickerState 55 | import androidx.compose.material3.ExperimentalMaterial3Api 56 | import androidx.compose.material3.Icon 57 | import androidx.compose.material3.IconButton 58 | import androidx.compose.material3.LinearProgressIndicator 59 | import androidx.compose.material3.MaterialTheme 60 | import androidx.compose.material3.OutlinedTextField 61 | import androidx.compose.material3.RadioButton 62 | import androidx.compose.material3.RadioButtonDefaults 63 | import androidx.compose.material3.Scaffold 64 | import androidx.compose.material3.Surface 65 | import androidx.compose.material3.Switch 66 | import androidx.compose.material3.Text 67 | import androidx.compose.material3.TextButton 68 | import androidx.compose.material3.TopAppBar 69 | import androidx.compose.material3.rememberDateRangePickerState 70 | import androidx.compose.runtime.Composable 71 | import androidx.compose.runtime.LaunchedEffect 72 | import androidx.compose.runtime.MutableState 73 | import androidx.compose.runtime.collectAsState 74 | import androidx.compose.runtime.derivedStateOf 75 | import androidx.compose.runtime.getValue 76 | import androidx.compose.runtime.mutableStateListOf 77 | import androidx.compose.runtime.mutableStateOf 78 | import androidx.compose.runtime.remember 79 | import androidx.compose.runtime.setValue 80 | import androidx.compose.ui.Alignment 81 | import androidx.compose.ui.Modifier 82 | import androidx.compose.ui.input.pointer.PointerEventPass 83 | import androidx.compose.ui.input.pointer.pointerInput 84 | import androidx.compose.ui.res.painterResource 85 | import androidx.compose.ui.text.SpanStyle 86 | import androidx.compose.ui.text.buildAnnotatedString 87 | import androidx.compose.ui.text.font.FontWeight 88 | import androidx.compose.ui.text.withStyle 89 | import androidx.compose.ui.unit.dp 90 | import androidx.compose.ui.unit.sp 91 | import androidx.compose.ui.window.Dialog 92 | import androidx.compose.ui.window.DialogProperties 93 | import androidx.navigation.NavHostController 94 | import com.vishnu.whatsappcleaner.Constants 95 | import com.vishnu.whatsappcleaner.MainViewModel 96 | import com.vishnu.whatsappcleaner.R 97 | import com.vishnu.whatsappcleaner.Target 98 | import com.vishnu.whatsappcleaner.model.ListDirectory 99 | import com.vishnu.whatsappcleaner.model.ListFile 100 | import java.text.DateFormat 101 | 102 | @OptIn(ExperimentalMaterial3Api::class) 103 | @Composable 104 | fun DetailsScreen(navController: NavHostController, viewModel: MainViewModel) { 105 | val listDirectory = navController.previousBackStackEntry?.savedStateHandle?.get( 106 | Constants.DETAILS_LIST_ITEM 107 | ) 108 | 109 | if (listDirectory == null) return Surface {} 110 | 111 | val fileList by viewModel.fileList.collectAsState() 112 | val sentList by viewModel.sentList.collectAsState() 113 | val privateList by viewModel.privateList.collectAsState() 114 | val isInProgress by viewModel.isInProgress.collectAsState() 115 | val isGridView by viewModel.isGridView.collectAsState() 116 | val fileReloadTrigger by viewModel.fileReloadTrigger.collectAsState() 117 | 118 | var selectedItems = remember { mutableStateListOf() } 119 | var sortBy = remember { mutableStateOf("Date") } 120 | var isSortDescending = remember { mutableStateOf(true) } 121 | 122 | val dateRangePickerState = rememberDateRangePickerState() 123 | 124 | var showConfirmationDialog by remember { mutableStateOf(false) } 125 | var showSortDialog by remember { mutableStateOf(false) } 126 | var isAllSelected by remember { mutableStateOf(false) } 127 | 128 | val tabs = listOf("Received", "Sent", "Private") 129 | 130 | val pagerState = rememberPagerState( 131 | initialPage = 0, 132 | pageCount = { 133 | if (listDirectory.hasSent) { 134 | if (listDirectory.hasPrivate) 3 135 | else 2 136 | } else 1 137 | } 138 | ) 139 | 140 | var selectedTabIndex by remember { mutableStateOf(pagerState.currentPage) } 141 | 142 | val gridStates = remember { 143 | List(3) { LazyGridState() } 144 | } 145 | val listStates = remember { 146 | List(3) { LazyListState() } 147 | } 148 | 149 | val showHeader by remember { 150 | derivedStateOf { 151 | if (isGridView) { 152 | gridStates[pagerState.currentPage].firstVisibleItemIndex < 1 153 | } else { 154 | listStates[pagerState.currentPage].firstVisibleItemIndex < 1 155 | } 156 | } 157 | } 158 | 159 | LaunchedEffect( 160 | fileReloadTrigger, 161 | sortBy.value, 162 | isSortDescending.value, 163 | dateRangePickerState.selectedStartDateMillis, 164 | dateRangePickerState.selectedEndDateMillis 165 | ) { 166 | viewModel.getFileList( 167 | Target.Received, 168 | listDirectory.path, 169 | sortBy.value, 170 | isSortDescending.value, 171 | dateRangePickerState.selectedStartDateMillis, 172 | dateRangePickerState.selectedEndDateMillis 173 | ) 174 | 175 | if (listDirectory.hasSent) { 176 | viewModel.getFileList( 177 | Target.Sent, 178 | "${listDirectory.path}/Sent", 179 | sortBy.value, 180 | isSortDescending.value, 181 | dateRangePickerState.selectedStartDateMillis, 182 | dateRangePickerState.selectedEndDateMillis 183 | ) 184 | } 185 | 186 | if (listDirectory.hasPrivate) { 187 | viewModel.getFileList( 188 | Target.Private, 189 | "${listDirectory.path}/Private", 190 | sortBy.value, 191 | isSortDescending.value, 192 | dateRangePickerState.selectedStartDateMillis, 193 | dateRangePickerState.selectedEndDateMillis 194 | ) 195 | } 196 | } 197 | 198 | LaunchedEffect(pagerState.currentPage) { 199 | if (selectedItems.isNotEmpty() || isAllSelected) { 200 | selectedItems.clear() 201 | isAllSelected = false 202 | } 203 | } 204 | 205 | LaunchedEffect(selectedTabIndex) { 206 | if (selectedTabIndex != pagerState.currentPage) { 207 | pagerState.animateScrollToPage(selectedTabIndex) 208 | } 209 | } 210 | 211 | LaunchedEffect(pagerState.currentPage) { 212 | selectedTabIndex = pagerState.currentPage 213 | } 214 | 215 | Scaffold( 216 | topBar = { 217 | DetailScreenTopBar( 218 | title = listDirectory.name, 219 | toggleGridView = { 220 | viewModel.toggleViewType() 221 | }, 222 | isGridView = isGridView, 223 | onSortClick = { 224 | showSortDialog = true 225 | dateRangePickerState.setSelection(null, null) 226 | selectedItems.clear() 227 | isAllSelected = false 228 | } 229 | ) 230 | } 231 | ) { innerPadding -> 232 | Column( 233 | modifier = Modifier 234 | .fillMaxSize() 235 | .padding(innerPadding), 236 | horizontalAlignment = Alignment.CenterHorizontally 237 | ) { 238 | if (isInProgress) LinearProgressIndicator( 239 | modifier = Modifier 240 | .fillMaxWidth() 241 | .wrapContentHeight() 242 | .padding(8.dp), 243 | ) 244 | 245 | AnimatedVisibility( 246 | visible = showHeader, 247 | ) { 248 | Banner( 249 | Modifier.padding(16.dp), 250 | buildAnnotatedString { 251 | val size = listDirectory.size 252 | val parts = size.split(" ") 253 | if (parts.size == 2) { 254 | withStyle(SpanStyle(fontSize = 24.sp)) { append(parts[0]) } 255 | withStyle(SpanStyle(fontSize = 18.sp)) { append(" ${parts[1]}") } 256 | } else { 257 | withStyle(SpanStyle(fontSize = 24.sp)) { append(size) } 258 | } 259 | } 260 | ) 261 | } 262 | 263 | if (listDirectory.hasSent || listDirectory.hasPrivate) { 264 | CustomTabLayout( 265 | modifier = Modifier 266 | .fillMaxWidth() 267 | .padding(horizontal = 16.dp, vertical = 8.dp), 268 | selectedItemIndex = pagerState.currentPage, 269 | items = tabs, 270 | onTabSelected = { index -> 271 | selectedTabIndex = index 272 | } 273 | ) 274 | } 275 | 276 | HorizontalPager( 277 | state = pagerState, 278 | modifier = Modifier 279 | .weight(1f) 280 | .fillMaxWidth() 281 | ) { page -> 282 | val list = when (page) { 283 | 0 -> fileList 284 | 1 -> sentList 285 | else -> privateList 286 | } 287 | 288 | Column( 289 | Modifier 290 | .fillMaxSize() 291 | ) { 292 | if (list.isNotEmpty()) { 293 | IconButton( 294 | modifier = Modifier 295 | .align(Alignment.End) 296 | .padding(8.dp) 297 | .size(32.dp), 298 | onClick = { 299 | isAllSelected = !isAllSelected 300 | if (isAllSelected) selectedItems.addAll(list) 301 | else selectedItems.clear() 302 | } 303 | ) { 304 | Icon( 305 | modifier = Modifier.size(32.dp), 306 | painter = painterResource(id = if (isAllSelected) R.drawable.check_circle_filled else R.drawable.check_circle), 307 | tint = MaterialTheme.colorScheme.primary, 308 | contentDescription = "select all" 309 | ) 310 | } 311 | } 312 | 313 | if (list.isNotEmpty()) { 314 | if (isGridView) { 315 | LazyVerticalGrid( 316 | state = gridStates[page], 317 | modifier = Modifier 318 | .fillMaxSize() 319 | .padding(8.dp), 320 | columns = GridCells.Fixed(3) 321 | ) { 322 | items(list) { 323 | ItemGridCard(it, navController, selectedItems.contains(it)) { 324 | if (selectedItems.contains(it)) selectedItems.remove(it) 325 | else selectedItems.add(it) 326 | } 327 | } 328 | } 329 | } else { 330 | LazyColumn( 331 | state = listStates[page], 332 | modifier = Modifier 333 | .fillMaxSize() 334 | .padding(8.dp) 335 | ) { 336 | items(list) { 337 | ItemListCard(it, navController, selectedItems.contains(it)) { 338 | if (selectedItems.contains(it)) selectedItems.remove(it) 339 | else selectedItems.add(it) 340 | } 341 | } 342 | } 343 | } 344 | } else { 345 | Column( 346 | modifier = Modifier.fillMaxSize(), 347 | verticalArrangement = Arrangement.Center, 348 | horizontalAlignment = Alignment.CenterHorizontally 349 | ) { 350 | Icon( 351 | modifier = Modifier 352 | .fillMaxSize(0.4f) 353 | .padding(8.dp), 354 | painter = painterResource(id = R.drawable.clean), 355 | contentDescription = "empty", 356 | tint = MaterialTheme.colorScheme.secondaryContainer 357 | ) 358 | Text( 359 | text = "Nothing to clean", 360 | fontSize = 24.sp, 361 | fontWeight = FontWeight.Medium 362 | ) 363 | } 364 | } 365 | } 366 | } 367 | 368 | CleanUpButton( 369 | modifier = Modifier 370 | .fillMaxWidth() 371 | .padding(horizontal = 16.dp, vertical = 4.dp), 372 | selectedItems = selectedItems, 373 | onShowDialog = { showConfirmationDialog = true } 374 | ) 375 | } 376 | } 377 | 378 | if (showSortDialog) { 379 | SortDialog( 380 | navController, 381 | onDismissRequest = { 382 | showSortDialog = false 383 | }, 384 | sortBy, 385 | isSortDescending, 386 | dateRangePickerState 387 | ) 388 | } 389 | 390 | if (showConfirmationDialog) { 391 | ConfirmationDialog( 392 | onDismissRequest = { 393 | showConfirmationDialog = false 394 | selectedItems.clear() 395 | }, 396 | onConfirmation = { 397 | viewModel.delete(selectedItems.toList()) 398 | showConfirmationDialog = false 399 | selectedItems.clear() 400 | }, 401 | selectedItems, 402 | navController 403 | ) 404 | } 405 | } 406 | 407 | @OptIn(ExperimentalMaterial3Api::class) 408 | @Composable 409 | fun DetailScreenTopBar( 410 | modifier: Modifier = Modifier, 411 | title: String = "", 412 | toggleGridView: () -> Unit, 413 | isGridView: Boolean, 414 | onSortClick: () -> Unit 415 | ) { 416 | TopAppBar( 417 | modifier = modifier, 418 | title = { 419 | Title(text = title, modifier = Modifier) 420 | }, 421 | actions = { 422 | IconButton( 423 | modifier = Modifier 424 | .size(32.dp), 425 | onClick = { 426 | toggleGridView() 427 | } 428 | ) { 429 | Icon( 430 | modifier = Modifier 431 | .size(32.dp), 432 | painter = 433 | if (isGridView) 434 | painterResource(id = R.drawable.ic_view_list) 435 | else 436 | painterResource(id = R.drawable.ic_grid_view), 437 | tint = MaterialTheme.colorScheme.primary, 438 | contentDescription = "grid list view", 439 | ) 440 | } 441 | 442 | Spacer(modifier = Modifier.width(12.dp)) 443 | 444 | IconButton( 445 | modifier = Modifier 446 | .size(32.dp), 447 | onClick = { onSortClick() } 448 | ) { 449 | Icon( 450 | modifier = Modifier.size(32.dp), 451 | painter = painterResource(id = R.drawable.ic_sort), 452 | tint = MaterialTheme.colorScheme.primary, 453 | contentDescription = "sort", 454 | ) 455 | } 456 | Spacer(modifier = Modifier.width(8.dp)) 457 | } 458 | ) 459 | } 460 | 461 | @OptIn(ExperimentalMaterial3Api::class) 462 | @Composable 463 | fun SortDialog( 464 | navController: NavHostController, 465 | onDismissRequest: () -> Unit, 466 | sortBy: MutableState, 467 | isSortDescending: MutableState, 468 | dateRangePickerState: DateRangePickerState, 469 | ) { 470 | var showDatePicker by remember { mutableStateOf(false) } 471 | 472 | Dialog( 473 | onDismissRequest = onDismissRequest, 474 | properties = DialogProperties( 475 | usePlatformDefaultWidth = false, 476 | dismissOnClickOutside = true, 477 | dismissOnBackPress = true, 478 | decorFitsSystemWindows = true 479 | ), 480 | ) { 481 | var isDescending by remember { mutableStateOf(isSortDescending) } 482 | var selectedItem by remember { mutableStateOf(sortBy) } 483 | 484 | Card( 485 | modifier = Modifier 486 | .fillMaxWidth() 487 | .wrapContentHeight() 488 | .padding(vertical = 64.dp, horizontal = 32.dp), 489 | shape = RoundedCornerShape(16.dp), 490 | colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.background) 491 | ) { 492 | Column( 493 | modifier = Modifier 494 | .fillMaxWidth() 495 | .wrapContentHeight() 496 | .padding(16.dp), 497 | ) { 498 | Text( 499 | modifier = Modifier 500 | .wrapContentHeight() 501 | .padding(8.dp), 502 | text = "Sort Criteria", 503 | style = MaterialTheme.typography.headlineLarge, 504 | ) 505 | 506 | listOf( 507 | "Date", 508 | "Size", 509 | "Name", 510 | ).forEach { item -> 511 | Row( 512 | modifier = Modifier 513 | .fillMaxWidth() 514 | .clickable { 515 | selectedItem.value = item 516 | }, 517 | verticalAlignment = Alignment.CenterVertically, 518 | ) { 519 | RadioButton( 520 | selected = sortBy.value == item, 521 | onClick = { 522 | selectedItem.value = item 523 | }, 524 | enabled = true, 525 | colors = RadioButtonDefaults.colors( 526 | selectedColor = MaterialTheme.colorScheme.primary 527 | ) 528 | ) 529 | Text(text = item, modifier = Modifier.padding(start = 8.dp)) 530 | } 531 | } 532 | 533 | if (showDatePicker) DatePickerDialog( 534 | onDismissRequest = { showDatePicker = false }, 535 | confirmButton = { 536 | TextButton( 537 | onClick = { 538 | showDatePicker = false 539 | } 540 | ) { Text("OK") } 541 | }, 542 | dismissButton = { 543 | TextButton( 544 | onClick = { 545 | showDatePicker = false 546 | } 547 | ) { Text("Cancel") } 548 | } 549 | ) { 550 | DateRangePicker(state = dateRangePickerState) 551 | } 552 | 553 | Row( 554 | modifier = Modifier 555 | .padding(8.dp), 556 | verticalAlignment = Alignment.CenterVertically 557 | ) { 558 | when (selectedItem.value) { 559 | "Date" -> { 560 | OutlinedTextField( 561 | modifier = Modifier 562 | .fillMaxWidth() 563 | .weight(1f) 564 | .pointerInput(Unit) { 565 | awaitEachGesture { 566 | awaitFirstDown(pass = PointerEventPass.Initial) 567 | val upEvent = 568 | waitForUpOrCancellation(pass = PointerEventPass.Initial) 569 | if (upEvent != null) { 570 | showDatePicker = true 571 | } 572 | } 573 | }, 574 | readOnly = true, 575 | value = if (dateRangePickerState.selectedEndDateMillis != null) 576 | DateFormat.getDateInstance() 577 | .format(dateRangePickerState.selectedStartDateMillis) 578 | else 579 | "N/A", 580 | onValueChange = {}, 581 | label = { Text("From Date") }, 582 | ) 583 | 584 | Spacer(modifier = Modifier.width(8.dp)) 585 | 586 | OutlinedTextField( 587 | modifier = Modifier 588 | .fillMaxWidth() 589 | .weight(1f) 590 | .pointerInput(Unit) { 591 | awaitEachGesture { 592 | awaitFirstDown(pass = PointerEventPass.Initial) 593 | val upEvent = 594 | waitForUpOrCancellation(pass = PointerEventPass.Initial) 595 | if (upEvent != null) { 596 | showDatePicker = true 597 | } 598 | } 599 | }, 600 | readOnly = true, 601 | value = if (dateRangePickerState.selectedEndDateMillis != null) 602 | DateFormat.getDateInstance() 603 | .format(dateRangePickerState.selectedEndDateMillis) 604 | else 605 | "N/A", 606 | onValueChange = {}, 607 | label = { Text("To Date") }, 608 | ) 609 | } 610 | } 611 | } 612 | 613 | Row( 614 | modifier = Modifier 615 | .padding(8.dp), 616 | verticalAlignment = Alignment.CenterVertically 617 | ) { 618 | Switch( 619 | checked = isDescending.value, 620 | onCheckedChange = { isDescending.value = it } 621 | ) 622 | 623 | Text(text = "Descending", modifier = Modifier.padding(start = 8.dp)) 624 | } 625 | 626 | TextButton( 627 | modifier = Modifier 628 | .fillMaxWidth() 629 | .padding(4.dp), 630 | colors = ButtonDefaults.outlinedButtonColors(containerColor = MaterialTheme.colorScheme.primaryContainer), 631 | shape = RoundedCornerShape(16.dp), 632 | contentPadding = PaddingValues(4.dp), 633 | onClick = { 634 | sortBy.value = selectedItem.value 635 | isSortDescending.value = isDescending.value 636 | onDismissRequest() 637 | } 638 | ) { 639 | Text( 640 | text = buildAnnotatedString { 641 | withStyle( 642 | SpanStyle( 643 | color = MaterialTheme.colorScheme.onPrimaryContainer, 644 | fontWeight = FontWeight.Medium, 645 | letterSpacing = 1.sp 646 | ) 647 | ) { 648 | append("Apply") 649 | } 650 | }, 651 | fontWeight = FontWeight.Medium, 652 | style = MaterialTheme.typography.bodyLarge 653 | ) 654 | } 655 | } 656 | } 657 | } 658 | } 659 | 660 | @Composable 661 | fun ConfirmationDialog( 662 | onDismissRequest: () -> Unit, 663 | onConfirmation: () -> Unit, 664 | list: List, 665 | navController: NavHostController 666 | ) { 667 | Dialog( 668 | onDismissRequest = onDismissRequest, 669 | properties = DialogProperties( 670 | usePlatformDefaultWidth = false, 671 | dismissOnClickOutside = true, 672 | dismissOnBackPress = true, 673 | decorFitsSystemWindows = true 674 | ), 675 | ) { 676 | Card( 677 | modifier = Modifier 678 | .fillMaxWidth() 679 | .wrapContentHeight() 680 | .padding(vertical = 64.dp, horizontal = 32.dp), 681 | shape = RoundedCornerShape(16.dp), 682 | colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.background) 683 | ) { 684 | Column( 685 | modifier = Modifier 686 | .fillMaxWidth() 687 | .wrapContentHeight() 688 | .padding(16.dp), 689 | ) { 690 | Row( 691 | modifier = Modifier.wrapContentHeight(), 692 | horizontalArrangement = Arrangement.Center, 693 | ) { 694 | Column( 695 | Modifier 696 | .weight(0.6f) 697 | .fillMaxWidth() 698 | ) { 699 | Text( 700 | modifier = Modifier 701 | .wrapContentHeight() 702 | .padding(vertical = 4.dp) 703 | .align(Alignment.Start), 704 | text = "Confirm Cleanup", 705 | style = MaterialTheme.typography.titleLarge, 706 | ) 707 | 708 | Text( 709 | modifier = Modifier 710 | .wrapContentHeight() 711 | .padding(vertical = 2.dp) 712 | .align(Alignment.Start), 713 | text = "The following files will be deleted.", 714 | style = MaterialTheme.typography.bodyMedium, 715 | ) 716 | } 717 | 718 | TextButton( 719 | modifier = Modifier 720 | .weight(0.4f) 721 | .fillMaxWidth() 722 | .padding(8.dp), 723 | colors = ButtonDefaults.outlinedButtonColors( 724 | containerColor = MaterialTheme.colorScheme.primaryContainer, 725 | contentColor = MaterialTheme.colorScheme.onPrimaryContainer 726 | ), 727 | shape = RoundedCornerShape(16.dp), 728 | contentPadding = PaddingValues(vertical = 16.dp, horizontal = 16.dp), 729 | onClick = onConfirmation, 730 | content = { 731 | Text( 732 | text = "Confirm", 733 | style = MaterialTheme.typography.titleMedium, 734 | fontWeight = FontWeight.Medium 735 | ) 736 | }, 737 | ) 738 | } 739 | 740 | // todo: no preview & replace it with count + red colored CTA 741 | LazyVerticalGrid( 742 | modifier = Modifier 743 | .wrapContentHeight(), 744 | columns = GridCells.Fixed(3), 745 | ) { 746 | items(list) { ItemGridCard(it, navController, selectionEnabled = false) {} } 747 | } 748 | } 749 | } 750 | } 751 | } 752 | -------------------------------------------------------------------------------- /app/src/main/java/com/vishnu/whatsappcleaner/ui/HomeScreen.kt: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright (C) 2025 Vishnu Sanal T 3 | * 4 | * This file is part of WhatsAppCleaner. 5 | * 6 | * Quotes Status Creator is free software: you can redistribute it and/or modify 7 | * it under the terms of the GNU General Public License as published by 8 | * the Free Software Foundation, either version 3 of the License, or 9 | * (at your option) any later version. 10 | * 11 | * This program is distributed in the hope that it will be useful, 12 | * but WITHOUT ANY WARRANTY; without even the implied warranty of 13 | * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 14 | * GNU General Public License for more details. 15 | * 16 | * You should have received a copy of the GNU General Public License 17 | * along with this program. If not, see . 18 | */ 19 | 20 | package com.vishnu.whatsappcleaner.ui 21 | 22 | import androidx.compose.foundation.layout.Arrangement 23 | import androidx.compose.foundation.layout.Column 24 | import androidx.compose.foundation.layout.fillMaxSize 25 | import androidx.compose.foundation.layout.padding 26 | import androidx.compose.foundation.lazy.LazyColumn 27 | import androidx.compose.foundation.lazy.items 28 | import androidx.compose.material3.ExperimentalMaterial3Api 29 | import androidx.compose.material3.MaterialTheme 30 | import androidx.compose.material3.Scaffold 31 | import androidx.compose.material3.Text 32 | import androidx.compose.material3.TopAppBar 33 | import androidx.compose.runtime.Composable 34 | import androidx.compose.runtime.LaunchedEffect 35 | import androidx.compose.runtime.State 36 | import androidx.compose.runtime.collectAsState 37 | import androidx.compose.runtime.getValue 38 | import androidx.compose.runtime.mutableStateOf 39 | import androidx.compose.runtime.remember 40 | import androidx.compose.runtime.setValue 41 | import androidx.compose.ui.Alignment 42 | import androidx.compose.ui.Modifier 43 | import androidx.compose.ui.res.stringResource 44 | import androidx.compose.ui.text.SpanStyle 45 | import androidx.compose.ui.text.buildAnnotatedString 46 | import androidx.compose.ui.text.withStyle 47 | import androidx.compose.ui.unit.dp 48 | import androidx.compose.ui.unit.sp 49 | import androidx.navigation.NavHostController 50 | import com.valentinilk.shimmer.shimmer 51 | import com.vishnu.whatsappcleaner.BuildConfig 52 | import com.vishnu.whatsappcleaner.Constants 53 | import com.vishnu.whatsappcleaner.MainViewModel 54 | import com.vishnu.whatsappcleaner.R 55 | import com.vishnu.whatsappcleaner.ViewState 56 | import com.vishnu.whatsappcleaner.model.ListDirectory 57 | 58 | @Composable 59 | fun HomeScreen(navController: NavHostController, viewModel: MainViewModel) { 60 | var forceReload by remember { mutableStateOf(false) } 61 | 62 | forceReload = navController.currentBackStackEntry?.savedStateHandle?.get( 63 | Constants.FORCE_RELOAD_FILE_LIST 64 | ) ?: false 65 | 66 | val directoryItem: State>>> = 67 | viewModel.directoryItem.collectAsState() 68 | 69 | if (forceReload) { 70 | viewModel.getDirectoryList() 71 | } 72 | 73 | // trigger when moving from permission screen to home screen on Android 15 74 | LaunchedEffect(key1 = null) { 75 | viewModel.getDirectoryList() 76 | } 77 | 78 | Scaffold( 79 | topBar = { 80 | HomeTopBar(modifier = Modifier) 81 | } 82 | ) { contentPadding -> 83 | Column( 84 | Modifier.padding(contentPadding), 85 | horizontalAlignment = Alignment.CenterHorizontally, 86 | verticalArrangement = Arrangement.Center 87 | ) { 88 | val modifier = if (directoryItem.value is ViewState.Success) Modifier 89 | else Modifier.shimmer() 90 | 91 | when (directoryItem.value) { 92 | is ViewState.Success -> { 93 | LazyColumn( 94 | Modifier.weight(1f), 95 | horizontalAlignment = Alignment.CenterHorizontally 96 | ) { 97 | item { 98 | ListSizeHeader( 99 | modifier, 100 | directoryItem 101 | ) 102 | } 103 | items((directoryItem.value as ViewState.Success>>).data.second) { 104 | SingleCard(it, navController) 105 | } 106 | } 107 | } 108 | 109 | is ViewState.Loading -> LazyColumn( 110 | Modifier.weight(1f), 111 | horizontalAlignment = Alignment.CenterHorizontally 112 | ) { 113 | item { 114 | ListSizeHeader( 115 | modifier, 116 | directoryItem 117 | ) 118 | } 119 | items(ListDirectory.getDirectoryList(Constants.LIST_LOADING_INDICATION)) { 120 | SingleCard(it, navController) 121 | } 122 | } 123 | 124 | is ViewState.Error -> Text( 125 | modifier = Modifier 126 | .fillMaxSize() 127 | .align(Alignment.CenterHorizontally) 128 | .padding(16.dp), 129 | text = "Error loading directory list\n${(directoryItem.value as ViewState.Error).message}", 130 | style = MaterialTheme.typography.labelSmall, 131 | ) 132 | } 133 | 134 | if (BuildConfig.DEBUG) 135 | Text( 136 | modifier = Modifier 137 | .align(Alignment.CenterHorizontally) 138 | .padding(4.dp), 139 | text = "Debug Build ${BuildConfig.VERSION_NAME}", 140 | style = MaterialTheme.typography.labelSmall, 141 | ) 142 | } 143 | } 144 | } 145 | 146 | @Composable 147 | fun ListSizeHeader( 148 | modifier: Modifier = Modifier, 149 | viewState: State>>> 150 | ) = Banner( 151 | modifier.padding(16.dp), 152 | buildAnnotatedString { 153 | when (viewState.value) { 154 | is ViewState.Success -> { 155 | var size = 156 | (viewState.value as ViewState.Success>>).data.first 157 | 158 | if (size.contains(" ")) { 159 | val split = size.split(" ") 160 | withStyle(SpanStyle(fontSize = 24.sp)) { 161 | append(split.get(0)) 162 | } 163 | withStyle(SpanStyle(fontSize = 18.sp)) { 164 | append(" ${split.get(1)}") 165 | } 166 | } else { 167 | withStyle(SpanStyle(fontSize = 24.sp)) { 168 | append(size) 169 | } 170 | } 171 | } 172 | 173 | is ViewState.Loading -> withStyle(SpanStyle(fontSize = 18.sp)) { 174 | append("Loading...") 175 | } 176 | 177 | is ViewState.Error -> withStyle(SpanStyle(fontSize = 18.sp)) { 178 | append("Error") 179 | } 180 | } 181 | } 182 | ) 183 | 184 | @OptIn(ExperimentalMaterial3Api::class) 185 | @Composable 186 | fun HomeTopBar(modifier: Modifier = Modifier) { 187 | TopAppBar( 188 | modifier = modifier, 189 | title = { 190 | Title( 191 | modifier = Modifier, 192 | text = stringResource(R.string.app_name) 193 | ) 194 | } 195 | ) 196 | } 197 | -------------------------------------------------------------------------------- /app/src/main/java/com/vishnu/whatsappcleaner/ui/PermissionScreen.kt: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright (C) 2025 Vishnu Sanal T 3 | * 4 | * This file is part of WhatsAppCleaner. 5 | * 6 | * Quotes Status Creator is free software: you can redistribute it and/or modify 7 | * it under the terms of the GNU General Public License as published by 8 | * the Free Software Foundation, either version 3 of the License, or 9 | * (at your option) any later version. 10 | * 11 | * This program is distributed in the hope that it will be useful, 12 | * but WITHOUT ANY WARRANTY; without even the implied warranty of 13 | * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 14 | * GNU General Public License for more details. 15 | * 16 | * You should have received a copy of the GNU General Public License 17 | * along with this program. If not, see . 18 | */ 19 | 20 | package com.vishnu.whatsappcleaner.ui 21 | 22 | import androidx.compose.foundation.background 23 | import androidx.compose.foundation.layout.Column 24 | import androidx.compose.foundation.layout.Spacer 25 | import androidx.compose.foundation.layout.fillMaxHeight 26 | import androidx.compose.foundation.layout.fillMaxWidth 27 | import androidx.compose.foundation.layout.padding 28 | import androidx.compose.material3.MaterialTheme 29 | import androidx.compose.material3.OutlinedButton 30 | import androidx.compose.material3.Scaffold 31 | import androidx.compose.material3.Text 32 | import androidx.compose.runtime.Composable 33 | import androidx.compose.ui.Alignment 34 | import androidx.compose.ui.Modifier 35 | import androidx.compose.ui.layout.ContentScale 36 | import androidx.compose.ui.text.style.TextAlign 37 | import androidx.compose.ui.unit.dp 38 | import androidx.navigation.NavHostController 39 | import com.bumptech.glide.integration.compose.ExperimentalGlideComposeApi 40 | import com.bumptech.glide.integration.compose.GlideImage 41 | import com.bumptech.glide.integration.compose.placeholder 42 | import com.vishnu.whatsappcleaner.R 43 | 44 | @OptIn(ExperimentalGlideComposeApi::class) 45 | @Composable 46 | fun PermissionScreen( 47 | navController: NavHostController, 48 | permissionsGranted: Pair, 49 | requestPermission: () -> Unit, 50 | chooseDirectory: () -> Unit 51 | ) { 52 | Scaffold( 53 | Modifier.background(MaterialTheme.colorScheme.background) 54 | ) { contentPadding -> 55 | Column(Modifier.padding(contentPadding)) { 56 | Text( 57 | modifier = Modifier.padding(16.dp, 32.dp, 16.dp, 0.dp), 58 | text = "Welcome!", 59 | style = MaterialTheme.typography.titleLarge, 60 | textAlign = TextAlign.Start, 61 | color = MaterialTheme.colorScheme.onBackground, 62 | ) 63 | 64 | Text( 65 | modifier = Modifier.padding(16.dp), 66 | text = "Thanks for installing WhatsAppCleaner. Please follow this quick setup to get started.", 67 | style = MaterialTheme.typography.bodyMedium, 68 | textAlign = TextAlign.Start, 69 | color = MaterialTheme.colorScheme.onBackground, 70 | ) 71 | 72 | Text( 73 | modifier = Modifier 74 | .fillMaxWidth() 75 | .padding(horizontal = 16.dp, vertical = 4.dp), 76 | text = "1. Please grant the required permissions to WhatsAppCleaner", 77 | textAlign = TextAlign.Justify, 78 | style = MaterialTheme.typography.bodyMedium, 79 | ) 80 | 81 | OutlinedButton( 82 | modifier = Modifier 83 | .fillMaxWidth() 84 | .padding(16.dp), 85 | enabled = !permissionsGranted.first, 86 | content = { 87 | Text( 88 | if (!permissionsGranted.first) 89 | "Grant storage permissions" 90 | else 91 | "Storage permission granted" 92 | ) 93 | }, 94 | onClick = { 95 | requestPermission() 96 | } 97 | ) 98 | 99 | if (!permissionsGranted.first) 100 | Spacer(Modifier.weight(1f)) 101 | 102 | Text( 103 | modifier = Modifier 104 | .fillMaxWidth() 105 | .padding(horizontal = 16.dp, vertical = 4.dp), 106 | text = "2. Please grant access to the WhatsApp directory as shown in the picture below", 107 | textAlign = TextAlign.Justify, 108 | style = MaterialTheme.typography.bodyMedium, 109 | ) 110 | 111 | OutlinedButton( 112 | modifier = Modifier 113 | .fillMaxWidth() 114 | .padding(16.dp), 115 | enabled = permissionsGranted.first && !permissionsGranted.second, 116 | content = { 117 | Text( 118 | if (!permissionsGranted.first) 119 | "Grant storage permissions" 120 | else 121 | "Choose WhatsApp directory" 122 | ) 123 | }, 124 | onClick = { 125 | chooseDirectory() 126 | }, 127 | ) 128 | 129 | if (permissionsGranted.first && !permissionsGranted.second) 130 | GlideImage( 131 | modifier = Modifier 132 | .padding(16.dp) 133 | .align(Alignment.CenterHorizontally) 134 | .fillMaxWidth() 135 | .fillMaxHeight(), 136 | model = R.drawable.permission_hint, 137 | contentScale = ContentScale.Inside, 138 | loading = placeholder(R.drawable.image), 139 | failure = placeholder(R.drawable.error), 140 | contentDescription = "permission hint" 141 | ) 142 | } 143 | } 144 | } 145 | -------------------------------------------------------------------------------- /app/src/main/java/com/vishnu/whatsappcleaner/ui/theme/Color.kt: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright (C) 2025 Vishnu Sanal T 3 | * 4 | * This file is part of WhatsAppCleaner. 5 | * 6 | * Quotes Status Creator is free software: you can redistribute it and/or modify 7 | * it under the terms of the GNU General Public License as published by 8 | * the Free Software Foundation, either version 3 of the License, or 9 | * (at your option) any later version. 10 | * 11 | * This program is distributed in the hope that it will be useful, 12 | * but WITHOUT ANY WARRANTY; without even the implied warranty of 13 | * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 14 | * GNU General Public License for more details. 15 | * 16 | * You should have received a copy of the GNU General Public License 17 | * along with this program. If not, see . 18 | */ 19 | 20 | package com.vishnu.whatsappcleaner.ui.theme 21 | 22 | import androidx.compose.ui.graphics.Color 23 | 24 | val primaryLight = Color(0xFF296A47) 25 | val onPrimaryLight = Color(0xFFFFFFFF) 26 | val primaryContainerLight = Color(0xFFAEF2C5) 27 | val onPrimaryContainerLight = Color(0xFF002110) 28 | val secondaryLight = Color(0xFF4E6355) 29 | val onSecondaryLight = Color(0xFFFFFFFF) 30 | val secondaryContainerLight = Color(0xFFD1E8D6) 31 | val onSecondaryContainerLight = Color(0xFF0C1F14) 32 | val tertiaryLight = Color(0xFF3B6471) 33 | val onTertiaryLight = Color(0xFFFFFFFF) 34 | val tertiaryContainerLight = Color(0xFFBFE9F8) 35 | val onTertiaryContainerLight = Color(0xFF001F27) 36 | val errorLight = Color(0xFFBA1A1A) 37 | val onErrorLight = Color(0xFFFFFFFF) 38 | val errorContainerLight = Color(0xFFFFDAD6) 39 | val onErrorContainerLight = Color(0xFF410002) 40 | val backgroundLight = Color(0xFFF6FBF4) 41 | val onBackgroundLight = Color(0xFF171D19) 42 | val surfaceLight = Color(0xFFF6FBF4) 43 | val onSurfaceLight = Color(0xFF171D19) 44 | val surfaceVariantLight = Color(0xFFDCE5DC) 45 | val onSurfaceVariantLight = Color(0xFF404942) 46 | val outlineLight = Color(0xFF717972) 47 | val outlineVariantLight = Color(0xFFC0C9C0) 48 | val scrimLight = Color(0xFF000000) 49 | val inverseSurfaceLight = Color(0xFF2C322D) 50 | val inverseOnSurfaceLight = Color(0xFFEDF2EB) 51 | val inversePrimaryLight = Color(0xFF93D5AA) 52 | val surfaceDimLight = Color(0xFFD6DBD5) 53 | val surfaceBrightLight = Color(0xFFF6FBF4) 54 | val surfaceContainerLowestLight = Color(0xFFFFFFFF) 55 | val surfaceContainerLowLight = Color(0xFFF0F5EE) 56 | val surfaceContainerLight = Color(0xFFEAEFE8) 57 | val surfaceContainerHighLight = Color(0xFFE4EAE3) 58 | val surfaceContainerHighestLight = Color(0xFFDFE4DD) 59 | 60 | val primaryDark = Color(0xFF93D5AA) 61 | val onPrimaryDark = Color(0xFF003920) 62 | val primaryContainerDark = Color(0xFF095131) 63 | val onPrimaryContainerDark = Color(0xFFAEF2C5) 64 | val secondaryDark = Color(0xFFB5CCBA) 65 | val onSecondaryDark = Color(0xFF213528) 66 | val secondaryContainerDark = Color(0xFF374B3E) 67 | val onSecondaryContainerDark = Color(0xFFD1E8D6) 68 | val tertiaryDark = Color(0xFFA3CDDB) 69 | val onTertiaryDark = Color(0xFF033541) 70 | val tertiaryContainerDark = Color(0xFF224C58) 71 | val onTertiaryContainerDark = Color(0xFFBFE9F8) 72 | val errorDark = Color(0xFFFFB4AB) 73 | val onErrorDark = Color(0xFF690005) 74 | val errorContainerDark = Color(0xFF93000A) 75 | val onErrorContainerDark = Color(0xFFFFDAD6) 76 | val backgroundDark = Color(0xFF0F1511) 77 | val onBackgroundDark = Color(0xFFDFE4DD) 78 | val surfaceDark = Color(0xFF0F1511) 79 | val onSurfaceDark = Color(0xFFDFE4DD) 80 | val surfaceVariantDark = Color(0xFF404942) 81 | val onSurfaceVariantDark = Color(0xFFC0C9C0) 82 | val outlineDark = Color(0xFF8A938B) 83 | val outlineVariantDark = Color(0xFF404942) 84 | val scrimDark = Color(0xFF000000) 85 | val inverseSurfaceDark = Color(0xFFDFE4DD) 86 | val inverseOnSurfaceDark = Color(0xFF2C322D) 87 | val inversePrimaryDark = Color(0xFF296A47) 88 | val surfaceDimDark = Color(0xFF0F1511) 89 | val surfaceBrightDark = Color(0xFF353B36) 90 | val surfaceContainerLowestDark = Color(0xFF0A0F0C) 91 | val surfaceContainerLowDark = Color(0xFF171D19) 92 | val surfaceContainerDark = Color(0xFF1B211D) 93 | val surfaceContainerHighDark = Color(0xFF262B27) 94 | val surfaceContainerHighestDark = Color(0xFF313632) 95 | -------------------------------------------------------------------------------- /app/src/main/java/com/vishnu/whatsappcleaner/ui/theme/Theme.kt: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright (C) 2025 Vishnu Sanal T 3 | * 4 | * This file is part of WhatsAppCleaner. 5 | * 6 | * Quotes Status Creator is free software: you can redistribute it and/or modify 7 | * it under the terms of the GNU General Public License as published by 8 | * the Free Software Foundation, either version 3 of the License, or 9 | * (at your option) any later version. 10 | * 11 | * This program is distributed in the hope that it will be useful, 12 | * but WITHOUT ANY WARRANTY; without even the implied warranty of 13 | * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 14 | * GNU General Public License for more details. 15 | * 16 | * You should have received a copy of the GNU General Public License 17 | * along with this program. If not, see . 18 | */ 19 | 20 | package com.vishnu.whatsappcleaner.ui.theme 21 | 22 | import android.graphics.Typeface 23 | import android.os.Build 24 | import android.os.Build.VERSION_CODES 25 | import androidx.compose.foundation.isSystemInDarkTheme 26 | import androidx.compose.material3.MaterialTheme 27 | import androidx.compose.material3.Typography 28 | import androidx.compose.material3.darkColorScheme 29 | import androidx.compose.material3.dynamicDarkColorScheme 30 | import androidx.compose.material3.dynamicLightColorScheme 31 | import androidx.compose.material3.lightColorScheme 32 | import androidx.compose.runtime.Composable 33 | import androidx.compose.ui.platform.LocalContext 34 | import androidx.compose.ui.text.font.Font 35 | import androidx.compose.ui.text.font.FontFamily 36 | import com.vishnu.whatsappcleaner.R 37 | 38 | private val LightColorScheme = lightColorScheme( 39 | primary = primaryLight, 40 | onPrimary = onPrimaryLight, 41 | primaryContainer = primaryContainerLight, 42 | onPrimaryContainer = onPrimaryContainerLight, 43 | secondary = secondaryLight, 44 | onSecondary = onSecondaryLight, 45 | secondaryContainer = secondaryContainerLight, 46 | onSecondaryContainer = onSecondaryContainerLight, 47 | tertiary = tertiaryLight, 48 | onTertiary = onTertiaryLight, 49 | tertiaryContainer = tertiaryContainerLight, 50 | onTertiaryContainer = onTertiaryContainerLight, 51 | error = errorLight, 52 | onError = onErrorLight, 53 | errorContainer = errorContainerLight, 54 | onErrorContainer = onErrorContainerLight, 55 | background = backgroundLight, 56 | onBackground = onBackgroundLight, 57 | surface = surfaceLight, 58 | onSurface = onSurfaceLight, 59 | surfaceVariant = surfaceVariantLight, 60 | onSurfaceVariant = onSurfaceVariantLight, 61 | outline = outlineLight, 62 | outlineVariant = outlineVariantLight, 63 | scrim = scrimLight, 64 | inverseSurface = inverseSurfaceLight, 65 | inverseOnSurface = inverseOnSurfaceLight, 66 | inversePrimary = inversePrimaryLight, 67 | surfaceDim = surfaceDimLight, 68 | surfaceBright = surfaceBrightLight, 69 | surfaceContainerLowest = surfaceContainerLowestLight, 70 | surfaceContainerLow = surfaceContainerLowLight, 71 | surfaceContainer = surfaceContainerLight, 72 | surfaceContainerHigh = surfaceContainerHighLight, 73 | surfaceContainerHighest = surfaceContainerHighestLight, 74 | ) 75 | 76 | private val DarkColorScheme = darkColorScheme( 77 | primary = primaryDark, 78 | onPrimary = onPrimaryDark, 79 | primaryContainer = primaryContainerDark, 80 | onPrimaryContainer = onPrimaryContainerDark, 81 | secondary = secondaryDark, 82 | onSecondary = onSecondaryDark, 83 | secondaryContainer = secondaryContainerDark, 84 | onSecondaryContainer = onSecondaryContainerDark, 85 | tertiary = tertiaryDark, 86 | onTertiary = onTertiaryDark, 87 | tertiaryContainer = tertiaryContainerDark, 88 | onTertiaryContainer = onTertiaryContainerDark, 89 | error = errorDark, 90 | onError = onErrorDark, 91 | errorContainer = errorContainerDark, 92 | onErrorContainer = onErrorContainerDark, 93 | background = backgroundDark, 94 | onBackground = onBackgroundDark, 95 | surface = surfaceDark, 96 | onSurface = onSurfaceDark, 97 | surfaceVariant = surfaceVariantDark, 98 | onSurfaceVariant = onSurfaceVariantDark, 99 | outline = outlineDark, 100 | outlineVariant = outlineVariantDark, 101 | scrim = scrimDark, 102 | inverseSurface = inverseSurfaceDark, 103 | inverseOnSurface = inverseOnSurfaceDark, 104 | inversePrimary = inversePrimaryDark, 105 | surfaceDim = surfaceDimDark, 106 | surfaceBright = surfaceBrightDark, 107 | surfaceContainerLowest = surfaceContainerLowestDark, 108 | surfaceContainerLow = surfaceContainerLowDark, 109 | surfaceContainer = surfaceContainerDark, 110 | surfaceContainerHigh = surfaceContainerHighDark, 111 | surfaceContainerHighest = surfaceContainerHighestDark, 112 | ) 113 | 114 | private val fontFamily = if (Build.VERSION.SDK_INT > VERSION_CODES.N) 115 | FontFamily(Font(R.font.geist)) 116 | else 117 | FontFamily(Typeface.DEFAULT) 118 | 119 | @Composable 120 | fun WhatsAppCleanerTheme( 121 | darkTheme: Boolean = isSystemInDarkTheme(), 122 | // Dynamic color is available on Android 12+ 123 | dynamicColor: Boolean = false, 124 | content: @Composable () -> Unit 125 | ) { 126 | val colorScheme = when { 127 | dynamicColor && Build.VERSION.SDK_INT >= VERSION_CODES.S -> { 128 | val context = LocalContext.current 129 | if (darkTheme) dynamicDarkColorScheme(context) else dynamicLightColorScheme(context) 130 | } 131 | 132 | darkTheme -> DarkColorScheme 133 | else -> LightColorScheme 134 | } 135 | 136 | val typography = Typography( 137 | displayLarge = Typography.displayLarge.copy(fontFamily = fontFamily), 138 | displayMedium = Typography.displayMedium.copy(fontFamily = fontFamily), 139 | displaySmall = Typography.displaySmall.copy(fontFamily = fontFamily), 140 | 141 | headlineLarge = Typography.headlineLarge.copy(fontFamily = fontFamily), 142 | headlineMedium = Typography.headlineMedium.copy(fontFamily = fontFamily), 143 | headlineSmall = Typography.headlineSmall.copy(fontFamily = fontFamily), 144 | 145 | titleLarge = Typography.titleLarge.copy(fontFamily = fontFamily), 146 | titleMedium = Typography.titleMedium.copy(fontFamily = fontFamily), 147 | titleSmall = Typography.titleSmall.copy(fontFamily = fontFamily), 148 | 149 | bodyLarge = Typography.bodyLarge.copy(fontFamily = fontFamily), 150 | bodyMedium = Typography.bodyMedium.copy(fontFamily = fontFamily), 151 | bodySmall = Typography.bodySmall.copy(fontFamily = fontFamily), 152 | 153 | labelLarge = Typography.labelLarge.copy(fontFamily = fontFamily), 154 | labelMedium = Typography.labelMedium.copy(fontFamily = fontFamily), 155 | labelSmall = Typography.labelSmall.copy(fontFamily = fontFamily) 156 | ) 157 | 158 | MaterialTheme( 159 | colorScheme = colorScheme, 160 | typography = typography, 161 | content = content 162 | ) 163 | } 164 | -------------------------------------------------------------------------------- /app/src/main/java/com/vishnu/whatsappcleaner/ui/theme/Type.kt: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright (C) 2025 Vishnu Sanal T 3 | * 4 | * This file is part of WhatsAppCleaner. 5 | * 6 | * Quotes Status Creator is free software: you can redistribute it and/or modify 7 | * it under the terms of the GNU General Public License as published by 8 | * the Free Software Foundation, either version 3 of the License, or 9 | * (at your option) any later version. 10 | * 11 | * This program is distributed in the hope that it will be useful, 12 | * but WITHOUT ANY WARRANTY; without even the implied warranty of 13 | * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 14 | * GNU General Public License for more details. 15 | * 16 | * You should have received a copy of the GNU General Public License 17 | * along with this program. If not, see . 18 | */ 19 | 20 | package com.vishnu.whatsappcleaner.ui.theme 21 | 22 | import androidx.compose.material3.Typography 23 | import androidx.compose.ui.text.TextStyle 24 | import androidx.compose.ui.text.font.FontFamily 25 | import androidx.compose.ui.text.font.FontWeight 26 | import androidx.compose.ui.unit.sp 27 | 28 | // Set of Material typography styles to start with 29 | val Typography = Typography( 30 | bodyLarge = TextStyle( 31 | fontFamily = FontFamily.Default, 32 | fontWeight = FontWeight.Normal, 33 | fontSize = 16.sp, 34 | lineHeight = 24.sp, 35 | letterSpacing = 0.5.sp 36 | ) 37 | /* Other default text styles to override 38 | titleLarge = TextStyle( 39 | fontFamily = FontFamily.Default, 40 | fontWeight = FontWeight.Normal, 41 | fontSize = 22.sp, 42 | lineHeight = 28.sp, 43 | letterSpacing = 0.sp 44 | ), 45 | labelSmall = TextStyle( 46 | fontFamily = FontFamily.Default, 47 | fontWeight = FontWeight.Medium, 48 | fontSize = 11.sp, 49 | lineHeight = 16.sp, 50 | letterSpacing = 0.5.sp 51 | ) 52 | */ 53 | ) 54 | -------------------------------------------------------------------------------- /app/src/main/res/drawable/audio.xml: -------------------------------------------------------------------------------- 1 | 6 | 9 | 10 | -------------------------------------------------------------------------------- /app/src/main/res/drawable/check_circle.xml: -------------------------------------------------------------------------------- 1 | 6 | 9 | 10 | -------------------------------------------------------------------------------- /app/src/main/res/drawable/check_circle_filled.xml: -------------------------------------------------------------------------------- 1 | 7 | 10 | 11 | -------------------------------------------------------------------------------- /app/src/main/res/drawable/clean.xml: -------------------------------------------------------------------------------- 1 | 7 | 8 | 11 | 12 | 13 | -------------------------------------------------------------------------------- /app/src/main/res/drawable/document.xml: -------------------------------------------------------------------------------- 1 | 6 | 9 | 10 | -------------------------------------------------------------------------------- /app/src/main/res/drawable/empty.xml: -------------------------------------------------------------------------------- 1 | 6 | 7 | 10 | 11 | 12 | -------------------------------------------------------------------------------- /app/src/main/res/drawable/error.xml: -------------------------------------------------------------------------------- 1 | 6 | 7 | 10 | 11 | 12 | -------------------------------------------------------------------------------- /app/src/main/res/drawable/gif.xml: -------------------------------------------------------------------------------- 1 | 6 | 9 | 10 | -------------------------------------------------------------------------------- /app/src/main/res/drawable/ic_grid_view.xml: -------------------------------------------------------------------------------- 1 | 6 | 7 | 10 | 11 | 14 | 15 | 18 | 19 | 22 | 23 | 24 | -------------------------------------------------------------------------------- /app/src/main/res/drawable/ic_select_all.xml: -------------------------------------------------------------------------------- 1 | 6 | 9 | 10 | -------------------------------------------------------------------------------- /app/src/main/res/drawable/ic_sort.xml: -------------------------------------------------------------------------------- 1 | 7 | 10 | 11 | -------------------------------------------------------------------------------- /app/src/main/res/drawable/ic_view_list.xml: -------------------------------------------------------------------------------- 1 | 7 | 8 | 11 | 12 | 13 | -------------------------------------------------------------------------------- /app/src/main/res/drawable/image.xml: -------------------------------------------------------------------------------- 1 | 6 | 9 | 10 | -------------------------------------------------------------------------------- /app/src/main/res/drawable/open_in.xml: -------------------------------------------------------------------------------- 1 | 8 | 11 | 12 | 13 | -------------------------------------------------------------------------------- /app/src/main/res/drawable/permission_hint.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/VishnuSanal/WhatsAppCleaner/e1ce32ff5d0a3160938c233fb815d8946be8027a/app/src/main/res/drawable/permission_hint.webp -------------------------------------------------------------------------------- /app/src/main/res/drawable/profile.xml: -------------------------------------------------------------------------------- 1 | 6 | 9 | 10 | -------------------------------------------------------------------------------- /app/src/main/res/drawable/recycling.xml: -------------------------------------------------------------------------------- 1 | 6 | 9 | -------------------------------------------------------------------------------- /app/src/main/res/drawable/recycling_round.xml: -------------------------------------------------------------------------------- 1 | 6 | 9 | -------------------------------------------------------------------------------- /app/src/main/res/drawable/status.xml: -------------------------------------------------------------------------------- 1 | 6 | 9 | 10 | -------------------------------------------------------------------------------- /app/src/main/res/drawable/sticker.xml: -------------------------------------------------------------------------------- 1 | 6 | 9 | 10 | -------------------------------------------------------------------------------- /app/src/main/res/drawable/unknown.xml: -------------------------------------------------------------------------------- 1 | 6 | 7 | 10 | 11 | 12 | -------------------------------------------------------------------------------- /app/src/main/res/drawable/video.xml: -------------------------------------------------------------------------------- 1 | 6 | 9 | 10 | -------------------------------------------------------------------------------- /app/src/main/res/drawable/video_notes.xml: -------------------------------------------------------------------------------- 1 | 6 | 9 | 10 | 11 | -------------------------------------------------------------------------------- /app/src/main/res/drawable/voice.xml: -------------------------------------------------------------------------------- 1 | 6 | 9 | 10 | -------------------------------------------------------------------------------- /app/src/main/res/drawable/wallpaper.xml: -------------------------------------------------------------------------------- 1 | 6 | 9 | 10 | -------------------------------------------------------------------------------- /app/src/main/res/font/geist.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/VishnuSanal/WhatsAppCleaner/e1ce32ff5d0a3160938c233fb815d8946be8027a/app/src/main/res/font/geist.ttf -------------------------------------------------------------------------------- /app/src/main/res/values/colors.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | #FFBB86FC 4 | #FF6200EE 5 | #FF3700B3 6 | #FF03DAC5 7 | #FF018786 8 | #FF000000 9 | #FFFFFFFF 10 | -------------------------------------------------------------------------------- /app/src/main/res/values/strings.xml: -------------------------------------------------------------------------------- 1 | 2 | WhatsAppCleaner 3 | ca-app-pub-9851547532747429~9247958807 4 | ca-app-pub-9851547532747429/4256631784 5 | ca-app-pub-9851547532747429/5956726261 6 | -------------------------------------------------------------------------------- /app/src/main/res/values/themes.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 |