├── .editorconfig ├── .github ├── ISSUE_TEMPLATE │ ├── bug_report.md │ ├── feature_request.md │ └── quick-crash-report.md └── workflows │ ├── build_debug.yml │ └── release_build.yml ├── .gitignore ├── LICENSE ├── README.md ├── STATUS.md ├── app ├── build.gradle.kts ├── proguard.pro └── src │ ├── androidTest │ ├── assets │ │ ├── fdroid_index_v1.jar │ │ ├── fdroid_index_v1.json │ │ ├── fdroid_index_v2.json │ │ ├── index-v1.jar │ │ ├── izzy_diff.json │ │ ├── izzy_entry.jar │ │ ├── izzy_entry.json │ │ ├── izzy_index_v1.jar │ │ ├── izzy_index_v1.json │ │ ├── izzy_index_v2.json │ │ └── izzy_index_v2_updated.json │ └── kotlin │ │ └── com │ │ └── looker │ │ └── droidify │ │ ├── index │ │ └── RepositoryUpdaterTest.kt │ │ └── sync │ │ ├── Downloader.kt │ │ ├── EntrySyncableTest.kt │ │ ├── IndexValidator.kt │ │ ├── V1SyncableTest.kt │ │ └── common │ │ ├── Benchmark.kt │ │ ├── Repo.kt │ │ └── Resource.kt │ ├── main │ ├── AndroidManifest.xml │ ├── ic_launcher-playstore.png │ ├── kotlin │ │ └── com │ │ │ └── looker │ │ │ └── droidify │ │ │ ├── Droidify.kt │ │ │ ├── MainActivity.kt │ │ │ ├── content │ │ │ └── ProductPreferences.kt │ │ │ ├── database │ │ │ ├── CursorOwner.kt │ │ │ ├── Database.kt │ │ │ ├── ObservableCursor.kt │ │ │ ├── QueryBuilder.kt │ │ │ ├── QueryLoader.kt │ │ │ └── RepositoryExporter.kt │ │ │ ├── datastore │ │ │ ├── PreferenceSettingsRepository.kt │ │ │ ├── Settings.kt │ │ │ ├── SettingsRepository.kt │ │ │ ├── exporter │ │ │ │ └── SettingsExporter.kt │ │ │ ├── extension │ │ │ │ └── Preferences.kt │ │ │ ├── migration │ │ │ │ └── ProtoToPreferenceMigration.kt │ │ │ └── model │ │ │ │ ├── AutoSync.kt │ │ │ │ ├── InstallerType.kt │ │ │ │ ├── LegacyInstallerComponent.kt │ │ │ │ ├── ProxyPreference.kt │ │ │ │ ├── ProxyType.kt │ │ │ │ ├── SortOrder.kt │ │ │ │ └── Theme.kt │ │ │ ├── di │ │ │ ├── CoroutinesModule.kt │ │ │ ├── DatastoreModule.kt │ │ │ ├── InstallModule.kt │ │ │ └── NetworkModule.kt │ │ │ ├── domain │ │ │ ├── AppRepository.kt │ │ │ ├── RepoRepository.kt │ │ │ └── model │ │ │ │ ├── App.kt │ │ │ │ ├── DataFile.kt │ │ │ │ ├── Fingerprint.kt │ │ │ │ ├── Package.kt │ │ │ │ ├── PackageName.kt │ │ │ │ └── Repo.kt │ │ │ ├── graphics │ │ │ ├── DrawableWrapper.kt │ │ │ └── PaddingDrawable.kt │ │ │ ├── index │ │ │ ├── IndexMerger.kt │ │ │ ├── IndexV1Parser.kt │ │ │ └── RepositoryUpdater.kt │ │ │ ├── installer │ │ │ ├── InstallManager.kt │ │ │ ├── installers │ │ │ │ ├── Installer.kt │ │ │ │ ├── InstallerPermission.kt │ │ │ │ ├── LegacyInstaller.kt │ │ │ │ ├── root │ │ │ │ │ └── RootInstaller.kt │ │ │ │ ├── session │ │ │ │ │ ├── SessionInstaller.kt │ │ │ │ │ └── SessionInstallerReceiver.kt │ │ │ │ └── shizuku │ │ │ │ │ └── ShizukuInstaller.kt │ │ │ ├── model │ │ │ │ ├── InstallItem.kt │ │ │ │ └── InstallState.kt │ │ │ └── notification │ │ │ │ └── InstallNotification.kt │ │ │ ├── model │ │ │ ├── InstalledItem.kt │ │ │ ├── Product.kt │ │ │ ├── ProductItem.kt │ │ │ ├── ProductPreference.kt │ │ │ ├── Release.kt │ │ │ └── Repository.kt │ │ │ ├── network │ │ │ ├── DataSize.kt │ │ │ ├── Downloader.kt │ │ │ ├── KtorDownloader.kt │ │ │ ├── NetworkResponse.kt │ │ │ ├── header │ │ │ │ ├── HeadersBuilder.kt │ │ │ │ └── KtorHeadersBuilder.kt │ │ │ └── validation │ │ │ │ ├── FileValidator.kt │ │ │ │ └── ValidationException.kt │ │ │ ├── receivers │ │ │ └── InstalledAppReceiver.kt │ │ │ ├── service │ │ │ ├── Connection.kt │ │ │ ├── ConnectionService.kt │ │ │ ├── DownloadService.kt │ │ │ ├── ReleaseFileValidator.kt │ │ │ └── SyncService.kt │ │ │ ├── sync │ │ │ ├── IndexValidator.kt │ │ │ ├── Parser.kt │ │ │ ├── SyncPreference.kt │ │ │ ├── Syncable.kt │ │ │ ├── common │ │ │ │ ├── IndexConverter.kt │ │ │ │ ├── IndexDownloader.kt │ │ │ │ ├── IndexJarValidator.kt │ │ │ │ └── JsonParser.kt │ │ │ ├── utils │ │ │ │ └── JarFile.kt │ │ │ ├── v1 │ │ │ │ ├── V1Parser.kt │ │ │ │ ├── V1Syncable.kt │ │ │ │ └── model │ │ │ │ │ ├── AppV1.kt │ │ │ │ │ ├── IndexV1.kt │ │ │ │ │ ├── Localized.kt │ │ │ │ │ ├── PackageV1.kt │ │ │ │ │ └── RepoV1.kt │ │ │ └── v2 │ │ │ │ ├── DiffParser.kt │ │ │ │ ├── EntryParser.kt │ │ │ │ ├── EntrySyncable.kt │ │ │ │ ├── V2Parser.kt │ │ │ │ └── model │ │ │ │ ├── Entry.kt │ │ │ │ ├── FileV2.kt │ │ │ │ ├── IndexV2.kt │ │ │ │ ├── Localization.kt │ │ │ │ ├── PackageV2.kt │ │ │ │ └── RepoV2.kt │ │ │ ├── ui │ │ │ ├── MessageDialog.kt │ │ │ ├── ScreenFragment.kt │ │ │ ├── appDetail │ │ │ │ ├── AppDetailAdapter.kt │ │ │ │ ├── AppDetailFragment.kt │ │ │ │ ├── AppDetailViewModel.kt │ │ │ │ ├── ScreenshotsAdapter.kt │ │ │ │ └── ShizukuErrorDialog.kt │ │ │ ├── appList │ │ │ │ ├── AppListAdapter.kt │ │ │ │ ├── AppListFragment.kt │ │ │ │ └── AppListViewModel.kt │ │ │ ├── favourites │ │ │ │ ├── FavouriteFragmentAdapter.kt │ │ │ │ ├── FavouritesFragment.kt │ │ │ │ └── FavouritesViewModel.kt │ │ │ ├── repository │ │ │ │ ├── EditRepositoryFragment.kt │ │ │ │ ├── RepositoriesAdapter.kt │ │ │ │ ├── RepositoriesFragment.kt │ │ │ │ ├── RepositoryFragment.kt │ │ │ │ └── RepositoryViewModel.kt │ │ │ ├── settings │ │ │ │ ├── SettingsFragment.kt │ │ │ │ └── SettingsViewModel.kt │ │ │ └── tabsFragment │ │ │ │ ├── TabsFragment.kt │ │ │ │ └── TabsViewModel.kt │ │ │ ├── utility │ │ │ ├── PackageItemResolver.kt │ │ │ ├── ProgressInputStream.kt │ │ │ ├── common │ │ │ │ ├── Constants.kt │ │ │ │ ├── Deeplinks.kt │ │ │ │ ├── Exporter.kt │ │ │ │ ├── Notification.kt │ │ │ │ ├── Permissions.kt │ │ │ │ ├── Scroller.kt │ │ │ │ ├── SdkCheck.kt │ │ │ │ ├── Text.kt │ │ │ │ ├── cache │ │ │ │ │ └── Cache.kt │ │ │ │ ├── device │ │ │ │ │ ├── Huawei.kt │ │ │ │ │ └── Miui.kt │ │ │ │ ├── extension │ │ │ │ │ ├── Collections.kt │ │ │ │ │ ├── Context.kt │ │ │ │ │ ├── Cursor.kt │ │ │ │ │ ├── DateTime.kt │ │ │ │ │ ├── Exception.kt │ │ │ │ │ ├── File.kt │ │ │ │ │ ├── Flow.kt │ │ │ │ │ ├── Insets.kt │ │ │ │ │ ├── Intent.kt │ │ │ │ │ ├── Json.kt │ │ │ │ │ ├── Number.kt │ │ │ │ │ ├── PackageInfo.kt │ │ │ │ │ ├── Service.kt │ │ │ │ │ └── View.kt │ │ │ │ ├── result │ │ │ │ │ └── Result.kt │ │ │ │ └── signature │ │ │ │ │ └── HashChecker.kt │ │ │ ├── extension │ │ │ │ ├── Android.kt │ │ │ │ ├── Connection.kt │ │ │ │ ├── Fragment.kt │ │ │ │ ├── PackageInfo.kt │ │ │ │ └── Resources.kt │ │ │ └── serialization │ │ │ │ ├── ProductItemSerialization.kt │ │ │ │ ├── ProductPreferenceSerialization.kt │ │ │ │ ├── ProductSerialization.kt │ │ │ │ ├── ReleaseSerialization.kt │ │ │ │ └── RepositorySerialization.kt │ │ │ ├── widget │ │ │ ├── CursorRecyclerAdapter.kt │ │ │ ├── DividerItemDecoration.kt │ │ │ ├── EnumRecyclerAdapter.kt │ │ │ ├── FocusSearchView.kt │ │ │ └── StableRecyclerAdapter.kt │ │ │ └── work │ │ │ └── CleanUpWorker.kt │ └── res │ │ ├── anim │ │ ├── slide_right_fade_in.xml │ │ └── slide_right_fade_out.xml │ │ ├── animator │ │ ├── slide_in.xml │ │ ├── slide_in_keep.xml │ │ └── slide_out.xml │ │ ├── color │ │ └── favourite_icon_color.xml │ │ ├── drawable │ │ ├── arrow_up.xml │ │ ├── background_border.xml │ │ ├── favourite_icon.xml │ │ ├── ic_add.xml │ │ ├── ic_apk_install.xml │ │ ├── ic_arrow_down.xml │ │ ├── ic_bug_report.xml │ │ ├── ic_cancel.xml │ │ ├── ic_cannot_load.xml │ │ ├── ic_check.xml │ │ ├── ic_code.xml │ │ ├── ic_copyright.xml │ │ ├── ic_delete.xml │ │ ├── ic_donate.xml │ │ ├── ic_donate_bitcoin.xml │ │ ├── ic_donate_liberapay.xml │ │ ├── ic_donate_litecoin.xml │ │ ├── ic_donate_opencollective.xml │ │ ├── ic_download.xml │ │ ├── ic_email.xml │ │ ├── ic_favourite.xml │ │ ├── ic_favourite_checked.xml │ │ ├── ic_history.xml │ │ ├── ic_image.xml │ │ ├── ic_language.xml │ │ ├── ic_launch.xml │ │ ├── ic_launcher_foreground.xml │ │ ├── ic_launcher_monochrome.xml │ │ ├── ic_new_releases.xml │ │ ├── ic_perm_device_information.xml │ │ ├── ic_person.xml │ │ ├── ic_proxy.xml │ │ ├── ic_public.xml │ │ ├── ic_save.xml │ │ ├── ic_search.xml │ │ ├── ic_share.xml │ │ ├── ic_sort.xml │ │ ├── ic_source_code.xml │ │ ├── ic_sync.xml │ │ ├── ic_sync_type.xml │ │ ├── ic_themes.xml │ │ ├── ic_time.xml │ │ ├── ic_tune.xml │ │ ├── ic_video.xml │ │ └── tv_banner.xml │ │ ├── layout │ │ ├── app_detail_header.xml │ │ ├── download_status.xml │ │ ├── edit_repository.xml │ │ ├── enum_type.xml │ │ ├── expand_view_button.xml │ │ ├── fragment.xml │ │ ├── install_button.xml │ │ ├── link_item.xml │ │ ├── permissions_item.xml │ │ ├── product_item.xml │ │ ├── recycler_view_with_fab.xml │ │ ├── release_item.xml │ │ ├── repository_item.xml │ │ ├── repository_page.xml │ │ ├── section_item.xml │ │ ├── settings_page.xml │ │ ├── switch_item.xml │ │ ├── switch_type.xml │ │ ├── tabs_toolbar.xml │ │ ├── title_text_item.xml │ │ └── video_button.xml │ │ ├── mipmap-anydpi-v26 │ │ ├── ic_launcher.xml │ │ └── ic_launcher_round.xml │ │ ├── mipmap-hdpi │ │ └── ic_launcher.png │ │ ├── mipmap-mdpi │ │ ├── ic_launcher.png │ │ └── ic_launcher_round.png │ │ ├── mipmap-xhdpi │ │ ├── ic_launcher.png │ │ └── ic_launcher_round.png │ │ ├── mipmap-xxhdpi │ │ ├── ic_launcher.png │ │ └── ic_launcher_round.png │ │ ├── mipmap-xxxhdpi │ │ ├── ic_launcher.png │ │ └── ic_launcher_round.png │ │ ├── resources.properties │ │ ├── values-ar │ │ └── strings.xml │ │ ├── values-az │ │ └── strings.xml │ │ ├── values-be │ │ └── strings.xml │ │ ├── values-bg │ │ └── strings.xml │ │ ├── values-bn │ │ └── strings.xml │ │ ├── values-ca │ │ └── strings.xml │ │ ├── values-ckb │ │ └── strings.xml │ │ ├── values-cs │ │ └── strings.xml │ │ ├── values-da │ │ └── strings.xml │ │ ├── values-de │ │ └── strings.xml │ │ ├── values-el │ │ └── strings.xml │ │ ├── values-eo │ │ └── strings.xml │ │ ├── values-es │ │ └── strings.xml │ │ ├── values-et │ │ └── strings.xml │ │ ├── values-eu │ │ └── strings.xml │ │ ├── values-fa │ │ └── strings.xml │ │ ├── values-fi │ │ └── strings.xml │ │ ├── values-fr │ │ └── strings.xml │ │ ├── values-gl │ │ └── strings.xml │ │ ├── values-hi │ │ └── strings.xml │ │ ├── values-hr │ │ └── strings.xml │ │ ├── values-hu │ │ └── strings.xml │ │ ├── values-ia │ │ └── strings.xml │ │ ├── values-in │ │ └── strings.xml │ │ ├── values-it │ │ └── strings.xml │ │ ├── values-iw │ │ └── strings.xml │ │ ├── values-ja │ │ └── strings.xml │ │ ├── values-jbo │ │ └── strings.xml │ │ ├── values-kn │ │ └── strings.xml │ │ ├── values-ko │ │ └── strings.xml │ │ ├── values-lt │ │ └── strings.xml │ │ ├── values-lv │ │ └── strings.xml │ │ ├── values-ml │ │ └── strings.xml │ │ ├── values-ms │ │ └── strings.xml │ │ ├── values-nb-rNO │ │ └── strings.xml │ │ ├── values-night │ │ └── base_theme.xml │ │ ├── values-nl │ │ └── strings.xml │ │ ├── values-nn │ │ └── strings.xml │ │ ├── values-or │ │ └── strings.xml │ │ ├── values-pa │ │ └── strings.xml │ │ ├── values-pl │ │ └── strings.xml │ │ ├── values-pt-rBR │ │ └── strings.xml │ │ ├── values-pt │ │ └── strings.xml │ │ ├── values-ro │ │ └── strings.xml │ │ ├── values-ru │ │ └── strings.xml │ │ ├── values-ryu │ │ └── strings.xml │ │ ├── values-si │ │ └── strings.xml │ │ ├── values-sl │ │ └── strings.xml │ │ ├── values-sr │ │ └── strings.xml │ │ ├── values-su │ │ └── strings.xml │ │ ├── values-sv │ │ └── strings.xml │ │ ├── values-ta │ │ └── strings.xml │ │ ├── values-tl │ │ └── strings.xml │ │ ├── values-tr │ │ └── strings.xml │ │ ├── values-uk │ │ └── strings.xml │ │ ├── values-ur │ │ └── strings.xml │ │ ├── values-v31 │ │ └── styles.xml │ │ ├── values-vi │ │ └── strings.xml │ │ ├── values-zh-rCN │ │ └── strings.xml │ │ ├── values-zh-rTW │ │ └── strings.xml │ │ ├── values │ │ ├── base_theme.xml │ │ ├── colors.xml │ │ ├── dimen.xml │ │ ├── ic_launcher_background.xml │ │ ├── ids.xml │ │ ├── strings.xml │ │ └── styles.xml │ │ └── xml │ │ └── network_security_config.xml │ └── test │ └── kotlin │ └── com │ └── looker │ └── droidify │ └── network │ └── KtorDownloaderTest.kt ├── build.gradle.kts ├── gradle.properties ├── gradle ├── libs.versions.toml └── wrapper │ ├── gradle-wrapper.jar │ └── gradle-wrapper.properties ├── gradlew ├── gradlew.bat ├── metadata ├── en-US │ ├── changelogs │ │ ├── 42.txt │ │ ├── 43.txt │ │ ├── 47.txt │ │ ├── 48.txt │ │ ├── 49.txt │ │ ├── 50.txt │ │ ├── 51.txt │ │ ├── 52.txt │ │ ├── 53.txt │ │ ├── 54.txt │ │ ├── 55.txt │ │ ├── 56.txt │ │ ├── 57.txt │ │ ├── 58.txt │ │ ├── 581.txt │ │ ├── 582.txt │ │ ├── 583.txt │ │ ├── 584.txt │ │ ├── 590.txt │ │ ├── 591.txt │ │ ├── 592.txt │ │ ├── 593.txt │ │ ├── 594.txt │ │ ├── 595.txt │ │ ├── 600.txt │ │ ├── 610.txt │ │ ├── 620.txt │ │ ├── 630.txt │ │ ├── 640.txt │ │ └── 650.txt │ ├── full_description.txt │ ├── images │ │ ├── featureGraphic.png │ │ ├── icon.png │ │ ├── phoneScreenshots │ │ │ ├── 1.png │ │ │ ├── 2.png │ │ │ ├── 3.png │ │ │ └── 4.png │ │ ├── promoGraphic.png │ │ └── tvBanner.png │ └── short_description.txt └── es-AR │ ├── changelogs │ └── 4.txt │ ├── full_description.txt │ └── short_description.txt ├── renovate.json ├── settings.gradle.kts └── update.sh /.editorconfig: -------------------------------------------------------------------------------- 1 | root = true 2 | 3 | [*] 4 | charset = utf-8 5 | insert_final_newline = true 6 | trim_trailing_whitespace = true 7 | 8 | [*.{kt,kts}] 9 | indent_size = 4 10 | ij_kotlin_allow_trailing_comma = true 11 | ij_kotlin_allow_trailing_comma_on_call_site = true 12 | ij_kotlin_name_count_to_use_star_import = 999 13 | ij_kotlin_name_count_to_use_star_import_for_members = 999 14 | 15 | [*.{yml,yaml}] 16 | indent_size = 2 17 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/bug_report.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Bug report 3 | about: Create a report to help us improve 4 | title: "[BUG]" 5 | labels: bug 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 | **Smartphone (please complete the following information):** 27 | - Device: [e.g. Pixel 6] 28 | - OS: [e.g. Android 12L] 29 | - Version [e.g. v0.5.1] 30 | 31 | **Additional context** 32 | Add any other context about the problem here. 33 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/feature_request.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Feature request 3 | about: Suggest an idea for this project 4 | title: '' 5 | labels: enhancement 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/ISSUE_TEMPLATE/quick-crash-report.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Quick crash report 3 | about: Create a report to help us improve 4 | title: "[Crash]" 5 | labels: bug 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 | **Smartphone (please complete the following information):** 21 | - Device: [e.g. Pixel 6] 22 | - OS: [e.g. Android 12L] 23 | - Version [e.g. v0.5.1] 24 | 25 | **Additional context** 26 | Add any other context about the problem here. Like what are the changes in settings that you made. 27 | -------------------------------------------------------------------------------- /.github/workflows/build_debug.yml: -------------------------------------------------------------------------------- 1 | name: Build Debug APK 2 | 3 | on: 4 | push: 5 | branches: 6 | - 'main' 7 | paths-ignore: 8 | - '**.md' 9 | - '**.yml' 10 | - '**.xml' 11 | pull_request_review: 12 | paths-ignore: 13 | - '**.md' 14 | types: submitted 15 | workflow_dispatch: 16 | 17 | concurrency: 18 | group: ${{ github.workflow }} 19 | cancel-in-progress: true 20 | 21 | jobs: 22 | build: 23 | 24 | runs-on: ubuntu-latest 25 | permissions: 26 | contents: read 27 | packages: write 28 | 29 | steps: 30 | - name: Check out repository 31 | uses: actions/checkout@v4 32 | with: 33 | submodules: true 34 | 35 | - name: Validate Gradle Wrapper 36 | uses: gradle/actions/setup-gradle@v4 37 | 38 | - name: Setup Gradle 39 | uses: gradle/wrapper-validation-action@v3 40 | 41 | - name: Set up Java 17 42 | uses: actions/setup-java@v4 43 | with: 44 | java-version: 17 45 | distribution: 'adopt' 46 | cache: gradle 47 | 48 | - name: Grant execution permission to Gradle Wrapper 49 | run: chmod +x gradlew 50 | 51 | - name: Build Debug APK 52 | run: ./gradlew assembleDebug 53 | 54 | - name: Sign Apk 55 | continue-on-error: true 56 | id: sign_apk 57 | uses: r0adkll/sign-android-release@v1 58 | with: 59 | releaseDir: app/build/outputs/apk/debug 60 | signingKeyBase64: ${{ secrets.KEY_BASE64 }} 61 | alias: ${{ secrets.KEY_ALIAS }} 62 | keyStorePassword: ${{ secrets.KEYSTORE_PASS }} 63 | keyPassword: ${{ secrets.KEYSTORE_PASS }} 64 | 65 | - name: Remove file that aren't signed 66 | continue-on-error: true 67 | run: | 68 | ls | grep 'signed\.apk$' && find . -type f -name '*.apk' ! -name '*-signed.apk' -delete 69 | 70 | - name: Upload the APK 71 | uses: actions/upload-artifact@v4 72 | with: 73 | name: debug 74 | path: app/build/outputs/apk/debug/app-debug*.apk 75 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | *.iml 2 | .gradle 3 | /local.properties 4 | /.idea/caches 5 | /.idea/libraries 6 | /.idea/modules.xml 7 | /.idea/workspace.xml 8 | /.idea/navEditor.xml 9 | /.idea/assetWizardSettings.xml 10 | .DS_Store 11 | /app/build/ 12 | /build 13 | /captures 14 | .externalNativeBuild 15 | .cxx 16 | local.properties 17 | /.idea/ 18 | /build-logic/structure/build/ 19 | /core-datastore/build/ 20 | /app/release/ 21 | /app/alpha/ 22 | /.kotlin/ 23 | -------------------------------------------------------------------------------- /STATUS.md: -------------------------------------------------------------------------------- 1 | _17-Aug-2023_ 2 | # Project Status 3 | 4 | I was holding back releases, because I was re-writing the whole structure of the app. But I think this is taking too long. 5 | It is only natural that users will grow impatient for an update, thats why I will be releasing new versions soon. 6 | 7 | ## Why the delay: 8 | - I had exams and submissions in my college 9 | - I am really unwell mentally 10 | - I was not able to create a solid structure for the new backend 11 | 12 | ## What now? 13 | - Next release will be on **19-Aug**, and will contain many bug fixes and stability improvements. 14 | - All the [progress](https://github.com/Droid-ify/client/pull/309) made in past months is still here and will help in future. 15 | - We will be missing on the new index format introduced by fdroid for some future releases. 16 | -------------------------------------------------------------------------------- /app/proguard.pro: -------------------------------------------------------------------------------- 1 | -dontobfuscate 2 | 3 | # Disable ServiceLoader reproducibility-breaking optimizations 4 | -keep class kotlinx.coroutines.CoroutineExceptionHandler 5 | -keep class kotlinx.coroutines.internal.MainDispatcherFactory 6 | 7 | -dontwarn kotlinx.serialization.KSerializer 8 | -dontwarn kotlinx.serialization.Serializable 9 | -dontwarn org.slf4j.impl.StaticLoggerBinder 10 | -------------------------------------------------------------------------------- /app/src/androidTest/assets/fdroid_index_v1.jar: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Droid-ify/client/4d9c0b777889dce50ec179dc17ec38d7c98eb7b8/app/src/androidTest/assets/fdroid_index_v1.jar -------------------------------------------------------------------------------- /app/src/androidTest/assets/index-v1.jar: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Droid-ify/client/4d9c0b777889dce50ec179dc17ec38d7c98eb7b8/app/src/androidTest/assets/index-v1.jar -------------------------------------------------------------------------------- /app/src/androidTest/assets/izzy_entry.jar: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Droid-ify/client/4d9c0b777889dce50ec179dc17ec38d7c98eb7b8/app/src/androidTest/assets/izzy_entry.jar -------------------------------------------------------------------------------- /app/src/androidTest/assets/izzy_entry.json: -------------------------------------------------------------------------------- 1 | {"timestamp": 1725903808000, "version": 20002, "index": {"name": "/index-v2.json", "sha256": "5c5d5b6495efd95c0e7b849df4f1411b6337272cdee2b28defc4eb0f1c4bae42", "size": 7134576, "numPackages": 1201}, "diffs": {"1725491992000": {"name": "/diff/1725491992000.json", "sha256": "58b2633fd72a8b517a69354f15ee88d028c55c92b4122158f0dd63cca82ff37b", "size": 220333, "numPackages": 55}, "1725492213000": {"name": "/diff/1725492213000.json", "sha256": "7aa22b070d9f6f77fd069cab7fdbb38aa9f734d01982d9b9fadb3560807367ff", "size": 218833, "numPackages": 55}, "1725558218000": {"name": "/diff/1725558218000.json", "sha256": "cfae51610c44ec8dd73f390f7af46f71ebf4b2233151ec2f40f8c403775d815b", "size": 208567, "numPackages": 54}, "1725581280000": {"name": "/diff/1725581280000.json", "sha256": "9c5e39cc363e2a98c35fab6ec389f6b1a272fb92298c8f7f8eb158ba5ad3f7b1", "size": 207136, "numPackages": 48}, "1725645028000": {"name": "/diff/1725645028000.json", "sha256": "996c8e982e21dbdbcf25424ea4d3f32a7b67f7eea249db2392ea31a2bef033f6", "size": 98678, "numPackages": 47}, "1725731263000": {"name": "/diff/1725731263000.json", "sha256": "5dd06ef6da469b3881933b076ca0d989372477300b1f43070ae6b041763539da", "size": 80050, "numPackages": 37}, "1725746579000": {"name": "/diff/1725746579000.json", "sha256": "8bb1c009b828a3cecaa8180c08ac7a81b51c2d8b036566ec08fb7159dc61127a", "size": 58098, "numPackages": 31}, "1725807608000": {"name": "/diff/1725807608000.json", "sha256": "95ffb733c6e1e839f18c90e1cc704e554b0ae0eb26b52f0cf6437ee7a91ec96e", "size": 53358, "numPackages": 30}, "1725817837000": {"name": "/diff/1725817837000.json", "sha256": "21fed03d9e1b89cc2c4753084bcf49b681b4e4219375d9fdb5db1dd48a9a2fd3", "size": 16366, "numPackages": 28}, "1725885326000": {"name": "/diff/1725885326000.json", "sha256": "f2f22d1a56262276e07c68d5d71a149a50ddfa8c47b82bb3b63e0077f58121b6", "size": 13324, "numPackages": 9}}} -------------------------------------------------------------------------------- /app/src/androidTest/assets/izzy_index_v1.jar: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Droid-ify/client/4d9c0b777889dce50ec179dc17ec38d7c98eb7b8/app/src/androidTest/assets/izzy_index_v1.jar -------------------------------------------------------------------------------- /app/src/androidTest/kotlin/com/looker/droidify/sync/IndexValidator.kt: -------------------------------------------------------------------------------- 1 | package com.looker.droidify.sync 2 | 3 | import com.looker.droidify.domain.model.Fingerprint 4 | import java.util.jar.JarEntry 5 | 6 | val FakeIndexValidator = object : IndexValidator { 7 | override suspend fun validate( 8 | jarEntry: JarEntry, 9 | expectedFingerprint: Fingerprint? 10 | ): Fingerprint { 11 | return expectedFingerprint ?: Fingerprint("0".repeat(64)) 12 | } 13 | } 14 | -------------------------------------------------------------------------------- /app/src/androidTest/kotlin/com/looker/droidify/sync/common/Benchmark.kt: -------------------------------------------------------------------------------- 1 | package com.looker.droidify.sync.common 2 | 3 | import kotlin.math.pow 4 | import kotlin.math.sqrt 5 | 6 | internal inline fun benchmark( 7 | repetition: Int, 8 | extraMessage: String? = null, 9 | block: () -> Long, 10 | ): String { 11 | if (extraMessage != null) { 12 | println("=".repeat(50)) 13 | println(extraMessage) 14 | println("=".repeat(50)) 15 | } 16 | val times = DoubleArray(repetition) 17 | repeat(repetition) { iteration -> 18 | System.gc() 19 | System.runFinalization() 20 | times[iteration] = block().toDouble() 21 | } 22 | val meanAndDeviation = times.culledMeanAndDeviation() 23 | return buildString { 24 | append("=".repeat(50)) 25 | append("\n") 26 | append(times.joinToString(" | ")) 27 | append("\n") 28 | append("${meanAndDeviation.first} ms ± ${meanAndDeviation.second.toFloat()} ms") 29 | append("\n") 30 | append("=".repeat(50)) 31 | append("\n") 32 | } 33 | } 34 | 35 | private fun DoubleArray.culledMeanAndDeviation(): Pair { 36 | sort() 37 | return meanAndDeviation() 38 | } 39 | 40 | private fun DoubleArray.meanAndDeviation(): Pair { 41 | val mean = average() 42 | return mean to sqrt(fold(0.0) { acc, value -> acc + (value - mean).pow(2) } / size) 43 | } 44 | -------------------------------------------------------------------------------- /app/src/androidTest/kotlin/com/looker/droidify/sync/common/Repo.kt: -------------------------------------------------------------------------------- 1 | package com.looker.droidify.sync.common 2 | 3 | import com.looker.droidify.domain.model.Authentication 4 | import com.looker.droidify.domain.model.Fingerprint 5 | import com.looker.droidify.domain.model.Repo 6 | import com.looker.droidify.domain.model.VersionInfo 7 | 8 | val Izzy = Repo( 9 | id = 1L, 10 | enabled = true, 11 | address = "https://apt.izzysoft.de/fdroid/repo", 12 | name = "IzzyOnDroid F-Droid Repo", 13 | description = "This is a repository of apps to be used with F-Droid. Applications in this repository are official binaries built by the original application developers, taken from their resp. repositories (mostly Github, GitLab, Codeberg). Updates for the apps are usually fetched daily, and you can expect daily index updates.", 14 | fingerprint = Fingerprint("3BF0D6ABFEAE2F401707B6D966BE743BF0EEE49C2561B9BA39073711F628937A"), 15 | authentication = Authentication("", ""), 16 | versionInfo = VersionInfo(0L, null), 17 | mirrors = emptyList(), 18 | antiFeatures = emptyList(), 19 | categories = emptyList(), 20 | ) 21 | -------------------------------------------------------------------------------- /app/src/androidTest/kotlin/com/looker/droidify/sync/common/Resource.kt: -------------------------------------------------------------------------------- 1 | package com.looker.droidify.sync.common 2 | 3 | import androidx.test.platform.app.InstrumentationRegistry 4 | import java.io.InputStream 5 | 6 | fun assets(name: String): InputStream { 7 | return InstrumentationRegistry.getInstrumentation().context.assets.open(name) 8 | } 9 | -------------------------------------------------------------------------------- /app/src/main/ic_launcher-playstore.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Droid-ify/client/4d9c0b777889dce50ec179dc17ec38d7c98eb7b8/app/src/main/ic_launcher-playstore.png -------------------------------------------------------------------------------- /app/src/main/kotlin/com/looker/droidify/database/ObservableCursor.kt: -------------------------------------------------------------------------------- 1 | package com.looker.droidify.database 2 | 3 | import android.database.ContentObservable 4 | import android.database.ContentObserver 5 | import android.database.Cursor 6 | import android.database.CursorWrapper 7 | 8 | class ObservableCursor( 9 | cursor: Cursor, 10 | private val observable: ( 11 | register: Boolean, 12 | observer: () -> Unit 13 | ) -> Unit 14 | ) : CursorWrapper(cursor) { 15 | private var registered = false 16 | private val contentObservable = ContentObservable() 17 | 18 | private val onChange: () -> Unit = { 19 | contentObservable.dispatchChange(false, null) 20 | } 21 | 22 | init { 23 | observable(true, onChange) 24 | registered = true 25 | } 26 | 27 | override fun registerContentObserver(observer: ContentObserver) { 28 | super.registerContentObserver(observer) 29 | contentObservable.registerObserver(observer) 30 | } 31 | 32 | override fun unregisterContentObserver(observer: ContentObserver) { 33 | super.unregisterContentObserver(observer) 34 | contentObservable.unregisterObserver(observer) 35 | } 36 | 37 | @Deprecated("Deprecated in Java") 38 | @Suppress("DEPRECATION") 39 | override fun requery(): Boolean { 40 | if (!registered) { 41 | observable(true, onChange) 42 | registered = true 43 | } 44 | return super.requery() 45 | } 46 | 47 | @Deprecated("Deprecated in Java") 48 | @Suppress("DEPRECATION") 49 | override fun deactivate() { 50 | super.deactivate() 51 | deactivateOrClose() 52 | } 53 | 54 | override fun close() { 55 | super.close() 56 | contentObservable.unregisterAll() 57 | deactivateOrClose() 58 | } 59 | 60 | private fun deactivateOrClose() { 61 | observable(false, onChange) 62 | registered = false 63 | } 64 | } 65 | -------------------------------------------------------------------------------- /app/src/main/kotlin/com/looker/droidify/datastore/SettingsRepository.kt: -------------------------------------------------------------------------------- 1 | package com.looker.droidify.datastore 2 | 3 | import android.net.Uri 4 | import com.looker.droidify.datastore.model.AutoSync 5 | import com.looker.droidify.datastore.model.InstallerType 6 | import com.looker.droidify.datastore.model.LegacyInstallerComponent 7 | import com.looker.droidify.datastore.model.ProxyType 8 | import com.looker.droidify.datastore.model.SortOrder 9 | import com.looker.droidify.datastore.model.Theme 10 | import kotlinx.coroutines.flow.Flow 11 | import kotlinx.coroutines.flow.distinctUntilChanged 12 | import kotlinx.coroutines.flow.map 13 | import kotlin.time.Duration 14 | 15 | interface SettingsRepository { 16 | 17 | val data: Flow 18 | 19 | suspend fun getInitial(): Settings 20 | 21 | suspend fun export(target: Uri) 22 | 23 | suspend fun import(target: Uri) 24 | 25 | suspend fun setLanguage(language: String) 26 | 27 | suspend fun enableIncompatibleVersion(enable: Boolean) 28 | 29 | suspend fun enableNotifyUpdates(enable: Boolean) 30 | 31 | suspend fun enableUnstableUpdates(enable: Boolean) 32 | 33 | suspend fun setIgnoreSignature(enable: Boolean) 34 | 35 | suspend fun setTheme(theme: Theme) 36 | 37 | suspend fun setDynamicTheme(enable: Boolean) 38 | 39 | suspend fun setInstallerType(installerType: InstallerType) 40 | 41 | suspend fun setLegacyInstallerComponent(component: LegacyInstallerComponent?) 42 | 43 | suspend fun setAutoUpdate(allow: Boolean) 44 | 45 | suspend fun setAutoSync(autoSync: AutoSync) 46 | 47 | suspend fun setSortOrder(sortOrder: SortOrder) 48 | 49 | suspend fun setProxyType(proxyType: ProxyType) 50 | 51 | suspend fun setProxyHost(proxyHost: String) 52 | 53 | suspend fun setProxyPort(proxyPort: Int) 54 | 55 | suspend fun setCleanUpInterval(interval: Duration) 56 | 57 | suspend fun setCleanupInstant() 58 | 59 | suspend fun setHomeScreenSwiping(value: Boolean) 60 | 61 | suspend fun toggleFavourites(packageName: String) 62 | } 63 | 64 | inline fun SettingsRepository.get(crossinline block: suspend Settings.() -> T): Flow { 65 | return data.map(block).distinctUntilChanged() 66 | } 67 | -------------------------------------------------------------------------------- /app/src/main/kotlin/com/looker/droidify/datastore/exporter/SettingsExporter.kt: -------------------------------------------------------------------------------- 1 | package com.looker.droidify.datastore.exporter 2 | 3 | import android.content.Context 4 | import android.net.Uri 5 | import com.looker.droidify.utility.common.Exporter 6 | import com.looker.droidify.datastore.Settings 7 | import kotlinx.coroutines.CoroutineDispatcher 8 | import kotlinx.coroutines.CoroutineScope 9 | import kotlinx.coroutines.cancel 10 | import kotlinx.coroutines.launch 11 | import kotlinx.coroutines.withContext 12 | import kotlinx.serialization.ExperimentalSerializationApi 13 | import kotlinx.serialization.SerializationException 14 | import kotlinx.serialization.json.Json 15 | import kotlinx.serialization.json.decodeFromStream 16 | import kotlinx.serialization.json.encodeToStream 17 | import java.io.IOException 18 | 19 | @OptIn(ExperimentalSerializationApi::class) 20 | class SettingsExporter( 21 | private val context: Context, 22 | private val scope: CoroutineScope, 23 | private val ioDispatcher: CoroutineDispatcher, 24 | private val json: Json 25 | ) : Exporter { 26 | 27 | override suspend fun export(item: Settings, target: Uri) { 28 | scope.launch(ioDispatcher) { 29 | try { 30 | context.contentResolver.openOutputStream(target).use { 31 | if (it != null) json.encodeToStream(item, it) 32 | } 33 | } catch (e: SerializationException) { 34 | e.printStackTrace() 35 | cancel() 36 | } catch (e: IOException) { 37 | e.printStackTrace() 38 | cancel() 39 | } 40 | } 41 | } 42 | 43 | override suspend fun import(target: Uri): Settings = withContext(ioDispatcher) { 44 | try { 45 | context.contentResolver.openInputStream(target).use { 46 | checkNotNull(it) { "Null input stream for import file" } 47 | json.decodeFromStream(it) 48 | } 49 | } catch (e: SerializationException) { 50 | e.printStackTrace() 51 | throw IllegalStateException(e.message) 52 | } catch (e: IOException) { 53 | e.printStackTrace() 54 | throw IllegalStateException(e.message) 55 | } 56 | } 57 | } 58 | -------------------------------------------------------------------------------- /app/src/main/kotlin/com/looker/droidify/datastore/migration/ProtoToPreferenceMigration.kt: -------------------------------------------------------------------------------- 1 | package com.looker.droidify.datastore.migration 2 | 3 | import com.looker.droidify.datastore.PreferenceSettingsRepository.PreferencesKeys.setting 4 | import com.looker.droidify.datastore.Settings 5 | import kotlinx.coroutines.flow.first 6 | 7 | class ProtoToPreferenceMigration( 8 | private val oldDataStore: androidx.datastore.core.DataStore 9 | ) : androidx.datastore.core.DataMigration { 10 | override suspend fun cleanUp() { 11 | } 12 | 13 | override suspend fun shouldMigrate(currentData: androidx.datastore.preferences.core.Preferences): Boolean { 14 | return currentData.asMap().isEmpty() 15 | } 16 | 17 | override suspend fun migrate(currentData: androidx.datastore.preferences.core.Preferences): androidx.datastore.preferences.core.Preferences { 18 | val settings = oldDataStore.data.first() 19 | val preferences = currentData.toMutablePreferences() 20 | preferences.setting(settings) 21 | return preferences 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /app/src/main/kotlin/com/looker/droidify/datastore/model/AutoSync.kt: -------------------------------------------------------------------------------- 1 | package com.looker.droidify.datastore.model 2 | 3 | enum class AutoSync { 4 | ALWAYS, 5 | WIFI_ONLY, 6 | WIFI_PLUGGED_IN, 7 | NEVER 8 | } 9 | -------------------------------------------------------------------------------- /app/src/main/kotlin/com/looker/droidify/datastore/model/InstallerType.kt: -------------------------------------------------------------------------------- 1 | package com.looker.droidify.datastore.model 2 | 3 | import com.looker.droidify.utility.common.device.Miui 4 | 5 | enum class InstallerType { 6 | LEGACY, 7 | SESSION, 8 | SHIZUKU, 9 | ROOT; 10 | 11 | companion object { 12 | val Default: InstallerType 13 | get() = if (Miui.isMiui) { 14 | if (Miui.isMiuiOptimizationDisabled()) SESSION else LEGACY 15 | } else { 16 | SESSION 17 | } 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /app/src/main/kotlin/com/looker/droidify/datastore/model/LegacyInstallerComponent.kt: -------------------------------------------------------------------------------- 1 | package com.looker.droidify.datastore.model 2 | 3 | import kotlinx.serialization.Serializable 4 | 5 | @Serializable 6 | sealed class LegacyInstallerComponent { 7 | @Serializable 8 | object Unspecified : LegacyInstallerComponent() 9 | 10 | @Serializable 11 | object AlwaysChoose : LegacyInstallerComponent() 12 | 13 | @Serializable 14 | data class Component( 15 | val clazz: String, 16 | val activity: String, 17 | ) : LegacyInstallerComponent() { 18 | fun update( 19 | newClazz: String? = null, 20 | newActivity: String? = null, 21 | ): Component = copy( 22 | clazz = newClazz ?: clazz, 23 | activity = newActivity ?: activity 24 | ) 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /app/src/main/kotlin/com/looker/droidify/datastore/model/ProxyPreference.kt: -------------------------------------------------------------------------------- 1 | package com.looker.droidify.datastore.model 2 | 3 | import kotlinx.serialization.Serializable 4 | 5 | @Serializable 6 | data class ProxyPreference( 7 | val type: ProxyType = ProxyType.DIRECT, 8 | val host: String = "localhost", 9 | val port: Int = 9050 10 | ) { 11 | fun update( 12 | newType: ProxyType? = null, 13 | newHost: String? = null, 14 | newPort: Int? = null 15 | ): ProxyPreference = copy( 16 | type = newType ?: type, 17 | host = newHost ?: host, 18 | port = newPort ?: port 19 | ) 20 | } 21 | -------------------------------------------------------------------------------- /app/src/main/kotlin/com/looker/droidify/datastore/model/ProxyType.kt: -------------------------------------------------------------------------------- 1 | package com.looker.droidify.datastore.model 2 | 3 | enum class ProxyType { 4 | DIRECT, 5 | HTTP, 6 | SOCKS 7 | } 8 | -------------------------------------------------------------------------------- /app/src/main/kotlin/com/looker/droidify/datastore/model/SortOrder.kt: -------------------------------------------------------------------------------- 1 | package com.looker.droidify.datastore.model 2 | 3 | // todo: Add Support for sorting by size 4 | enum class SortOrder { 5 | UPDATED, 6 | ADDED, 7 | NAME 8 | } 9 | -------------------------------------------------------------------------------- /app/src/main/kotlin/com/looker/droidify/datastore/model/Theme.kt: -------------------------------------------------------------------------------- 1 | package com.looker.droidify.datastore.model 2 | 3 | enum class Theme { 4 | SYSTEM, 5 | SYSTEM_BLACK, 6 | LIGHT, 7 | DARK, 8 | AMOLED 9 | } 10 | -------------------------------------------------------------------------------- /app/src/main/kotlin/com/looker/droidify/di/CoroutinesModule.kt: -------------------------------------------------------------------------------- 1 | package com.looker.droidify.di 2 | 3 | import dagger.Module 4 | import dagger.Provides 5 | import dagger.hilt.InstallIn 6 | import dagger.hilt.components.SingletonComponent 7 | import kotlinx.coroutines.CoroutineDispatcher 8 | import kotlinx.coroutines.CoroutineScope 9 | import kotlinx.coroutines.Dispatchers 10 | import kotlinx.coroutines.SupervisorJob 11 | import javax.inject.Qualifier 12 | import javax.inject.Singleton 13 | 14 | @Retention(AnnotationRetention.RUNTIME) 15 | @Qualifier 16 | annotation class IoDispatcher 17 | 18 | @Retention(AnnotationRetention.RUNTIME) 19 | @Qualifier 20 | annotation class DefaultDispatcher 21 | 22 | @Retention(AnnotationRetention.RUNTIME) 23 | @Qualifier 24 | annotation class ApplicationScope 25 | 26 | @Module 27 | @InstallIn(SingletonComponent::class) 28 | object CoroutinesModule { 29 | 30 | @Provides 31 | @IoDispatcher 32 | fun providesIODispatcher(): CoroutineDispatcher = Dispatchers.IO 33 | 34 | @Provides 35 | @DefaultDispatcher 36 | fun providesDefaultDispatcher(): CoroutineDispatcher = Dispatchers.Default 37 | 38 | @Provides 39 | @Singleton 40 | @ApplicationScope 41 | fun providesCoroutineScope( 42 | @DefaultDispatcher dispatcher: CoroutineDispatcher 43 | ): CoroutineScope = CoroutineScope(SupervisorJob() + dispatcher) 44 | } 45 | -------------------------------------------------------------------------------- /app/src/main/kotlin/com/looker/droidify/di/InstallModule.kt: -------------------------------------------------------------------------------- 1 | package com.looker.droidify.di 2 | 3 | import android.content.Context 4 | import com.looker.droidify.datastore.SettingsRepository 5 | import com.looker.droidify.installer.InstallManager 6 | import dagger.Module 7 | import dagger.Provides 8 | import dagger.hilt.InstallIn 9 | import dagger.hilt.android.qualifiers.ApplicationContext 10 | import dagger.hilt.components.SingletonComponent 11 | import javax.inject.Singleton 12 | 13 | @Module 14 | @InstallIn(SingletonComponent::class) 15 | object InstallModule { 16 | 17 | @Singleton 18 | @Provides 19 | fun providesInstaller( 20 | @ApplicationContext context: Context, 21 | settingsRepository: SettingsRepository 22 | ): InstallManager = InstallManager(context, settingsRepository) 23 | } 24 | -------------------------------------------------------------------------------- /app/src/main/kotlin/com/looker/droidify/di/NetworkModule.kt: -------------------------------------------------------------------------------- 1 | package com.looker.droidify.di 2 | 3 | import com.looker.droidify.network.Downloader 4 | import com.looker.droidify.network.KtorDownloader 5 | import dagger.Module 6 | import dagger.Provides 7 | import dagger.hilt.InstallIn 8 | import dagger.hilt.components.SingletonComponent 9 | import io.ktor.client.engine.okhttp.OkHttp 10 | import kotlinx.coroutines.CoroutineDispatcher 11 | import javax.inject.Singleton 12 | 13 | @Module 14 | @InstallIn(SingletonComponent::class) 15 | object NetworkModule { 16 | 17 | @Singleton 18 | @Provides 19 | fun provideDownloader( 20 | @IoDispatcher 21 | dispatcher: CoroutineDispatcher 22 | ): Downloader = KtorDownloader( 23 | httpClientEngine = OkHttp.create(), 24 | dispatcher = dispatcher, 25 | ) 26 | 27 | } 28 | -------------------------------------------------------------------------------- /app/src/main/kotlin/com/looker/droidify/domain/AppRepository.kt: -------------------------------------------------------------------------------- 1 | package com.looker.droidify.domain 2 | 3 | import com.looker.droidify.domain.model.App 4 | import com.looker.droidify.domain.model.AppMinimal 5 | import com.looker.droidify.domain.model.Author 6 | import com.looker.droidify.domain.model.Package 7 | import com.looker.droidify.domain.model.PackageName 8 | import kotlinx.coroutines.flow.Flow 9 | 10 | interface AppRepository { 11 | 12 | fun getApps(): Flow> 13 | 14 | fun getApp(packageName: PackageName): Flow> 15 | 16 | fun getAppFromAuthor(author: Author): Flow> 17 | 18 | fun getPackages(packageName: PackageName): Flow> 19 | 20 | /** 21 | * returns true is the app is added successfully 22 | * returns false if the app was already in the favourites and so it is removed 23 | */ 24 | suspend fun addToFavourite(packageName: PackageName): Boolean 25 | } 26 | -------------------------------------------------------------------------------- /app/src/main/kotlin/com/looker/droidify/domain/RepoRepository.kt: -------------------------------------------------------------------------------- 1 | package com.looker.droidify.domain 2 | 3 | import com.looker.droidify.domain.model.Repo 4 | import kotlinx.coroutines.flow.Flow 5 | 6 | interface RepoRepository { 7 | 8 | suspend fun getRepo(id: Long): Repo? 9 | 10 | fun getRepos(): Flow> 11 | 12 | suspend fun updateRepo(repo: Repo) 13 | 14 | suspend fun enableRepository(repo: Repo, enable: Boolean) 15 | 16 | suspend fun sync(repo: Repo): Boolean 17 | 18 | suspend fun syncAll(): Boolean 19 | } 20 | -------------------------------------------------------------------------------- /app/src/main/kotlin/com/looker/droidify/domain/model/App.kt: -------------------------------------------------------------------------------- 1 | package com.looker.droidify.domain.model 2 | 3 | data class App( 4 | val repoId: Long, 5 | val appId: Long, 6 | val categories: List, 7 | val links: Links, 8 | val metadata: Metadata, 9 | val author: Author, 10 | val screenshots: Screenshots, 11 | val graphics: Graphics, 12 | val donation: Donation, 13 | val preferredSigner: String = "", 14 | val packages: List 15 | ) 16 | 17 | data class Author( 18 | val id: Long, 19 | val name: String, 20 | val email: String, 21 | val web: String 22 | ) 23 | 24 | data class Donation( 25 | val regularUrl: String? = null, 26 | val bitcoinAddress: String? = null, 27 | val flattrId: String? = null, 28 | val liteCoinAddress: String? = null, 29 | val openCollectiveId: String? = null, 30 | val librePayId: String? = null, 31 | ) 32 | 33 | data class Graphics( 34 | val featureGraphic: String = "", 35 | val promoGraphic: String = "", 36 | val tvBanner: String = "", 37 | val video: String = "" 38 | ) 39 | 40 | data class Links( 41 | val changelog: String = "", 42 | val issueTracker: String = "", 43 | val sourceCode: String = "", 44 | val translation: String = "", 45 | val webSite: String = "" 46 | ) 47 | 48 | data class Metadata( 49 | val name: String, 50 | val packageName: PackageName, 51 | val added: Long, 52 | val description: String, 53 | val icon: String, 54 | val lastUpdated: Long, 55 | val license: String, 56 | val suggestedVersionCode: Long, 57 | val suggestedVersionName: String, 58 | val summary: String 59 | ) 60 | 61 | data class Screenshots( 62 | val phone: List = emptyList(), 63 | val sevenInch: List = emptyList(), 64 | val tenInch: List = emptyList(), 65 | val tv: List = emptyList(), 66 | val wear: List = emptyList() 67 | ) 68 | 69 | data class AppMinimal( 70 | val appId: Long, 71 | val name: String, 72 | val summary: String, 73 | val icon: String, 74 | val suggestedVersion: String, 75 | ) 76 | 77 | fun App.minimal() = AppMinimal( 78 | appId = appId, 79 | name = metadata.name, 80 | summary = metadata.summary, 81 | icon = metadata.icon, 82 | suggestedVersion = metadata.suggestedVersionName, 83 | ) 84 | -------------------------------------------------------------------------------- /app/src/main/kotlin/com/looker/droidify/domain/model/DataFile.kt: -------------------------------------------------------------------------------- 1 | package com.looker.droidify.domain.model 2 | 3 | interface DataFile { 4 | val name: String 5 | val hash: String 6 | val size: Long 7 | } 8 | -------------------------------------------------------------------------------- /app/src/main/kotlin/com/looker/droidify/domain/model/Fingerprint.kt: -------------------------------------------------------------------------------- 1 | package com.looker.droidify.domain.model 2 | 3 | import java.security.MessageDigest 4 | import java.security.cert.Certificate 5 | import java.util.Locale 6 | 7 | @JvmInline 8 | value class Fingerprint(val value: String) { 9 | init { 10 | require(value.isNotBlank() && value.length == FINGERPRINT_LENGTH) { "Invalid Fingerprint: $value" } 11 | } 12 | 13 | override fun toString(): String = value 14 | } 15 | 16 | @Suppress("NOTHING_TO_INLINE") 17 | inline fun Fingerprint.check(found: Fingerprint): Boolean { 18 | return found.value.equals(value, ignoreCase = true) 19 | } 20 | 21 | private const val FINGERPRINT_LENGTH = 64 22 | 23 | fun ByteArray.hex(): String = joinToString(separator = "") { byte -> 24 | "%02x".format(Locale.US, byte.toInt() and 0xff) 25 | } 26 | 27 | fun Fingerprint.formattedString(): String = value.windowed(2, 2, false) 28 | .take(FINGERPRINT_LENGTH / 2).joinToString(separator = " ") { it.uppercase(Locale.US) } 29 | 30 | fun Certificate.fingerprint(): Fingerprint { 31 | val bytes = encoded 32 | return if (bytes.size >= 256) { 33 | try { 34 | val fingerprint = MessageDigest.getInstance("sha256").digest(bytes) 35 | Fingerprint(fingerprint.hex().uppercase()) 36 | } catch (e: Exception) { 37 | e.printStackTrace() 38 | Fingerprint("") 39 | } 40 | } else { 41 | Fingerprint("") 42 | } 43 | } 44 | -------------------------------------------------------------------------------- /app/src/main/kotlin/com/looker/droidify/domain/model/Package.kt: -------------------------------------------------------------------------------- 1 | package com.looker.droidify.domain.model 2 | 3 | data class Package( 4 | val id: Long, 5 | val installed: Boolean, 6 | val added: Long, 7 | val apk: ApkFile, 8 | val platforms: Platforms, 9 | val features: List, 10 | val antiFeatures: List, 11 | val manifest: Manifest, 12 | val whatsNew: String 13 | ) 14 | 15 | data class ApkFile( 16 | override val name: String, 17 | override val hash: String, 18 | override val size: Long 19 | ) : DataFile 20 | 21 | data class Manifest( 22 | val versionCode: Long, 23 | val versionName: String, 24 | val usesSDKs: SDKs, 25 | val signer: Set, 26 | val permissions: List 27 | ) 28 | 29 | @JvmInline 30 | value class Platforms(val value: List) 31 | 32 | data class SDKs( 33 | val min: Int = -1, 34 | val max: Int = -1, 35 | val target: Int = -1 36 | ) 37 | 38 | // means the max sdk here and any sdk value as -1 means not valid 39 | data class Permission( 40 | val name: String, 41 | val sdKs: SDKs 42 | ) 43 | -------------------------------------------------------------------------------- /app/src/main/kotlin/com/looker/droidify/domain/model/PackageName.kt: -------------------------------------------------------------------------------- 1 | package com.looker.droidify.domain.model 2 | 3 | @JvmInline 4 | value class PackageName(val name: String) 5 | 6 | fun String.toPackageName() = PackageName(this) 7 | -------------------------------------------------------------------------------- /app/src/main/kotlin/com/looker/droidify/domain/model/Repo.kt: -------------------------------------------------------------------------------- 1 | package com.looker.droidify.domain.model 2 | 3 | data class Repo( 4 | val id: Long, 5 | val enabled: Boolean, 6 | val address: String, 7 | val name: String, 8 | val description: String, 9 | val fingerprint: Fingerprint?, 10 | val authentication: Authentication, 11 | val versionInfo: VersionInfo, 12 | val mirrors: List, 13 | val antiFeatures: List, 14 | val categories: List 15 | ) { 16 | val shouldAuthenticate = 17 | authentication.username.isNotEmpty() && authentication.password.isNotEmpty() 18 | 19 | fun update(fingerprint: Fingerprint, timestamp: Long? = null, etag: String? = null): Repo { 20 | return copy( 21 | fingerprint = fingerprint, 22 | versionInfo = timestamp?.let { VersionInfo(timestamp = it, etag = etag) } ?: versionInfo 23 | ) 24 | } 25 | } 26 | 27 | data class AntiFeature( 28 | val id: Long, 29 | val name: String, 30 | val icon: String = "", 31 | val description: String = "" 32 | ) 33 | 34 | data class Category( 35 | val id: Long, 36 | val name: String, 37 | val icon: String = "", 38 | val description: String = "" 39 | ) 40 | 41 | data class Authentication( 42 | val username: String, 43 | val password: String 44 | ) 45 | 46 | data class VersionInfo( 47 | val timestamp: Long, 48 | val etag: String? 49 | ) 50 | -------------------------------------------------------------------------------- /app/src/main/kotlin/com/looker/droidify/graphics/DrawableWrapper.kt: -------------------------------------------------------------------------------- 1 | package com.looker.droidify.graphics 2 | 3 | import android.graphics.Canvas 4 | import android.graphics.ColorFilter 5 | import android.graphics.Rect 6 | import android.graphics.drawable.Drawable 7 | 8 | open class DrawableWrapper(val drawable: Drawable) : Drawable() { 9 | init { 10 | drawable.callback = object : Callback { 11 | override fun invalidateDrawable(who: Drawable) { 12 | callback?.invalidateDrawable(who) 13 | } 14 | 15 | override fun scheduleDrawable(who: Drawable, what: Runnable, `when`: Long) { 16 | callback?.scheduleDrawable(who, what, `when`) 17 | } 18 | 19 | override fun unscheduleDrawable(who: Drawable, what: Runnable) { 20 | callback?.unscheduleDrawable(who, what) 21 | } 22 | } 23 | } 24 | 25 | override fun onBoundsChange(bounds: Rect) { 26 | drawable.bounds = bounds 27 | } 28 | 29 | override fun getIntrinsicWidth(): Int = drawable.intrinsicWidth 30 | override fun getIntrinsicHeight(): Int = drawable.intrinsicHeight 31 | override fun getMinimumWidth(): Int = drawable.minimumWidth 32 | override fun getMinimumHeight(): Int = drawable.minimumHeight 33 | 34 | override fun draw(canvas: Canvas) { 35 | drawable.draw(canvas) 36 | } 37 | 38 | override fun getAlpha(): Int { 39 | return drawable.alpha 40 | } 41 | 42 | override fun setAlpha(alpha: Int) { 43 | drawable.alpha = alpha 44 | } 45 | 46 | override fun getColorFilter(): ColorFilter? { 47 | return drawable.colorFilter 48 | } 49 | 50 | override fun setColorFilter(colorFilter: ColorFilter?) { 51 | drawable.colorFilter = colorFilter 52 | } 53 | 54 | @Suppress("DEPRECATION") 55 | override fun getOpacity(): Int = drawable.opacity 56 | } 57 | -------------------------------------------------------------------------------- /app/src/main/kotlin/com/looker/droidify/graphics/PaddingDrawable.kt: -------------------------------------------------------------------------------- 1 | package com.looker.droidify.graphics 2 | 3 | import android.graphics.Rect 4 | import android.graphics.drawable.Drawable 5 | import kotlin.math.roundToInt 6 | 7 | class PaddingDrawable( 8 | drawable: Drawable, 9 | private val horizontalFactor: Float, 10 | private val aspectRatio: Float = 16f / 9f 11 | ) : DrawableWrapper(drawable) { 12 | override fun getIntrinsicWidth(): Int = 13 | (horizontalFactor * super.getIntrinsicWidth()).roundToInt() 14 | 15 | override fun getIntrinsicHeight(): Int = 16 | ((horizontalFactor * aspectRatio) * super.getIntrinsicHeight()).roundToInt() 17 | 18 | override fun onBoundsChange(bounds: Rect) { 19 | val width = (bounds.width() / horizontalFactor).roundToInt() 20 | val height = (bounds.height() / (horizontalFactor * aspectRatio)).roundToInt() 21 | val left = (bounds.width() - width) / 2 22 | val top = (bounds.height() - height) / 2 23 | drawable.setBounds( 24 | bounds.left + left, 25 | bounds.top + top, 26 | bounds.left + left + width, 27 | bounds.top + top + height 28 | ) 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /app/src/main/kotlin/com/looker/droidify/installer/installers/Installer.kt: -------------------------------------------------------------------------------- 1 | package com.looker.droidify.installer.installers 2 | 3 | import com.looker.droidify.domain.model.PackageName 4 | import com.looker.droidify.installer.model.InstallItem 5 | import com.looker.droidify.installer.model.InstallState 6 | 7 | interface Installer : AutoCloseable { 8 | 9 | suspend fun install(installItem: InstallItem): InstallState 10 | 11 | suspend fun uninstall(packageName: PackageName) 12 | 13 | } 14 | -------------------------------------------------------------------------------- /app/src/main/kotlin/com/looker/droidify/installer/model/InstallItem.kt: -------------------------------------------------------------------------------- 1 | package com.looker.droidify.installer.model 2 | 3 | import com.looker.droidify.domain.model.PackageName 4 | import com.looker.droidify.domain.model.toPackageName 5 | 6 | class InstallItem( 7 | val packageName: PackageName, 8 | val installFileName: String 9 | ) 10 | 11 | infix fun String.installFrom(fileName: String) = InstallItem(this.toPackageName(), fileName) 12 | -------------------------------------------------------------------------------- /app/src/main/kotlin/com/looker/droidify/installer/model/InstallState.kt: -------------------------------------------------------------------------------- 1 | package com.looker.droidify.installer.model 2 | 3 | enum class InstallState { Failed, Pending, Installing, Installed } 4 | 5 | inline val InstallState.isCancellable: Boolean 6 | get() = this == InstallState.Pending 7 | -------------------------------------------------------------------------------- /app/src/main/kotlin/com/looker/droidify/model/InstalledItem.kt: -------------------------------------------------------------------------------- 1 | package com.looker.droidify.model 2 | 3 | class InstalledItem( 4 | val packageName: String, 5 | val version: String, 6 | val versionCode: Long, 7 | val signature: String 8 | ) 9 | -------------------------------------------------------------------------------- /app/src/main/kotlin/com/looker/droidify/model/ProductItem.kt: -------------------------------------------------------------------------------- 1 | package com.looker.droidify.model 2 | 3 | import android.os.Parcelable 4 | import android.view.View 5 | import com.looker.droidify.utility.common.extension.dpi 6 | import kotlinx.parcelize.Parcelize 7 | 8 | data class ProductItem( 9 | var repositoryId: Long, 10 | var packageName: String, 11 | var name: String, 12 | var summary: String, 13 | val icon: String, 14 | val metadataIcon: String, 15 | val version: String, 16 | var installedVersion: String, 17 | var compatible: Boolean, 18 | var canUpdate: Boolean, 19 | var matchRank: Int 20 | ) { 21 | sealed interface Section : Parcelable { 22 | 23 | @Parcelize 24 | object All : Section 25 | 26 | @Parcelize 27 | class Category(val name: String) : Section 28 | 29 | @Parcelize 30 | class Repository(val id: Long, val name: String) : Section 31 | } 32 | 33 | private val supportedDpi = intArrayOf(120, 160, 240, 320, 480, 640) 34 | private var deviceDpi: Int = -1 35 | 36 | fun icon( 37 | view: View, 38 | repository: Repository 39 | ): String? { 40 | if (packageName.isBlank()) return null 41 | if (icon.isBlank() && metadataIcon.isBlank()) return null 42 | if (repository.version < 11 && icon.isNotBlank()) { 43 | return "${repository.address}/icons/$icon" 44 | } 45 | if (icon.isNotBlank()) { 46 | if (deviceDpi == -1) { 47 | deviceDpi = supportedDpi.find { it >= view.dpi } ?: supportedDpi.last() 48 | } 49 | return "${repository.address}/icons-$deviceDpi/$icon" 50 | } 51 | if (metadataIcon.isNotBlank()) { 52 | return "${repository.address}/$packageName/$metadataIcon" 53 | } 54 | return null 55 | } 56 | } 57 | -------------------------------------------------------------------------------- /app/src/main/kotlin/com/looker/droidify/model/ProductPreference.kt: -------------------------------------------------------------------------------- 1 | package com.looker.droidify.model 2 | 3 | data class ProductPreference(val ignoreUpdates: Boolean, val ignoreVersionCode: Long) { 4 | fun shouldIgnoreUpdate(versionCode: Long): Boolean { 5 | return ignoreUpdates || ignoreVersionCode == versionCode 6 | } 7 | } 8 | -------------------------------------------------------------------------------- /app/src/main/kotlin/com/looker/droidify/model/Release.kt: -------------------------------------------------------------------------------- 1 | package com.looker.droidify.model 2 | 3 | import android.net.Uri 4 | 5 | data class Release( 6 | val selected: Boolean, 7 | val version: String, 8 | val versionCode: Long, 9 | val added: Long, 10 | val size: Long, 11 | val minSdkVersion: Int, 12 | val targetSdkVersion: Int, 13 | val maxSdkVersion: Int, 14 | val source: String, 15 | val release: String, 16 | val hash: String, 17 | val hashType: String, 18 | val signature: String, 19 | val obbMain: String, 20 | val obbMainHash: String, 21 | val obbMainHashType: String, 22 | val obbPatch: String, 23 | val obbPatchHash: String, 24 | val obbPatchHashType: String, 25 | val permissions: List, 26 | val features: List, 27 | val platforms: List, 28 | val incompatibilities: List 29 | ) { 30 | sealed class Incompatibility { 31 | object MinSdk : Incompatibility() 32 | object MaxSdk : Incompatibility() 33 | object Platform : Incompatibility() 34 | class Feature(val feature: String) : Incompatibility() 35 | } 36 | 37 | val identifier: String 38 | get() = "$versionCode.$hash" 39 | 40 | fun getDownloadUrl(repository: Repository): String { 41 | return Uri.parse(repository.address).buildUpon().appendPath(release).build().toString() 42 | } 43 | 44 | val cacheFileName: String 45 | get() = "${hash.replace('/', '-')}.apk" 46 | } 47 | -------------------------------------------------------------------------------- /app/src/main/kotlin/com/looker/droidify/network/DataSize.kt: -------------------------------------------------------------------------------- 1 | package com.looker.droidify.network 2 | 3 | import java.util.Locale 4 | 5 | @JvmInline 6 | value class DataSize(val value: Long) { 7 | 8 | companion object { 9 | private const val BYTE_SIZE = 1024L 10 | private val sizeFormats = listOf("%.0f B", "%.0f kB", "%.1f MB", "%.2f GB") 11 | } 12 | 13 | override fun toString(): String { 14 | val (size, index) = generateSequence(Pair(value.toFloat(), 0)) { (size, index) -> 15 | if (size >= BYTE_SIZE) { 16 | Pair(size / BYTE_SIZE, index + 1) 17 | } else { 18 | null 19 | } 20 | }.take(sizeFormats.size).last() 21 | return sizeFormats[index].format(Locale.US, size) 22 | } 23 | } 24 | 25 | infix fun DataSize.percentBy(denominator: DataSize?): Int = value percentBy denominator?.value 26 | 27 | infix fun Long.percentBy(denominator: Long?): Int { 28 | if (denominator == null || denominator < 1) return -1 29 | return (this * 100 / denominator).toInt() 30 | } 31 | -------------------------------------------------------------------------------- /app/src/main/kotlin/com/looker/droidify/network/Downloader.kt: -------------------------------------------------------------------------------- 1 | package com.looker.droidify.network 2 | 3 | import com.looker.droidify.network.header.HeadersBuilder 4 | import com.looker.droidify.network.validation.FileValidator 5 | import java.io.File 6 | import java.net.Proxy 7 | 8 | interface Downloader { 9 | 10 | fun setProxy(proxy: Proxy) 11 | 12 | suspend fun headCall( 13 | url: String, 14 | headers: HeadersBuilder.() -> Unit = {} 15 | ): NetworkResponse 16 | 17 | suspend fun downloadToFile( 18 | url: String, 19 | target: File, 20 | validator: FileValidator? = null, 21 | headers: HeadersBuilder.() -> Unit = {}, 22 | block: ProgressListener? = null 23 | ): NetworkResponse 24 | } 25 | 26 | typealias ProgressListener = suspend (bytesReceived: DataSize, contentLength: DataSize) -> Unit 27 | -------------------------------------------------------------------------------- /app/src/main/kotlin/com/looker/droidify/network/NetworkResponse.kt: -------------------------------------------------------------------------------- 1 | package com.looker.droidify.network 2 | 3 | import com.looker.droidify.network.validation.ValidationException 4 | import java.util.Date 5 | 6 | sealed interface NetworkResponse { 7 | 8 | sealed interface Error : NetworkResponse { 9 | 10 | data class ConnectionTimeout(val exception: Exception) : Error 11 | 12 | data class SocketTimeout(val exception: Exception) : Error 13 | 14 | data class IO(val exception: Exception) : Error 15 | 16 | data class Validation(val exception: ValidationException) : Error 17 | 18 | data class Unknown(val exception: Exception) : Error 19 | 20 | data class Http(val statusCode: Int) : Error 21 | } 22 | 23 | data class Success( 24 | val statusCode: Int, 25 | val lastModified: Date?, 26 | val etag: String? 27 | ) : NetworkResponse 28 | } 29 | -------------------------------------------------------------------------------- /app/src/main/kotlin/com/looker/droidify/network/header/HeadersBuilder.kt: -------------------------------------------------------------------------------- 1 | package com.looker.droidify.network.header 2 | 3 | import java.util.Date 4 | 5 | interface HeadersBuilder { 6 | 7 | infix fun String.headsWith(value: Any?) 8 | 9 | fun etag(etagString: String) 10 | 11 | fun ifModifiedSince(date: Date) 12 | 13 | fun ifModifiedSince(date: String) 14 | 15 | fun authentication(username: String, password: String) 16 | 17 | fun authentication(base64: String) 18 | 19 | fun inRange(start: Number?, end: Number? = null) 20 | } 21 | -------------------------------------------------------------------------------- /app/src/main/kotlin/com/looker/droidify/network/header/KtorHeadersBuilder.kt: -------------------------------------------------------------------------------- 1 | package com.looker.droidify.network.header 2 | 3 | import io.ktor.http.HttpHeaders 4 | import io.ktor.util.encodeBase64 5 | import java.text.SimpleDateFormat 6 | import java.util.Date 7 | import java.util.Locale 8 | import java.util.TimeZone 9 | 10 | internal class KtorHeadersBuilder( 11 | private val builder: io.ktor.http.HeadersBuilder 12 | ) : HeadersBuilder { 13 | 14 | override fun String.headsWith(value: Any?) { 15 | if (value == null) return 16 | with(builder) { 17 | append(this@headsWith, value.toString()) 18 | } 19 | } 20 | 21 | override fun etag(etagString: String) { 22 | HttpHeaders.ETag headsWith etagString 23 | } 24 | 25 | override fun ifModifiedSince(date: Date) { 26 | HttpHeaders.IfModifiedSince headsWith date.toFormattedString() 27 | } 28 | 29 | override fun ifModifiedSince(date: String) { 30 | HttpHeaders.IfModifiedSince headsWith date 31 | } 32 | 33 | override fun authentication(username: String, password: String) { 34 | HttpHeaders.Authorization headsWith "Basic ${"$username:$password".encodeBase64()}" 35 | } 36 | 37 | override fun authentication(base64: String) { 38 | HttpHeaders.Authorization headsWith base64 39 | } 40 | 41 | override fun inRange(start: Number?, end: Number?) { 42 | if (start == null) return 43 | val valueString = if (end != null) "bytes=$start-$end" else "bytes=$start-" 44 | HttpHeaders.Range headsWith valueString 45 | } 46 | 47 | private companion object { 48 | val HTTP_DATE_FORMAT: SimpleDateFormat 49 | get() = SimpleDateFormat("EEE, dd MMM yyyy HH:mm:ss z", Locale.US).apply { 50 | timeZone = TimeZone.getTimeZone("GMT") 51 | } 52 | 53 | fun Date.toFormattedString(): String = HTTP_DATE_FORMAT.format(this) 54 | } 55 | } 56 | -------------------------------------------------------------------------------- /app/src/main/kotlin/com/looker/droidify/network/validation/FileValidator.kt: -------------------------------------------------------------------------------- 1 | package com.looker.droidify.network.validation 2 | 3 | import java.io.File 4 | 5 | interface FileValidator { 6 | 7 | @Throws(ValidationException::class) 8 | suspend fun validate(file: File) 9 | 10 | } 11 | -------------------------------------------------------------------------------- /app/src/main/kotlin/com/looker/droidify/network/validation/ValidationException.kt: -------------------------------------------------------------------------------- 1 | package com.looker.droidify.network.validation 2 | 3 | class ValidationException(override val message: String) : Exception(message) 4 | 5 | @Suppress("NOTHING_TO_INLINE") 6 | inline fun invalid(message: String): Nothing = throw ValidationException(message) 7 | -------------------------------------------------------------------------------- /app/src/main/kotlin/com/looker/droidify/receivers/InstalledAppReceiver.kt: -------------------------------------------------------------------------------- 1 | package com.looker.droidify.receivers 2 | 3 | import android.content.BroadcastReceiver 4 | import android.content.Context 5 | import android.content.Intent 6 | import android.content.pm.PackageManager 7 | import com.looker.droidify.utility.common.extension.getPackageInfoCompat 8 | import com.looker.droidify.database.Database 9 | import com.looker.droidify.utility.extension.toInstalledItem 10 | 11 | class InstalledAppReceiver(private val packageManager: PackageManager) : BroadcastReceiver() { 12 | override fun onReceive(context: Context, intent: Intent) { 13 | val packageName = 14 | intent.data?.let { if (it.scheme == "package") it.schemeSpecificPart else null } 15 | if (packageName != null) { 16 | when (intent.action.orEmpty()) { 17 | Intent.ACTION_PACKAGE_ADDED, 18 | Intent.ACTION_PACKAGE_REMOVED 19 | -> { 20 | val packageInfo = packageManager.getPackageInfoCompat(packageName) 21 | if (packageInfo != null) { 22 | Database.InstalledAdapter.put(packageInfo.toInstalledItem()) 23 | } else { 24 | Database.InstalledAdapter.delete(packageName) 25 | } 26 | } 27 | } 28 | } 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /app/src/main/kotlin/com/looker/droidify/service/Connection.kt: -------------------------------------------------------------------------------- 1 | package com.looker.droidify.service 2 | 3 | import android.content.ComponentName 4 | import android.content.Context 5 | import android.content.Intent 6 | import android.content.ServiceConnection 7 | import android.os.IBinder 8 | 9 | class Connection>( 10 | private val serviceClass: Class, 11 | private val onBind: ((Connection, B) -> Unit)? = null, 12 | private val onUnbind: ((Connection, B) -> Unit)? = null 13 | ) : ServiceConnection { 14 | var binder: B? = null 15 | private set 16 | 17 | private fun handleUnbind() { 18 | binder?.let { 19 | binder = null 20 | onUnbind?.invoke(this, it) 21 | } 22 | } 23 | 24 | override fun onServiceConnected(componentName: ComponentName, binder: IBinder) { 25 | @Suppress("UNCHECKED_CAST") 26 | binder as B 27 | this.binder = binder 28 | onBind?.invoke(this, binder) 29 | } 30 | 31 | override fun onServiceDisconnected(componentName: ComponentName) { 32 | handleUnbind() 33 | } 34 | 35 | fun bind(context: Context) { 36 | context.bindService(Intent(context, serviceClass), this, Context.BIND_AUTO_CREATE) 37 | } 38 | 39 | fun unbind(context: Context) { 40 | context.unbindService(this) 41 | handleUnbind() 42 | } 43 | } 44 | -------------------------------------------------------------------------------- /app/src/main/kotlin/com/looker/droidify/service/ConnectionService.kt: -------------------------------------------------------------------------------- 1 | package com.looker.droidify.service 2 | 3 | import android.app.Service 4 | import android.content.Intent 5 | import android.os.IBinder 6 | import kotlinx.coroutines.CoroutineScope 7 | import kotlinx.coroutines.Dispatchers 8 | import kotlinx.coroutines.SupervisorJob 9 | import kotlinx.coroutines.cancel 10 | 11 | abstract class ConnectionService : Service() { 12 | 13 | private val supervisorJob = SupervisorJob() 14 | val lifecycleScope = CoroutineScope(Dispatchers.Main + supervisorJob) 15 | 16 | abstract override fun onBind(intent: Intent): T 17 | 18 | override fun onDestroy() { 19 | super.onDestroy() 20 | lifecycleScope.cancel() 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /app/src/main/kotlin/com/looker/droidify/service/ReleaseFileValidator.kt: -------------------------------------------------------------------------------- 1 | package com.looker.droidify.service 2 | 3 | import android.content.Context 4 | import androidx.annotation.StringRes 5 | import com.looker.droidify.utility.common.extension.calculateHash 6 | import com.looker.droidify.utility.common.extension.getPackageArchiveInfoCompat 7 | import com.looker.droidify.utility.common.extension.singleSignature 8 | import com.looker.droidify.utility.common.extension.versionCodeCompat 9 | import com.looker.droidify.network.validation.FileValidator 10 | import com.looker.droidify.utility.common.signature.Hash 11 | import com.looker.droidify.network.validation.invalid 12 | import com.looker.droidify.utility.common.signature.verifyHash 13 | import com.looker.droidify.model.Release 14 | import java.io.File 15 | import com.looker.droidify.R.string as strings 16 | 17 | class ReleaseFileValidator( 18 | private val context: Context, 19 | private val packageName: String, 20 | private val release: Release 21 | ) : FileValidator { 22 | 23 | override suspend fun validate(file: File) { 24 | val hash = Hash(release.hashType, release.hash) 25 | if (!file.verifyHash(hash)) { 26 | invalid(getString(strings.integrity_check_error_DESC)) 27 | } 28 | val packageInfo = context.packageManager.getPackageArchiveInfoCompat(file.path) 29 | ?: invalid(getString(strings.file_format_error_DESC)) 30 | if (packageInfo.packageName != packageName || 31 | packageInfo.versionCodeCompat != release.versionCode 32 | ) { 33 | invalid(getString(strings.invalid_metadata_error_DESC)) 34 | } 35 | 36 | packageInfo.singleSignature 37 | ?.calculateHash() 38 | ?.takeIf { it.isNotBlank() || it == release.signature } 39 | ?: invalid(getString(strings.invalid_signature_error_DESC)) 40 | 41 | packageInfo.permissions 42 | ?.asSequence() 43 | .orEmpty() 44 | .map { it.name } 45 | .toSet() 46 | .takeIf { release.permissions.containsAll(it) } 47 | ?: invalid(getString(strings.invalid_permissions_error_DESC)) 48 | } 49 | 50 | private fun getString(@StringRes id: Int): String = context.getString(id) 51 | } 52 | -------------------------------------------------------------------------------- /app/src/main/kotlin/com/looker/droidify/sync/IndexValidator.kt: -------------------------------------------------------------------------------- 1 | package com.looker.droidify.sync 2 | 3 | import com.looker.droidify.domain.model.Fingerprint 4 | import com.looker.droidify.network.validation.ValidationException 5 | import java.util.jar.JarEntry 6 | 7 | interface IndexValidator { 8 | 9 | @Throws(ValidationException::class) 10 | suspend fun validate( 11 | jarEntry: JarEntry, 12 | expectedFingerprint: Fingerprint?, 13 | ): Fingerprint 14 | 15 | } 16 | -------------------------------------------------------------------------------- /app/src/main/kotlin/com/looker/droidify/sync/Parser.kt: -------------------------------------------------------------------------------- 1 | package com.looker.droidify.sync 2 | 3 | import com.looker.droidify.domain.model.Fingerprint 4 | import com.looker.droidify.domain.model.Repo 5 | import java.io.File 6 | 7 | interface Parser { 8 | 9 | suspend fun parse( 10 | file: File, 11 | repo: Repo, 12 | ): Pair 13 | 14 | } 15 | -------------------------------------------------------------------------------- /app/src/main/kotlin/com/looker/droidify/sync/SyncPreference.kt: -------------------------------------------------------------------------------- 1 | package com.looker.droidify.sync 2 | 3 | import android.app.job.JobInfo 4 | import androidx.work.Constraints 5 | import androidx.work.NetworkType 6 | 7 | data class SyncPreference( 8 | val networkType: NetworkType, 9 | val pluggedIn: Boolean = false, 10 | val batteryNotLow: Boolean = true, 11 | ) 12 | 13 | fun SyncPreference.toJobNetworkType() = when (networkType) { 14 | NetworkType.NOT_REQUIRED -> JobInfo.NETWORK_TYPE_NONE 15 | NetworkType.UNMETERED -> JobInfo.NETWORK_TYPE_UNMETERED 16 | else -> JobInfo.NETWORK_TYPE_ANY 17 | } 18 | 19 | fun SyncPreference.toWorkConstraints(): Constraints = Constraints( 20 | requiredNetworkType = networkType, 21 | requiresCharging = pluggedIn, 22 | requiresBatteryNotLow = batteryNotLow 23 | ) 24 | -------------------------------------------------------------------------------- /app/src/main/kotlin/com/looker/droidify/sync/Syncable.kt: -------------------------------------------------------------------------------- 1 | package com.looker.droidify.sync 2 | 3 | import com.looker.droidify.domain.model.Fingerprint 4 | import com.looker.droidify.domain.model.Repo 5 | 6 | /** 7 | * Expected Architecture: [https://excalidraw.com/#json=JqpGunWTJONjq-ecDNiPg,j9t0X4coeNvIG7B33GTq6A] 8 | * 9 | * Current Issue: When downloading entry.jar we need to re-call the synchronizer, 10 | * which this arch doesn't allow. 11 | */ 12 | interface Syncable { 13 | 14 | val parser: Parser 15 | 16 | suspend fun sync( 17 | repo: Repo, 18 | ): Pair 19 | 20 | } 21 | -------------------------------------------------------------------------------- /app/src/main/kotlin/com/looker/droidify/sync/common/IndexDownloader.kt: -------------------------------------------------------------------------------- 1 | package com.looker.droidify.sync.common 2 | 3 | import android.content.Context 4 | import com.looker.droidify.utility.common.cache.Cache 5 | import com.looker.droidify.domain.model.Repo 6 | import com.looker.droidify.network.Downloader 7 | import kotlinx.coroutines.Dispatchers 8 | import kotlinx.coroutines.withContext 9 | import java.io.File 10 | import java.util.Date 11 | 12 | suspend fun Downloader.downloadIndex( 13 | context: Context, 14 | repo: Repo, 15 | fileName: String, 16 | url: String, 17 | diff: Boolean = false, 18 | ): File = withContext(Dispatchers.IO) { 19 | val tempFile = Cache.getIndexFile(context, "repo_${repo.id}_$fileName") 20 | downloadToFile( 21 | url = url, 22 | target = tempFile, 23 | headers = { 24 | if (repo.shouldAuthenticate) { 25 | authentication( 26 | repo.authentication.username, 27 | repo.authentication.password 28 | ) 29 | } 30 | if (repo.versionInfo.timestamp > 0L && !diff) { 31 | ifModifiedSince(Date(repo.versionInfo.timestamp)) 32 | } 33 | } 34 | ) 35 | tempFile 36 | } 37 | 38 | const val INDEX_V1_NAME = "index-v1.jar" 39 | const val ENTRY_V2_NAME = "entry.jar" 40 | const val INDEX_V2_NAME = "index-v2.json" 41 | -------------------------------------------------------------------------------- /app/src/main/kotlin/com/looker/droidify/sync/common/IndexJarValidator.kt: -------------------------------------------------------------------------------- 1 | package com.looker.droidify.sync.common 2 | 3 | import com.looker.droidify.domain.model.Fingerprint 4 | import com.looker.droidify.domain.model.check 5 | import com.looker.droidify.domain.model.fingerprint 6 | import com.looker.droidify.network.validation.invalid 7 | import com.looker.droidify.sync.utils.certificate 8 | import com.looker.droidify.sync.utils.codeSigner 9 | import kotlinx.coroutines.CoroutineDispatcher 10 | import kotlinx.coroutines.withContext 11 | import java.util.jar.JarEntry 12 | 13 | class IndexJarValidator( 14 | private val dispatcher: CoroutineDispatcher 15 | ) : com.looker.droidify.sync.IndexValidator { 16 | override suspend fun validate( 17 | jarEntry: JarEntry, 18 | expectedFingerprint: Fingerprint? 19 | ): Fingerprint = withContext(dispatcher) { 20 | val fingerprint = try { 21 | jarEntry 22 | .codeSigner 23 | .certificate 24 | .fingerprint() 25 | } catch (e: IllegalStateException) { 26 | invalid(e.message ?: "Unknown Exception") 27 | } catch (e: IllegalArgumentException) { 28 | invalid(e.message ?: "Error creating Fingerprint object") 29 | } 30 | if (expectedFingerprint == null) { 31 | fingerprint 32 | } else { 33 | if (expectedFingerprint.check(fingerprint)) { 34 | expectedFingerprint 35 | } else { 36 | invalid( 37 | "Expected Fingerprint: ${expectedFingerprint}, " + 38 | "Acquired Fingerprint: $fingerprint" 39 | ) 40 | } 41 | } 42 | } 43 | } 44 | -------------------------------------------------------------------------------- /app/src/main/kotlin/com/looker/droidify/sync/common/JsonParser.kt: -------------------------------------------------------------------------------- 1 | package com.looker.droidify.sync.common 2 | 3 | import kotlinx.serialization.json.Json 4 | 5 | val JsonParser = Json { 6 | ignoreUnknownKeys = true 7 | coerceInputValues = true 8 | isLenient = true 9 | } 10 | -------------------------------------------------------------------------------- /app/src/main/kotlin/com/looker/droidify/sync/utils/JarFile.kt: -------------------------------------------------------------------------------- 1 | package com.looker.droidify.sync.utils 2 | 3 | import java.io.File 4 | import java.security.CodeSigner 5 | import java.security.cert.Certificate 6 | import java.util.jar.JarEntry 7 | import java.util.jar.JarFile 8 | 9 | fun File.toJarFile(verify: Boolean = true): JarFile = JarFile(this, verify) 10 | 11 | @get:Throws(IllegalStateException::class) 12 | val JarEntry.codeSigner: CodeSigner 13 | get() = codeSigners?.singleOrNull() 14 | ?: error("index.jar must be signed by a single code signer, Current: $codeSigners") 15 | 16 | @get:Throws(IllegalStateException::class) 17 | val CodeSigner.certificate: Certificate 18 | get() = signerCertPath?.certificates?.singleOrNull() 19 | ?: error("index.jar code signer should have only one certificate") 20 | -------------------------------------------------------------------------------- /app/src/main/kotlin/com/looker/droidify/sync/v1/V1Parser.kt: -------------------------------------------------------------------------------- 1 | package com.looker.droidify.sync.v1 2 | 3 | import com.looker.droidify.domain.model.Fingerprint 4 | import com.looker.droidify.domain.model.Repo 5 | import com.looker.droidify.sync.IndexValidator 6 | import com.looker.droidify.sync.Parser 7 | import com.looker.droidify.sync.utils.toJarFile 8 | import com.looker.droidify.sync.v1.model.IndexV1 9 | import kotlinx.coroutines.CoroutineDispatcher 10 | import kotlinx.coroutines.withContext 11 | import kotlinx.serialization.json.Json 12 | import java.io.File 13 | 14 | class V1Parser( 15 | private val dispatcher: CoroutineDispatcher, 16 | private val json: Json, 17 | private val validator: IndexValidator, 18 | ) : Parser { 19 | 20 | override suspend fun parse( 21 | file: File, 22 | repo: Repo, 23 | ): Pair = withContext(dispatcher) { 24 | val jar = file.toJarFile() 25 | val entry = jar.getJarEntry("index-v1.json") 26 | val indexString = jar.getInputStream(entry).use { 27 | it.readBytes().decodeToString() 28 | } 29 | validator.validate(entry, repo.fingerprint) to json.decodeFromString(indexString) 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /app/src/main/kotlin/com/looker/droidify/sync/v1/V1Syncable.kt: -------------------------------------------------------------------------------- 1 | package com.looker.droidify.sync.v1 2 | 3 | import android.content.Context 4 | import com.looker.droidify.domain.model.Fingerprint 5 | import com.looker.droidify.domain.model.Repo 6 | import com.looker.droidify.sync.Parser 7 | import com.looker.droidify.sync.Syncable 8 | import com.looker.droidify.sync.common.INDEX_V1_NAME 9 | import com.looker.droidify.sync.common.IndexJarValidator 10 | import com.looker.droidify.sync.common.JsonParser 11 | import com.looker.droidify.sync.common.downloadIndex 12 | import com.looker.droidify.sync.common.toV2 13 | import com.looker.droidify.sync.v1.model.IndexV1 14 | import com.looker.droidify.sync.v2.model.IndexV2 15 | import com.looker.droidify.network.Downloader 16 | import kotlinx.coroutines.CoroutineDispatcher 17 | import kotlinx.coroutines.withContext 18 | 19 | class V1Syncable( 20 | private val context: Context, 21 | private val downloader: Downloader, 22 | private val dispatcher: CoroutineDispatcher, 23 | ) : Syncable { 24 | override val parser: Parser 25 | get() = V1Parser( 26 | dispatcher = dispatcher, 27 | json = JsonParser, 28 | validator = IndexJarValidator(dispatcher), 29 | ) 30 | 31 | override suspend fun sync(repo: Repo): Pair = 32 | withContext(dispatcher) { 33 | val jar = downloader.downloadIndex( 34 | context = context, 35 | repo = repo, 36 | url = repo.address.removeSuffix("/") + "/$INDEX_V1_NAME", 37 | fileName = INDEX_V1_NAME, 38 | ) 39 | val (fingerprint, indexV1) = parser.parse(jar, repo) 40 | jar.delete() 41 | fingerprint to indexV1.toV2() 42 | } 43 | } 44 | -------------------------------------------------------------------------------- /app/src/main/kotlin/com/looker/droidify/sync/v1/model/AppV1.kt: -------------------------------------------------------------------------------- 1 | package com.looker.droidify.sync.v1.model 2 | 3 | /* 4 | * AppV1 is licensed under the GPL 3.0 to FDroid Organization. 5 | * */ 6 | 7 | import kotlinx.serialization.Serializable 8 | 9 | @Serializable 10 | data class AppV1( 11 | val packageName: String, 12 | val icon: String? = null, 13 | val name: String? = null, 14 | val description: String? = null, 15 | val summary: String? = null, 16 | val added: Long? = null, 17 | val antiFeatures: List = emptyList(), 18 | val authorEmail: String? = null, 19 | val authorName: String? = null, 20 | val authorPhone: String? = null, 21 | val authorWebSite: String? = null, 22 | val binaries: String? = null, 23 | val bitcoin: String? = null, 24 | val categories: List = emptyList(), 25 | val changelog: String? = null, 26 | val donate: String? = null, 27 | val flattrID: String? = null, 28 | val issueTracker: String? = null, 29 | val lastUpdated: Long? = null, 30 | val liberapay: String? = null, 31 | val liberapayID: String? = null, 32 | val license: String, 33 | val litecoin: String? = null, 34 | val localized: Map? = null, 35 | val openCollective: String? = null, 36 | val sourceCode: String? = null, 37 | val suggestedVersionCode: String? = null, 38 | val translation: String? = null, 39 | val webSite: String? = null, 40 | ) 41 | 42 | -------------------------------------------------------------------------------- /app/src/main/kotlin/com/looker/droidify/sync/v1/model/IndexV1.kt: -------------------------------------------------------------------------------- 1 | package com.looker.droidify.sync.v1.model 2 | 3 | /* 4 | * IndexV1 is licensed under the GPL 3.0 to FDroid Organization. 5 | * */ 6 | 7 | import kotlinx.serialization.Serializable 8 | 9 | @Serializable 10 | data class IndexV1( 11 | val repo: RepoV1, 12 | val apps: List = emptyList(), 13 | val packages: Map> = emptyMap(), 14 | ) 15 | -------------------------------------------------------------------------------- /app/src/main/kotlin/com/looker/droidify/sync/v1/model/Localized.kt: -------------------------------------------------------------------------------- 1 | package com.looker.droidify.sync.v1.model 2 | 3 | /* 4 | * Localized is licensed under the GPL 3.0 to FDroid Organization. 5 | * */ 6 | 7 | import kotlinx.serialization.Serializable 8 | 9 | @Serializable 10 | data class Localized( 11 | val icon: String? = null, 12 | val name: String? = null, 13 | val description: String? = null, 14 | val summary: String? = null, 15 | val featureGraphic: String? = null, 16 | val phoneScreenshots: List? = null, 17 | val promoGraphic: String? = null, 18 | val sevenInchScreenshots: List? = null, 19 | val tenInchScreenshots: List? = null, 20 | val tvBanner: String? = null, 21 | val tvScreenshots: List? = null, 22 | val video: String? = null, 23 | val wearScreenshots: List? = null, 24 | val whatsNew: String? = null, 25 | ) 26 | -------------------------------------------------------------------------------- /app/src/main/kotlin/com/looker/droidify/sync/v1/model/PackageV1.kt: -------------------------------------------------------------------------------- 1 | package com.looker.droidify.sync.v1.model 2 | 3 | /* 4 | * PackageV1, PermissionV1 are licensed under the GPL 3.0 to FDroid Organization. 5 | * */ 6 | 7 | import kotlinx.serialization.SerialName 8 | import kotlinx.serialization.Serializable 9 | 10 | @Serializable 11 | data class PackageV1( 12 | val added: Long? = null, 13 | val apkName: String, 14 | val hash: String, 15 | val hashType: String, 16 | val minSdkVersion: Int? = null, 17 | val maxSdkVersion: Int? = null, 18 | val targetSdkVersion: Int? = minSdkVersion, 19 | val packageName: String, 20 | val sig: String? = null, 21 | val signer: String? = null, 22 | val size: Long, 23 | @SerialName("srcname") 24 | val srcName: String? = null, 25 | @SerialName("uses-permission") 26 | val usesPermission: List = emptyList(), 27 | @SerialName("uses-permission-sdk-23") 28 | val usesPermission23: List = emptyList(), 29 | val versionCode: Long? = null, 30 | val versionName: String, 31 | @SerialName("nativecode") 32 | val nativeCode: List? = null, 33 | val features: List? = null, 34 | val antiFeatures: List? = null, 35 | ) 36 | 37 | typealias PermissionV1 = Array 38 | 39 | val PermissionV1.name: String get() = first()!! 40 | val PermissionV1.maxSdk: Int? get() = getOrNull(1)?.toInt() 41 | -------------------------------------------------------------------------------- /app/src/main/kotlin/com/looker/droidify/sync/v1/model/RepoV1.kt: -------------------------------------------------------------------------------- 1 | package com.looker.droidify.sync.v1.model 2 | 3 | /* 4 | * RepoV1 is licensed under the GPL 3.0 to FDroid Organization. 5 | * */ 6 | 7 | import kotlinx.serialization.Serializable 8 | 9 | @Serializable 10 | data class RepoV1( 11 | val address: String, 12 | val icon: String, 13 | val name: String, 14 | val description: String, 15 | val timestamp: Long, 16 | val version: Int, 17 | val mirrors: List = emptyList(), 18 | ) 19 | -------------------------------------------------------------------------------- /app/src/main/kotlin/com/looker/droidify/sync/v2/DiffParser.kt: -------------------------------------------------------------------------------- 1 | package com.looker.droidify.sync.v2 2 | 3 | import com.looker.droidify.domain.model.Fingerprint 4 | import com.looker.droidify.domain.model.Repo 5 | import com.looker.droidify.sync.Parser 6 | import com.looker.droidify.sync.v2.model.IndexV2Diff 7 | import kotlinx.coroutines.CoroutineDispatcher 8 | import kotlinx.coroutines.withContext 9 | import kotlinx.serialization.json.Json 10 | import java.io.File 11 | 12 | class DiffParser( 13 | private val dispatcher: CoroutineDispatcher, 14 | private val json: Json, 15 | ) : Parser { 16 | 17 | override suspend fun parse( 18 | file: File, 19 | repo: Repo 20 | ): Pair = withContext(dispatcher) { 21 | requireNotNull(repo.fingerprint) { 22 | "Fingerprint should not be null when parsing diff" 23 | } to json.decodeFromString(file.readBytes().decodeToString()) 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /app/src/main/kotlin/com/looker/droidify/sync/v2/EntryParser.kt: -------------------------------------------------------------------------------- 1 | package com.looker.droidify.sync.v2 2 | 3 | import com.looker.droidify.domain.model.Fingerprint 4 | import com.looker.droidify.domain.model.Repo 5 | import com.looker.droidify.sync.IndexValidator 6 | import com.looker.droidify.sync.Parser 7 | import com.looker.droidify.sync.utils.toJarFile 8 | import com.looker.droidify.sync.v2.model.Entry 9 | import kotlinx.coroutines.CoroutineDispatcher 10 | import kotlinx.coroutines.withContext 11 | import kotlinx.serialization.json.Json 12 | import java.io.File 13 | 14 | class EntryParser( 15 | private val dispatcher: CoroutineDispatcher, 16 | private val json: Json, 17 | private val validator: IndexValidator, 18 | ) : Parser { 19 | 20 | override suspend fun parse( 21 | file: File, 22 | repo: Repo, 23 | ): Pair = withContext(dispatcher) { 24 | val jar = file.toJarFile() 25 | val entry = jar.getJarEntry("entry.json") 26 | val entryString = jar.getInputStream(entry).use { 27 | it.readBytes().decodeToString() 28 | } 29 | validator.validate(entry, repo.fingerprint) to json.decodeFromString(entryString) 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /app/src/main/kotlin/com/looker/droidify/sync/v2/V2Parser.kt: -------------------------------------------------------------------------------- 1 | package com.looker.droidify.sync.v2 2 | 3 | import com.looker.droidify.domain.model.Fingerprint 4 | import com.looker.droidify.domain.model.Repo 5 | import com.looker.droidify.sync.Parser 6 | import com.looker.droidify.sync.v2.model.IndexV2 7 | import kotlinx.coroutines.CoroutineDispatcher 8 | import kotlinx.coroutines.withContext 9 | import kotlinx.serialization.json.Json 10 | import java.io.File 11 | 12 | class V2Parser( 13 | private val dispatcher: CoroutineDispatcher, 14 | private val json: Json, 15 | ) : Parser { 16 | 17 | override suspend fun parse( 18 | file: File, 19 | repo: Repo, 20 | ): Pair = withContext(dispatcher) { 21 | requireNotNull(repo.fingerprint) { 22 | "Fingerprint should not be null if index v2 is being fetched" 23 | } to json.decodeFromString(file.readBytes().decodeToString()) 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /app/src/main/kotlin/com/looker/droidify/sync/v2/model/Entry.kt: -------------------------------------------------------------------------------- 1 | package com.looker.droidify.sync.v2.model 2 | 3 | /* 4 | * Entry and EntryFile are licensed under the GPL 3.0 to FDroid Organization. 5 | * */ 6 | 7 | import kotlinx.serialization.Serializable 8 | 9 | @Serializable 10 | data class Entry( 11 | val timestamp: Long, 12 | val version: Long, 13 | val index: EntryFile, 14 | val diffs: Map 15 | ) { 16 | 17 | fun getDiff(timestamp: Long): EntryFile? { 18 | return if (this.timestamp == timestamp) null 19 | else diffs[timestamp] ?: index 20 | } 21 | 22 | } 23 | 24 | @Serializable 25 | data class EntryFile( 26 | val name: String, 27 | val sha256: String, 28 | val size: Long, 29 | val numPackages: Long, 30 | ) 31 | -------------------------------------------------------------------------------- /app/src/main/kotlin/com/looker/droidify/sync/v2/model/FileV2.kt: -------------------------------------------------------------------------------- 1 | package com.looker.droidify.sync.v2.model 2 | 3 | /* 4 | * FileV2 is licensed under the GPL 3.0 to FDroid Organization. 5 | * */ 6 | 7 | import kotlinx.serialization.Serializable 8 | 9 | @Serializable 10 | data class FileV2( 11 | val name: String, 12 | val sha256: String? = null, 13 | val size: Long? = null, 14 | ) 15 | -------------------------------------------------------------------------------- /app/src/main/kotlin/com/looker/droidify/sync/v2/model/IndexV2.kt: -------------------------------------------------------------------------------- 1 | package com.looker.droidify.sync.v2.model 2 | 3 | /* 4 | * IndexV2, RepoV2 are licensed under the GPL 3.0 to FDroid Organization. 5 | * */ 6 | 7 | import kotlinx.serialization.Serializable 8 | 9 | @Serializable 10 | data class IndexV2( 11 | val repo: RepoV2, 12 | val packages: Map 13 | ) 14 | 15 | @Serializable 16 | data class IndexV2Diff( 17 | val repo: RepoV2Diff, 18 | val packages: Map 19 | ) { 20 | fun patchInto(index: IndexV2, saveIndex: (IndexV2) -> Unit): IndexV2 { 21 | val packagesToRemove = packages.filter { it.value == null }.keys 22 | val packagesToAdd = packages 23 | .mapNotNull { (key, value) -> 24 | value?.let { value -> 25 | if (index.packages.keys.contains(key)) 26 | index.packages[key]?.let { value.patchInto(it) } 27 | else value.toPackage() 28 | }?.let { key to it } 29 | } 30 | 31 | val newIndex = index.copy( 32 | repo = repo.patchInto(index.repo), 33 | packages = index.packages.minus(packagesToRemove).plus(packagesToAdd), 34 | ) 35 | saveIndex(newIndex) 36 | return newIndex 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /app/src/main/kotlin/com/looker/droidify/sync/v2/model/Localization.kt: -------------------------------------------------------------------------------- 1 | package com.looker.droidify.sync.v2.model 2 | 3 | typealias LocalizedString = Map 4 | typealias NullableLocalizedString = Map 5 | typealias LocalizedIcon = Map 6 | typealias LocalizedList = Map> 7 | typealias LocalizedFiles = Map> 8 | -------------------------------------------------------------------------------- /app/src/main/kotlin/com/looker/droidify/ui/ScreenFragment.kt: -------------------------------------------------------------------------------- 1 | package com.looker.droidify.ui 2 | 3 | import android.os.Bundle 4 | import android.view.LayoutInflater 5 | import android.view.View 6 | import android.view.ViewGroup 7 | import androidx.fragment.app.Fragment 8 | import com.google.android.material.appbar.MaterialToolbar 9 | import com.looker.droidify.databinding.FragmentBinding 10 | 11 | open class ScreenFragment : Fragment() { 12 | private var _fragmentBinding: FragmentBinding? = null 13 | val fragmentBinding get() = _fragmentBinding!! 14 | val toolbar: MaterialToolbar get() = fragmentBinding.toolbar 15 | 16 | override fun onCreate(savedInstanceState: Bundle?) { 17 | super.onCreate(savedInstanceState) 18 | _fragmentBinding = FragmentBinding.inflate(layoutInflater) 19 | } 20 | 21 | override fun onCreateView( 22 | inflater: LayoutInflater, 23 | container: ViewGroup?, 24 | savedInstanceState: Bundle? 25 | ): View = fragmentBinding.root 26 | 27 | override fun onDestroyView() { 28 | super.onDestroyView() 29 | _fragmentBinding = null 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /app/src/main/kotlin/com/looker/droidify/ui/appDetail/ShizukuErrorDialog.kt: -------------------------------------------------------------------------------- 1 | package com.looker.droidify.ui.appDetail 2 | 3 | import android.content.Context 4 | import com.google.android.material.dialog.MaterialAlertDialogBuilder 5 | import com.looker.droidify.R.string as stringRes 6 | 7 | fun shizukuDialog( 8 | context: Context, 9 | shizukuState: ShizukuState, 10 | openShizuku: () -> Unit, 11 | switchInstaller: () -> Unit 12 | ) = with(MaterialAlertDialogBuilder(context)) { 13 | when { 14 | shizukuState.isNotAlive -> { 15 | setTitle(stringRes.error_shizuku_service_unavailable) 16 | setMessage(stringRes.error_shizuku_not_running_DESC) 17 | } 18 | 19 | shizukuState.isNotGranted -> { 20 | setTitle(stringRes.error_shizuku_not_granted) 21 | setMessage(stringRes.error_shizuku_not_granted_DESC) 22 | } 23 | 24 | shizukuState.isNotInstalled -> { 25 | setTitle(stringRes.error_shizuku_not_installed) 26 | setMessage(stringRes.error_shizuku_not_installed_DESC) 27 | } 28 | } 29 | setPositiveButton(stringRes.switch_to_default_installer) { _, _ -> 30 | switchInstaller() 31 | } 32 | setNeutralButton(stringRes.open_shizuku) { _, _ -> 33 | openShizuku() 34 | } 35 | setNegativeButton(stringRes.cancel, null) 36 | } 37 | -------------------------------------------------------------------------------- /app/src/main/kotlin/com/looker/droidify/ui/favourites/FavouritesViewModel.kt: -------------------------------------------------------------------------------- 1 | package com.looker.droidify.ui.favourites 2 | 3 | import androidx.lifecycle.ViewModel 4 | import com.looker.droidify.database.Database 5 | import com.looker.droidify.datastore.SettingsRepository 6 | import com.looker.droidify.datastore.get 7 | import com.looker.droidify.model.Product 8 | import com.looker.droidify.utility.common.extension.asStateFlow 9 | import dagger.hilt.android.lifecycle.HiltViewModel 10 | import kotlinx.coroutines.flow.StateFlow 11 | import kotlinx.coroutines.flow.map 12 | import javax.inject.Inject 13 | 14 | @HiltViewModel 15 | class FavouritesViewModel @Inject constructor( 16 | settingsRepository: SettingsRepository, 17 | ) : ViewModel() { 18 | 19 | val favouriteApps: StateFlow>> = 20 | settingsRepository 21 | .get { favouriteApps } 22 | .map { favourites -> 23 | favourites.mapNotNull { app -> 24 | Database.ProductAdapter.get(app, null).ifEmpty { null } 25 | } 26 | }.asStateFlow(emptyList()) 27 | 28 | } 29 | -------------------------------------------------------------------------------- /app/src/main/kotlin/com/looker/droidify/ui/repository/RepositoryViewModel.kt: -------------------------------------------------------------------------------- 1 | package com.looker.droidify.ui.repository 2 | 3 | import android.content.Context 4 | import androidx.lifecycle.SavedStateHandle 5 | import androidx.lifecycle.ViewModel 6 | import androidx.lifecycle.viewModelScope 7 | import com.looker.droidify.utility.common.extension.asStateFlow 8 | import com.looker.droidify.model.Repository 9 | import com.looker.droidify.database.Database 10 | import com.looker.droidify.service.Connection 11 | import com.looker.droidify.service.SyncService 12 | import dagger.hilt.android.lifecycle.HiltViewModel 13 | import kotlinx.coroutines.flow.combine 14 | import kotlinx.coroutines.flow.first 15 | import kotlinx.coroutines.launch 16 | import javax.inject.Inject 17 | 18 | @HiltViewModel 19 | class RepositoryViewModel @Inject constructor( 20 | savedStateHandle: SavedStateHandle 21 | ) : ViewModel() { 22 | 23 | val id: Long = savedStateHandle[ARG_REPO_ID] ?: -1 24 | 25 | private val repoStream = Database.RepositoryAdapter.getStream(id) 26 | 27 | private val countStream = Database.ProductAdapter.getCountStream(id) 28 | 29 | val state = combine(repoStream, countStream) { repo, count -> 30 | RepositoryPageItem(repo, count) 31 | }.asStateFlow(RepositoryPageItem()) 32 | 33 | private val syncConnection = Connection(SyncService::class.java) 34 | 35 | fun bindService(context: Context) { 36 | syncConnection.bind(context) 37 | } 38 | 39 | fun unbindService(context: Context) { 40 | syncConnection.unbind(context) 41 | } 42 | 43 | fun enabledRepository(enable: Boolean) { 44 | viewModelScope.launch { 45 | val repo = repoStream.first { it != null }!! 46 | syncConnection.binder?.setEnabled(repo, enable) 47 | } 48 | } 49 | 50 | fun deleteRepository(onDelete: () -> Unit) { 51 | if (syncConnection.binder?.deleteRepository(id) == true) { 52 | onDelete() 53 | } 54 | } 55 | 56 | companion object { 57 | const val ARG_REPO_ID = "repo_id" 58 | } 59 | } 60 | 61 | data class RepositoryPageItem( 62 | val repo: Repository? = null, 63 | val appCount: Int = 0 64 | ) 65 | -------------------------------------------------------------------------------- /app/src/main/kotlin/com/looker/droidify/utility/ProgressInputStream.kt: -------------------------------------------------------------------------------- 1 | package com.looker.droidify.utility 2 | 3 | import java.io.InputStream 4 | 5 | fun InputStream.getProgress(callback: (Long) -> Unit): InputStream = 6 | ProgressInputStream(this, callback) 7 | 8 | private class ProgressInputStream( 9 | private val inputStream: InputStream, 10 | private val callback: (Long) -> Unit 11 | ) : InputStream() { 12 | private var count = 0L 13 | 14 | private inline fun notify(one: Boolean, read: () -> T): T { 15 | val result = read() 16 | count += if (one) 1L else result.toLong() 17 | callback(count) 18 | return result 19 | } 20 | 21 | override fun read(): Int = notify(true) { inputStream.read() } 22 | override fun read(b: ByteArray): Int = notify(false) { inputStream.read(b) } 23 | override fun read(b: ByteArray, off: Int, len: Int): Int = 24 | notify(false) { inputStream.read(b, off, len) } 25 | 26 | override fun skip(n: Long): Long = notify(false) { inputStream.skip(n) } 27 | 28 | override fun available(): Int { 29 | return inputStream.available() 30 | } 31 | 32 | override fun close() { 33 | inputStream.close() 34 | super.close() 35 | } 36 | } 37 | -------------------------------------------------------------------------------- /app/src/main/kotlin/com/looker/droidify/utility/common/Constants.kt: -------------------------------------------------------------------------------- 1 | package com.looker.droidify.utility.common 2 | 3 | object Constants { 4 | const val NOTIFICATION_CHANNEL_SYNCING = "syncing" 5 | const val NOTIFICATION_CHANNEL_UPDATES = "updates" 6 | const val NOTIFICATION_CHANNEL_DOWNLOADING = "downloading" 7 | const val NOTIFICATION_CHANNEL_INSTALL = "install" 8 | 9 | const val NOTIFICATION_ID_SYNCING = 1 10 | const val NOTIFICATION_ID_UPDATES = 2 11 | const val NOTIFICATION_ID_DOWNLOADING = 3 12 | const val NOTIFICATION_ID_INSTALL = 4 13 | 14 | const val JOB_ID_SYNC = 1 15 | } 16 | -------------------------------------------------------------------------------- /app/src/main/kotlin/com/looker/droidify/utility/common/Exporter.kt: -------------------------------------------------------------------------------- 1 | package com.looker.droidify.utility.common 2 | 3 | import android.net.Uri 4 | 5 | interface Exporter { 6 | 7 | suspend fun export(item: T, target: Uri) 8 | 9 | suspend fun import(target: Uri): T 10 | 11 | } 12 | -------------------------------------------------------------------------------- /app/src/main/kotlin/com/looker/droidify/utility/common/Notification.kt: -------------------------------------------------------------------------------- 1 | package com.looker.droidify.utility.common 2 | 3 | import android.app.NotificationChannel 4 | import android.app.NotificationManager 5 | import android.content.Context 6 | import android.os.Build 7 | import com.looker.droidify.utility.common.extension.notificationManager 8 | 9 | fun Context.createNotificationChannel( 10 | id: String, 11 | name: String, 12 | description: String? = null, 13 | showBadge: Boolean = false, 14 | ) { 15 | sdkAbove(Build.VERSION_CODES.O) { 16 | val channel = NotificationChannel( 17 | id, 18 | name, 19 | NotificationManager.IMPORTANCE_LOW 20 | ).apply { 21 | setDescription(description) 22 | setShowBadge(showBadge) 23 | setSound(null, null) 24 | } 25 | notificationManager?.createNotificationChannel(channel) 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /app/src/main/kotlin/com/looker/droidify/utility/common/Permissions.kt: -------------------------------------------------------------------------------- 1 | package com.looker.droidify.utility.common 2 | 3 | import android.Manifest 4 | import android.app.Activity 5 | import android.content.Context 6 | import android.content.pm.PackageManager 7 | import android.os.Build 8 | import android.provider.Settings 9 | import androidx.core.content.ContextCompat 10 | import androidx.core.net.toUri 11 | import com.looker.droidify.utility.common.extension.intent 12 | import com.looker.droidify.utility.common.extension.powerManager 13 | 14 | fun Context.isIgnoreBatteryEnabled() = 15 | powerManager?.isIgnoringBatteryOptimizations(packageName) == true 16 | 17 | fun Context.requestBatteryFreedom() { 18 | if (!isIgnoreBatteryEnabled()) { 19 | val intent = intent(Settings.ACTION_REQUEST_IGNORE_BATTERY_OPTIMIZATIONS) { 20 | data = "package:$packageName".toUri() 21 | } 22 | runCatching { 23 | startActivity(intent) 24 | } 25 | } 26 | } 27 | 28 | fun Activity.requestNotificationPermission( 29 | request: (permission: String) -> Unit, 30 | onGranted: () -> Unit = {} 31 | ) { 32 | when { 33 | ContextCompat.checkSelfPermission( 34 | this, 35 | Manifest.permission.POST_NOTIFICATIONS 36 | ) == PackageManager.PERMISSION_GRANTED -> { 37 | onGranted() 38 | } 39 | 40 | shouldShowRequestPermissionRationale(Manifest.permission.POST_NOTIFICATIONS) -> { 41 | sdkAbove(Build.VERSION_CODES.TIRAMISU) { 42 | request(Manifest.permission.POST_NOTIFICATIONS) 43 | } 44 | } 45 | 46 | else -> { 47 | sdkAbove(Build.VERSION_CODES.TIRAMISU) { 48 | request(Manifest.permission.POST_NOTIFICATIONS) 49 | } 50 | } 51 | } 52 | } 53 | -------------------------------------------------------------------------------- /app/src/main/kotlin/com/looker/droidify/utility/common/Scroller.kt: -------------------------------------------------------------------------------- 1 | package com.looker.droidify.utility.common 2 | 3 | import android.content.Context 4 | import android.util.DisplayMetrics 5 | import android.view.View 6 | import androidx.recyclerview.widget.LinearSmoothScroller 7 | import androidx.recyclerview.widget.RecyclerView 8 | import kotlin.math.abs 9 | 10 | /** 11 | * A custom LinearSmoothScroller that increases the scrolling speed quadratically 12 | * based on the distance already scrolled. 13 | * 14 | * @param context The context used to access resources. 15 | */ 16 | class Scroller(context: Context) : LinearSmoothScroller(context) { 17 | private var distanceScrolled = 0 18 | 19 | /** 20 | * Calculates the speed per pixel based on the display metrics and the distance 21 | * already scrolled. The speed increases quadratically over time. 22 | * 23 | * @param displayMetrics The display metrics used to calculate the speed. 24 | * @return The speed per pixel. 25 | */ 26 | override fun calculateSpeedPerPixel(displayMetrics: DisplayMetrics): Float { 27 | return (10f / displayMetrics.densityDpi) / (1 + 0.001f * distanceScrolled * distanceScrolled) 28 | } 29 | 30 | /** 31 | * Called when the target view is found. Resets the distance scrolled. 32 | * 33 | * @param targetView The target view. 34 | * @param state The current state of RecyclerView. 35 | * @param action The action to be performed. 36 | */ 37 | override fun onTargetFound(targetView: View, state: RecyclerView.State, action: Action) { 38 | super.onTargetFound(targetView, state, action) 39 | distanceScrolled = 0 40 | } 41 | 42 | /** 43 | * Called when seeking the target step. Accumulates the distance scrolled. 44 | * 45 | * @param dx The amount of horizontal scroll. 46 | * @param dy The amount of vertical scroll. 47 | * @param state The current state of RecyclerView. 48 | * @param action The action to be performed. 49 | */ 50 | override fun onSeekTargetStep(dx: Int, dy: Int, state: RecyclerView.State, action: Action) { 51 | super.onSeekTargetStep(dx, dy, state, action) 52 | distanceScrolled += abs(dy) 53 | } 54 | } 55 | -------------------------------------------------------------------------------- /app/src/main/kotlin/com/looker/droidify/utility/common/SdkCheck.kt: -------------------------------------------------------------------------------- 1 | package com.looker.droidify.utility.common 2 | 3 | import android.os.Build 4 | import androidx.annotation.ChecksSdkIntAtLeast 5 | 6 | @ChecksSdkIntAtLeast(parameter = 0, lambda = 1) 7 | inline fun sdkAbove(sdk: Int, onSuccessful: () -> Unit) { 8 | if (Build.VERSION.SDK_INT >= sdk) onSuccessful() 9 | } 10 | 11 | object SdkCheck { 12 | 13 | val sdk: Int = Build.VERSION.SDK_INT 14 | 15 | // Allows auto install if target sdk of apk is one less then current sdk 16 | fun canAutoInstall(targetSdk: Int) = targetSdk >= sdk - 1 && isSnowCake 17 | 18 | @get:ChecksSdkIntAtLeast(api = Build.VERSION_CODES.TIRAMISU) 19 | val isTiramisu: Boolean get() = sdk >= Build.VERSION_CODES.TIRAMISU 20 | 21 | @get:ChecksSdkIntAtLeast(api = Build.VERSION_CODES.R) 22 | val isR: Boolean get() = sdk >= Build.VERSION_CODES.R 23 | 24 | @get:ChecksSdkIntAtLeast(api = Build.VERSION_CODES.P) 25 | val isPie: Boolean get() = sdk >= Build.VERSION_CODES.P 26 | 27 | @get:ChecksSdkIntAtLeast(api = Build.VERSION_CODES.O) 28 | val isOreo: Boolean get() = sdk >= Build.VERSION_CODES.O 29 | 30 | @get:ChecksSdkIntAtLeast(api = Build.VERSION_CODES.S) 31 | val isSnowCake: Boolean get() = sdk >= Build.VERSION_CODES.S 32 | 33 | @get:ChecksSdkIntAtLeast(api = Build.VERSION_CODES.N) 34 | val isNougat: Boolean get() = sdk >= Build.VERSION_CODES.N 35 | } 36 | 37 | val sdkName by lazy { 38 | mapOf( 39 | 16 to "4.1", 40 | 17 to "4.2", 41 | 18 to "4.3", 42 | 19 to "4.4", 43 | 21 to "5.0", 44 | 22 to "5.1", 45 | 23 to "6", 46 | 24 to "7.0", 47 | 25 to "7.1", 48 | 26 to "8.0", 49 | 27 to "8.1", 50 | 28 to "9", 51 | 29 to "10", 52 | 30 to "11", 53 | 31 to "12", 54 | 32 to "12L", 55 | 33 to "13", 56 | 34 to "14", 57 | 35 to "15", 58 | 36 to "16", 59 | ) 60 | } 61 | -------------------------------------------------------------------------------- /app/src/main/kotlin/com/looker/droidify/utility/common/Text.kt: -------------------------------------------------------------------------------- 1 | package com.looker.droidify.utility.common 2 | 3 | import android.util.Log 4 | 5 | fun T.nullIfEmpty(): T? { 6 | return if (isNullOrBlank()) null else this 7 | } 8 | 9 | fun Any.log( 10 | message: Any?, 11 | tag: String = this::class.java.simpleName + ".DEBUG", 12 | type: Int = Log.DEBUG 13 | ) { 14 | Log.println(type, tag, message.toString()) 15 | } 16 | -------------------------------------------------------------------------------- /app/src/main/kotlin/com/looker/droidify/utility/common/device/Huawei.kt: -------------------------------------------------------------------------------- 1 | package com.looker.droidify.utility.common.device 2 | 3 | object Huawei { 4 | val isHuaweiEmui: Boolean 5 | get() { 6 | return try { 7 | Class.forName("com.huawei.android.os.BuildEx") 8 | true 9 | } catch (e: Exception) { 10 | false 11 | } 12 | } 13 | } 14 | -------------------------------------------------------------------------------- /app/src/main/kotlin/com/looker/droidify/utility/common/device/Miui.kt: -------------------------------------------------------------------------------- 1 | package com.looker.droidify.utility.common.device 2 | 3 | import android.annotation.SuppressLint 4 | import android.util.Log 5 | 6 | object Miui { 7 | val isMiui by lazy { 8 | getSystemProperty("ro.miui.ui.version.name")?.isNotEmpty() ?: false 9 | } 10 | 11 | @SuppressLint("PrivateApi") 12 | fun isMiuiOptimizationDisabled(): Boolean { 13 | val sysProp = getSystemProperty("persist.sys.miui_optimization") 14 | if (sysProp == "0" || sysProp == "false") { 15 | return true 16 | } 17 | 18 | return try { 19 | Class.forName("android.miui.AppOpsUtils") 20 | .getDeclaredMethod("isXOptMode") 21 | .invoke(null) as Boolean 22 | } catch (e: Exception) { 23 | false 24 | } 25 | } 26 | 27 | @SuppressLint("PrivateApi") 28 | private fun getSystemProperty(key: String?): String? { 29 | return try { 30 | Class.forName("android.os.SystemProperties") 31 | .getDeclaredMethod("get", String::class.java) 32 | .invoke(null, key) as String 33 | } catch (e: Exception) { 34 | Log.e("Miui", "Unable to use SystemProperties.get()", e) 35 | null 36 | } 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /app/src/main/kotlin/com/looker/droidify/utility/common/extension/Collections.kt: -------------------------------------------------------------------------------- 1 | package com.looker.droidify.utility.common.extension 2 | 3 | inline fun Map.updateAsMutable(block: MutableMap.() -> Unit): Map { 4 | return toMutableMap().apply(block) 5 | } 6 | 7 | inline fun Set.updateAsMutable(block: MutableSet.() -> Unit): Set { 8 | return toMutableSet().apply(block) 9 | } 10 | 11 | inline fun MutableSet.addAndCompute(item: T, block: (isAdded: Boolean) -> Unit): Boolean = 12 | add(item).apply { block(this) } 13 | 14 | inline fun List.updateAsMutable(block: MutableList.() -> Unit): List { 15 | return toMutableList().apply(block) 16 | } 17 | -------------------------------------------------------------------------------- /app/src/main/kotlin/com/looker/droidify/utility/common/extension/Cursor.kt: -------------------------------------------------------------------------------- 1 | package com.looker.droidify.utility.common.extension 2 | 3 | import android.database.Cursor 4 | 5 | fun Cursor.asSequence(): Sequence { 6 | return generateSequence { if (moveToNext()) this else null } 7 | } 8 | 9 | fun Cursor.firstOrNull(): Cursor? { 10 | return if (moveToFirst()) this else null 11 | } 12 | -------------------------------------------------------------------------------- /app/src/main/kotlin/com/looker/droidify/utility/common/extension/DateTime.kt: -------------------------------------------------------------------------------- 1 | package com.looker.droidify.utility.common.extension 2 | 3 | import java.text.SimpleDateFormat 4 | import java.util.Date 5 | import java.util.Locale 6 | import java.util.TimeZone 7 | 8 | private val HTTP_DATE_FORMAT: SimpleDateFormat 9 | get() = SimpleDateFormat("EEE, dd MMM yyyy HH:mm:ss z", Locale.US).apply { 10 | timeZone = TimeZone.getTimeZone("GMT") 11 | } 12 | 13 | fun Date.toFormattedString(): String = HTTP_DATE_FORMAT.format(this) 14 | -------------------------------------------------------------------------------- /app/src/main/kotlin/com/looker/droidify/utility/common/extension/Exception.kt: -------------------------------------------------------------------------------- 1 | package com.looker.droidify.utility.common.extension 2 | 3 | import kotlinx.coroutines.CancellationException 4 | 5 | inline fun Exception.exceptCancellation() { 6 | printStackTrace() 7 | if (this is CancellationException) throw this 8 | } 9 | -------------------------------------------------------------------------------- /app/src/main/kotlin/com/looker/droidify/utility/common/extension/File.kt: -------------------------------------------------------------------------------- 1 | package com.looker.droidify.utility.common.extension 2 | 3 | import java.io.File 4 | 5 | val File.size: Long? 6 | get() = if (exists()) length().takeIf { it > 0L } else null 7 | 8 | -------------------------------------------------------------------------------- /app/src/main/kotlin/com/looker/droidify/utility/common/extension/Flow.kt: -------------------------------------------------------------------------------- 1 | package com.looker.droidify.utility.common.extension 2 | 3 | import androidx.lifecycle.ViewModel 4 | import androidx.lifecycle.viewModelScope 5 | import kotlinx.coroutines.CoroutineScope 6 | import kotlinx.coroutines.ExperimentalCoroutinesApi 7 | import kotlinx.coroutines.channels.Channel 8 | import kotlinx.coroutines.channels.ReceiveChannel 9 | import kotlinx.coroutines.channels.consumeEach 10 | import kotlinx.coroutines.channels.produce 11 | import kotlinx.coroutines.flow.Flow 12 | import kotlinx.coroutines.flow.SharingStarted 13 | import kotlinx.coroutines.flow.StateFlow 14 | import kotlinx.coroutines.flow.stateIn 15 | 16 | context(ViewModel) 17 | fun Flow.asStateFlow( 18 | initialValue: T, 19 | scope: CoroutineScope = viewModelScope, 20 | started: SharingStarted = SharingStarted.WhileSubscribed(5_000) 21 | ): StateFlow = stateIn( 22 | scope = scope, 23 | started = started, 24 | initialValue = initialValue 25 | ) 26 | 27 | context(CoroutineScope) 28 | @OptIn(ExperimentalCoroutinesApi::class) 29 | fun ReceiveChannel.filter( 30 | block: suspend (T) -> Boolean 31 | ): ReceiveChannel = produce(capacity = Channel.UNLIMITED) { 32 | consumeEach { item -> 33 | if (block(item)) send(item) 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /app/src/main/kotlin/com/looker/droidify/utility/common/extension/Intent.kt: -------------------------------------------------------------------------------- 1 | package com.looker.droidify.utility.common.extension 2 | 3 | import android.app.PendingIntent 4 | import android.content.Context 5 | import android.content.Intent 6 | import android.net.Uri 7 | import androidx.core.app.TaskStackBuilder 8 | import com.looker.droidify.utility.common.SdkCheck 9 | 10 | fun intent(action: String, block: Intent.() -> Unit = {}): Intent { 11 | return Intent(action).apply(block) 12 | } 13 | 14 | inline val intentFlagCompat 15 | get() = if (SdkCheck.isSnowCake) { 16 | PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_MUTABLE 17 | } else { 18 | PendingIntent.FLAG_UPDATE_CURRENT 19 | } 20 | 21 | fun Intent.toPendingIntent(context: Context): PendingIntent? = 22 | TaskStackBuilder 23 | .create(context) 24 | .addNextIntentWithParentStack(this) 25 | .getPendingIntent(0, intentFlagCompat) 26 | 27 | operator fun Uri?.get(key: String): String? = this?.getQueryParameter(key) 28 | -------------------------------------------------------------------------------- /app/src/main/kotlin/com/looker/droidify/utility/common/extension/Number.kt: -------------------------------------------------------------------------------- 1 | package com.looker.droidify.utility.common.extension 2 | 3 | import android.content.res.Resources 4 | import android.util.TypedValue 5 | import android.view.View 6 | import kotlin.math.roundToInt 7 | 8 | val Number.dpToPx 9 | get() = TypedValue.applyDimension( 10 | TypedValue.COMPLEX_UNIT_DIP, 11 | this.toFloat(), 12 | Resources.getSystem().displayMetrics 13 | ) 14 | 15 | context(View) 16 | val Int.dp: Int 17 | get() = (this * resources.displayMetrics.density).roundToInt() 18 | -------------------------------------------------------------------------------- /app/src/main/kotlin/com/looker/droidify/utility/common/extension/Service.kt: -------------------------------------------------------------------------------- 1 | package com.looker.droidify.utility.common.extension 2 | 3 | import android.app.Service 4 | import android.content.Intent 5 | import com.looker.droidify.utility.common.SdkCheck 6 | 7 | fun Service.startServiceCompat() { 8 | val intent = Intent(this, this::class.java) 9 | if (SdkCheck.isOreo) { 10 | startForegroundService(intent) 11 | } else { 12 | startService(intent) 13 | } 14 | } 15 | 16 | fun Service.stopForegroundCompat(removeNotification: Boolean = true) { 17 | @Suppress("DEPRECATION") 18 | if (SdkCheck.isNougat) { 19 | stopForeground( 20 | if (removeNotification) { 21 | Service.STOP_FOREGROUND_REMOVE 22 | } else { 23 | Service.STOP_FOREGROUND_DETACH 24 | } 25 | ) 26 | } else { 27 | stopForeground(removeNotification) 28 | } 29 | stopSelf() 30 | } 31 | -------------------------------------------------------------------------------- /app/src/main/kotlin/com/looker/droidify/utility/common/result/Result.kt: -------------------------------------------------------------------------------- 1 | package com.looker.droidify.utility.common.result 2 | 3 | sealed interface Result { 4 | data class Success(val data: T) : Result 5 | 6 | data class Error( 7 | val exception: Throwable? = null, 8 | val data: T? = null 9 | ) : Result 10 | } 11 | -------------------------------------------------------------------------------- /app/src/main/kotlin/com/looker/droidify/utility/common/signature/HashChecker.kt: -------------------------------------------------------------------------------- 1 | package com.looker.droidify.utility.common.signature 2 | 3 | import com.looker.droidify.domain.model.hex 4 | import com.looker.droidify.utility.common.extension.exceptCancellation 5 | import kotlinx.coroutines.Dispatchers 6 | import kotlinx.coroutines.ensureActive 7 | import kotlinx.coroutines.withContext 8 | import java.io.File 9 | import java.security.MessageDigest 10 | 11 | suspend fun File.verifyHash(hash: Hash): Boolean { 12 | return try { 13 | if (!hash.isValid() || !exists()) return false 14 | calculateHash(hash.type) 15 | ?.equals(hash.hash, true) 16 | ?: false 17 | } catch (e: Exception) { 18 | e.exceptCancellation() 19 | false 20 | } 21 | } 22 | 23 | suspend fun File.calculateHash(hashType: String): String? { 24 | return try { 25 | if (hashType.isBlank() || !exists()) return null 26 | MessageDigest 27 | .getInstance(hashType) 28 | .readBytesFrom(this) 29 | ?.hex() 30 | } catch (e: Exception) { 31 | e.exceptCancellation() 32 | null 33 | } 34 | } 35 | 36 | private suspend fun MessageDigest.readBytesFrom( 37 | file: File, 38 | ): ByteArray? = withContext(Dispatchers.IO) { 39 | try { 40 | if (file.length() < DIRECT_READ_LIMIT) return@withContext digest(file.readBytes()) 41 | val buffer = ByteArray(DEFAULT_BUFFER_SIZE) 42 | file.inputStream().use { input -> 43 | var bytesRead = input.read(buffer) 44 | while (bytesRead >= 0) { 45 | ensureActive() 46 | update(buffer, 0, bytesRead) 47 | bytesRead = input.read(buffer) 48 | } 49 | digest() 50 | } 51 | } catch (e: Exception) { 52 | e.exceptCancellation() 53 | null 54 | } 55 | } 56 | 57 | // 25 MB 58 | private const val DIRECT_READ_LIMIT = 25 * 1024 * 1024 59 | 60 | data class Hash( 61 | val type: String, 62 | val hash: String, 63 | ) { 64 | fun isValid(): Boolean = type.isNotBlank() && hash.isNotBlank() 65 | } 66 | -------------------------------------------------------------------------------- /app/src/main/kotlin/com/looker/droidify/utility/extension/Android.kt: -------------------------------------------------------------------------------- 1 | @file:Suppress("PackageDirectoryMismatch") 2 | 3 | package com.looker.droidify.utility.extension.android 4 | 5 | import android.os.Build 6 | 7 | object Android { 8 | val name: String = "Android ${Build.VERSION.RELEASE}" 9 | 10 | val platforms = Build.SUPPORTED_ABIS.toSet() 11 | 12 | val primaryPlatform: String? = Build.SUPPORTED_64_BIT_ABIS?.firstOrNull() 13 | ?: Build.SUPPORTED_32_BIT_ABIS?.firstOrNull() 14 | } 15 | -------------------------------------------------------------------------------- /app/src/main/kotlin/com/looker/droidify/utility/extension/Connection.kt: -------------------------------------------------------------------------------- 1 | package com.looker.droidify.utility.extension 2 | 3 | import com.looker.droidify.model.InstalledItem 4 | import com.looker.droidify.model.Product 5 | import com.looker.droidify.model.Repository 6 | import com.looker.droidify.model.findSuggested 7 | import com.looker.droidify.service.Connection 8 | import com.looker.droidify.service.DownloadService 9 | import com.looker.droidify.utility.extension.android.Android 10 | 11 | fun Connection.startUpdate( 12 | packageName: String, 13 | installedItem: InstalledItem?, 14 | products: List> 15 | ) { 16 | if (binder == null || products.isEmpty()) return 17 | 18 | val (product, repository) = products.findSuggested(installedItem) ?: return 19 | 20 | val compatibleReleases = product.selectedReleases 21 | .filter { installedItem == null || installedItem.signature == it.signature } 22 | .ifEmpty { return } 23 | 24 | val selectedRelease = compatibleReleases.singleOrNull() ?: compatibleReleases.run { 25 | filter { Android.primaryPlatform in it.platforms }.minByOrNull { it.platforms.size } 26 | ?: minByOrNull { it.platforms.size } 27 | ?: firstOrNull() 28 | } ?: return 29 | 30 | requireNotNull(binder).enqueue( 31 | packageName = packageName, 32 | name = product.name, 33 | repository = repository, 34 | release = selectedRelease, 35 | isUpdate = installedItem != null 36 | ) 37 | } 38 | -------------------------------------------------------------------------------- /app/src/main/kotlin/com/looker/droidify/utility/extension/Fragment.kt: -------------------------------------------------------------------------------- 1 | package com.looker.droidify.utility.extension 2 | 3 | import androidx.fragment.app.Fragment 4 | import com.looker.droidify.MainActivity 5 | 6 | inline val Fragment.mainActivity: MainActivity 7 | get() = requireActivity() as MainActivity 8 | -------------------------------------------------------------------------------- /app/src/main/kotlin/com/looker/droidify/utility/extension/PackageInfo.kt: -------------------------------------------------------------------------------- 1 | package com.looker.droidify.utility.extension 2 | 3 | import android.content.pm.PackageInfo 4 | import com.looker.droidify.utility.common.extension.calculateHash 5 | import com.looker.droidify.utility.common.extension.singleSignature 6 | import com.looker.droidify.utility.common.extension.versionCodeCompat 7 | import com.looker.droidify.model.InstalledItem 8 | 9 | fun PackageInfo.toInstalledItem(): InstalledItem { 10 | val signatureString = singleSignature?.calculateHash().orEmpty() 11 | return InstalledItem( 12 | packageName, 13 | versionName.orEmpty(), 14 | versionCodeCompat, 15 | signatureString 16 | ) 17 | } 18 | -------------------------------------------------------------------------------- /app/src/main/kotlin/com/looker/droidify/utility/extension/Resources.kt: -------------------------------------------------------------------------------- 1 | @file:Suppress("PackageDirectoryMismatch") 2 | 3 | package com.looker.droidify.utility.extension.resources 4 | 5 | import android.content.res.Resources 6 | import android.graphics.Typeface 7 | import kotlin.math.roundToInt 8 | 9 | object TypefaceExtra { 10 | val medium = Typeface.create("sans-serif-medium", Typeface.NORMAL)!! 11 | val light = Typeface.create("sans-serif-light", Typeface.NORMAL)!! 12 | } 13 | 14 | fun Resources.sizeScaled(size: Int): Int { 15 | return (size * displayMetrics.density).roundToInt() 16 | } 17 | -------------------------------------------------------------------------------- /app/src/main/kotlin/com/looker/droidify/utility/serialization/ProductPreferenceSerialization.kt: -------------------------------------------------------------------------------- 1 | package com.looker.droidify.utility.serialization 2 | 3 | import com.fasterxml.jackson.core.JsonGenerator 4 | import com.fasterxml.jackson.core.JsonParser 5 | import com.looker.droidify.utility.common.extension.forEachKey 6 | import com.looker.droidify.model.ProductPreference 7 | 8 | fun ProductPreference.serialize(generator: JsonGenerator) { 9 | generator.writeBooleanField("ignoreUpdates", ignoreUpdates) 10 | generator.writeNumberField("ignoreVersionCode", ignoreVersionCode) 11 | } 12 | 13 | fun JsonParser.productPreference(): ProductPreference { 14 | var ignoreUpdates = false 15 | var ignoreVersionCode = 0L 16 | forEachKey { 17 | when { 18 | it.boolean("ignoreUpdates") -> ignoreUpdates = valueAsBoolean 19 | it.number("ignoreVersionCode") -> ignoreVersionCode = valueAsLong 20 | else -> skipChildren() 21 | } 22 | } 23 | return ProductPreference(ignoreUpdates, ignoreVersionCode) 24 | } 25 | -------------------------------------------------------------------------------- /app/src/main/kotlin/com/looker/droidify/widget/CursorRecyclerAdapter.kt: -------------------------------------------------------------------------------- 1 | package com.looker.droidify.widget 2 | 3 | import android.database.Cursor 4 | import androidx.recyclerview.widget.RecyclerView 5 | 6 | abstract class CursorRecyclerAdapter, VH : RecyclerView.ViewHolder> : 7 | EnumRecyclerAdapter() { 8 | init { 9 | super.setHasStableIds(true) 10 | } 11 | 12 | private var rowIdIndex = 0 13 | 14 | var cursor: Cursor? = null 15 | set(value) { 16 | if (field != value) { 17 | field?.close() 18 | field = value 19 | rowIdIndex = value?.getColumnIndexOrThrow("_id") ?: 0 20 | notifyDataSetChanged() 21 | } 22 | } 23 | 24 | final override fun setHasStableIds(hasStableIds: Boolean) { 25 | throw UnsupportedOperationException() 26 | } 27 | 28 | override fun getItemCount(): Int = cursor?.count ?: 0 29 | override fun getItemId(position: Int): Long = moveTo(position).getLong(rowIdIndex) 30 | 31 | fun moveTo(position: Int): Cursor { 32 | val cursor = cursor!! 33 | cursor.moveToPosition(position) 34 | return cursor 35 | } 36 | } 37 | -------------------------------------------------------------------------------- /app/src/main/kotlin/com/looker/droidify/widget/EnumRecyclerAdapter.kt: -------------------------------------------------------------------------------- 1 | package com.looker.droidify.widget 2 | 3 | import android.util.SparseArray 4 | import android.view.ViewGroup 5 | import androidx.recyclerview.widget.RecyclerView 6 | 7 | abstract class EnumRecyclerAdapter, VH : RecyclerView.ViewHolder> : 8 | RecyclerView.Adapter() { 9 | abstract val viewTypeClass: Class 10 | 11 | private val names = SparseArray() 12 | 13 | private fun getViewType(viewType: Int): VT { 14 | return java.lang.Enum.valueOf(viewTypeClass, names.get(viewType)) 15 | } 16 | 17 | final override fun getItemViewType(position: Int): Int { 18 | val enum = getItemEnumViewType(position) 19 | names.put(enum.ordinal, enum.name) 20 | return enum.ordinal 21 | } 22 | 23 | final override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): VH { 24 | return onCreateViewHolder(parent, getViewType(viewType)) 25 | } 26 | 27 | abstract fun getItemEnumViewType(position: Int): VT 28 | abstract fun onCreateViewHolder(parent: ViewGroup, viewType: VT): VH 29 | } 30 | -------------------------------------------------------------------------------- /app/src/main/kotlin/com/looker/droidify/widget/FocusSearchView.kt: -------------------------------------------------------------------------------- 1 | package com.looker.droidify.widget 2 | 3 | import android.content.Context 4 | import android.util.AttributeSet 5 | import android.view.KeyEvent 6 | import androidx.appcompat.widget.SearchView 7 | 8 | class FocusSearchView : SearchView { 9 | constructor(context: Context) : super(context) 10 | constructor(context: Context, attrs: AttributeSet?) : super(context, attrs) 11 | constructor(context: Context, attrs: AttributeSet?, defStyleAttr: Int) : super( 12 | context, 13 | attrs, 14 | defStyleAttr 15 | ) 16 | 17 | var allowFocus = true 18 | 19 | override fun dispatchKeyEventPreIme(event: KeyEvent): Boolean { 20 | // Always clear focus on back press 21 | return if (hasFocus() && event.keyCode == KeyEvent.KEYCODE_BACK) { 22 | if (event.action == KeyEvent.ACTION_UP) { 23 | clearFocus() 24 | } 25 | true 26 | } else { 27 | super.dispatchKeyEventPreIme(event) 28 | } 29 | } 30 | 31 | override fun setIconified(iconify: Boolean) { 32 | super.setIconified(iconify) 33 | 34 | // Don't focus view and raise keyboard unless allowed 35 | if (!iconify && !allowFocus) { 36 | clearFocus() 37 | } 38 | } 39 | } 40 | -------------------------------------------------------------------------------- /app/src/main/kotlin/com/looker/droidify/widget/StableRecyclerAdapter.kt: -------------------------------------------------------------------------------- 1 | package com.looker.droidify.widget 2 | 3 | import androidx.recyclerview.widget.RecyclerView 4 | 5 | abstract class StableRecyclerAdapter, VH : RecyclerView.ViewHolder> : 6 | EnumRecyclerAdapter() { 7 | private var nextId = 1L 8 | private val descriptorToId = mutableMapOf() 9 | 10 | init { 11 | super.setHasStableIds(true) 12 | } 13 | 14 | final override fun setHasStableIds(hasStableIds: Boolean) { 15 | throw UnsupportedOperationException() 16 | } 17 | 18 | override fun getItemId(position: Int): Long { 19 | val descriptor = getItemDescriptor(position) 20 | return descriptorToId[descriptor] ?: run { 21 | val id = nextId++ 22 | descriptorToId[descriptor] = id 23 | id 24 | } 25 | } 26 | 27 | abstract fun getItemDescriptor(position: Int): String 28 | } 29 | -------------------------------------------------------------------------------- /app/src/main/res/anim/slide_right_fade_in.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 7 | 11 | -------------------------------------------------------------------------------- /app/src/main/res/anim/slide_right_fade_out.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 7 | 11 | -------------------------------------------------------------------------------- /app/src/main/res/animator/slide_in.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 10 | 11 | 17 | 18 | 19 | -------------------------------------------------------------------------------- /app/src/main/res/animator/slide_in_keep.xml: -------------------------------------------------------------------------------- 1 | 2 | 4 | -------------------------------------------------------------------------------- /app/src/main/res/animator/slide_out.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 11 | 12 | 18 | 19 | 20 | -------------------------------------------------------------------------------- /app/src/main/res/color/favourite_icon_color.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | -------------------------------------------------------------------------------- /app/src/main/res/drawable/arrow_up.xml: -------------------------------------------------------------------------------- 1 | 7 | 10 | 11 | -------------------------------------------------------------------------------- /app/src/main/res/drawable/background_border.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | -------------------------------------------------------------------------------- /app/src/main/res/drawable/favourite_icon.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | -------------------------------------------------------------------------------- /app/src/main/res/drawable/ic_add.xml: -------------------------------------------------------------------------------- 1 | 7 | 10 | -------------------------------------------------------------------------------- /app/src/main/res/drawable/ic_apk_install.xml: -------------------------------------------------------------------------------- 1 | 7 | 10 | 11 | -------------------------------------------------------------------------------- /app/src/main/res/drawable/ic_arrow_down.xml: -------------------------------------------------------------------------------- 1 | 7 | 10 | -------------------------------------------------------------------------------- /app/src/main/res/drawable/ic_cancel.xml: -------------------------------------------------------------------------------- 1 | 7 | 10 | 11 | -------------------------------------------------------------------------------- /app/src/main/res/drawable/ic_cannot_load.xml: -------------------------------------------------------------------------------- 1 | 2 | 7 | 10 | 13 | 14 | -------------------------------------------------------------------------------- /app/src/main/res/drawable/ic_check.xml: -------------------------------------------------------------------------------- 1 | 7 | 10 | 11 | -------------------------------------------------------------------------------- /app/src/main/res/drawable/ic_code.xml: -------------------------------------------------------------------------------- 1 | 7 | 10 | 11 | -------------------------------------------------------------------------------- /app/src/main/res/drawable/ic_copyright.xml: -------------------------------------------------------------------------------- 1 | 7 | 10 | 11 | -------------------------------------------------------------------------------- /app/src/main/res/drawable/ic_delete.xml: -------------------------------------------------------------------------------- 1 | 7 | 10 | 11 | -------------------------------------------------------------------------------- /app/src/main/res/drawable/ic_donate.xml: -------------------------------------------------------------------------------- 1 | 7 | 10 | 11 | -------------------------------------------------------------------------------- /app/src/main/res/drawable/ic_donate_bitcoin.xml: -------------------------------------------------------------------------------- 1 | 7 | 10 | 11 | -------------------------------------------------------------------------------- /app/src/main/res/drawable/ic_donate_liberapay.xml: -------------------------------------------------------------------------------- 1 | 2 | 8 | 9 | 21 | 22 | 23 | -------------------------------------------------------------------------------- /app/src/main/res/drawable/ic_donate_litecoin.xml: -------------------------------------------------------------------------------- 1 | 2 | 8 | 9 | 13 | 14 | 15 | -------------------------------------------------------------------------------- /app/src/main/res/drawable/ic_donate_opencollective.xml: -------------------------------------------------------------------------------- 1 | 2 | 8 | 9 | 15 | 16 | 17 | -------------------------------------------------------------------------------- /app/src/main/res/drawable/ic_download.xml: -------------------------------------------------------------------------------- 1 | 7 | 10 | 11 | -------------------------------------------------------------------------------- /app/src/main/res/drawable/ic_email.xml: -------------------------------------------------------------------------------- 1 | 7 | 10 | 11 | -------------------------------------------------------------------------------- /app/src/main/res/drawable/ic_favourite.xml: -------------------------------------------------------------------------------- 1 | 7 | 10 | 11 | -------------------------------------------------------------------------------- /app/src/main/res/drawable/ic_favourite_checked.xml: -------------------------------------------------------------------------------- 1 | 7 | 10 | 11 | -------------------------------------------------------------------------------- /app/src/main/res/drawable/ic_history.xml: -------------------------------------------------------------------------------- 1 | 7 | 10 | 11 | -------------------------------------------------------------------------------- /app/src/main/res/drawable/ic_image.xml: -------------------------------------------------------------------------------- 1 | 7 | 10 | 11 | -------------------------------------------------------------------------------- /app/src/main/res/drawable/ic_launch.xml: -------------------------------------------------------------------------------- 1 | 7 | 10 | 11 | -------------------------------------------------------------------------------- /app/src/main/res/drawable/ic_new_releases.xml: -------------------------------------------------------------------------------- 1 | 7 | 8 | 11 | -------------------------------------------------------------------------------- /app/src/main/res/drawable/ic_perm_device_information.xml: -------------------------------------------------------------------------------- 1 | 7 | 10 | 11 | -------------------------------------------------------------------------------- /app/src/main/res/drawable/ic_person.xml: -------------------------------------------------------------------------------- 1 | 7 | 10 | 11 | -------------------------------------------------------------------------------- /app/src/main/res/drawable/ic_proxy.xml: -------------------------------------------------------------------------------- 1 | 7 | 10 | 11 | -------------------------------------------------------------------------------- /app/src/main/res/drawable/ic_public.xml: -------------------------------------------------------------------------------- 1 | 7 | 10 | 11 | -------------------------------------------------------------------------------- /app/src/main/res/drawable/ic_save.xml: -------------------------------------------------------------------------------- 1 | 7 | 10 | 11 | -------------------------------------------------------------------------------- /app/src/main/res/drawable/ic_search.xml: -------------------------------------------------------------------------------- 1 | 7 | 10 | 11 | -------------------------------------------------------------------------------- /app/src/main/res/drawable/ic_share.xml: -------------------------------------------------------------------------------- 1 | 7 | 10 | 11 | -------------------------------------------------------------------------------- /app/src/main/res/drawable/ic_sort.xml: -------------------------------------------------------------------------------- 1 | 8 | 11 | 12 | -------------------------------------------------------------------------------- /app/src/main/res/drawable/ic_source_code.xml: -------------------------------------------------------------------------------- 1 | 7 | 10 | -------------------------------------------------------------------------------- /app/src/main/res/drawable/ic_sync.xml: -------------------------------------------------------------------------------- 1 | 7 | 10 | 11 | -------------------------------------------------------------------------------- /app/src/main/res/drawable/ic_sync_type.xml: -------------------------------------------------------------------------------- 1 | 7 | 10 | 11 | -------------------------------------------------------------------------------- /app/src/main/res/drawable/ic_time.xml: -------------------------------------------------------------------------------- 1 | 7 | 10 | 11 | -------------------------------------------------------------------------------- /app/src/main/res/drawable/ic_tune.xml: -------------------------------------------------------------------------------- 1 | 7 | 10 | 11 | -------------------------------------------------------------------------------- /app/src/main/res/drawable/ic_video.xml: -------------------------------------------------------------------------------- 1 | 7 | 10 | 11 | -------------------------------------------------------------------------------- /app/src/main/res/layout/download_status.xml: -------------------------------------------------------------------------------- 1 | 2 | 7 | 8 | 16 | 17 | 23 | 24 | -------------------------------------------------------------------------------- /app/src/main/res/layout/enum_type.xml: -------------------------------------------------------------------------------- 1 | 2 | 9 | 10 | 15 | 16 | 22 | 23 | -------------------------------------------------------------------------------- /app/src/main/res/layout/expand_view_button.xml: -------------------------------------------------------------------------------- 1 | 2 | 11 | -------------------------------------------------------------------------------- /app/src/main/res/layout/fragment.xml: -------------------------------------------------------------------------------- 1 | 2 | 6 | 7 | 15 | 16 | 23 | 24 | 28 | 29 | 30 | 36 | 37 | -------------------------------------------------------------------------------- /app/src/main/res/layout/install_button.xml: -------------------------------------------------------------------------------- 1 | 2 | -------------------------------------------------------------------------------- /app/src/main/res/layout/link_item.xml: -------------------------------------------------------------------------------- 1 | 2 | 10 | 11 | 19 | 20 | 26 | 27 | 33 | 34 | 40 | 41 | 42 | -------------------------------------------------------------------------------- /app/src/main/res/layout/permissions_item.xml: -------------------------------------------------------------------------------- 1 | 2 | 10 | 11 | 20 | 21 | 28 | 29 | -------------------------------------------------------------------------------- /app/src/main/res/layout/recycler_view_with_fab.xml: -------------------------------------------------------------------------------- 1 | 2 | 7 | 8 | 12 | 13 | 22 | 23 | -------------------------------------------------------------------------------- /app/src/main/res/layout/repository_item.xml: -------------------------------------------------------------------------------- 1 | 2 | 10 | 11 | 18 | 19 | 24 | 30 | 31 | 39 | 40 | 41 | -------------------------------------------------------------------------------- /app/src/main/res/layout/section_item.xml: -------------------------------------------------------------------------------- 1 | 2 | 9 | 10 | 16 | 17 | 24 | 25 | -------------------------------------------------------------------------------- /app/src/main/res/layout/switch_item.xml: -------------------------------------------------------------------------------- 1 | 2 | -------------------------------------------------------------------------------- /app/src/main/res/layout/switch_type.xml: -------------------------------------------------------------------------------- 1 | 2 | 9 | 10 | 19 | 20 | 30 | 31 | 38 | 39 | -------------------------------------------------------------------------------- /app/src/main/res/layout/title_text_item.xml: -------------------------------------------------------------------------------- 1 | 2 | 8 | 9 | 15 | 16 | 22 | 23 | -------------------------------------------------------------------------------- /app/src/main/res/layout/video_button.xml: -------------------------------------------------------------------------------- 1 | 2 |