├── .editorconfig ├── .github ├── FUNDING.yml ├── ISSUE_TEMPLATE │ ├── bug.yml │ ├── config.yml │ └── feature_request.yml └── workflows │ └── workflow.yaml ├── .gitignore ├── .idea ├── AndroidProjectSystem.xml ├── androidTestResultsUserPreferences.xml ├── compiler.xml ├── deploymentTargetDropDown.xml ├── deploymentTargetSelector.xml ├── emulatorDisplays.xml ├── gradle.xml ├── icon.png ├── inspectionProfiles │ └── Project_Default.xml ├── kotlinc.xml ├── migrations.xml ├── misc.xml ├── runConfigurations.xml └── vcs.xml ├── LICENSE ├── Makefile ├── PRIVACY-POLICY.md ├── README.md ├── app ├── .gitignore ├── build.gradle.kts ├── compose_compiler_config.conf ├── lint.xml ├── proguard-rules.pro └── src │ ├── androidTest │ └── kotlin │ │ └── com │ │ └── w2sv │ │ └── filenavigator │ │ └── ui │ │ └── screen │ │ └── PermissionScreenTest.kt │ ├── debug │ └── res │ │ └── mipmap-anydpi │ │ ├── ic_launcher.xml │ │ └── ic_launcher_round.xml │ ├── main │ ├── AndroidManifest.xml │ ├── kotlin │ │ └── com │ │ │ └── w2sv │ │ │ └── filenavigator │ │ │ ├── Application.kt │ │ │ ├── MainActivity.kt │ │ │ └── ui │ │ │ ├── CompositionLocals.kt │ │ │ ├── designsystem │ │ │ ├── AppCardDefaults.kt │ │ │ ├── BorderAnimation.kt │ │ │ ├── Buttons.kt │ │ │ ├── DropdownMenu.kt │ │ │ ├── Icons.kt │ │ │ ├── Layout.kt │ │ │ ├── NavigationTransition.kt │ │ │ ├── Padding.kt │ │ │ ├── Snackbar.kt │ │ │ ├── Spacing.kt │ │ │ ├── SwitchItemRow.kt │ │ │ ├── TextField.kt │ │ │ ├── Tooltip.kt │ │ │ ├── TopAppBar.kt │ │ │ ├── TweakedSegmentedButton.kt │ │ │ ├── UnpaddedSwitch.kt │ │ │ └── drawer │ │ │ │ ├── DrawerRepelledAnimation.kt │ │ │ │ ├── NavigationDrawer.kt │ │ │ │ ├── NavigationDrawerSheetItemColumn.kt │ │ │ │ ├── ThemeSelectionRow.kt │ │ │ │ └── model │ │ │ │ └── AppPreferences.kt │ │ │ ├── modelext │ │ │ ├── FileTypeExtensions.kt │ │ │ └── MoveEntryExtensions.kt │ │ │ ├── screen │ │ │ ├── appsettings │ │ │ │ └── AppSettingsScreen.kt │ │ │ ├── home │ │ │ │ ├── HomeScreen.kt │ │ │ │ └── components │ │ │ │ │ ├── HomeScreenCard.kt │ │ │ │ │ ├── movehistory │ │ │ │ │ ├── MoveHistory.kt │ │ │ │ │ ├── MoveHistoryCard.kt │ │ │ │ │ └── rememberFirstDateRepresentations.kt │ │ │ │ │ └── statusdisplay │ │ │ │ │ └── NavigatorStatusCard.kt │ │ │ ├── missingpermissions │ │ │ │ ├── PermissionCard.kt │ │ │ │ └── RequiredPermissionsScreen.kt │ │ │ └── navigatorsettings │ │ │ │ ├── NavigatorSettingsScreen.kt │ │ │ │ └── components │ │ │ │ ├── AutoMoveIntroductionDialog.kt │ │ │ │ ├── ConfigurationColumn.kt │ │ │ │ ├── EnabledFileTypesBottomSheet.kt │ │ │ │ ├── FileTypeAccordion.kt │ │ │ │ ├── SourcesSurface.kt │ │ │ │ └── filetypeconfiguration │ │ │ │ ├── ColorPickerDialog.kt │ │ │ │ ├── CustomFileTypeDialog.kt │ │ │ │ ├── FileTypeConfigurationDialog.kt │ │ │ │ ├── FileTypeEditor.kt │ │ │ │ └── PresetFileTypeConfigurationDialog.kt │ │ │ ├── state │ │ │ ├── PostNotificationsPermissionState.kt │ │ │ └── ReversibleNavigatorConfig.kt │ │ │ ├── theme │ │ │ ├── Color.kt │ │ │ ├── Dims.kt │ │ │ ├── Theme.kt │ │ │ └── Typography.kt │ │ │ ├── util │ │ │ ├── CharSequenceText.kt │ │ │ ├── Color.kt │ │ │ ├── Easing.kt │ │ │ ├── FocusClearing.kt │ │ │ ├── MovableContent.kt │ │ │ ├── Saving.kt │ │ │ ├── SnackbarEmitter.kt │ │ │ ├── StateConversion.kt │ │ │ ├── TextEditor.kt │ │ │ └── activityViewModel.kt │ │ │ └── viewmodel │ │ │ ├── AppViewModel.kt │ │ │ ├── MoveHistoryViewModel.kt │ │ │ └── NavigatorViewModel.kt │ ├── play │ │ ├── contact-email.txt │ │ ├── default-language.txt │ │ ├── listings │ │ │ └── en-US │ │ │ │ ├── full-description.txt │ │ │ │ ├── graphics │ │ │ │ ├── feature-graphic │ │ │ │ │ └── 1.png │ │ │ │ ├── icon │ │ │ │ │ └── 1.png │ │ │ │ ├── large-tablet-screenshots │ │ │ │ │ ├── 1.jpg │ │ │ │ │ ├── 2.jpg │ │ │ │ │ └── 3.jpg │ │ │ │ ├── phone-screenshots │ │ │ │ │ ├── 1.jpg │ │ │ │ │ ├── 2.jpg │ │ │ │ │ └── 3.jpg │ │ │ │ └── tablet-screenshots │ │ │ │ │ ├── 1.jpg │ │ │ │ │ ├── 2.jpg │ │ │ │ │ └── 3.jpg │ │ │ │ ├── short-description.txt │ │ │ │ └── title.txt │ │ └── release-notes │ │ │ └── en-US │ │ │ └── production.txt │ └── res │ │ ├── font │ │ ├── raleway_black.ttf │ │ ├── raleway_bold.ttf │ │ ├── raleway_extrabold.ttf │ │ ├── raleway_extralight.ttf │ │ ├── raleway_light.ttf │ │ ├── raleway_medium.ttf │ │ ├── raleway_regular.ttf │ │ ├── raleway_semibold.ttf │ │ └── raleway_thin.ttf │ │ ├── mipmap-anydpi │ │ ├── ic_launcher.xml │ │ └── ic_launcher_round.xml │ │ ├── mipmap-hdpi │ │ ├── ic_launcher.webp │ │ └── ic_launcher_round.webp │ │ ├── mipmap-mdpi │ │ ├── ic_launcher.webp │ │ └── ic_launcher_round.webp │ │ ├── mipmap-xhdpi │ │ ├── ic_launcher.webp │ │ └── ic_launcher_round.webp │ │ ├── mipmap-xxhdpi │ │ ├── ic_launcher.webp │ │ └── ic_launcher_round.webp │ │ ├── mipmap-xxxhdpi │ │ ├── ic_launcher.webp │ │ └── ic_launcher_round.webp │ │ ├── values │ │ └── themes.xml │ │ └── xml │ │ ├── backup_rules.xml │ │ └── data_extraction_rules.xml │ ├── release │ └── generated │ │ └── baselineProfiles │ │ ├── baseline-prof.txt │ │ └── startup-prof.txt │ └── test │ └── kotlin │ └── com │ └── w2sv │ └── filenavigator │ └── ui │ ├── screen │ └── navigatorsettings │ │ └── components │ │ └── FileTypeCreationDialogKtTest.kt │ └── util │ ├── MockInvalidityReason.kt │ ├── ProxyTextEditorTest.kt │ └── StatefulTextEditorTest.kt ├── benchmarking ├── .gitignore ├── build.gradle.kts └── src │ └── main │ ├── AndroidManifest.xml │ └── kotlin │ └── com │ └── w2sv │ └── filenavigator │ ├── StartupBenchmarks.kt │ ├── UiDeviceExt.kt │ └── baselineprofile │ └── StartupBaselineProfile.kt ├── build-logic ├── .gitignore ├── convention │ ├── build.gradle.kts │ └── src │ │ └── main │ │ └── kotlin │ │ ├── ApplicationConventionPlugin.kt │ │ ├── HiltConventionPlugin.kt │ │ ├── LibraryConventionPlugin.kt │ │ ├── RoomConventionPlugin.kt │ │ └── helpers │ │ ├── BaseConfig.kt │ │ └── Extensions.kt ├── gradle.properties └── settings.gradle.kts ├── build.gradle.kts ├── core ├── common │ ├── .gitignore │ ├── build.gradle.kts │ └── src │ │ ├── main │ │ ├── kotlin │ │ │ └── com │ │ │ │ └── w2sv │ │ │ │ └── common │ │ │ │ ├── AppUrl.kt │ │ │ │ ├── di │ │ │ │ ├── AppDispatcher.kt │ │ │ │ └── CommonModule.kt │ │ │ │ └── util │ │ │ │ ├── ContentResolver.kt │ │ │ │ ├── DocumentFile.kt │ │ │ │ ├── DocumentUri.kt │ │ │ │ ├── ExternalStorageManager.kt │ │ │ │ ├── Formatting.kt │ │ │ │ ├── Logging.kt │ │ │ │ ├── LoggingBroadcastReceiver.kt │ │ │ │ ├── LoggingComponentActivity.kt │ │ │ │ ├── Map.kt │ │ │ │ ├── MediaId.kt │ │ │ │ ├── MediaUri.kt │ │ │ │ └── String.kt │ │ └── res │ │ │ ├── drawable │ │ │ ├── ic_apk_file_24.xml │ │ │ ├── ic_app_foreground_108.xml │ │ │ ├── ic_app_logo_24.xml │ │ │ ├── ic_app_monochrome_108.xml │ │ │ ├── ic_apps_24.xml │ │ │ ├── ic_audio_file_24.xml │ │ │ ├── ic_battery_low_24.xml │ │ │ ├── ic_book_24.xml │ │ │ ├── ic_bug_report_24.xml │ │ │ ├── ic_camera_24.xml │ │ │ ├── ic_cancel_24.xml │ │ │ ├── ic_contrast_24.xml │ │ │ ├── ic_copyright_24.xml │ │ │ ├── ic_custom_file_type_24.xml │ │ │ ├── ic_delete_24.xml │ │ │ ├── ic_delete_history_24.xml │ │ │ ├── ic_developer_24.xml │ │ │ ├── ic_donate_24.xml │ │ │ ├── ic_file_download_24.xml │ │ │ ├── ic_files_24.xml │ │ │ ├── ic_folder_edit_24.xml │ │ │ ├── ic_folder_open_24.xml │ │ │ ├── ic_folder_zip_24.xml │ │ │ ├── ic_github_24.xml │ │ │ ├── ic_history_24.xml │ │ │ ├── ic_image_24.xml │ │ │ ├── ic_info_outline_24.xml │ │ │ ├── ic_mic_24.xml │ │ │ ├── ic_more_vert_24.xml │ │ │ ├── ic_nightlight_24.xml │ │ │ ├── ic_notifications_24.xml │ │ │ ├── ic_palette_24.xml │ │ │ ├── ic_pdf_24.xml │ │ │ ├── ic_policy_24.xml │ │ │ ├── ic_restart_24.xml │ │ │ ├── ic_screenshot_24.xml │ │ │ ├── ic_settings_24.xml │ │ │ ├── ic_share_24.xml │ │ │ ├── ic_smartphone_24.xml │ │ │ ├── ic_star_rate_24.xml │ │ │ ├── ic_start_24.xml │ │ │ ├── ic_stop_24.xml │ │ │ ├── ic_storage_24.xml │ │ │ ├── ic_subdirectory_arrow_right_24.xml │ │ │ ├── ic_text_file_24.xml │ │ │ ├── ic_video_file_24.xml │ │ │ └── ic_warning_24.xml │ │ │ ├── values-zh-rCN │ │ │ └── strings.xml │ │ │ └── values │ │ │ ├── colors.xml │ │ │ └── strings.xml │ │ └── test │ │ └── kotlin │ │ └── com │ │ └── w2sv │ │ └── common │ │ └── util │ │ ├── DocumentUriTest.kt │ │ ├── MediaUriTest.kt │ │ └── StringKtTest.kt ├── database │ ├── .gitignore │ ├── build.gradle.kts │ ├── schemas │ │ ├── com.w2sv.data.storage.database.AppDatabase │ │ │ ├── 1.json │ │ │ └── 2.json │ │ ├── com.w2sv.database.AppDatabase │ │ │ ├── 3.json │ │ │ ├── 4.json │ │ │ └── 5.json │ │ └── com.w2sv.datastorage.database.AppDatabase │ │ │ └── 2.json │ └── src │ │ ├── androidTest │ │ └── kotlin │ │ │ └── com │ │ │ └── w2sv │ │ │ └── database │ │ │ └── migration │ │ │ └── MigrationTest.kt │ │ ├── main │ │ └── kotlin │ │ │ └── com │ │ │ └── w2sv │ │ │ └── database │ │ │ ├── AppDatabase.kt │ │ │ ├── dao │ │ │ └── MovedFileDao.kt │ │ │ ├── di │ │ │ ├── DataBaseBinderModule.kt │ │ │ └── DatabaseModule.kt │ │ │ ├── entity │ │ │ └── MovedFileEntity.kt │ │ │ ├── migration │ │ │ └── Migrations.kt │ │ │ ├── repository │ │ │ └── RoomMovedFileRepository.kt │ │ │ └── typeconverter │ │ │ ├── FileTypeConverter.kt │ │ │ ├── LocalDateTimeConverter.kt │ │ │ └── UriConverter.kt │ │ └── test │ │ └── kotlin │ │ └── com │ │ └── w2sv │ │ └── database │ │ ├── repository │ │ └── RoomMovedFileRepositoryTest.kt │ │ └── typeconverter │ │ └── FileTypeConverterTest.kt ├── datastore │ ├── .gitignore │ ├── build.gradle.kts │ ├── consumer-proguard-rules.pro │ └── src │ │ ├── main │ │ ├── kotlin │ │ │ └── com │ │ │ │ └── w2sv │ │ │ │ └── datastore │ │ │ │ ├── di │ │ │ │ ├── DataStoreBinderModule.kt │ │ │ │ └── DataStoreModule.kt │ │ │ │ ├── migration │ │ │ │ ├── NavigatorPreferencesToProtoMigration.kt │ │ │ │ └── PreMigrationNavigatorPreferencesKey.kt │ │ │ │ ├── preferences │ │ │ │ └── PreferencesRepositoryImpl.kt │ │ │ │ └── proto │ │ │ │ ├── ProtoMapper.kt │ │ │ │ └── navigatorconfig │ │ │ │ ├── NavigatorConfigDataSourceImpl.kt │ │ │ │ ├── NavigatorConfigMapper.kt │ │ │ │ └── NavigatorConfigProtoSerializer.kt │ │ └── proto │ │ │ └── navigator_config.proto │ │ └── test │ │ └── kotlin │ │ └── com │ │ └── w2sv │ │ └── datastore │ │ ├── migration │ │ ├── NavigatorPreferencesToProtoMigrationTest.kt │ │ └── PreMigrationNavigatorPreferencesKeyTest.kt │ │ └── proto │ │ └── navigatorconfig │ │ ├── NavigatorConfigDataSourceImplTest.kt │ │ └── NavigatorConfigMapperTest.kt ├── domain │ ├── .gitignore │ ├── build.gradle.kts │ └── src │ │ ├── main │ │ └── kotlin │ │ │ └── com │ │ │ └── w2sv │ │ │ └── domain │ │ │ ├── model │ │ │ ├── MovedFile.kt │ │ │ ├── Theme.kt │ │ │ ├── filetype │ │ │ │ ├── CustomFileType.kt │ │ │ │ ├── FileAndSourceType.kt │ │ │ │ ├── FileType.kt │ │ │ │ ├── PresetFileType.kt │ │ │ │ ├── PresetWrappingFileType.kt │ │ │ │ ├── SourceType.kt │ │ │ │ └── StaticFileType.kt │ │ │ ├── movedestination │ │ │ │ ├── ExternalDestination.kt │ │ │ │ ├── ExternalDestinationApi.kt │ │ │ │ ├── FileDestinationApi.kt │ │ │ │ ├── LocalDestination.kt │ │ │ │ ├── LocalDestinationApi.kt │ │ │ │ └── MoveDestinationApi.kt │ │ │ └── navigatorconfig │ │ │ │ ├── AutoMoveConfig.kt │ │ │ │ ├── FileTypeConfig.kt │ │ │ │ ├── NavigatorConfig.kt │ │ │ │ └── SourceConfig.kt │ │ │ ├── repository │ │ │ ├── MovedFileRepository.kt │ │ │ ├── NavigatorConfigDataSource.kt │ │ │ └── PreferencesRepository.kt │ │ │ └── usecase │ │ │ ├── GetMoveHistoryUseCase.kt │ │ │ ├── InsertMovedFileUseCase.kt │ │ │ └── MoveDestinationPathConverter.kt │ │ └── test │ │ └── kotlin │ │ └── com │ │ └── w2sv │ │ └── domain │ │ └── model │ │ ├── filetype │ │ ├── CustomFileTypeTest.kt │ │ ├── FileAndSourceTypeTest.kt │ │ ├── PresetFileTypeTest.kt │ │ └── PresetWrappingFileTypeTest.kt │ │ └── navigatorconfig │ │ └── NavigatorConfigTest.kt ├── navigator │ ├── .gitignore │ ├── build.gradle.kts │ └── src │ │ ├── main │ │ ├── AndroidManifest.xml │ │ ├── kotlin │ │ │ └── com │ │ │ │ └── w2sv │ │ │ │ └── navigator │ │ │ │ ├── FileNavigator.kt │ │ │ │ ├── FileNavigatorModule.kt │ │ │ │ ├── moving │ │ │ │ ├── MoveBroadcastReceiver.kt │ │ │ │ ├── MoveResultListener.kt │ │ │ │ ├── Moving.kt │ │ │ │ ├── api │ │ │ │ │ └── activity │ │ │ │ │ │ ├── AbstractDestinationPickerActivity.kt │ │ │ │ │ │ └── AbstractMoveActivity.kt │ │ │ │ ├── batch │ │ │ │ │ ├── BatchMoveBroadcastReceiver.kt │ │ │ │ │ └── CancelBatchMoveBroadcastReceiver.kt │ │ │ │ ├── model │ │ │ │ │ ├── DestinationSelectionManner.kt │ │ │ │ │ ├── MediaIdWithMediaType.kt │ │ │ │ │ ├── MoveBundle.kt │ │ │ │ │ ├── MoveFile.kt │ │ │ │ │ ├── MoveResult.kt │ │ │ │ │ └── NavigatorMoveDestination.kt │ │ │ │ └── quick │ │ │ │ │ └── QuickMoveDestinationAccessPermissionQueryActivity.kt │ │ │ │ ├── notifications │ │ │ │ ├── CleanupNotificationResourcesBroadcastReceiver.kt │ │ │ │ ├── NotificationModule.kt │ │ │ │ ├── NotificationResources.kt │ │ │ │ ├── api │ │ │ │ │ ├── AppNotificationManager.kt │ │ │ │ │ ├── MultiInstanceNotificationManager.kt │ │ │ │ │ ├── SingleInstanceNotificationManager.kt │ │ │ │ │ └── SummarizedMultiInstanceNotificationManager.kt │ │ │ │ └── appnotifications │ │ │ │ │ ├── AppNotificationChannel.kt │ │ │ │ │ ├── AppNotificationId.kt │ │ │ │ │ ├── AutoMoveDestinationInvalidNotificationManager.kt │ │ │ │ │ ├── FileNavigatorIsRunningNotificationManager.kt │ │ │ │ │ ├── Shared.kt │ │ │ │ │ ├── batchmove │ │ │ │ │ ├── BatchMoveDestinationPickerActivity.kt │ │ │ │ │ ├── BatchMoveNotificationManager.kt │ │ │ │ │ └── BatchMoveProgressNotificationManager.kt │ │ │ │ │ └── movefile │ │ │ │ │ ├── FileDeletionActivity.kt │ │ │ │ │ ├── FileDestinationPickerActivity.kt │ │ │ │ │ ├── MoveFileNotificationManager.kt │ │ │ │ │ └── ViewFileIfPresentActivity.kt │ │ │ │ ├── observing │ │ │ │ ├── FileObserver.kt │ │ │ │ ├── FileObserverFactory.kt │ │ │ │ ├── FileObserverModule.kt │ │ │ │ ├── MediaFileObserver.kt │ │ │ │ ├── NonMediaFileObserver.kt │ │ │ │ └── model │ │ │ │ │ ├── MediaStoreDataProducer.kt │ │ │ │ │ └── MediaStoreFileData.kt │ │ │ │ ├── quicktile │ │ │ │ └── FileNavigatorTileService.kt │ │ │ │ ├── shared │ │ │ │ ├── AlertDialog.kt │ │ │ │ ├── DialogHostingActivity.kt │ │ │ │ ├── Intent.kt │ │ │ │ └── Logging.kt │ │ │ │ └── system_broadcastreceiver │ │ │ │ ├── BootCompletedReceiver.kt │ │ │ │ ├── PowerSaveModeChangedReceiver.kt │ │ │ │ ├── SystemBroadcastReceiver.kt │ │ │ │ ├── di │ │ │ │ ├── SystemBroadcastReceiverBinderModule.kt │ │ │ │ └── SystemBroadcastReceiverModule.kt │ │ │ │ └── manager │ │ │ │ ├── NavigatorConfigControlledSystemBroadcastReceiverManager.kt │ │ │ │ └── NavigatorConfigControlledSystemBroadcastReceiverManagerImpl.kt │ │ └── res │ │ │ ├── layout │ │ │ ├── dialog_header.xml │ │ │ └── tile_dialog.xml │ │ │ └── values │ │ │ └── styles.xml │ │ └── test │ │ ├── kotlin │ │ ├── com │ │ │ └── w2sv │ │ │ │ └── navigator │ │ │ │ └── moving │ │ │ │ └── model │ │ │ │ ├── DestinationSelectionMannerTest.kt │ │ │ │ ├── MediaStoreFileDataTest.kt │ │ │ │ ├── MoveBundleTest.kt │ │ │ │ ├── MoveFileTest.kt │ │ │ │ └── NavigatorMoveDestinationTest.kt │ │ └── util │ │ │ ├── ResourceFileLoading.kt │ │ │ └── TestInstance.kt │ │ └── resources │ │ ├── Kyuss_Welcome_to_Sky_Valley.jpg │ │ ├── Mandelbulb.png │ │ ├── Sandro_Botticelli_-_La_Carte_de_l'Enfer.jpg │ │ ├── empty.txt │ │ └── other_empty.txt └── test │ ├── .gitignore │ ├── build.gradle.kts │ └── src │ └── main │ └── kotlin │ └── com │ └── w2sv │ └── test │ ├── Parcelable.kt │ └── TimberTestRule.kt ├── gradle.properties ├── gradle ├── libs.versions.toml └── wrapper │ ├── gradle-wrapper.jar │ └── gradle-wrapper.properties ├── gradlew ├── gradlew.bat ├── renovate.json └── settings.gradle.kts /.editorconfig: -------------------------------------------------------------------------------- 1 | # https://editorconfig.org 2 | # https://pinterest.github.io/ktlint/latest/rules/standard/ 3 | [*] 4 | indent_style = space 5 | indent_size = 4 6 | 7 | end_of_line = lf 8 | charset = utf-8 9 | trim_trailing_whitespace = true 10 | insert_final_newline = true 11 | 12 | [*.{kt,kts}] 13 | ktlint_code_style = android_studio 14 | 15 | max_line_length = 140 16 | ij_kotlin_allow_trailing_comma = false 17 | ij_kotlin_allow_trailing_comma_on_call_site = false 18 | 19 | # Prevent wildcard imports 20 | ij_kotlin_packages_to_use_import_on_demand = unset 21 | ktlint_standard_no-wildcard-imports = enabled 22 | 23 | ktlint_function_naming_ignore_when_annotated_with = Composable, Test 24 | ktlint_standard_filename = disabled 25 | ktlint_function_signature_body_expression_wrapping = always 26 | ktlint_function_signature_rule_force_multiline_when_parameter_count_greater_or_equal_than = 3 27 | ktlint_standard_discouraged-comment-location = disabled 28 | ktlint_standard_package-name = disabled 29 | 30 | [*.md] 31 | max_line_length = unset -------------------------------------------------------------------------------- /.github/FUNDING.yml: -------------------------------------------------------------------------------- 1 | # These are supported funding model platforms 2 | 3 | github: # Replace with up to 4 GitHub Sponsors-enabled usernames e.g., [user1, user2] 4 | patreon: # Replace with a single Patreon username 5 | open_collective: # Replace with a single Open Collective username 6 | ko_fi: # Replace with a single Ko-fi username 7 | tidelift: # Replace with a single Tidelift platform-name/package-name e.g., npm/babel 8 | community_bridge: # Replace with a single Community Bridge project-name e.g., cloud-foundry 9 | liberapay: # Replace with a single Liberapay username 10 | issuehunt: # Replace with a single IssueHunt username 11 | lfx_crowdfunding: # Replace with a single LFX Crowdfunding project-name e.g., cloud-foundry 12 | polar: # Replace with a single Polar username 13 | buy_me_a_coffee: w2sv 14 | custom: # Replace with up to 4 custom sponsorship URLs e.g., ['link1', 'link2'] 15 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/bug.yml: -------------------------------------------------------------------------------- 1 | name: Bug Report 2 | description: File a bug report 3 | title: "[Bug] " 4 | labels: [ "bug" ] 5 | body: 6 | - type: markdown 7 | attributes: 8 | value: | 9 | Thanks for taking the time to fill out this bug report! 10 | - type: textarea 11 | attributes: 12 | label: Overview 13 | description: Please describe exactly what the bug is and what you expected to happen instead. Images/videos of it are always helpful. 14 | validations: 15 | required: true 16 | - type: textarea 17 | attributes: 18 | label: How to reproduce 19 | description: | 20 | Describe step by step, how to reproduce the bug. 21 | validations: 22 | required: true 23 | - type: input 24 | id: version 25 | attributes: 26 | label: Version 27 | description: On which version of File Navigator did this bug appear? You can find the version in the header of the side bar. 28 | validations: 29 | required: true 30 | - type: input 31 | id: androidversion 32 | attributes: 33 | label: Android Version 34 | description: What is your Android Version? (i.e. Android 13) 35 | validations: 36 | required: true 37 | - type: input 38 | id: device 39 | attributes: 40 | label: Device Model 41 | description: What device do you use (i.e. Pixel 6) 42 | validations: 43 | required: true -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/config.yml: -------------------------------------------------------------------------------- 1 | blank_issues_enabled: false 2 | contact_links: 3 | - name: Discussion 4 | url: https://github.com/w2sv/FileNavigator/discussions 5 | about: You may also use the discussions for general ideas and questions. 6 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/feature_request.yml: -------------------------------------------------------------------------------- 1 | name: "Feature Request" 2 | description: Request a new feature the app is missing 3 | title: "[Feature Request] " 4 | labels: [ "enhancement" ] 5 | body: 6 | - type: textarea 7 | attributes: 8 | label: Overview 9 | description: Please describe the feature you'd like to see implemented. 10 | validations: 11 | required: true 12 | -------------------------------------------------------------------------------- /.github/workflows/workflow.yaml: -------------------------------------------------------------------------------- 1 | name: Build 2 | on: [ push, pull_request ] 3 | 4 | jobs: 5 | build: 6 | runs-on: ubuntu-latest 7 | steps: 8 | - uses: actions/checkout@v4 9 | 10 | - name: Setup JDK 11 | uses: actions/setup-java@v4 12 | with: 13 | java-version: 17 14 | distribution: temurin 15 | 16 | - name: Build 17 | run: | 18 | ./gradlew check 19 | ./gradlew assembleDebug 20 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | *.iml 2 | .gradle 3 | /local.properties 4 | /.idea/caches 5 | /.idea/libraries 6 | /.idea/modules.xml 7 | /.idea/workspace.xml 8 | /.idea/navEditor.xml 9 | /.idea/assetWizardSettings.xml 10 | .DS_Store 11 | /build 12 | /captures 13 | .externalNativeBuild 14 | .cxx 15 | local.properties 16 | keys.jks 17 | keystore.properties 18 | /.kotlin/ 19 | -------------------------------------------------------------------------------- /.idea/AndroidProjectSystem.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 6 | -------------------------------------------------------------------------------- /.idea/compiler.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | -------------------------------------------------------------------------------- /.idea/deploymentTargetDropDown.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | -------------------------------------------------------------------------------- /.idea/deploymentTargetSelector.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 16 | 17 | 18 | -------------------------------------------------------------------------------- /.idea/gradle.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 40 | 41 | -------------------------------------------------------------------------------- /.idea/icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/w2sv/FileNavigator/60cc5c220659dca1cdade72c68d8eed742f8d528/.idea/icon.png -------------------------------------------------------------------------------- /.idea/kotlinc.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 6 | -------------------------------------------------------------------------------- /.idea/migrations.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 9 | 10 | -------------------------------------------------------------------------------- /.idea/misc.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | -------------------------------------------------------------------------------- /.idea/runConfigurations.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 16 | 17 | -------------------------------------------------------------------------------- /.idea/vcs.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | -------------------------------------------------------------------------------- /PRIVACY-POLICY.md: -------------------------------------------------------------------------------- 1 | # File Navigator Privacy Policy 2 | 3 | File Navigator is an open source Android app developed by me, Janek Zangenberg, alias w2sv (W2SV). 4 | The code is available on [GitHub](https://github.com/w2sv/FileNavigator) under 5 | the [GPL-3.0 license](https://github.com/w2sv/FileNavigator/blob/main/LICENSE.md). 6 | 7 | File Navigator does not collect, let alone share, data of whichever kind. 8 | In-app configurations made by you are stored locally on your device, and will thus 9 | remain entirely private. 10 | 11 | File Navigator requires access to manage all files to register new navigatable files, 12 | corresponding to the 13 | properties which you may configure within the application, entering the file system, and to move 14 | them upon you 15 | explicitly selecting one 16 | of the move options presented in the notifications you will receive. It is important to note that 17 | this permission is __exclusively__ utilized for these specific purposes and is not employed for any 18 | other functionality, let alone data collection. 19 | 20 | Should you have any doubts or general inquiries regarding the above stated privacy policy, feel free 21 | to reach out to me @ zangenbergjanek@googlemail.com. 22 | 23 | Sincerely,\ 24 | Janek Zangenberg\ 25 | Berlin, Germany\ 26 | 07.11.2023 27 | -------------------------------------------------------------------------------- /app/.gitignore: -------------------------------------------------------------------------------- 1 | /build 2 | service-account-key.json -------------------------------------------------------------------------------- /app/compose_compiler_config.conf: -------------------------------------------------------------------------------- 1 | com.w2sv.domain.model.** 2 | java.time.** 3 | com.w2sv.common.DocumentUriToPathConverter -------------------------------------------------------------------------------- /app/lint.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | -------------------------------------------------------------------------------- /app/proguard-rules.pro: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/w2sv/FileNavigator/60cc5c220659dca1cdade72c68d8eed742f8d528/app/proguard-rules.pro -------------------------------------------------------------------------------- /app/src/androidTest/kotlin/com/w2sv/filenavigator/ui/screen/PermissionScreenTest.kt: -------------------------------------------------------------------------------- 1 | package com.w2sv.filenavigator.ui.screen 2 | 3 | import androidx.compose.ui.test.assertIsDisplayed 4 | import androidx.compose.ui.test.junit4.ComposeContentTestRule 5 | import androidx.compose.ui.test.junit4.createAndroidComposeRule 6 | import androidx.compose.ui.test.onNodeWithText 7 | import com.w2sv.filenavigator.MainActivity 8 | import org.junit.Rule 9 | import org.junit.Test 10 | 11 | class PermissionScreenTest { 12 | 13 | @get:Rule 14 | val composeContentTestRule: ComposeContentTestRule = createAndroidComposeRule() 15 | 16 | @Test 17 | fun testNavigationDrawerScreen() { 18 | with(composeContentTestRule) { 19 | waitForIdle() 20 | 21 | onNodeWithText("Missing Permissions") 22 | .assertIsDisplayed() 23 | } 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /app/src/debug/res/mipmap-anydpi/ic_launcher.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | -------------------------------------------------------------------------------- /app/src/debug/res/mipmap-anydpi/ic_launcher_round.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | -------------------------------------------------------------------------------- /app/src/main/AndroidManifest.xml: -------------------------------------------------------------------------------- 1 | 2 | 4 | 5 | 17 | 18 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | -------------------------------------------------------------------------------- /app/src/main/kotlin/com/w2sv/filenavigator/Application.kt: -------------------------------------------------------------------------------- 1 | package com.w2sv.filenavigator 2 | 3 | import android.app.Application 4 | import dagger.hilt.android.HiltAndroidApp 5 | import timber.log.Timber 6 | 7 | @HiltAndroidApp 8 | class Application : Application() { 9 | override fun onCreate() { 10 | super.onCreate() 11 | 12 | if (BuildConfig.DEBUG) { 13 | Timber.plant(Timber.DebugTree()) 14 | } 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /app/src/main/kotlin/com/w2sv/filenavigator/ui/CompositionLocals.kt: -------------------------------------------------------------------------------- 1 | package com.w2sv.filenavigator.ui 2 | 3 | import androidx.compose.runtime.compositionLocalOf 4 | import androidx.compose.runtime.staticCompositionLocalOf 5 | import com.ramcosta.composedestinations.navigation.DestinationsNavigator 6 | import com.w2sv.domain.usecase.MoveDestinationPathConverter 7 | 8 | val LocalDestinationsNavigator = 9 | staticCompositionLocalOf { 10 | throw UninitializedPropertyAccessException( 11 | "LocalDestinationsNavigator not yet provided" 12 | ) 13 | } 14 | 15 | val LocalMoveDestinationPathConverter = 16 | staticCompositionLocalOf { 17 | throw UninitializedPropertyAccessException( 18 | "LocalDocumentUriToPathConverter not yet provided" 19 | ) 20 | } 21 | 22 | val LocalUseDarkTheme = 23 | compositionLocalOf { false } 24 | -------------------------------------------------------------------------------- /app/src/main/kotlin/com/w2sv/filenavigator/ui/designsystem/AppCardDefaults.kt: -------------------------------------------------------------------------------- 1 | package com.w2sv.filenavigator.ui.designsystem 2 | 3 | import androidx.compose.material3.CardDefaults 4 | import androidx.compose.runtime.Composable 5 | import androidx.compose.ui.unit.dp 6 | 7 | object AppCardDefaults { 8 | val elevation 9 | @Composable 10 | get() = CardDefaults.elevatedCardElevation(defaultElevation = 4.dp) 11 | } 12 | -------------------------------------------------------------------------------- /app/src/main/kotlin/com/w2sv/filenavigator/ui/designsystem/Buttons.kt: -------------------------------------------------------------------------------- 1 | package com.w2sv.filenavigator.ui.designsystem 2 | 3 | import androidx.compose.foundation.BorderStroke 4 | import androidx.compose.material3.ButtonDefaults 5 | import androidx.compose.material3.ElevatedButton 6 | import androidx.compose.material3.MaterialTheme 7 | import androidx.compose.material3.Text 8 | import androidx.compose.runtime.Composable 9 | import androidx.compose.ui.Modifier 10 | import androidx.compose.ui.graphics.Color 11 | import androidx.compose.ui.unit.Dp 12 | import androidx.compose.ui.unit.dp 13 | 14 | @Composable 15 | fun DialogButton( 16 | text: String, 17 | onClick: () -> Unit, 18 | modifier: Modifier = Modifier, 19 | contentColor: Color = MaterialTheme.colorScheme.secondary, 20 | containerColor: Color = MaterialTheme.colorScheme.surfaceContainerLow, 21 | enabled: Boolean = true 22 | ) { 23 | ElevatedButton( 24 | onClick = onClick, 25 | modifier = modifier, 26 | enabled = enabled, 27 | border = if (enabled) { 28 | BorderStroke( 29 | Dp.Hairline, 30 | contentColor 31 | ) 32 | } else { 33 | null 34 | }, 35 | elevation = ButtonDefaults.elevatedButtonElevation(8.dp), 36 | colors = ButtonDefaults.elevatedButtonColors(contentColor = contentColor, containerColor = containerColor) 37 | ) { 38 | Text(text = text) 39 | } 40 | } 41 | 42 | @Composable 43 | fun HighlightedDialogButton( 44 | text: String, 45 | onClick: () -> Unit, 46 | modifier: Modifier = Modifier, 47 | enabled: Boolean = true 48 | ) { 49 | DialogButton( 50 | text = text, 51 | onClick = onClick, 52 | enabled = enabled, 53 | modifier = modifier, 54 | containerColor = MaterialTheme.colorScheme.primaryContainer, 55 | contentColor = MaterialTheme.colorScheme.onPrimaryContainer 56 | ) 57 | } 58 | -------------------------------------------------------------------------------- /app/src/main/kotlin/com/w2sv/filenavigator/ui/designsystem/Icons.kt: -------------------------------------------------------------------------------- 1 | package com.w2sv.filenavigator.ui.designsystem 2 | 3 | import androidx.compose.material3.Icon 4 | import androidx.compose.runtime.Composable 5 | import androidx.compose.ui.Modifier 6 | import androidx.compose.ui.graphics.Color 7 | import androidx.compose.ui.res.painterResource 8 | import androidx.compose.ui.unit.dp 9 | import com.w2sv.domain.model.filetype.FileType 10 | import com.w2sv.filenavigator.ui.modelext.color 11 | 12 | object IconSize { 13 | val Default = 24.dp 14 | val Big = 28.dp 15 | 16 | object IconButton { 17 | val Smaller = 36.dp 18 | } 19 | } 20 | 21 | @Composable 22 | fun FileTypeIcon( 23 | fileType: FileType, 24 | modifier: Modifier = Modifier, 25 | tint: Color = fileType.color 26 | ) { 27 | Icon( 28 | painter = painterResource(id = fileType.iconRes), 29 | contentDescription = null, 30 | modifier = modifier, 31 | tint = tint 32 | ) 33 | } 34 | -------------------------------------------------------------------------------- /app/src/main/kotlin/com/w2sv/filenavigator/ui/designsystem/NavigationTransition.kt: -------------------------------------------------------------------------------- 1 | package com.w2sv.filenavigator.ui.designsystem 2 | 3 | import androidx.compose.animation.AnimatedContentTransitionScope 4 | import androidx.compose.animation.EnterTransition 5 | import androidx.compose.animation.ExitTransition 6 | import androidx.compose.animation.core.FiniteAnimationSpec 7 | import androidx.compose.animation.core.tween 8 | import androidx.compose.animation.fadeIn 9 | import androidx.compose.animation.fadeOut 10 | import androidx.compose.animation.scaleIn 11 | import androidx.compose.animation.scaleOut 12 | import androidx.navigation.NavBackStackEntry 13 | import com.ramcosta.composedestinations.spec.DestinationStyle 14 | 15 | private typealias NavigationEnterTransition = (AnimatedContentTransitionScope.() -> EnterTransition?) 16 | private typealias NavigationExitTransition = (AnimatedContentTransitionScope.() -> ExitTransition?) 17 | 18 | abstract class PopNonPopIdenticalAnimatedDestinationStyle : DestinationStyle.Animated() { 19 | abstract override val enterTransition: NavigationEnterTransition 20 | abstract override val exitTransition: NavigationExitTransition 21 | 22 | override val popEnterTransition: NavigationEnterTransition get() = enterTransition 23 | override val popExitTransition: NavigationExitTransition get() = exitTransition 24 | } 25 | 26 | object NavigationTransitions : PopNonPopIdenticalAnimatedDestinationStyle() { 27 | override val enterTransition: NavigationEnterTransition = { 28 | fadeIn(animationSpec = animationSpec) + 29 | scaleIn(initialScale = 0.92f, animationSpec = animationSpec) 30 | } 31 | override val exitTransition: NavigationExitTransition = { 32 | fadeOut(animationSpec = animationSpec) + scaleOut( 33 | targetScale = 0.92f, 34 | animationSpec = animationSpec 35 | ) 36 | } 37 | } 38 | 39 | private val animationSpec: FiniteAnimationSpec = tween(220) 40 | -------------------------------------------------------------------------------- /app/src/main/kotlin/com/w2sv/filenavigator/ui/designsystem/Padding.kt: -------------------------------------------------------------------------------- 1 | package com.w2sv.filenavigator.ui.designsystem 2 | 3 | import androidx.compose.runtime.Composable 4 | import androidx.compose.ui.unit.Dp 5 | import androidx.compose.ui.unit.dp 6 | import com.w2sv.composed.isPortraitModeActive 7 | 8 | object Padding { 9 | val defaultHorizontal: Dp 10 | @Composable 11 | get() = if (isPortraitModeActive) 16.dp else 52.dp 12 | 13 | val fabButtonBottomPadding: Dp 14 | @Composable 15 | get() = if (isPortraitModeActive) 144.dp else 92.dp 16 | } 17 | -------------------------------------------------------------------------------- /app/src/main/kotlin/com/w2sv/filenavigator/ui/designsystem/Spacing.kt: -------------------------------------------------------------------------------- 1 | package com.w2sv.filenavigator.ui.designsystem 2 | 3 | import androidx.compose.ui.unit.dp 4 | 5 | object Spacing { 6 | val VerticalItemRow = 22.dp 7 | } 8 | -------------------------------------------------------------------------------- /app/src/main/kotlin/com/w2sv/filenavigator/ui/designsystem/Tooltip.kt: -------------------------------------------------------------------------------- 1 | package com.w2sv.filenavigator.ui.designsystem 2 | 3 | import android.annotation.SuppressLint 4 | import androidx.compose.material.icons.Icons 5 | import androidx.compose.material.icons.filled.Delete 6 | import androidx.compose.material3.Icon 7 | import androidx.compose.material3.IconButton 8 | import androidx.compose.material3.PlainTooltip 9 | import androidx.compose.material3.TooltipDefaults 10 | import androidx.compose.material3.TooltipScope 11 | import androidx.compose.runtime.Composable 12 | import androidx.compose.ui.Modifier 13 | import androidx.compose.ui.unit.dp 14 | 15 | @SuppressLint("ComposeUnstableReceiver") 16 | @Composable 17 | fun TooltipScope.DeletionTooltip( 18 | onClick: () -> Unit, 19 | contentDescription: String, 20 | modifier: Modifier = Modifier 21 | ) { 22 | PlainTooltip(caretSize = TooltipDefaults.caretSize, tonalElevation = 4.dp, shadowElevation = 4.dp, modifier = modifier) { 23 | IconButton(onClick = onClick) { 24 | Icon( 25 | imageVector = Icons.Default.Delete, 26 | contentDescription = contentDescription 27 | ) 28 | } 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /app/src/main/kotlin/com/w2sv/filenavigator/ui/designsystem/drawer/model/AppPreferences.kt: -------------------------------------------------------------------------------- 1 | package com.w2sv.filenavigator.ui.designsystem.drawer.model 2 | 3 | import androidx.compose.runtime.Composable 4 | import androidx.compose.runtime.Stable 5 | import androidx.compose.runtime.getValue 6 | import androidx.compose.runtime.remember 7 | import androidx.lifecycle.compose.collectAsStateWithLifecycle 8 | import com.w2sv.domain.model.Theme 9 | import com.w2sv.filenavigator.ui.util.activityViewModel 10 | import com.w2sv.filenavigator.ui.viewmodel.AppViewModel 11 | 12 | @Stable 13 | data class AppPreferences( 14 | val showStorageVolumeNames: () -> Boolean, 15 | val setShowStorageVolumeNames: (Boolean) -> Unit, 16 | val theme: () -> Theme, 17 | val setTheme: (Theme) -> Unit, 18 | val useAmoledBlackTheme: () -> Boolean, 19 | val setUseAmoledBlackTheme: (Boolean) -> Unit, 20 | val useDynamicColors: () -> Boolean, 21 | val setUseDynamicColors: (Boolean) -> Unit 22 | ) 23 | 24 | @Composable 25 | fun rememberAppPreferences(appVM: AppViewModel = activityViewModel()): AppPreferences { 26 | val showStorageVolumeNames by appVM.showStorageVolumeNames.collectAsStateWithLifecycle() 27 | val theme by appVM.theme.collectAsStateWithLifecycle() 28 | val useAmoledBlackTheme by appVM.useAmoledBlackTheme.collectAsStateWithLifecycle() 29 | val useDynamicColors by appVM.useDynamicColors.collectAsStateWithLifecycle() 30 | 31 | return remember { 32 | AppPreferences( 33 | showStorageVolumeNames = { showStorageVolumeNames }, 34 | setShowStorageVolumeNames = appVM::saveShowStorageVolumeNames, 35 | theme = { theme }, 36 | setTheme = appVM::saveTheme, 37 | useAmoledBlackTheme = { useAmoledBlackTheme }, 38 | setUseAmoledBlackTheme = appVM::saveUseAmoledBlackTheme, 39 | useDynamicColors = { useDynamicColors }, 40 | setUseDynamicColors = appVM::saveUseDynamicColors 41 | ) 42 | } 43 | } 44 | -------------------------------------------------------------------------------- /app/src/main/kotlin/com/w2sv/filenavigator/ui/modelext/FileTypeExtensions.kt: -------------------------------------------------------------------------------- 1 | package com.w2sv.filenavigator.ui.modelext 2 | 3 | import android.annotation.SuppressLint 4 | import androidx.compose.runtime.Composable 5 | import androidx.compose.runtime.ReadOnlyComposable 6 | import androidx.compose.ui.graphics.Color 7 | import androidx.compose.ui.res.stringResource 8 | import com.w2sv.domain.model.filetype.CustomFileType 9 | import com.w2sv.domain.model.filetype.FileType 10 | import com.w2sv.domain.model.filetype.PresetWrappingFileType 11 | 12 | /** 13 | * @return previously cached Color. 14 | */ 15 | val FileType.color: Color 16 | get() = colorCache.getOrPut(colorInt) { Color(colorInt) } 17 | 18 | private val colorCache = mutableMapOf() 19 | 20 | @SuppressLint("ComposeUnstableReceiver") 21 | @Composable 22 | @ReadOnlyComposable 23 | fun FileType.stringResource(): String = 24 | when (this) { 25 | is PresetWrappingFileType<*> -> stringResource(presetFileType.labelRes) 26 | is CustomFileType -> name 27 | } 28 | -------------------------------------------------------------------------------- /app/src/main/kotlin/com/w2sv/filenavigator/ui/screen/home/components/HomeScreenCard.kt: -------------------------------------------------------------------------------- 1 | package com.w2sv.filenavigator.ui.screen.home.components 2 | 3 | import androidx.compose.foundation.layout.Column 4 | import androidx.compose.foundation.layout.ColumnScope 5 | import androidx.compose.foundation.layout.padding 6 | import androidx.compose.material3.ElevatedCard 7 | import androidx.compose.runtime.Composable 8 | import androidx.compose.ui.Modifier 9 | import androidx.compose.ui.unit.dp 10 | import com.w2sv.filenavigator.ui.designsystem.AppCardDefaults 11 | 12 | @Composable 13 | fun HomeScreenCard(modifier: Modifier = Modifier, content: @Composable ColumnScope.() -> Unit) { 14 | ElevatedCard( 15 | modifier = modifier, 16 | elevation = AppCardDefaults.elevation 17 | ) { 18 | Column( 19 | modifier = Modifier 20 | .padding(24.dp), 21 | content = content 22 | ) 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /app/src/main/kotlin/com/w2sv/filenavigator/ui/state/PostNotificationsPermissionState.kt: -------------------------------------------------------------------------------- 1 | package com.w2sv.filenavigator.ui.state 2 | 3 | import android.Manifest 4 | import androidx.compose.runtime.Composable 5 | import androidx.compose.runtime.Stable 6 | import com.google.accompanist.permissions.PermissionState 7 | import com.google.accompanist.permissions.isGranted 8 | import com.google.accompanist.permissions.rememberPermissionState 9 | import com.w2sv.androidutils.os.postNotificationsPermissionRequired 10 | import com.w2sv.composed.OnChange 11 | 12 | @Stable 13 | @JvmInline 14 | value class PostNotificationsPermissionState(val state: PermissionState?) 15 | 16 | @Composable 17 | fun rememberPostNotificationsPermissionState( 18 | onPermissionResult: (Boolean) -> Unit, 19 | onStatusChanged: (Boolean) -> Unit 20 | ): PostNotificationsPermissionState = 21 | PostNotificationsPermissionState( 22 | state = if (postNotificationsPermissionRequired) { 23 | rememberPermissionState( 24 | permission = Manifest.permission.POST_NOTIFICATIONS, 25 | onPermissionResult = onPermissionResult 26 | ) 27 | .also { 28 | OnChange(value = it.status) { status -> 29 | onStatusChanged(status.isGranted) 30 | } 31 | } 32 | } else { 33 | null 34 | } 35 | ) 36 | -------------------------------------------------------------------------------- /app/src/main/kotlin/com/w2sv/filenavigator/ui/theme/Color.kt: -------------------------------------------------------------------------------- 1 | package com.w2sv.filenavigator.ui.theme 2 | 3 | import androidx.compose.material3.ColorScheme 4 | import androidx.compose.runtime.Composable 5 | import androidx.compose.runtime.ReadOnlyComposable 6 | import androidx.compose.ui.graphics.Color 7 | 8 | object AppColor { 9 | val success = Color(12, 173, 34, 200) 10 | val error = Color(201, 14, 52, 200) 11 | } 12 | 13 | val ColorScheme.onSurfaceDisabled: Color 14 | @Composable 15 | @ReadOnlyComposable 16 | get() = onSurface.copy(0.38f) 17 | 18 | val ColorScheme.onSurfaceVariantDecreasedAlpha: Color 19 | @Composable 20 | @ReadOnlyComposable 21 | get() = onSurfaceVariant.copy(0.6f) 22 | -------------------------------------------------------------------------------- /app/src/main/kotlin/com/w2sv/filenavigator/ui/theme/Dims.kt: -------------------------------------------------------------------------------- 1 | package com.w2sv.filenavigator.ui.theme 2 | 3 | const val DEFAULT_ANIMATION_DURATION = 500 4 | -------------------------------------------------------------------------------- /app/src/main/kotlin/com/w2sv/filenavigator/ui/util/Color.kt: -------------------------------------------------------------------------------- 1 | package com.w2sv.filenavigator.ui.util 2 | 3 | import androidx.compose.material3.MaterialTheme 4 | import androidx.compose.runtime.Composable 5 | import androidx.compose.runtime.ReadOnlyComposable 6 | import androidx.compose.ui.graphics.Color 7 | import com.w2sv.filenavigator.ui.theme.onSurfaceDisabled 8 | 9 | @Composable 10 | @ReadOnlyComposable 11 | fun Color.orOnSurfaceDisabledIf(condition: Boolean): Color = 12 | if (condition) MaterialTheme.colorScheme.onSurfaceDisabled else this 13 | -------------------------------------------------------------------------------- /app/src/main/kotlin/com/w2sv/filenavigator/ui/util/Easing.kt: -------------------------------------------------------------------------------- 1 | package com.w2sv.filenavigator.ui.util 2 | 3 | import android.view.animation.AnticipateInterpolator 4 | import android.view.animation.AnticipateOvershootInterpolator 5 | import android.view.animation.OvershootInterpolator 6 | import com.w2sv.composed.extensions.toEasing 7 | import com.w2sv.kotlinutils.threadUnsafeLazy 8 | 9 | object Easing { 10 | val Anticipate by threadUnsafeLazy { AnticipateInterpolator().toEasing() } 11 | val Overshoot by threadUnsafeLazy { OvershootInterpolator().toEasing() } 12 | val AnticipateOvershoot by threadUnsafeLazy { AnticipateOvershootInterpolator().toEasing() } 13 | } 14 | -------------------------------------------------------------------------------- /app/src/main/kotlin/com/w2sv/filenavigator/ui/util/MovableContent.kt: -------------------------------------------------------------------------------- 1 | package com.w2sv.filenavigator.ui.util 2 | 3 | import androidx.compose.runtime.Composable 4 | import androidx.compose.runtime.movableContentOf 5 | import androidx.compose.runtime.remember 6 | import androidx.compose.ui.Modifier 7 | 8 | typealias ModifierReceivingComposable = @Composable (Modifier) -> Unit 9 | 10 | @Composable 11 | fun

rememberMovableContentOf(content: @Composable (P) -> Unit): @Composable (P) -> Unit = 12 | remember { 13 | movableContentOf(content) 14 | } 15 | -------------------------------------------------------------------------------- /app/src/main/kotlin/com/w2sv/filenavigator/ui/util/Saving.kt: -------------------------------------------------------------------------------- 1 | package com.w2sv.filenavigator.ui.util 2 | 3 | import androidx.compose.runtime.saveable.listSaver 4 | import androidx.compose.runtime.snapshots.SnapshotStateList 5 | import androidx.compose.runtime.toMutableStateList 6 | 7 | // TODO: Composed 8 | fun snapshotStateListSaver() = 9 | listSaver, T>( 10 | save = { stateList -> stateList.toList() }, 11 | restore = { it.toMutableStateList() } 12 | ) 13 | -------------------------------------------------------------------------------- /app/src/main/kotlin/com/w2sv/filenavigator/ui/util/StateConversion.kt: -------------------------------------------------------------------------------- 1 | package com.w2sv.filenavigator.ui.util 2 | 3 | import android.annotation.SuppressLint 4 | import androidx.compose.runtime.Composable 5 | import androidx.lifecycle.Lifecycle 6 | import androidx.lifecycle.LifecycleOwner 7 | import androidx.lifecycle.compose.LocalLifecycleOwner 8 | import androidx.lifecycle.compose.collectAsStateWithLifecycle 9 | import kotlin.coroutines.CoroutineContext 10 | import kotlin.coroutines.EmptyCoroutineContext 11 | import kotlinx.coroutines.flow.StateFlow 12 | 13 | /** 14 | * A shorthand for 15 | * `StateFlow.collectAsStateWithLifecycle().value` 16 | */ 17 | @SuppressLint("ComposeUnstableReceiver") 18 | @Composable 19 | fun StateFlow.lifecycleAwareStateValue( 20 | lifecycleOwner: LifecycleOwner = LocalLifecycleOwner.current, 21 | minActiveState: Lifecycle.State = Lifecycle.State.STARTED, 22 | context: CoroutineContext = EmptyCoroutineContext 23 | ): T = 24 | collectAsStateWithLifecycle(lifecycleOwner, minActiveState, context).value 25 | -------------------------------------------------------------------------------- /app/src/main/kotlin/com/w2sv/filenavigator/ui/util/activityViewModel.kt: -------------------------------------------------------------------------------- 1 | package com.w2sv.filenavigator.ui.util 2 | 3 | import androidx.compose.runtime.Composable 4 | import androidx.compose.ui.platform.LocalContext 5 | import androidx.compose.ui.platform.LocalView 6 | import androidx.hilt.navigation.compose.hiltViewModel 7 | import androidx.lifecycle.ViewModel 8 | import androidx.lifecycle.ViewModelStoreOwner 9 | import androidx.lifecycle.findViewTreeViewModelStoreOwner 10 | import com.w2sv.androidutils.findActivity 11 | 12 | @Composable 13 | inline fun activityViewModel(): VM = 14 | hiltViewModel( 15 | LocalView.current.findViewTreeViewModelStoreOwner() 16 | ?: LocalContext.current.findActivity() as ViewModelStoreOwner 17 | ) 18 | -------------------------------------------------------------------------------- /app/src/main/kotlin/com/w2sv/filenavigator/ui/viewmodel/MoveHistoryViewModel.kt: -------------------------------------------------------------------------------- 1 | package com.w2sv.filenavigator.ui.viewmodel 2 | 3 | import androidx.lifecycle.ViewModel 4 | import androidx.lifecycle.viewModelScope 5 | import com.w2sv.domain.model.MovedFile 6 | import com.w2sv.domain.repository.MovedFileRepository 7 | import com.w2sv.domain.usecase.GetMoveHistoryUseCase 8 | import dagger.hilt.android.lifecycle.HiltViewModel 9 | import javax.inject.Inject 10 | import kotlinx.coroutines.Dispatchers 11 | import kotlinx.coroutines.Job 12 | import kotlinx.coroutines.flow.SharingStarted 13 | import kotlinx.coroutines.flow.stateIn 14 | import kotlinx.coroutines.launch 15 | 16 | @HiltViewModel 17 | class MoveHistoryViewModel @Inject constructor( 18 | private val movedFileRepository: MovedFileRepository, 19 | getMoveHistoryUseCase: GetMoveHistoryUseCase 20 | ) : 21 | ViewModel() { 22 | 23 | val moveHistory = getMoveHistoryUseCase 24 | .invoke() 25 | .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), emptyList()) 26 | 27 | fun launchHistoryDeletion(): Job = 28 | viewModelScope.launch(Dispatchers.IO) { 29 | movedFileRepository.deleteAll() 30 | } 31 | 32 | fun launchEntryDeletion(movedFile: MovedFile): Job = 33 | viewModelScope.launch(Dispatchers.IO) { 34 | movedFileRepository.delete(movedFile) 35 | } 36 | } 37 | -------------------------------------------------------------------------------- /app/src/main/play/contact-email.txt: -------------------------------------------------------------------------------- 1 | zangenbergjanek@googlemail.com 2 | -------------------------------------------------------------------------------- /app/src/main/play/default-language.txt: -------------------------------------------------------------------------------- 1 | en-US 2 | -------------------------------------------------------------------------------- /app/src/main/play/listings/en-US/full-description.txt: -------------------------------------------------------------------------------- 1 | Welcome to File Navigator - Your Ultimate File Sorting Solution! 2 | 3 | Are you tired of cluttered files on your device? Seeking a streamlined way to organize and manage them? Look no further! File Navigator is here to transform your file management experience. 4 | 5 | Key Features: 6 | 7 | 🔍 File Type Customization: File Navigator puts you in control. Select the specific file types you want to navigate, including Images, Videos, Audio, Text, APKs, PDFs, and Archives. 8 | 9 | 📁 Effortless File Organization: Configure the app to match your preferences. File Navigator allows you to define where different file types should be stored, ensuring they end up in the right place every time. 10 | 11 | 📬 Instant Notifications: When new files of your chosen file types are detected in your system, File Navigator sends you instant notifications. No more surprises - you'll always be up-to-date. 12 | 13 | 🚀 Seamless File Movement: With a simple tap from the notification, you can select the destination folder for the new file. No more manual searching and sorting. 14 | 15 | 🛡️ Precise Permissions: To deliver these exceptional features, File Navigator requests the 'access to manage all files' permission. Rest assured, this permission is solely used to move files you explicitly command to be moved. 16 | 17 | 📱 Simplify Your Digital Life: File Navigator is the ultimate solution to keep your files organized and readily accessible. Enjoy a clutter-free device and manage your files with ease. 18 | 19 | Don't let file chaos overwhelm you. Say goodbye to clutter and embrace the power of precise file management! 20 | -------------------------------------------------------------------------------- /app/src/main/play/listings/en-US/graphics/feature-graphic/1.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/w2sv/FileNavigator/60cc5c220659dca1cdade72c68d8eed742f8d528/app/src/main/play/listings/en-US/graphics/feature-graphic/1.png -------------------------------------------------------------------------------- /app/src/main/play/listings/en-US/graphics/icon/1.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/w2sv/FileNavigator/60cc5c220659dca1cdade72c68d8eed742f8d528/app/src/main/play/listings/en-US/graphics/icon/1.png -------------------------------------------------------------------------------- /app/src/main/play/listings/en-US/graphics/large-tablet-screenshots/1.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/w2sv/FileNavigator/60cc5c220659dca1cdade72c68d8eed742f8d528/app/src/main/play/listings/en-US/graphics/large-tablet-screenshots/1.jpg -------------------------------------------------------------------------------- /app/src/main/play/listings/en-US/graphics/large-tablet-screenshots/2.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/w2sv/FileNavigator/60cc5c220659dca1cdade72c68d8eed742f8d528/app/src/main/play/listings/en-US/graphics/large-tablet-screenshots/2.jpg -------------------------------------------------------------------------------- /app/src/main/play/listings/en-US/graphics/large-tablet-screenshots/3.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/w2sv/FileNavigator/60cc5c220659dca1cdade72c68d8eed742f8d528/app/src/main/play/listings/en-US/graphics/large-tablet-screenshots/3.jpg -------------------------------------------------------------------------------- /app/src/main/play/listings/en-US/graphics/phone-screenshots/1.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/w2sv/FileNavigator/60cc5c220659dca1cdade72c68d8eed742f8d528/app/src/main/play/listings/en-US/graphics/phone-screenshots/1.jpg -------------------------------------------------------------------------------- /app/src/main/play/listings/en-US/graphics/phone-screenshots/2.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/w2sv/FileNavigator/60cc5c220659dca1cdade72c68d8eed742f8d528/app/src/main/play/listings/en-US/graphics/phone-screenshots/2.jpg -------------------------------------------------------------------------------- /app/src/main/play/listings/en-US/graphics/phone-screenshots/3.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/w2sv/FileNavigator/60cc5c220659dca1cdade72c68d8eed742f8d528/app/src/main/play/listings/en-US/graphics/phone-screenshots/3.jpg -------------------------------------------------------------------------------- /app/src/main/play/listings/en-US/graphics/tablet-screenshots/1.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/w2sv/FileNavigator/60cc5c220659dca1cdade72c68d8eed742f8d528/app/src/main/play/listings/en-US/graphics/tablet-screenshots/1.jpg -------------------------------------------------------------------------------- /app/src/main/play/listings/en-US/graphics/tablet-screenshots/2.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/w2sv/FileNavigator/60cc5c220659dca1cdade72c68d8eed742f8d528/app/src/main/play/listings/en-US/graphics/tablet-screenshots/2.jpg -------------------------------------------------------------------------------- /app/src/main/play/listings/en-US/graphics/tablet-screenshots/3.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/w2sv/FileNavigator/60cc5c220659dca1cdade72c68d8eed742f8d528/app/src/main/play/listings/en-US/graphics/tablet-screenshots/3.jpg -------------------------------------------------------------------------------- /app/src/main/play/listings/en-US/short-description.txt: -------------------------------------------------------------------------------- 1 | The missing link between Android and a well-sorted file system 2 | -------------------------------------------------------------------------------- /app/src/main/play/listings/en-US/title.txt: -------------------------------------------------------------------------------- 1 | File Navigator 2 | -------------------------------------------------------------------------------- /app/src/main/play/release-notes/en-US/production.txt: -------------------------------------------------------------------------------- 1 | - Enable creation of custom file types 2 | - Enable configuration of file type colors, as well as the exclusion of file extensions for non-media types 3 | - Add 'html' to Text file type extensions 4 | - Enabling and disabling of file types may now be done via a single, cohesive bottom sheet 5 | - UI polishments 6 | - Performance improvements 7 | -------------------------------------------------------------------------------- /app/src/main/res/font/raleway_black.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/w2sv/FileNavigator/60cc5c220659dca1cdade72c68d8eed742f8d528/app/src/main/res/font/raleway_black.ttf -------------------------------------------------------------------------------- /app/src/main/res/font/raleway_bold.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/w2sv/FileNavigator/60cc5c220659dca1cdade72c68d8eed742f8d528/app/src/main/res/font/raleway_bold.ttf -------------------------------------------------------------------------------- /app/src/main/res/font/raleway_extrabold.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/w2sv/FileNavigator/60cc5c220659dca1cdade72c68d8eed742f8d528/app/src/main/res/font/raleway_extrabold.ttf -------------------------------------------------------------------------------- /app/src/main/res/font/raleway_extralight.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/w2sv/FileNavigator/60cc5c220659dca1cdade72c68d8eed742f8d528/app/src/main/res/font/raleway_extralight.ttf -------------------------------------------------------------------------------- /app/src/main/res/font/raleway_light.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/w2sv/FileNavigator/60cc5c220659dca1cdade72c68d8eed742f8d528/app/src/main/res/font/raleway_light.ttf -------------------------------------------------------------------------------- /app/src/main/res/font/raleway_medium.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/w2sv/FileNavigator/60cc5c220659dca1cdade72c68d8eed742f8d528/app/src/main/res/font/raleway_medium.ttf -------------------------------------------------------------------------------- /app/src/main/res/font/raleway_regular.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/w2sv/FileNavigator/60cc5c220659dca1cdade72c68d8eed742f8d528/app/src/main/res/font/raleway_regular.ttf -------------------------------------------------------------------------------- /app/src/main/res/font/raleway_semibold.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/w2sv/FileNavigator/60cc5c220659dca1cdade72c68d8eed742f8d528/app/src/main/res/font/raleway_semibold.ttf -------------------------------------------------------------------------------- /app/src/main/res/font/raleway_thin.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/w2sv/FileNavigator/60cc5c220659dca1cdade72c68d8eed742f8d528/app/src/main/res/font/raleway_thin.ttf -------------------------------------------------------------------------------- /app/src/main/res/mipmap-anydpi/ic_launcher.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | -------------------------------------------------------------------------------- /app/src/main/res/mipmap-anydpi/ic_launcher_round.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | -------------------------------------------------------------------------------- /app/src/main/res/mipmap-hdpi/ic_launcher.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/w2sv/FileNavigator/60cc5c220659dca1cdade72c68d8eed742f8d528/app/src/main/res/mipmap-hdpi/ic_launcher.webp -------------------------------------------------------------------------------- /app/src/main/res/mipmap-hdpi/ic_launcher_round.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/w2sv/FileNavigator/60cc5c220659dca1cdade72c68d8eed742f8d528/app/src/main/res/mipmap-hdpi/ic_launcher_round.webp -------------------------------------------------------------------------------- /app/src/main/res/mipmap-mdpi/ic_launcher.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/w2sv/FileNavigator/60cc5c220659dca1cdade72c68d8eed742f8d528/app/src/main/res/mipmap-mdpi/ic_launcher.webp -------------------------------------------------------------------------------- /app/src/main/res/mipmap-mdpi/ic_launcher_round.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/w2sv/FileNavigator/60cc5c220659dca1cdade72c68d8eed742f8d528/app/src/main/res/mipmap-mdpi/ic_launcher_round.webp -------------------------------------------------------------------------------- /app/src/main/res/mipmap-xhdpi/ic_launcher.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/w2sv/FileNavigator/60cc5c220659dca1cdade72c68d8eed742f8d528/app/src/main/res/mipmap-xhdpi/ic_launcher.webp -------------------------------------------------------------------------------- /app/src/main/res/mipmap-xhdpi/ic_launcher_round.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/w2sv/FileNavigator/60cc5c220659dca1cdade72c68d8eed742f8d528/app/src/main/res/mipmap-xhdpi/ic_launcher_round.webp -------------------------------------------------------------------------------- /app/src/main/res/mipmap-xxhdpi/ic_launcher.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/w2sv/FileNavigator/60cc5c220659dca1cdade72c68d8eed742f8d528/app/src/main/res/mipmap-xxhdpi/ic_launcher.webp -------------------------------------------------------------------------------- /app/src/main/res/mipmap-xxhdpi/ic_launcher_round.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/w2sv/FileNavigator/60cc5c220659dca1cdade72c68d8eed742f8d528/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.webp -------------------------------------------------------------------------------- /app/src/main/res/mipmap-xxxhdpi/ic_launcher.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/w2sv/FileNavigator/60cc5c220659dca1cdade72c68d8eed742f8d528/app/src/main/res/mipmap-xxxhdpi/ic_launcher.webp -------------------------------------------------------------------------------- /app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/w2sv/FileNavigator/60cc5c220659dca1cdade72c68d8eed742f8d528/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.webp -------------------------------------------------------------------------------- /app/src/main/res/values/themes.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 9 | -------------------------------------------------------------------------------- /app/src/main/res/xml/backup_rules.xml: -------------------------------------------------------------------------------- 1 | 8 | 9 | 13 | -------------------------------------------------------------------------------- /app/src/main/res/xml/data_extraction_rules.xml: -------------------------------------------------------------------------------- 1 | 6 | 7 | 8 | 12 | 13 | 19 | 20 | -------------------------------------------------------------------------------- /app/src/test/kotlin/com/w2sv/filenavigator/ui/screen/navigatorsettings/components/FileTypeCreationDialogKtTest.kt: -------------------------------------------------------------------------------- 1 | package com.w2sv.filenavigator.ui.screen.navigatorsettings.components 2 | 3 | class FileTypeCreationDialogKtTest { 4 | 5 | // @Test 6 | // fun `test get IsMediaFileTypeExtension`() { 7 | // fun test(expectedMediaFileType: PresetFileType.Media?, extension: String) { 8 | // val expectedResult = expectedMediaFileType?.let { FileExtensionInvalidityReason.IsNonExcludableFileTypeExtension(extension, it) } 9 | // assertEquals(expectedResult, IsNonExcludableFileTypeExtension.get(extension)) 10 | // } 11 | // 12 | // test(PresetFileType.Image, "jpg") 13 | // test(PresetFileType.Audio, "mp3") 14 | // test(null, "xml") 15 | // } 16 | } 17 | -------------------------------------------------------------------------------- /app/src/test/kotlin/com/w2sv/filenavigator/ui/util/MockInvalidityReason.kt: -------------------------------------------------------------------------------- 1 | package com.w2sv.filenavigator.ui.util 2 | 3 | enum class MockInvalidityReason(override val errorMessageRes: Int) : InputInvalidityReason { 4 | ContainsSpecialCharacters(0) 5 | } 6 | -------------------------------------------------------------------------------- /app/src/test/kotlin/com/w2sv/filenavigator/ui/util/ProxyTextEditorTest.kt: -------------------------------------------------------------------------------- 1 | package com.w2sv.filenavigator.ui.util 2 | 3 | import com.w2sv.common.util.containsSpecialCharacter 4 | import junit.framework.TestCase 5 | import org.junit.Before 6 | import org.junit.Test 7 | import org.junit.runner.RunWith 8 | import org.robolectric.RobolectricTestRunner 9 | 10 | @RunWith(RobolectricTestRunner::class) 11 | class ProxyTextEditorTest { 12 | 13 | private lateinit var proxyEditor: ProxyTextEditor 14 | private var proxyValue = "" 15 | 16 | @Before 17 | fun setUp() { 18 | proxyEditor = ProxyTextEditor( 19 | getValue = { proxyValue }, 20 | setValue = { proxyValue = it }, 21 | processInput = { it.trim() }, 22 | findInvalidityReason = { if (it.containsSpecialCharacter()) MockInvalidityReason.ContainsSpecialCharacters else null } 23 | ) 24 | } 25 | 26 | @Test 27 | fun `proxyEditor updates value correctly`() { 28 | proxyEditor.update(" new proxy input ") 29 | TestCase.assertEquals("new proxy input", proxyValue) 30 | } 31 | 32 | @Test 33 | fun `proxyEditor identifies valid input`() { 34 | proxyEditor.update("valid input") 35 | TestCase.assertTrue(proxyEditor.isValid) 36 | } 37 | 38 | @Test 39 | fun `proxyEditor identifies invalid input`() { 40 | proxyEditor.update("sdaf-x") 41 | TestCase.assertFalse(proxyEditor.isValid) 42 | TestCase.assertEquals( 43 | MockInvalidityReason.ContainsSpecialCharacters, 44 | proxyEditor.invalidityReason 45 | ) 46 | } 47 | 48 | @Test 49 | fun `proxyEditor pop resets value`() { 50 | proxyEditor.update("new proxy value") 51 | val popped = proxyEditor.pop() 52 | TestCase.assertEquals("new proxy value", popped) 53 | TestCase.assertEquals("", proxyValue) 54 | } 55 | } 56 | -------------------------------------------------------------------------------- /app/src/test/kotlin/com/w2sv/filenavigator/ui/util/StatefulTextEditorTest.kt: -------------------------------------------------------------------------------- 1 | package com.w2sv.filenavigator.ui.util 2 | 3 | import com.w2sv.common.util.containsSpecialCharacter 4 | import junit.framework.TestCase.assertEquals 5 | import junit.framework.TestCase.assertFalse 6 | import junit.framework.TestCase.assertTrue 7 | import org.junit.Before 8 | import org.junit.Test 9 | import org.junit.runner.RunWith 10 | import org.robolectric.RobolectricTestRunner 11 | 12 | @RunWith(RobolectricTestRunner::class) 13 | class StatefulTextEditorTest { 14 | 15 | private lateinit var statefulEditor: StatefulTextEditor 16 | 17 | @Before 18 | fun setUp() { 19 | statefulEditor = StatefulTextEditor( 20 | initialText = "hello", 21 | processInput = { it.trim() }, 22 | findInvalidityReason = { if (it.containsSpecialCharacter()) MockInvalidityReason.ContainsSpecialCharacters else null } 23 | ) 24 | } 25 | 26 | @Test 27 | fun `statefulEditor updates value correctly`() { 28 | statefulEditor.update(" new input ") 29 | assertEquals("new input", statefulEditor.getValue()) 30 | } 31 | 32 | @Test 33 | fun `statefulEditor identifies valid input`() { 34 | statefulEditor.update("valid input") 35 | assertTrue(statefulEditor.isValid) 36 | } 37 | 38 | @Test 39 | fun `statefulEditor identifies invalid input`() { 40 | statefulEditor.update(".asdfa") 41 | assertFalse(statefulEditor.isValid) 42 | assertEquals(MockInvalidityReason.ContainsSpecialCharacters, statefulEditor.invalidityReason) 43 | } 44 | 45 | @Test 46 | fun `statefulEditor pop resets value`() { 47 | statefulEditor.update("new value") 48 | val popped = statefulEditor.pop() 49 | assertEquals("new value", popped) 50 | assertEquals("hello", statefulEditor.getValue()) 51 | } 52 | } 53 | -------------------------------------------------------------------------------- /benchmarking/.gitignore: -------------------------------------------------------------------------------- 1 | /build -------------------------------------------------------------------------------- /benchmarking/build.gradle.kts: -------------------------------------------------------------------------------- 1 | import com.android.build.api.dsl.ManagedVirtualDevice 2 | 3 | plugins { 4 | alias(libs.plugins.ktlint) 5 | alias(libs.plugins.android.test) 6 | alias(libs.plugins.kotlin.android) 7 | alias(libs.plugins.baselineprofile) 8 | } 9 | 10 | val mvdName = "Pixel 6 API 33" 11 | 12 | android { 13 | namespace = "com.filenavigator.benchmarking" 14 | compileSdk = libs.versions.compileSdk.get().toInt() 15 | 16 | defaultConfig { 17 | minSdk = 28 18 | targetSdk = libs.versions.compileSdk.get().toInt() 19 | 20 | testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" 21 | } 22 | 23 | compileOptions { 24 | sourceCompatibility = JavaVersion.VERSION_17 25 | targetCompatibility = JavaVersion.VERSION_17 26 | } 27 | 28 | testOptions.managedDevices.allDevices { 29 | @Suppress("UnstableApiUsage") 30 | create(mvdName) { 31 | device = "Pixel 6" 32 | apiLevel = 33 33 | systemImageSource = "aosp" 34 | } 35 | } 36 | 37 | targetProjectPath = ":app" 38 | } 39 | 40 | // Baseline profile configuration: https://developer.android.com/topic/performance/baselineprofiles/configure-baselineprofiles 41 | baselineProfile { 42 | @Suppress("UnstableApiUsage") 43 | enableEmulatorDisplay = false 44 | useConnectedDevices = false 45 | managedDevices += mvdName 46 | } 47 | 48 | dependencies { 49 | implementation(libs.androidx.test.ext.junit) 50 | implementation(libs.androidx.benchmark.macro.junit4) 51 | implementation(libs.androidx.test.runner) 52 | } 53 | -------------------------------------------------------------------------------- /benchmarking/src/main/AndroidManifest.xml: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /benchmarking/src/main/kotlin/com/w2sv/filenavigator/UiDeviceExt.kt: -------------------------------------------------------------------------------- 1 | package com.w2sv.filenavigator 2 | 3 | import androidx.test.uiautomator.By 4 | import androidx.test.uiautomator.Direction 5 | import androidx.test.uiautomator.UiDevice 6 | import java.io.ByteArrayOutputStream 7 | 8 | private fun UiDevice.dumpWindowHierarchy(): String { 9 | val outputStream = ByteArrayOutputStream() 10 | dumpWindowHierarchy(outputStream) 11 | return outputStream.toString("UTF-8") 12 | } 13 | 14 | private fun UiDevice.flingListDown(resourceName: String) { 15 | findObject(By.res(resourceName)).fling(Direction.DOWN) 16 | waitForIdle() 17 | } 18 | -------------------------------------------------------------------------------- /build-logic/.gitignore: -------------------------------------------------------------------------------- 1 | /build 2 | /.gradle 3 | .kotlin 4 | convention/build 5 | -------------------------------------------------------------------------------- /build-logic/convention/build.gradle.kts: -------------------------------------------------------------------------------- 1 | import org.jetbrains.kotlin.gradle.dsl.JvmTarget 2 | 3 | plugins { 4 | `kotlin-dsl` 5 | alias(libs.plugins.ktlint) 6 | } 7 | 8 | java { 9 | sourceCompatibility = JavaVersion.VERSION_17 10 | targetCompatibility = JavaVersion.VERSION_17 11 | } 12 | 13 | kotlin { 14 | compilerOptions { 15 | jvmTarget = JvmTarget.JVM_17 16 | } 17 | } 18 | 19 | dependencies { 20 | compileOnly(libs.android.gradlePlugin) 21 | compileOnly(libs.kotlin.gradlePlugin) 22 | compileOnly(libs.ksp.gradlePlugin) 23 | } 24 | 25 | tasks { 26 | validatePlugins { 27 | enableStricterValidation = true 28 | failOnWarning = true 29 | } 30 | } 31 | 32 | gradlePlugin { 33 | plugins { 34 | register("library") { 35 | id = "filenavigator.library" 36 | implementationClass = "LibraryConventionPlugin" 37 | } 38 | register("application") { 39 | id = "filenavigator.application" 40 | implementationClass = "ApplicationConventionPlugin" 41 | } 42 | register("hilt") { 43 | id = "filenavigator.hilt" 44 | implementationClass = "HiltConventionPlugin" 45 | } 46 | register("room") { 47 | id = "filenavigator.room" 48 | implementationClass = "RoomConventionPlugin" 49 | } 50 | } 51 | } 52 | -------------------------------------------------------------------------------- /build-logic/convention/src/main/kotlin/ApplicationConventionPlugin.kt: -------------------------------------------------------------------------------- 1 | import helpers.Namespace 2 | import helpers.applyBaseConfig 3 | import helpers.applyPlugins 4 | import helpers.catalog 5 | import org.gradle.api.Plugin 6 | import org.gradle.api.Project 7 | 8 | class ApplicationConventionPlugin : Plugin { 9 | override fun apply(target: Project) { 10 | with(target) { 11 | pluginManager.applyPlugins("android-application", "kotlin-android", catalog = catalog) 12 | applyBaseConfig(Namespace.Manual("com.w2sv.filenavigator")) 13 | } 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /build-logic/convention/src/main/kotlin/HiltConventionPlugin.kt: -------------------------------------------------------------------------------- 1 | import helpers.applyPlugins 2 | import helpers.catalog 3 | import helpers.library 4 | import org.gradle.api.Plugin 5 | import org.gradle.api.Project 6 | import org.gradle.kotlin.dsl.dependencies 7 | 8 | class HiltConventionPlugin : Plugin { 9 | override fun apply(target: Project) { 10 | with(target) { 11 | pluginManager.applyPlugins("ksp", "hilt", catalog = catalog) 12 | 13 | dependencies { 14 | "implementation"(library("google.hilt")) 15 | "ksp"(library("google.hilt.compiler")) 16 | } 17 | } 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /build-logic/convention/src/main/kotlin/LibraryConventionPlugin.kt: -------------------------------------------------------------------------------- 1 | import helpers.applyBaseConfig 2 | import helpers.applyPlugins 3 | import helpers.catalog 4 | import org.gradle.api.Plugin 5 | import org.gradle.api.Project 6 | 7 | class LibraryConventionPlugin : Plugin { 8 | override fun apply(target: Project) { 9 | with(target) { 10 | pluginManager.applyPlugins("android-library", "kotlin-android", catalog = catalog) 11 | applyBaseConfig() 12 | } 13 | } 14 | } 15 | -------------------------------------------------------------------------------- /build-logic/convention/src/main/kotlin/RoomConventionPlugin.kt: -------------------------------------------------------------------------------- 1 | import com.google.devtools.ksp.gradle.KspExtension 2 | import helpers.applyPlugins 3 | import helpers.catalog 4 | import helpers.library 5 | import org.gradle.api.Plugin 6 | import org.gradle.api.Project 7 | import org.gradle.api.tasks.InputDirectory 8 | import org.gradle.api.tasks.PathSensitive 9 | import org.gradle.api.tasks.PathSensitivity 10 | import org.gradle.kotlin.dsl.configure 11 | import org.gradle.kotlin.dsl.dependencies 12 | import org.gradle.process.CommandLineArgumentProvider 13 | import java.io.File 14 | 15 | class RoomConventionPlugin : Plugin { 16 | override fun apply(target: Project) { 17 | with(target) { 18 | pluginManager.applyPlugins("ksp", catalog = catalog) 19 | 20 | extensions.configure { 21 | // The schemas directory contains a schema file for each version of the Room database. 22 | // This is required to enable Room auto migrations. 23 | // See https://developer.android.com/reference/kotlin/androidx/room/AutoMigration. 24 | arg(RoomSchemaArgProvider(File(projectDir, "schemas"))) 25 | } 26 | 27 | dependencies { 28 | "implementation"(library("androidx.room.runtime")) 29 | "implementation"(library("androidx.room.ktx")) 30 | "ksp"(library("androidx.room.compiler")) 31 | } 32 | } 33 | } 34 | 35 | /** 36 | * https://issuetracker.google.com/issues/132245929 37 | * [Export schemas](https://developer.android.com/training/data-storage/room/migrating-db-versions#export-schemas) 38 | */ 39 | class RoomSchemaArgProvider( 40 | @get:InputDirectory 41 | @get:PathSensitive(PathSensitivity.RELATIVE) 42 | val schemaDir: File, 43 | ) : CommandLineArgumentProvider { 44 | override fun asArguments() = listOf("room.schemaLocation=${schemaDir.path}") 45 | } 46 | } 47 | -------------------------------------------------------------------------------- /build-logic/convention/src/main/kotlin/helpers/Extensions.kt: -------------------------------------------------------------------------------- 1 | package helpers 2 | 3 | import org.gradle.api.Project 4 | import org.gradle.api.artifacts.MinimalExternalModuleDependency 5 | import org.gradle.api.artifacts.VersionCatalog 6 | import org.gradle.api.artifacts.VersionCatalogsExtension 7 | import org.gradle.api.plugins.PluginManager 8 | import org.gradle.api.provider.Provider 9 | import org.gradle.kotlin.dsl.getByType 10 | 11 | /** 12 | * @param alias The version alias that will be passed to [VersionCatalog.findVersion] 13 | */ 14 | internal fun VersionCatalog.findVersionInt(alias: String): Int = 15 | findVersion(alias).get().requiredVersion.toInt() 16 | 17 | /** 18 | * @param alias The plugin alias that will be passed to [VersionCatalog.findPlugin] 19 | */ 20 | internal fun VersionCatalog.findPluginId(alias: String): String = 21 | findPlugin(alias).get().get().pluginId 22 | 23 | internal val Project.catalog 24 | get(): VersionCatalog = extensions.getByType().named("libs") 25 | 26 | /** 27 | * @param alias The library alias that will be passed to [VersionCatalog.findLibrary] 28 | */ 29 | internal fun Project.library(alias: String): Provider = 30 | catalog.findLibrary(alias).get() 31 | 32 | internal fun PluginManager.applyPlugins(vararg pluginId: String, catalog: VersionCatalog) { 33 | pluginId.forEach { 34 | apply(catalog.findPluginId(it)) 35 | } 36 | } 37 | -------------------------------------------------------------------------------- /build-logic/gradle.properties: -------------------------------------------------------------------------------- 1 | # Gradle properties are not passed to included builds https://github.com/gradle/gradle/issues/2534 2 | org.gradle.parallel=true 3 | org.gradle.caching=true 4 | org.gradle.configureondemand=true 5 | org.gradle.configuration-cache=true 6 | org.gradle.configuration-cache.parallel=true 7 | -------------------------------------------------------------------------------- /build-logic/settings.gradle.kts: -------------------------------------------------------------------------------- 1 | @file:Suppress("UnstableApiUsage") 2 | 3 | enableFeaturePreview("TYPESAFE_PROJECT_ACCESSORS") 4 | 5 | dependencyResolutionManagement { 6 | repositories { 7 | mavenCentral() 8 | google() 9 | gradlePluginPortal() 10 | } 11 | versionCatalogs { 12 | create("libs") { 13 | from(files("../gradle/libs.versions.toml")) 14 | } 15 | } 16 | } 17 | 18 | rootProject.name = "build-logic" 19 | include(":convention") 20 | -------------------------------------------------------------------------------- /build.gradle.kts: -------------------------------------------------------------------------------- 1 | import com.github.benmanes.gradle.versions.updates.DependencyUpdatesTask 2 | 3 | plugins { 4 | alias(libs.plugins.android.application) apply false 5 | alias(libs.plugins.android.library) apply false 6 | alias(libs.plugins.android.test) apply false 7 | alias(libs.plugins.kotlin.android) apply false 8 | alias(libs.plugins.kotlin.compose.compiler) apply false 9 | alias(libs.plugins.kotlin.parcelize) apply false 10 | alias(libs.plugins.hilt) apply false 11 | alias(libs.plugins.ksp) apply false 12 | alias(libs.plugins.play) apply false 13 | alias(libs.plugins.baselineprofile) apply false 14 | alias(libs.plugins.ktlint) 15 | alias(libs.plugins.versions) 16 | alias(libs.plugins.versionCatalogUpdate) 17 | } 18 | 19 | tasks.withType { 20 | checkForGradleUpdate = true 21 | outputFormatter = "json" 22 | outputDir = "build/dependencyUpdates" 23 | reportfileName = "report" 24 | 25 | rejectVersionIf { 26 | isNonStable(candidate.version) && !isNonStable(currentVersion) 27 | } 28 | } 29 | 30 | // Taken from https://github.com/ben-manes/gradle-versions-plugin#rejectversionsif-and-componentselection 31 | private fun isNonStable(version: String): Boolean { 32 | val stableKeyword = listOf("RELEASE", "FINAL", "GA").any { version.uppercase().contains(it) } 33 | val regex = "^[0-9,.v-]+(-r)?$".toRegex() 34 | val isStable = stableKeyword || regex.matches(version) 35 | return isStable.not() 36 | } 37 | -------------------------------------------------------------------------------- /core/common/.gitignore: -------------------------------------------------------------------------------- 1 | /build -------------------------------------------------------------------------------- /core/common/build.gradle.kts: -------------------------------------------------------------------------------- 1 | plugins { 2 | alias(libs.plugins.filenavigator.library) 3 | alias(libs.plugins.filenavigator.hilt) 4 | alias(libs.plugins.kotlin.parcelize) 5 | } 6 | 7 | dependencies { 8 | implementation(libs.w2sv.androidutils.core) 9 | implementation(libs.w2sv.kotlinutils) 10 | implementation(libs.w2sv.simplestorage) 11 | implementation(libs.slimber) 12 | 13 | implementation(libs.androidx.core) 14 | 15 | // Unit tests 16 | testImplementation(projects.core.test) 17 | } 18 | -------------------------------------------------------------------------------- /core/common/src/main/kotlin/com/w2sv/common/AppUrl.kt: -------------------------------------------------------------------------------- 1 | package com.w2sv.common 2 | 3 | object AppUrl { 4 | const val LICENSE = "https://github.com/w2sv/FileNavigator/blob/main/LICENSE" 5 | const val PRIVACY_POLICY = "https://github.com/w2sv/FileNavigator/blob/main/PRIVACY-POLICY.md" 6 | const val GITHUB_REPOSITORY = "https://github.com/w2sv/FileNavigator" 7 | const val CREATE_ISSUE = "https://github.com/w2sv/FileNavigator/issues/new" 8 | const val GOOGLE_PLAY_DEVELOPER_PAGE = 9 | "https://play.google.com/store/apps/dev?id=6884111703871536890" 10 | const val DONATE = "https://buymeacoffee.com/w2sv" 11 | const val PLAYSTORE_LISTING = "https://play.google.com/store/apps/details?id=com.w2sv.filenavigator" 12 | } 13 | -------------------------------------------------------------------------------- /core/common/src/main/kotlin/com/w2sv/common/di/AppDispatcher.kt: -------------------------------------------------------------------------------- 1 | package com.w2sv.common.di 2 | 3 | import javax.inject.Qualifier 4 | 5 | @Qualifier 6 | @Retention(AnnotationRetention.RUNTIME) 7 | annotation class GlobalScope(val appDispatcher: AppDispatcher) 8 | 9 | enum class AppDispatcher { 10 | Default, 11 | IO 12 | } 13 | -------------------------------------------------------------------------------- /core/common/src/main/kotlin/com/w2sv/common/di/CommonModule.kt: -------------------------------------------------------------------------------- 1 | package com.w2sv.common.di 2 | 3 | import android.content.Context 4 | import android.os.PowerManager 5 | import dagger.Module 6 | import dagger.Provides 7 | import dagger.hilt.InstallIn 8 | import dagger.hilt.android.qualifiers.ApplicationContext 9 | import dagger.hilt.components.SingletonComponent 10 | import javax.inject.Singleton 11 | import kotlinx.coroutines.CoroutineScope 12 | import kotlinx.coroutines.Dispatchers 13 | 14 | @InstallIn(SingletonComponent::class) 15 | @Module 16 | object CommonModule { 17 | 18 | @Provides 19 | @GlobalScope(AppDispatcher.Default) 20 | fun defaultScope(): CoroutineScope = 21 | CoroutineScope(Dispatchers.Default) 22 | 23 | @Provides 24 | @GlobalScope(AppDispatcher.IO) 25 | fun ioScope(): CoroutineScope = 26 | CoroutineScope(Dispatchers.IO) 27 | 28 | @Provides 29 | @Singleton 30 | fun powerManager(@ApplicationContext context: Context): PowerManager = 31 | context.getSystemService(PowerManager::class.java) 32 | } 33 | -------------------------------------------------------------------------------- /core/common/src/main/kotlin/com/w2sv/common/util/ContentResolver.kt: -------------------------------------------------------------------------------- 1 | package com.w2sv.common.util 2 | 3 | import android.content.ContentResolver 4 | import android.content.Context 5 | import android.content.Intent 6 | import android.graphics.Bitmap 7 | import android.net.Uri 8 | import com.w2sv.androidutils.graphics.loadBitmap 9 | import com.w2sv.androidutils.hasPermission 10 | import java.io.FileNotFoundException 11 | import slimber.log.e 12 | 13 | fun ContentResolver.loadBitmapWithFileNotFoundHandling(uri: Uri): Bitmap? = 14 | try { 15 | loadBitmap(uri) 16 | } catch (e: FileNotFoundException) { 17 | e(e) 18 | null 19 | } 20 | 21 | /** 22 | * Remedies "Failed query: java.lang.SecurityException: Permission Denial: opening provider com.android.externalstorage.ExternalStorageProvider from ProcessRecord{6fc17ee 8097:com.w2sv.filenavigator.debug/u0a753} (pid=8097, uid=10753) requires that you obtain access using ACTION_OPEN_DOCUMENT or related APIs" 23 | */ 24 | fun ContentResolver.takePersistableReadAndWriteUriPermission(treeUri: Uri) { 25 | takePersistableUriPermission( 26 | treeUri, 27 | Intent.FLAG_GRANT_READ_URI_PERMISSION or Intent.FLAG_GRANT_WRITE_URI_PERMISSION 28 | ) 29 | } 30 | 31 | fun Uri.hasReadAndWritePermission(context: Context): Boolean = 32 | hasPermission(context, Intent.FLAG_GRANT_READ_URI_PERMISSION) && hasPermission( 33 | context, 34 | Intent.FLAG_GRANT_WRITE_URI_PERMISSION 35 | ) 36 | -------------------------------------------------------------------------------- /core/common/src/main/kotlin/com/w2sv/common/util/DocumentFile.kt: -------------------------------------------------------------------------------- 1 | package com.w2sv.common.util 2 | 3 | import android.content.Context 4 | import androidx.documentfile.provider.DocumentFile 5 | import com.anggrayudi.storage.file.child 6 | 7 | fun DocumentFile.hasChild( 8 | context: Context, 9 | path: String, 10 | requiresWriteAccess: Boolean = false 11 | ): Boolean = 12 | child(context, path, requiresWriteAccess) != null 13 | -------------------------------------------------------------------------------- /core/common/src/main/kotlin/com/w2sv/common/util/ExternalStorageManager.kt: -------------------------------------------------------------------------------- 1 | package com.w2sv.common.util 2 | 3 | import android.content.Context 4 | import android.content.Intent 5 | import android.net.Uri 6 | import android.os.Build 7 | import android.os.Environment 8 | import android.provider.Settings 9 | import androidx.annotation.RequiresApi 10 | import com.w2sv.androidutils.os.manageExternalStoragePermissionRequired 11 | 12 | @RequiresApi(Build.VERSION_CODES.R) 13 | fun goToManageExternalStorageSettings(context: Context) { 14 | context.startActivity( 15 | Intent( 16 | Settings.ACTION_MANAGE_APP_ALL_FILES_ACCESS_PERMISSION, 17 | Uri.fromParts("package", context.packageName, null) 18 | ) 19 | ) 20 | } 21 | 22 | val isExternalStorageManger: Boolean 23 | get() = !manageExternalStoragePermissionRequired || Environment.isExternalStorageManager() 24 | -------------------------------------------------------------------------------- /core/common/src/main/kotlin/com/w2sv/common/util/Formatting.kt: -------------------------------------------------------------------------------- 1 | package com.w2sv.common.util 2 | 3 | import java.text.NumberFormat 4 | import java.util.Locale 5 | 6 | fun String.removeSlashSuffix(): String = 7 | removeSuffix("/") 8 | 9 | fun String.slashPrefixed(): String = 10 | "/$this" 11 | 12 | fun String.lineBreakSuffixed(): String = 13 | "$this\n" 14 | 15 | fun String.colonSuffixed(): String = 16 | "$this:" 17 | 18 | fun formattedFileSize(bytes: Long, locale: Locale = Locale.getDefault()): String { 19 | if (bytes in -999..999) { 20 | return "$bytes B" 21 | } 22 | val dimensionPrefixIterator = "kMGTPE".iterator() 23 | var dimensionPrefix = dimensionPrefixIterator.next() 24 | var byteCount = bytes.toDouble() 25 | while (byteCount <= -999_950 || byteCount >= 999_950) { 26 | byteCount /= 1000 27 | dimensionPrefix = dimensionPrefixIterator.next() 28 | } 29 | val numberFormat = NumberFormat.getNumberInstance(locale).apply { 30 | maximumFractionDigits = 3 31 | } 32 | return "${numberFormat.format(byteCount / 1000)} ${dimensionPrefix}B" 33 | } 34 | -------------------------------------------------------------------------------- /core/common/src/main/kotlin/com/w2sv/common/util/Logging.kt: -------------------------------------------------------------------------------- 1 | package com.w2sv.common.util 2 | 3 | import slimber.log.i 4 | 5 | inline fun T.log(makeMessage: (T) -> String = { it.toString() }): T = 6 | also { i { makeMessage(this) } } 7 | 8 | val Any.logIdentifier 9 | get() = this::class.java.simpleName 10 | -------------------------------------------------------------------------------- /core/common/src/main/kotlin/com/w2sv/common/util/LoggingBroadcastReceiver.kt: -------------------------------------------------------------------------------- 1 | package com.w2sv.common.util 2 | 3 | import android.content.BroadcastReceiver 4 | import android.content.Context 5 | import android.content.Intent 6 | import androidx.annotation.CallSuper 7 | import com.w2sv.androidutils.os.logString 8 | import slimber.log.i 9 | 10 | /** 11 | * A [BroadcastReceiver] that logs upon its [onReceive] being called. 12 | */ 13 | abstract class LoggingBroadcastReceiver : BroadcastReceiver() { 14 | 15 | @CallSuper 16 | override fun onReceive(context: Context, intent: Intent) { 17 | i { "$logIdentifier.onReceive | ${intent.logString()}" } 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /core/common/src/main/kotlin/com/w2sv/common/util/LoggingComponentActivity.kt: -------------------------------------------------------------------------------- 1 | package com.w2sv.common.util 2 | 3 | import android.os.Bundle 4 | import androidx.activity.ComponentActivity 5 | import slimber.log.i 6 | 7 | /** 8 | * A [ComponentActivity] that logs upon reaching its most important lifecycle states. 9 | */ 10 | abstract class LoggingComponentActivity : ComponentActivity() { 11 | 12 | private val logIdentifier 13 | get() = this::class.simpleName 14 | 15 | override fun onCreate(savedInstanceState: Bundle?) { 16 | super.onCreate(savedInstanceState) 17 | i { "$logIdentifier onCreate" } 18 | } 19 | 20 | override fun onStart() { 21 | super.onStart() 22 | i { "$logIdentifier onStart" } 23 | } 24 | 25 | override fun onResume() { 26 | super.onResume() 27 | i { "$logIdentifier onResume" } 28 | } 29 | 30 | override fun onPause() { 31 | super.onPause() 32 | i { "$logIdentifier onPause" } 33 | } 34 | 35 | override fun onStop() { 36 | super.onStop() 37 | i { "$logIdentifier onStop" } 38 | } 39 | 40 | override fun onDestroy() { 41 | super.onDestroy() 42 | i { "$logIdentifier onDestroy" } 43 | } 44 | } 45 | -------------------------------------------------------------------------------- /core/common/src/main/kotlin/com/w2sv/common/util/Map.kt: -------------------------------------------------------------------------------- 1 | package com.w2sv.common.util 2 | 3 | import com.w2sv.kotlinutils.filterToSet 4 | 5 | // TODO: kotlinutils 6 | 7 | fun Map.filterKeysByValue(predicate: (V) -> Boolean): List = 8 | keys.filter { predicate(getValue(it)) } 9 | 10 | fun Map.filterKeysByValueToSet(predicate: (V) -> Boolean): Set = 11 | keys.filterToSet { predicate(getValue(it)) } 12 | 13 | fun syncMapKeys( 14 | source: Map, 15 | target: MutableMap, 16 | valueOnAddedKeys: V 17 | ) { 18 | val addedKeys = source.keys - target.keys 19 | val removedKeys = target.keys - source.keys 20 | 21 | addedKeys.forEach { target[it] = valueOnAddedKeys } 22 | removedKeys.forEach { target.remove(it) } 23 | } 24 | -------------------------------------------------------------------------------- /core/common/src/main/kotlin/com/w2sv/common/util/MediaId.kt: -------------------------------------------------------------------------------- 1 | package com.w2sv.common.util 2 | 3 | import android.content.ContentUris 4 | import android.net.Uri 5 | import slimber.log.e 6 | 7 | @JvmInline 8 | value class MediaId(val value: Long) { 9 | 10 | companion object { 11 | fun fromUri(uri: Uri): MediaId? { 12 | return try { 13 | val parsedId = ContentUris.parseId(uri) 14 | if (parsedId != -1L) { 15 | MediaId(parsedId) 16 | } else { 17 | null 18 | } 19 | } catch (e: Exception) { 20 | e { e.stackTraceToString() } 21 | null 22 | } 23 | } 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /core/common/src/main/kotlin/com/w2sv/common/util/MediaUri.kt: -------------------------------------------------------------------------------- 1 | package com.w2sv.common.util 2 | 3 | import android.content.Context 4 | import android.net.Uri 5 | import android.os.Parcelable 6 | import android.provider.MediaStore 7 | import androidx.core.net.toUri 8 | import kotlinx.parcelize.Parcelize 9 | 10 | @Parcelize 11 | @JvmInline 12 | value class MediaUri(val uri: Uri) : Parcelable { 13 | 14 | fun documentUri(context: Context): DocumentUri? = 15 | MediaStore.getDocumentUri(context, uri)?.documentUri 16 | 17 | fun id(): MediaId? = 18 | MediaId.fromUri(uri) 19 | 20 | companion object { 21 | fun fromDocumentUri(context: Context, documentUri: DocumentUri): MediaUri? = 22 | MediaStore.getMediaUri( 23 | context, 24 | documentUri.uri 25 | ) 26 | ?.mediaUri 27 | 28 | fun parse(uriString: String): MediaUri = 29 | uriString.toUri().mediaUri 30 | } 31 | } 32 | 33 | val Uri.mediaUri: MediaUri 34 | get() = MediaUri(this) 35 | -------------------------------------------------------------------------------- /core/common/src/main/kotlin/com/w2sv/common/util/String.kt: -------------------------------------------------------------------------------- 1 | package com.w2sv.common.util 2 | 3 | fun String.containsSpecialCharacter(): Boolean = 4 | any { !it.isLetterOrDigit() && it != ' ' } 5 | -------------------------------------------------------------------------------- /core/common/src/main/res/drawable/ic_apk_file_24.xml: -------------------------------------------------------------------------------- 1 | 6 | 9 | 10 | -------------------------------------------------------------------------------- /core/common/src/main/res/drawable/ic_app_foreground_108.xml: -------------------------------------------------------------------------------- 1 | 7 | 10 | 11 | -------------------------------------------------------------------------------- /core/common/src/main/res/drawable/ic_app_logo_24.xml: -------------------------------------------------------------------------------- 1 | 7 | 10 | 11 | -------------------------------------------------------------------------------- /core/common/src/main/res/drawable/ic_app_monochrome_108.xml: -------------------------------------------------------------------------------- 1 | 7 | 10 | 11 | -------------------------------------------------------------------------------- /core/common/src/main/res/drawable/ic_apps_24.xml: -------------------------------------------------------------------------------- 1 | 6 | 9 | 10 | -------------------------------------------------------------------------------- /core/common/src/main/res/drawable/ic_audio_file_24.xml: -------------------------------------------------------------------------------- 1 | 6 | 9 | 10 | -------------------------------------------------------------------------------- /core/common/src/main/res/drawable/ic_battery_low_24.xml: -------------------------------------------------------------------------------- 1 | 7 | 10 | 11 | -------------------------------------------------------------------------------- /core/common/src/main/res/drawable/ic_book_24.xml: -------------------------------------------------------------------------------- 1 | 6 | 9 | 10 | -------------------------------------------------------------------------------- /core/common/src/main/res/drawable/ic_bug_report_24.xml: -------------------------------------------------------------------------------- 1 | 7 | 10 | 11 | -------------------------------------------------------------------------------- /core/common/src/main/res/drawable/ic_camera_24.xml: -------------------------------------------------------------------------------- 1 | 6 | 9 | 10 | -------------------------------------------------------------------------------- /core/common/src/main/res/drawable/ic_cancel_24.xml: -------------------------------------------------------------------------------- 1 | 6 | 9 | 10 | -------------------------------------------------------------------------------- /core/common/src/main/res/drawable/ic_contrast_24.xml: -------------------------------------------------------------------------------- 1 | 7 | 10 | 11 | -------------------------------------------------------------------------------- /core/common/src/main/res/drawable/ic_copyright_24.xml: -------------------------------------------------------------------------------- 1 | 7 | 10 | 11 | -------------------------------------------------------------------------------- /core/common/src/main/res/drawable/ic_custom_file_type_24.xml: -------------------------------------------------------------------------------- 1 | 6 | 9 | 16 | 17 | -------------------------------------------------------------------------------- /core/common/src/main/res/drawable/ic_delete_24.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | -------------------------------------------------------------------------------- /core/common/src/main/res/drawable/ic_delete_history_24.xml: -------------------------------------------------------------------------------- 1 | 7 | 10 | 11 | -------------------------------------------------------------------------------- /core/common/src/main/res/drawable/ic_donate_24.xml: -------------------------------------------------------------------------------- 1 | 6 | 9 | 10 | -------------------------------------------------------------------------------- /core/common/src/main/res/drawable/ic_file_download_24.xml: -------------------------------------------------------------------------------- 1 | 6 | 9 | 10 | -------------------------------------------------------------------------------- /core/common/src/main/res/drawable/ic_files_24.xml: -------------------------------------------------------------------------------- 1 | 7 | 8 | 14 | 15 | 21 | 22 | 28 | 29 | 30 | -------------------------------------------------------------------------------- /core/common/src/main/res/drawable/ic_folder_edit_24.xml: -------------------------------------------------------------------------------- 1 | 6 | 9 | 10 | -------------------------------------------------------------------------------- /core/common/src/main/res/drawable/ic_folder_open_24.xml: -------------------------------------------------------------------------------- 1 | 6 | 9 | 10 | -------------------------------------------------------------------------------- /core/common/src/main/res/drawable/ic_folder_zip_24.xml: -------------------------------------------------------------------------------- 1 | 6 | 9 | 10 | -------------------------------------------------------------------------------- /core/common/src/main/res/drawable/ic_github_24.xml: -------------------------------------------------------------------------------- 1 | 6 | 9 | 10 | -------------------------------------------------------------------------------- /core/common/src/main/res/drawable/ic_history_24.xml: -------------------------------------------------------------------------------- 1 | 7 | 10 | 11 | -------------------------------------------------------------------------------- /core/common/src/main/res/drawable/ic_image_24.xml: -------------------------------------------------------------------------------- 1 | 6 | 9 | 10 | -------------------------------------------------------------------------------- /core/common/src/main/res/drawable/ic_info_outline_24.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | -------------------------------------------------------------------------------- /core/common/src/main/res/drawable/ic_mic_24.xml: -------------------------------------------------------------------------------- 1 | 6 | 9 | 10 | -------------------------------------------------------------------------------- /core/common/src/main/res/drawable/ic_more_vert_24.xml: -------------------------------------------------------------------------------- 1 | 8 | 9 | 12 | 13 | 14 | -------------------------------------------------------------------------------- /core/common/src/main/res/drawable/ic_nightlight_24.xml: -------------------------------------------------------------------------------- 1 | 6 | 9 | 10 | -------------------------------------------------------------------------------- /core/common/src/main/res/drawable/ic_notifications_24.xml: -------------------------------------------------------------------------------- 1 | 7 | 10 | 11 | -------------------------------------------------------------------------------- /core/common/src/main/res/drawable/ic_palette_24.xml: -------------------------------------------------------------------------------- 1 | 6 | 9 | 10 | -------------------------------------------------------------------------------- /core/common/src/main/res/drawable/ic_pdf_24.xml: -------------------------------------------------------------------------------- 1 | 6 | 9 | 10 | -------------------------------------------------------------------------------- /core/common/src/main/res/drawable/ic_policy_24.xml: -------------------------------------------------------------------------------- 1 | 7 | 10 | 11 | -------------------------------------------------------------------------------- /core/common/src/main/res/drawable/ic_restart_24.xml: -------------------------------------------------------------------------------- 1 | 6 | 9 | 10 | -------------------------------------------------------------------------------- /core/common/src/main/res/drawable/ic_screenshot_24.xml: -------------------------------------------------------------------------------- 1 | 6 | 9 | 10 | -------------------------------------------------------------------------------- /core/common/src/main/res/drawable/ic_settings_24.xml: -------------------------------------------------------------------------------- 1 | 6 | 9 | 10 | -------------------------------------------------------------------------------- /core/common/src/main/res/drawable/ic_share_24.xml: -------------------------------------------------------------------------------- 1 | 6 | 9 | 10 | -------------------------------------------------------------------------------- /core/common/src/main/res/drawable/ic_smartphone_24.xml: -------------------------------------------------------------------------------- 1 | 6 | 9 | 10 | -------------------------------------------------------------------------------- /core/common/src/main/res/drawable/ic_star_rate_24.xml: -------------------------------------------------------------------------------- 1 | 6 | 9 | 10 | -------------------------------------------------------------------------------- /core/common/src/main/res/drawable/ic_start_24.xml: -------------------------------------------------------------------------------- 1 | 7 | 10 | 11 | -------------------------------------------------------------------------------- /core/common/src/main/res/drawable/ic_stop_24.xml: -------------------------------------------------------------------------------- 1 | 7 | 10 | 11 | -------------------------------------------------------------------------------- /core/common/src/main/res/drawable/ic_storage_24.xml: -------------------------------------------------------------------------------- 1 | 7 | 8 | 11 | 12 | 13 | -------------------------------------------------------------------------------- /core/common/src/main/res/drawable/ic_subdirectory_arrow_right_24.xml: -------------------------------------------------------------------------------- 1 | 7 | 8 | 11 | 12 | 13 | -------------------------------------------------------------------------------- /core/common/src/main/res/drawable/ic_text_file_24.xml: -------------------------------------------------------------------------------- 1 | 7 | 10 | 11 | -------------------------------------------------------------------------------- /core/common/src/main/res/drawable/ic_video_file_24.xml: -------------------------------------------------------------------------------- 1 | 6 | 9 | 10 | -------------------------------------------------------------------------------- /core/common/src/main/res/drawable/ic_warning_24.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | -------------------------------------------------------------------------------- /core/common/src/main/res/values/colors.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | #00DFC2 4 | #00696E 5 | -------------------------------------------------------------------------------- /core/common/src/test/kotlin/com/w2sv/common/util/MediaUriTest.kt: -------------------------------------------------------------------------------- 1 | package com.w2sv.common.util 2 | 3 | import com.w2sv.test.testParceling 4 | import junit.framework.TestCase.assertEquals 5 | import org.junit.Test 6 | import org.junit.runner.RunWith 7 | import org.robolectric.RobolectricTestRunner 8 | 9 | @RunWith(RobolectricTestRunner::class) 10 | internal class MediaUriTest { 11 | 12 | private val mediaUri = MediaUri.parse("content://media/external/images/media/1000012597") 13 | 14 | @Test 15 | fun testParceling() { 16 | mediaUri.testParceling() 17 | } 18 | 19 | @Test 20 | fun testId() { 21 | assertEquals(MediaId(value = 1000012597), mediaUri.id()) 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /core/common/src/test/kotlin/com/w2sv/common/util/StringKtTest.kt: -------------------------------------------------------------------------------- 1 | package com.w2sv.common.util 2 | 3 | import junit.framework.TestCase.assertEquals 4 | import org.junit.Test 5 | 6 | class StringKtTest { 7 | 8 | @Test 9 | fun containsSpecialCharacter() { 10 | fun test(expected: Boolean, input: String) { 11 | assertEquals(expected, input.containsSpecialCharacter()) 12 | } 13 | 14 | test(false, "") 15 | test(false, "sadfa") 16 | test(false, "sdafa6") 17 | test(false, "sfadfa sdafaxczv") 18 | test(true, ".") 19 | test(true, "sxczv.-cvx") 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /core/database/.gitignore: -------------------------------------------------------------------------------- 1 | /build -------------------------------------------------------------------------------- /core/database/build.gradle.kts: -------------------------------------------------------------------------------- 1 | plugins { 2 | alias(libs.plugins.filenavigator.library) 3 | alias(libs.plugins.filenavigator.hilt) 4 | alias(libs.plugins.filenavigator.room) 5 | alias(libs.plugins.kotlin.parcelize) 6 | } 7 | 8 | android { 9 | sourceSets { 10 | // Adds exported schema location as test app assets. 11 | getByName("androidTest").assets.srcDir("$projectDir/schemas") 12 | } 13 | } 14 | 15 | dependencies { 16 | implementation(projects.core.common) 17 | implementation(projects.core.domain) 18 | implementation(libs.androidx.core) 19 | implementation(libs.w2sv.androidutils.core) 20 | implementation(libs.slimber) 21 | implementation(libs.w2sv.simplestorage) 22 | 23 | testImplementation(libs.bundles.unitTest) 24 | 25 | androidTestImplementation(libs.bundles.instrumentationTest) 26 | androidTestImplementation(libs.androidx.room.testing) 27 | } 28 | -------------------------------------------------------------------------------- /core/database/schemas/com.w2sv.data.storage.database.AppDatabase/2.json: -------------------------------------------------------------------------------- 1 | { 2 | "formatVersion": 1, 3 | "database": { 4 | "version": 2, 5 | "identityHash": "820627affaef61c06cbda50dd76a8048", 6 | "entities": [ 7 | { 8 | "tableName": "MoveEntryEntity", 9 | "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`fileName` TEXT NOT NULL, `fileType` TEXT NOT NULL, `fileSourceKind` TEXT NOT NULL, `destinationDocumentUri` TEXT NOT NULL, `dateTime` TEXT NOT NULL, PRIMARY KEY(`dateTime`))", 10 | "fields": [ 11 | { 12 | "fieldPath": "fileName", 13 | "columnName": "fileName", 14 | "affinity": "TEXT", 15 | "notNull": true 16 | }, 17 | { 18 | "fieldPath": "fileType", 19 | "columnName": "fileType", 20 | "affinity": "TEXT", 21 | "notNull": true 22 | }, 23 | { 24 | "fieldPath": "fileSourceKind", 25 | "columnName": "fileSourceKind", 26 | "affinity": "TEXT", 27 | "notNull": true 28 | }, 29 | { 30 | "fieldPath": "destinationDocumentUri", 31 | "columnName": "destinationDocumentUri", 32 | "affinity": "TEXT", 33 | "notNull": true 34 | }, 35 | { 36 | "fieldPath": "dateTime", 37 | "columnName": "dateTime", 38 | "affinity": "TEXT", 39 | "notNull": true 40 | } 41 | ], 42 | "primaryKey": { 43 | "autoGenerate": false, 44 | "columnNames": [ 45 | "dateTime" 46 | ] 47 | }, 48 | "indices": [], 49 | "foreignKeys": [] 50 | } 51 | ], 52 | "views": [], 53 | "setupQueries": [ 54 | "CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)", 55 | "INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, '820627affaef61c06cbda50dd76a8048')" 56 | ] 57 | } 58 | } -------------------------------------------------------------------------------- /core/database/schemas/com.w2sv.datastorage.database.AppDatabase/2.json: -------------------------------------------------------------------------------- 1 | { 2 | "formatVersion": 1, 3 | "database": { 4 | "version": 2, 5 | "identityHash": "820627affaef61c06cbda50dd76a8048", 6 | "entities": [ 7 | { 8 | "tableName": "MoveEntryEntity", 9 | "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`fileName` TEXT NOT NULL, `fileType` TEXT NOT NULL, `fileSourceKind` TEXT NOT NULL, `destinationDocumentUri` TEXT NOT NULL, `dateTime` TEXT NOT NULL, PRIMARY KEY(`dateTime`))", 10 | "fields": [ 11 | { 12 | "fieldPath": "fileName", 13 | "columnName": "fileName", 14 | "affinity": "TEXT", 15 | "notNull": true 16 | }, 17 | { 18 | "fieldPath": "fileType", 19 | "columnName": "fileType", 20 | "affinity": "TEXT", 21 | "notNull": true 22 | }, 23 | { 24 | "fieldPath": "fileSourceKind", 25 | "columnName": "fileSourceKind", 26 | "affinity": "TEXT", 27 | "notNull": true 28 | }, 29 | { 30 | "fieldPath": "destinationDocumentUri", 31 | "columnName": "destinationDocumentUri", 32 | "affinity": "TEXT", 33 | "notNull": true 34 | }, 35 | { 36 | "fieldPath": "dateTime", 37 | "columnName": "dateTime", 38 | "affinity": "TEXT", 39 | "notNull": true 40 | } 41 | ], 42 | "primaryKey": { 43 | "autoGenerate": false, 44 | "columnNames": [ 45 | "dateTime" 46 | ] 47 | }, 48 | "indices": [], 49 | "foreignKeys": [] 50 | } 51 | ], 52 | "views": [], 53 | "setupQueries": [ 54 | "CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)", 55 | "INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, '820627affaef61c06cbda50dd76a8048')" 56 | ] 57 | } 58 | } -------------------------------------------------------------------------------- /core/database/src/main/kotlin/com/w2sv/database/AppDatabase.kt: -------------------------------------------------------------------------------- 1 | package com.w2sv.database 2 | 3 | import androidx.room.Database 4 | import androidx.room.RoomDatabase 5 | import androidx.room.TypeConverters 6 | import com.w2sv.database.dao.MovedFileDao 7 | import com.w2sv.database.entity.MovedFileEntity 8 | import com.w2sv.database.typeconverter.FileTypeConverter 9 | import com.w2sv.database.typeconverter.LocalDateTimeConverter 10 | import com.w2sv.database.typeconverter.UriConverter 11 | 12 | @Database( 13 | entities = [MovedFileEntity::class], 14 | version = 5, 15 | exportSchema = true 16 | ) 17 | @TypeConverters( 18 | LocalDateTimeConverter::class, 19 | UriConverter::class, 20 | FileTypeConverter::class 21 | ) 22 | internal abstract class AppDatabase : RoomDatabase() { 23 | abstract fun getMovedFileDao(): MovedFileDao 24 | } 25 | -------------------------------------------------------------------------------- /core/database/src/main/kotlin/com/w2sv/database/dao/MovedFileDao.kt: -------------------------------------------------------------------------------- 1 | package com.w2sv.database.dao 2 | 3 | import androidx.room.Dao 4 | import androidx.room.Delete 5 | import androidx.room.Insert 6 | import androidx.room.Query 7 | import com.w2sv.database.entity.MovedFileEntity 8 | import kotlinx.coroutines.flow.Flow 9 | 10 | @Dao 11 | internal interface MovedFileDao { 12 | @Query("SELECT * FROM MovedFileEntity ORDER BY moveDateTime DESC") 13 | fun loadAllInDescendingOrder(): Flow> 14 | 15 | @Insert 16 | fun insert(entry: MovedFileEntity) 17 | 18 | @Query("DELETE FROM MovedFileEntity") 19 | fun deleteAll() 20 | 21 | @Delete 22 | fun delete(entry: MovedFileEntity) 23 | } 24 | -------------------------------------------------------------------------------- /core/database/src/main/kotlin/com/w2sv/database/di/DataBaseBinderModule.kt: -------------------------------------------------------------------------------- 1 | package com.w2sv.database.di 2 | 3 | import com.w2sv.database.repository.RoomMovedFileRepository 4 | import com.w2sv.domain.repository.MovedFileRepository 5 | import dagger.Binds 6 | import dagger.Module 7 | import dagger.hilt.InstallIn 8 | import dagger.hilt.components.SingletonComponent 9 | 10 | @Module 11 | @InstallIn(SingletonComponent::class) 12 | internal interface DataBaseBinderModule { 13 | 14 | @Binds 15 | fun bindsMoveEntryRepository(impl: RoomMovedFileRepository): MovedFileRepository 16 | } 17 | -------------------------------------------------------------------------------- /core/database/src/main/kotlin/com/w2sv/database/di/DatabaseModule.kt: -------------------------------------------------------------------------------- 1 | package com.w2sv.database.di 2 | 3 | import android.content.Context 4 | import androidx.room.Room 5 | import com.w2sv.database.AppDatabase 6 | import com.w2sv.database.dao.MovedFileDao 7 | import com.w2sv.database.migration.Migrations 8 | import dagger.Module 9 | import dagger.Provides 10 | import dagger.hilt.InstallIn 11 | import dagger.hilt.android.qualifiers.ApplicationContext 12 | import dagger.hilt.components.SingletonComponent 13 | import javax.inject.Singleton 14 | 15 | @InstallIn(SingletonComponent::class) 16 | @Module 17 | internal object DatabaseModule { 18 | 19 | @Singleton 20 | @Provides 21 | fun appDatabase(@ApplicationContext context: Context): AppDatabase = 22 | Room 23 | .databaseBuilder( 24 | context, 25 | AppDatabase::class.java, 26 | "app-database" 27 | ) 28 | .addMigrations( 29 | Migrations.Migration2to3(context = context), 30 | Migrations.Migration3to4, 31 | Migrations.Migration4to5 32 | ) 33 | .fallbackToDestructiveMigration(true) 34 | .build() 35 | 36 | @Provides 37 | fun moveEntryDao(appDatabase: AppDatabase): MovedFileDao = 38 | appDatabase.getMovedFileDao() 39 | } 40 | -------------------------------------------------------------------------------- /core/database/src/main/kotlin/com/w2sv/database/repository/RoomMovedFileRepository.kt: -------------------------------------------------------------------------------- 1 | package com.w2sv.database.repository 2 | 3 | import com.w2sv.database.dao.MovedFileDao 4 | import com.w2sv.database.entity.MovedFileEntity 5 | import com.w2sv.domain.model.MovedFile 6 | import com.w2sv.domain.repository.MovedFileRepository 7 | import javax.inject.Inject 8 | import kotlinx.coroutines.flow.Flow 9 | import kotlinx.coroutines.flow.map 10 | 11 | internal class RoomMovedFileRepository @Inject constructor( 12 | private val movedFileDao: MovedFileDao 13 | ) : MovedFileRepository { 14 | 15 | override suspend fun insert(file: MovedFile) { 16 | movedFileDao.insert(MovedFileEntity(file)) 17 | } 18 | 19 | override suspend fun delete(file: MovedFile) { 20 | movedFileDao.delete(MovedFileEntity(file)) 21 | } 22 | 23 | override suspend fun deleteAll() { 24 | movedFileDao.deleteAll() 25 | } 26 | 27 | override fun getAllInDescendingOrder(): Flow> = 28 | movedFileDao 29 | .loadAllInDescendingOrder() 30 | .map { it.map { entity -> entity.asExternal() } } 31 | } 32 | -------------------------------------------------------------------------------- /core/database/src/main/kotlin/com/w2sv/database/typeconverter/LocalDateTimeConverter.kt: -------------------------------------------------------------------------------- 1 | package com.w2sv.database.typeconverter 2 | 3 | import androidx.room.TypeConverter 4 | import java.time.LocalDateTime 5 | 6 | internal object LocalDateTimeConverter { 7 | 8 | @TypeConverter 9 | fun toDate(dateString: String): LocalDateTime = 10 | LocalDateTime.parse(dateString) 11 | 12 | @TypeConverter 13 | fun toDateString(date: LocalDateTime): String = 14 | date.toString() 15 | } 16 | -------------------------------------------------------------------------------- /core/database/src/main/kotlin/com/w2sv/database/typeconverter/UriConverter.kt: -------------------------------------------------------------------------------- 1 | package com.w2sv.database.typeconverter 2 | 3 | import android.net.Uri 4 | import androidx.core.net.toUri 5 | import androidx.room.TypeConverter 6 | 7 | internal object UriConverter { 8 | 9 | @TypeConverter 10 | fun fromUri(uri: Uri?): String { 11 | return uri?.toString() ?: "" 12 | } 13 | 14 | @TypeConverter 15 | fun toUri(uriString: String): Uri? { 16 | return if (uriString.isNotEmpty()) uriString.toUri() else null 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /core/database/src/test/kotlin/com/w2sv/database/typeconverter/FileTypeConverterTest.kt: -------------------------------------------------------------------------------- 1 | package com.w2sv.database.typeconverter 2 | 3 | import com.w2sv.domain.model.filetype.CustomFileType 4 | import com.w2sv.domain.model.filetype.FileType 5 | import com.w2sv.domain.model.filetype.PresetFileType 6 | import junit.framework.TestCase.assertEquals 7 | import org.junit.Test 8 | 9 | internal class FileTypeConverterTest { 10 | 11 | @Test 12 | fun testBackAndForthPresetFileTypeConversion() { 13 | PresetFileType.values.forEach { 14 | assertEquals( 15 | it, 16 | it.toDefaultFileType().backAndForthConverted().wrappedPresetTypeOrNull 17 | ) 18 | } 19 | } 20 | 21 | @Test 22 | fun testBackAndForthCustomFileTypeConversion() { 23 | val customFileType = CustomFileType("Html", emptyList(), 342523, 1006) 24 | val recreatedFileType = customFileType.backAndForthConverted() as CustomFileType 25 | 26 | assertEquals(customFileType.name, recreatedFileType.name) 27 | assertEquals(customFileType.colorInt, recreatedFileType.colorInt) 28 | assertEquals(customFileType.ordinal, recreatedFileType.ordinal) 29 | } 30 | } 31 | 32 | private fun FileType.backAndForthConverted(): FileType = 33 | FileTypeConverter.toFileType(FileTypeConverter.fromFileType(this)) 34 | -------------------------------------------------------------------------------- /core/datastore/.gitignore: -------------------------------------------------------------------------------- 1 | /build -------------------------------------------------------------------------------- /core/datastore/build.gradle.kts: -------------------------------------------------------------------------------- 1 | import com.google.protobuf.gradle.id 2 | 3 | plugins { 4 | alias(libs.plugins.filenavigator.library) 5 | alias(libs.plugins.filenavigator.hilt) 6 | alias(libs.plugins.protobuf) 7 | alias(libs.plugins.kotlin.parcelize) 8 | } 9 | 10 | android { 11 | defaultConfig { 12 | consumerProguardFiles("consumer-proguard-rules.pro") 13 | } 14 | } 15 | 16 | // Setup protobuf configuration, generating lite Java and Kotlin classes 17 | protobuf { 18 | protoc { 19 | artifact = libs.protobuf.protoc.get().toString() 20 | } 21 | generateProtoTasks { 22 | all().forEach { task -> 23 | task.builtins { 24 | register("java") { 25 | option("lite") 26 | } 27 | id("kotlin") // Enables kotlin DSL 28 | } 29 | } 30 | } 31 | } 32 | 33 | dependencies { 34 | implementation(projects.core.common) 35 | implementation(projects.core.domain) 36 | 37 | implementation(libs.androidx.core) 38 | implementation(libs.protobuf.kotlin.lite) 39 | 40 | implementation(libs.w2sv.kotlinutils) 41 | implementation(libs.w2sv.datastoreutils.preferences) 42 | implementation(libs.w2sv.datastoreutils.datastoreflow) 43 | implementation(libs.w2sv.androidutils.core) 44 | implementation(libs.slimber) 45 | 46 | testImplementation(projects.core.test) 47 | } 48 | -------------------------------------------------------------------------------- /core/datastore/consumer-proguard-rules.pro: -------------------------------------------------------------------------------- 1 | # Prevent Proto DataStore fields from being deleted 2 | -keepclassmembers class * extends com.google.protobuf.GeneratedMessageLite { 3 | ; 4 | } -------------------------------------------------------------------------------- /core/datastore/src/main/kotlin/com/w2sv/datastore/di/DataStoreBinderModule.kt: -------------------------------------------------------------------------------- 1 | package com.w2sv.datastore.di 2 | 3 | import com.w2sv.datastore.preferences.PreferencesRepositoryImpl 4 | import com.w2sv.datastore.proto.navigatorconfig.NavigatorConfigDataSourceImpl 5 | import com.w2sv.domain.repository.NavigatorConfigDataSource 6 | import com.w2sv.domain.repository.PreferencesRepository 7 | import dagger.Binds 8 | import dagger.Module 9 | import dagger.hilt.InstallIn 10 | import dagger.hilt.components.SingletonComponent 11 | 12 | @InstallIn(SingletonComponent::class) 13 | @Module 14 | internal interface DataStoreBinderModule { 15 | 16 | @Binds 17 | fun bindsNavigatorConfigDataSource(impl: NavigatorConfigDataSourceImpl): NavigatorConfigDataSource 18 | 19 | @Binds 20 | fun bindsPreferencesRepository(impl: PreferencesRepositoryImpl): PreferencesRepository 21 | } 22 | -------------------------------------------------------------------------------- /core/datastore/src/main/kotlin/com/w2sv/datastore/proto/ProtoMapper.kt: -------------------------------------------------------------------------------- 1 | package com.w2sv.datastore.proto 2 | 3 | internal interface ProtoMapper { 4 | fun toExternal(proto: Proto): External 5 | fun toProto(external: External): Proto 6 | } 7 | -------------------------------------------------------------------------------- /core/datastore/src/main/kotlin/com/w2sv/datastore/proto/navigatorconfig/NavigatorConfigProtoSerializer.kt: -------------------------------------------------------------------------------- 1 | package com.w2sv.datastore.proto.navigatorconfig 2 | 3 | import androidx.datastore.core.CorruptionException 4 | import androidx.datastore.core.Serializer 5 | import com.google.protobuf.InvalidProtocolBufferException 6 | import com.w2sv.datastore.NavigatorConfigProto 7 | import com.w2sv.domain.model.navigatorconfig.NavigatorConfig 8 | import java.io.InputStream 9 | import java.io.OutputStream 10 | 11 | internal object NavigatorConfigProtoSerializer : Serializer { 12 | override val defaultValue: NavigatorConfigProto by lazy { 13 | NavigatorConfig.default.toProto(false) 14 | } 15 | 16 | override suspend fun readFrom(input: InputStream): NavigatorConfigProto = 17 | try { 18 | // readFrom is already called on the data store background thread 19 | NavigatorConfigProto.parseFrom(input) 20 | } catch (exception: InvalidProtocolBufferException) { 21 | throw CorruptionException("Cannot read proto.", exception) 22 | } 23 | 24 | override suspend fun writeTo(t: NavigatorConfigProto, output: OutputStream) { 25 | // writeTo is already called on the data store background thread 26 | t.writeTo(output) 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /core/datastore/src/main/proto/navigator_config.proto: -------------------------------------------------------------------------------- 1 | syntax = "proto3"; 2 | 3 | option java_package = "com.w2sv.datastore"; 4 | option java_multiple_files = true; 5 | 6 | message NavigatorConfigProto { 7 | map file_type_to_config = 1; 8 | map extension_preset_file_types = 6; 9 | map extension_configurable_file_types = 7; 10 | map custom_file_types = 8; 11 | bool disable_on_low_battery = 2; 12 | bool start_on_boot = 3; 13 | bool has_been_migrated = 4; 14 | bool show_batch_move_notification = 5; 15 | } 16 | 17 | message ExtensionPresetFileTypeProto { 18 | int32 color = 1; 19 | } 20 | 21 | message ExtensionConfigurableFileTypeProto { 22 | int32 color = 1; 23 | repeated string excluded_extensions = 2; 24 | } 25 | 26 | message CustomFileTypeProto { 27 | string name = 1; 28 | repeated string extensions = 2; 29 | int32 color = 3; 30 | int32 ordinal = 4; 31 | } 32 | 33 | message FileTypeConfigProto { 34 | bool enabled = 1; 35 | map source_type_to_config = 2; 36 | } 37 | 38 | message SourceConfigProto { 39 | bool enabled = 1; 40 | repeated string last_move_destinations = 2; 41 | AutoMoveConfigProto auto_move_config = 3; 42 | } 43 | 44 | message AutoMoveConfigProto { 45 | bool enabled = 1; 46 | string destination = 2; 47 | } 48 | -------------------------------------------------------------------------------- /core/datastore/src/test/kotlin/com/w2sv/datastore/migration/PreMigrationNavigatorPreferencesKeyTest.kt: -------------------------------------------------------------------------------- 1 | package com.w2sv.datastore.migration 2 | 3 | import junit.framework.TestCase.assertEquals 4 | import org.junit.Test 5 | 6 | class PreMigrationNavigatorPreferencesKeyTest { 7 | 8 | @Test 9 | fun keys() { 10 | assertEquals( 11 | "[disableNavigatorOnLowBattery, Image, Image.Camera.IS_ENABLED, Image.Camera.LAST_MOVE_DESTINATION, " + 12 | "Image.Screenshot.IS_ENABLED, Image.Screenshot.LAST_MOVE_DESTINATION, Image.OtherApp.IS_ENABLED, " + 13 | "Image.OtherApp.LAST_MOVE_DESTINATION, Image.Download.IS_ENABLED, Image.Download.LAST_MOVE_DESTINATION, " + 14 | "Video, Video.Camera.IS_ENABLED, Video.Camera.LAST_MOVE_DESTINATION, Video.OtherApp.IS_ENABLED, " + 15 | "Video.OtherApp.LAST_MOVE_DESTINATION, Video.Download.IS_ENABLED, Video.Download.LAST_MOVE_DESTINATION, " + 16 | "Audio, Audio.Recording.IS_ENABLED, Audio.Recording.LAST_MOVE_DESTINATION, Audio.OtherApp.IS_ENABLED, " + 17 | "Audio.OtherApp.LAST_MOVE_DESTINATION, Audio.Download.IS_ENABLED, Audio.Download.LAST_MOVE_DESTINATION, " + 18 | "PDF, PDF.Download.IS_ENABLED, PDF.Download.LAST_MOVE_DESTINATION, Text, Text.Download.IS_ENABLED," + 19 | " Text.Download.LAST_MOVE_DESTINATION, Archive, Archive.Download.IS_ENABLED, Archive.Download.LAST_MOVE_DESTINATION, " + 20 | "APK, APK.Download.IS_ENABLED, APK.Download.LAST_MOVE_DESTINATION, EBook, EBook.Download.IS_ENABLED," + 21 | " EBook.Download.LAST_MOVE_DESTINATION]", 22 | PreMigrationNavigatorPreferencesKey.keys().toList().toString() 23 | ) 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /core/domain/.gitignore: -------------------------------------------------------------------------------- 1 | /build -------------------------------------------------------------------------------- /core/domain/build.gradle.kts: -------------------------------------------------------------------------------- 1 | plugins { 2 | alias(libs.plugins.filenavigator.library) 3 | alias(libs.plugins.filenavigator.hilt) 4 | alias(libs.plugins.kotlin.parcelize) 5 | } 6 | 7 | dependencies { 8 | implementation(projects.core.common) 9 | 10 | api(libs.w2sv.datastoreutils.datastoreflow) 11 | implementation(libs.w2sv.androidutils.core) 12 | implementation(libs.w2sv.kotlinutils) 13 | implementation(libs.slimber) 14 | implementation(libs.w2sv.simplestorage) 15 | implementation(libs.androidx.core) 16 | 17 | testImplementation(projects.core.test) 18 | } 19 | -------------------------------------------------------------------------------- /core/domain/src/main/kotlin/com/w2sv/domain/model/Theme.kt: -------------------------------------------------------------------------------- 1 | package com.w2sv.domain.model 2 | 3 | enum class Theme { 4 | Light, 5 | Default, 6 | Dark 7 | } 8 | -------------------------------------------------------------------------------- /core/domain/src/main/kotlin/com/w2sv/domain/model/filetype/CustomFileType.kt: -------------------------------------------------------------------------------- 1 | package com.w2sv.domain.model.filetype 2 | 3 | import android.content.Context 4 | import android.graphics.Color 5 | import android.os.Parcelable 6 | import androidx.annotation.ColorInt 7 | import androidx.annotation.DrawableRes 8 | import androidx.annotation.VisibleForTesting 9 | import com.w2sv.core.common.R 10 | import kotlin.random.Random 11 | import kotlinx.parcelize.IgnoredOnParcel 12 | import kotlinx.parcelize.Parcelize 13 | 14 | @Parcelize 15 | data class CustomFileType( 16 | val name: String, 17 | override val fileExtensions: List, 18 | @ColorInt override val colorInt: Int, 19 | override val ordinal: Int 20 | ) : StaticFileType.NonMedia, 21 | FileType, 22 | Parcelable { 23 | 24 | @IgnoredOnParcel 25 | @DrawableRes 26 | override val iconRes: Int = R.drawable.ic_custom_file_type_24 27 | 28 | override fun label(context: Context): String = 29 | name 30 | 31 | companion object { 32 | fun newEmpty(existingFileTypes: Collection): CustomFileType = 33 | CustomFileType( 34 | name = "", 35 | fileExtensions = emptyList(), 36 | colorInt = randomColor(), 37 | ordinal = maxOf(MIN_ORDINAL, existingFileTypes.maxOfOrNull { it.ordinal }?.let { it + 1 } ?: MIN_ORDINAL) 38 | ) 39 | 40 | @VisibleForTesting 41 | internal const val MIN_ORDINAL = 1_000 42 | } 43 | } 44 | 45 | @ColorInt 46 | private fun randomColor(): Int = 47 | Color.rgb( 48 | Random.Default.nextInt(256), 49 | Random.Default.nextInt(256), 50 | Random.Default.nextInt(256) 51 | ) 52 | -------------------------------------------------------------------------------- /core/domain/src/main/kotlin/com/w2sv/domain/model/filetype/FileAndSourceType.kt: -------------------------------------------------------------------------------- 1 | package com.w2sv.domain.model.filetype 2 | 3 | import android.content.Context 4 | import android.os.Parcelable 5 | import androidx.annotation.DrawableRes 6 | import com.w2sv.core.common.R 7 | import kotlinx.parcelize.IgnoredOnParcel 8 | import kotlinx.parcelize.Parcelize 9 | 10 | @Parcelize 11 | data class FileAndSourceType(val fileType: FileType, val sourceType: SourceType) : Parcelable { 12 | 13 | @IgnoredOnParcel 14 | @get:DrawableRes 15 | val iconRes: Int by lazy { 16 | when { 17 | sourceType in listOf(SourceType.Screenshot, SourceType.Camera, SourceType.Recording) -> sourceType.iconRes 18 | else -> fileType.iconRes 19 | } 20 | } 21 | 22 | /** 23 | * @return 24 | * - Gif -> 'GIF' 25 | * - Photo -> 'Photo' 26 | * - Screenshot, Recording -> sourceTypeLabel 27 | * - Download -> '{fileTypeLabel} Download' 28 | * - else -> fileTypeLabel 29 | */ 30 | fun label(context: Context, isGif: Boolean): String = 31 | when { 32 | isGif -> context.getString(R.string.gif) 33 | fileType.wrappedPresetTypeOrNull is PresetFileType.Image && sourceType == SourceType.Camera -> context.getString( 34 | R.string.photo 35 | ) 36 | sourceType == SourceType.Screenshot || sourceType == SourceType.Recording -> context.getString( 37 | sourceType.labelRes 38 | ) 39 | 40 | fileType is CustomFileType -> fileType.name 41 | 42 | sourceType == SourceType.Download -> context.getString( 43 | R.string.file_type_download, 44 | context.getString((fileType as AnyPresetWrappingFileType).presetFileType.labelRes) 45 | ) 46 | 47 | else -> context.getString((fileType as AnyPresetWrappingFileType).presetFileType.labelRes) 48 | } 49 | } 50 | -------------------------------------------------------------------------------- /core/domain/src/main/kotlin/com/w2sv/domain/model/filetype/FileType.kt: -------------------------------------------------------------------------------- 1 | package com.w2sv.domain.model.filetype 2 | 3 | import android.os.Parcelable 4 | import kotlinx.parcelize.IgnoredOnParcel 5 | 6 | sealed interface FileType : StaticFileType.ExtensionSet, Parcelable { 7 | val colorInt: Int 8 | 9 | @IgnoredOnParcel 10 | val asExtensionConfigurableTypeOrNull: PresetWrappingFileType.ExtensionConfigurable? 11 | get() = this as? PresetWrappingFileType.ExtensionConfigurable 12 | 13 | @IgnoredOnParcel 14 | val wrappedPresetTypeOrNull: PresetFileType? 15 | get() = (this as? PresetWrappingFileType<*>)?.presetFileType 16 | 17 | @IgnoredOnParcel 18 | val isMediaType: Boolean 19 | get() = wrappedPresetTypeOrNull is PresetFileType.Media 20 | } 21 | -------------------------------------------------------------------------------- /core/domain/src/main/kotlin/com/w2sv/domain/model/filetype/SourceType.kt: -------------------------------------------------------------------------------- 1 | package com.w2sv.domain.model.filetype 2 | 3 | import androidx.annotation.DrawableRes 4 | import androidx.annotation.StringRes 5 | import com.w2sv.core.common.R 6 | 7 | enum class SourceType( 8 | @StringRes val labelRes: Int, 9 | @DrawableRes val iconRes: Int 10 | ) { 11 | Camera( 12 | R.string.camera, 13 | R.drawable.ic_camera_24 14 | ), 15 | Screenshot( 16 | R.string.screenshot, 17 | R.drawable.ic_screenshot_24 18 | ), 19 | Recording( 20 | R.string.recording, 21 | R.drawable.ic_mic_24 22 | ), 23 | Download( 24 | R.string.download, 25 | R.drawable.ic_file_download_24 26 | ), 27 | OtherApp( 28 | R.string.other_app, 29 | R.drawable.ic_apps_24 30 | ) 31 | } 32 | -------------------------------------------------------------------------------- /core/domain/src/main/kotlin/com/w2sv/domain/model/filetype/StaticFileType.kt: -------------------------------------------------------------------------------- 1 | package com.w2sv.domain.model.filetype 2 | 3 | import android.content.Context 4 | import com.anggrayudi.storage.media.MediaType 5 | import com.w2sv.domain.model.navigatorconfig.FileTypeConfig 6 | import com.w2sv.domain.model.navigatorconfig.SourceConfig 7 | 8 | interface StaticFileType { 9 | val mediaType: MediaType 10 | val sourceTypes: List 11 | val ordinal: Int 12 | val iconRes: Int 13 | 14 | fun label(context: Context): String 15 | 16 | fun defaultConfig(enabled: Boolean = true): FileTypeConfig = 17 | FileTypeConfig( 18 | enabled = enabled, 19 | sourceTypeConfigMap = sourceTypes.associateWith { SourceConfig() } 20 | ) 21 | 22 | interface ExtensionSet : StaticFileType { 23 | val fileExtensions: Collection 24 | } 25 | 26 | interface ExtensionConfigurable : StaticFileType { 27 | val defaultFileExtensions: Set 28 | } 29 | 30 | sealed interface NonMedia : StaticFileType { 31 | override val mediaType get() = MediaType.DOWNLOADS 32 | override val sourceTypes get() = listOf(SourceType.Download) 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /core/domain/src/main/kotlin/com/w2sv/domain/model/movedestination/ExternalDestination.kt: -------------------------------------------------------------------------------- 1 | package com.w2sv.domain.model.movedestination 2 | 3 | import android.content.Context 4 | import com.w2sv.common.util.DocumentUri 5 | import com.w2sv.core.common.R 6 | 7 | data class ExternalDestination( 8 | override val documentUri: DocumentUri, 9 | override val providerPackageName: String?, 10 | override val providerAppLabel: String? 11 | ) : ExternalDestinationApi, FileDestinationApi { 12 | 13 | override fun uiRepresentation(context: Context): String { 14 | return providerAppLabel 15 | ?: documentUri.uri.authority 16 | ?: context.getString(R.string.unrecognized_destination) 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /core/domain/src/main/kotlin/com/w2sv/domain/model/movedestination/ExternalDestinationApi.kt: -------------------------------------------------------------------------------- 1 | package com.w2sv.domain.model.movedestination 2 | 3 | interface ExternalDestinationApi : MoveDestinationApi { 4 | val providerAppLabel: String? 5 | val providerPackageName: String? 6 | } 7 | -------------------------------------------------------------------------------- /core/domain/src/main/kotlin/com/w2sv/domain/model/movedestination/FileDestinationApi.kt: -------------------------------------------------------------------------------- 1 | package com.w2sv.domain.model.movedestination 2 | 3 | import android.content.Context 4 | 5 | interface FileDestinationApi : MoveDestinationApi { 6 | override fun fileName(context: Context): String = 7 | documentFile(context).name!! // TODO 8 | } 9 | -------------------------------------------------------------------------------- /core/domain/src/main/kotlin/com/w2sv/domain/model/movedestination/LocalDestinationApi.kt: -------------------------------------------------------------------------------- 1 | package com.w2sv.domain.model.movedestination 2 | 3 | import android.content.Context 4 | 5 | interface LocalDestinationApi : MoveDestinationApi { 6 | val isVolumeRoot: Boolean 7 | 8 | fun pathRepresentation(context: Context, includeVolumeName: Boolean): String 9 | } 10 | -------------------------------------------------------------------------------- /core/domain/src/main/kotlin/com/w2sv/domain/model/movedestination/MoveDestinationApi.kt: -------------------------------------------------------------------------------- 1 | package com.w2sv.domain.model.movedestination 2 | 3 | import android.content.Context 4 | import androidx.documentfile.provider.DocumentFile 5 | import com.w2sv.common.util.DocumentUri 6 | 7 | interface MoveDestinationApi { 8 | val documentUri: DocumentUri 9 | fun fileName(context: Context): String 10 | 11 | fun uiRepresentation(context: Context): String 12 | 13 | /** 14 | * @see DocumentFile.fromSingleUri 15 | */ 16 | fun documentFile(context: Context): DocumentFile = 17 | documentUri.documentFile(context) 18 | } 19 | -------------------------------------------------------------------------------- /core/domain/src/main/kotlin/com/w2sv/domain/model/navigatorconfig/AutoMoveConfig.kt: -------------------------------------------------------------------------------- 1 | package com.w2sv.domain.model.navigatorconfig 2 | 3 | import com.w2sv.domain.model.movedestination.LocalDestinationApi 4 | 5 | data class AutoMoveConfig( 6 | val enabled: Boolean, 7 | val destination: LocalDestinationApi? 8 | ) { 9 | companion object { 10 | val Empty = AutoMoveConfig(enabled = false, destination = null) 11 | } 12 | } 13 | -------------------------------------------------------------------------------- /core/domain/src/main/kotlin/com/w2sv/domain/model/navigatorconfig/FileTypeConfig.kt: -------------------------------------------------------------------------------- 1 | package com.w2sv.domain.model.navigatorconfig 2 | 3 | import com.w2sv.domain.model.filetype.SourceType 4 | 5 | typealias SourceTypeConfigMap = Map 6 | 7 | data class FileTypeConfig( 8 | val enabled: Boolean, 9 | val sourceTypeConfigMap: SourceTypeConfigMap 10 | ) 11 | -------------------------------------------------------------------------------- /core/domain/src/main/kotlin/com/w2sv/domain/model/navigatorconfig/SourceConfig.kt: -------------------------------------------------------------------------------- 1 | package com.w2sv.domain.model.navigatorconfig 2 | 3 | import com.w2sv.domain.model.movedestination.LocalDestinationApi 4 | 5 | data class SourceConfig( 6 | val enabled: Boolean = true, 7 | val quickMoveDestinations: List = emptyList(), 8 | val autoMoveConfig: AutoMoveConfig = AutoMoveConfig.Empty 9 | ) 10 | -------------------------------------------------------------------------------- /core/domain/src/main/kotlin/com/w2sv/domain/repository/MovedFileRepository.kt: -------------------------------------------------------------------------------- 1 | package com.w2sv.domain.repository 2 | 3 | import com.w2sv.domain.model.MovedFile 4 | import kotlinx.coroutines.flow.Flow 5 | 6 | interface MovedFileRepository { 7 | suspend fun insert(file: MovedFile) 8 | fun getAllInDescendingOrder(): Flow> 9 | suspend fun delete(file: MovedFile) 10 | suspend fun deleteAll() 11 | } 12 | -------------------------------------------------------------------------------- /core/domain/src/main/kotlin/com/w2sv/domain/repository/NavigatorConfigDataSource.kt: -------------------------------------------------------------------------------- 1 | package com.w2sv.domain.repository 2 | 3 | import com.w2sv.datastoreutils.datastoreflow.DataStoreFlow 4 | import com.w2sv.domain.model.filetype.FileType 5 | import com.w2sv.domain.model.filetype.SourceType 6 | import com.w2sv.domain.model.movedestination.LocalDestinationApi 7 | import com.w2sv.domain.model.navigatorconfig.NavigatorConfig 8 | import kotlinx.coroutines.flow.Flow 9 | 10 | interface NavigatorConfigDataSource { 11 | val navigatorConfig: DataStoreFlow 12 | 13 | // ================== 14 | // Auto move 15 | // ================== 16 | 17 | suspend fun unsetAutoMoveConfig(fileType: FileType, sourceType: SourceType) 18 | 19 | // ================== 20 | // Quick move 21 | // ================== 22 | 23 | suspend fun saveQuickMoveDestination( 24 | fileType: FileType, 25 | sourceType: SourceType, 26 | destination: LocalDestinationApi 27 | ) 28 | 29 | suspend fun unsetQuickMoveDestination(fileType: FileType, sourceType: SourceType) 30 | 31 | fun quickMoveDestinations(fileType: FileType, sourceType: SourceType): Flow> 32 | } 33 | -------------------------------------------------------------------------------- /core/domain/src/main/kotlin/com/w2sv/domain/repository/PreferencesRepository.kt: -------------------------------------------------------------------------------- 1 | package com.w2sv.domain.repository 2 | 3 | import com.w2sv.datastoreutils.datastoreflow.DataStoreFlow 4 | import com.w2sv.datastoreutils.datastoreflow.DataStoreStateFlow 5 | import com.w2sv.domain.model.Theme 6 | 7 | interface PreferencesRepository { 8 | val theme: DataStoreFlow 9 | val useAmoledBlackTheme: DataStoreFlow 10 | val useDynamicColors: DataStoreFlow 11 | val postNotificationsPermissionRequested: DataStoreFlow 12 | val showStorageVolumeNames: DataStoreFlow 13 | val showAutoMoveIntroduction: DataStoreFlow 14 | val showQuickMovePermissionQueryExplanation: DataStoreStateFlow 15 | } 16 | -------------------------------------------------------------------------------- /core/domain/src/main/kotlin/com/w2sv/domain/usecase/InsertMovedFileUseCase.kt: -------------------------------------------------------------------------------- 1 | package com.w2sv.domain.usecase 2 | 3 | import com.w2sv.domain.model.MovedFile 4 | import com.w2sv.domain.repository.MovedFileRepository 5 | import javax.inject.Inject 6 | import kotlinx.coroutines.Dispatchers 7 | import kotlinx.coroutines.withContext 8 | 9 | class InsertMovedFileUseCase @Inject constructor(private val movedFileRepository: MovedFileRepository) { 10 | suspend operator fun invoke(movedFile: MovedFile) { 11 | withContext(Dispatchers.IO) { 12 | movedFileRepository.insert(movedFile) 13 | } 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /core/domain/src/main/kotlin/com/w2sv/domain/usecase/MoveDestinationPathConverter.kt: -------------------------------------------------------------------------------- 1 | package com.w2sv.domain.usecase 2 | 3 | import android.content.Context 4 | import com.w2sv.common.di.AppDispatcher 5 | import com.w2sv.common.di.GlobalScope 6 | import com.w2sv.domain.model.movedestination.ExternalDestinationApi 7 | import com.w2sv.domain.model.movedestination.LocalDestinationApi 8 | import com.w2sv.domain.model.movedestination.MoveDestinationApi 9 | import com.w2sv.domain.repository.PreferencesRepository 10 | import javax.inject.Inject 11 | import javax.inject.Singleton 12 | import kotlinx.coroutines.CoroutineScope 13 | import kotlinx.coroutines.flow.SharingStarted 14 | 15 | @Singleton 16 | class MoveDestinationPathConverter @Inject constructor( 17 | preferencesRepository: PreferencesRepository, 18 | @GlobalScope(AppDispatcher.Default) scope: CoroutineScope 19 | ) { 20 | private val showStorageVolumeNames = 21 | preferencesRepository.showStorageVolumeNames.stateIn(scope, SharingStarted.Eagerly) 22 | 23 | operator fun invoke(moveDestination: MoveDestinationApi, context: Context): String = 24 | when (moveDestination) { 25 | is LocalDestinationApi -> { 26 | moveDestination.pathRepresentation( 27 | context = context, 28 | includeVolumeName = showStorageVolumeNames.value 29 | ) 30 | } 31 | 32 | is ExternalDestinationApi -> { 33 | moveDestination.uiRepresentation( 34 | context 35 | ) 36 | } 37 | 38 | else -> throw IllegalArgumentException() 39 | } 40 | } 41 | -------------------------------------------------------------------------------- /core/domain/src/test/kotlin/com/w2sv/domain/model/filetype/CustomFileTypeTest.kt: -------------------------------------------------------------------------------- 1 | package com.w2sv.domain.model.filetype 2 | 3 | import com.w2sv.test.testParceling 4 | import junit.framework.TestCase.assertEquals 5 | import org.junit.Test 6 | import org.junit.runner.RunWith 7 | import org.robolectric.RobolectricTestRunner 8 | 9 | @RunWith(RobolectricTestRunner::class) 10 | class CustomFileTypeTest { 11 | 12 | @Test 13 | fun testParceling() { 14 | CustomFileType( 15 | name = "Html", 16 | fileExtensions = listOf("html"), 17 | colorInt = 2134124, 18 | ordinal = 1004 19 | ) 20 | .testParceling() 21 | } 22 | 23 | @Test 24 | fun testNewEmpty() { 25 | fun test(existingOrdinals: List, expectedOrdinal: Int) { 26 | assertEquals( 27 | expectedOrdinal, 28 | CustomFileType.newEmpty( 29 | existingOrdinals.map { 30 | CustomFileType( 31 | name = "", 32 | fileExtensions = emptyList(), 33 | colorInt = -1, 34 | ordinal = it 35 | ) 36 | } 37 | ).ordinal 38 | ) 39 | } 40 | 41 | test(listOf(34, 0, 1, 3, 1003, 1007), 1008) 42 | test(listOf(0, 1, 3), CustomFileType.MIN_ORDINAL) 43 | } 44 | } 45 | -------------------------------------------------------------------------------- /core/domain/src/test/kotlin/com/w2sv/domain/model/filetype/FileAndSourceTypeTest.kt: -------------------------------------------------------------------------------- 1 | package com.w2sv.domain.model.filetype 2 | 3 | import com.w2sv.test.testParceling 4 | import org.junit.Test 5 | import org.junit.runner.RunWith 6 | import org.robolectric.RobolectricTestRunner 7 | 8 | @RunWith(RobolectricTestRunner::class) 9 | class FileAndSourceTypeTest { 10 | 11 | @Test 12 | fun `test parcelling`() { 13 | FileAndSourceType(PresetFileType.Image.toFileType(), SourceType.Screenshot).testParceling() 14 | FileAndSourceType(PresetFileType.Image.toFileType(color = 78325), SourceType.Screenshot).testParceling() 15 | FileAndSourceType(PresetFileType.EBook.toFileType(color = 234453, setOf("sdasf", "xscvs")), SourceType.Download).testParceling() 16 | FileAndSourceType(CustomFileType("Custom", listOf("ext"), 2345213, 1008), SourceType.Download).testParceling() 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /core/domain/src/test/kotlin/com/w2sv/domain/model/filetype/PresetFileTypeTest.kt: -------------------------------------------------------------------------------- 1 | package com.w2sv.domain.model.filetype 2 | 3 | import junit.framework.TestCase.assertEquals 4 | import org.junit.Test 5 | 6 | class PresetFileTypeTest { 7 | 8 | @Test 9 | fun testOrdinalsMap() { 10 | assertEquals( 11 | "{Image=0, Video=1, Audio=2, PDF=3, Text=4, Archive=5, APK=6, EBook=7}", 12 | PresetFileType.ordinalsMap.toString() 13 | ) 14 | } 15 | 16 | @Test 17 | fun testGet() { 18 | assertEquals(PresetFileType.Image, PresetFileType[0]) 19 | assertEquals(PresetFileType.EBook, PresetFileType[7]) 20 | } 21 | 22 | @Test 23 | fun testExtensionSetToFileType() { 24 | assertEquals( 25 | PresetWrappingFileType.ExtensionSet(PresetFileType.Image, PresetFileType.Image.defaultColorInt), 26 | PresetFileType.Image.toDefaultFileType() 27 | ) 28 | 29 | assertEquals( 30 | PresetWrappingFileType.ExtensionSet(PresetFileType.Image, 342347), 31 | PresetFileType.Image.toFileType(342347) 32 | ) 33 | } 34 | 35 | @Test 36 | fun testExtensionConfigurableToFileType() { 37 | assertEquals( 38 | PresetWrappingFileType.ExtensionConfigurable( 39 | presetFileType = PresetFileType.Archive, 40 | colorInt = PresetFileType.Archive.defaultColorInt, 41 | excludedExtensions = emptySet() 42 | ), 43 | PresetFileType.Archive.toDefaultFileType() 44 | ) 45 | 46 | assertEquals( 47 | PresetWrappingFileType.ExtensionConfigurable( 48 | presetFileType = PresetFileType.Archive, 49 | colorInt = 124325, 50 | excludedExtensions = setOf("sdfa") 51 | ), 52 | PresetFileType.Archive.toFileType(124325, setOf("sdfa")) 53 | ) 54 | } 55 | } 56 | -------------------------------------------------------------------------------- /core/navigator/.gitignore: -------------------------------------------------------------------------------- 1 | /build -------------------------------------------------------------------------------- /core/navigator/build.gradle.kts: -------------------------------------------------------------------------------- 1 | plugins { 2 | alias(libs.plugins.filenavigator.library) 3 | alias(libs.plugins.filenavigator.hilt) 4 | alias(libs.plugins.kotlin.parcelize) 5 | } 6 | 7 | android { 8 | buildFeatures { 9 | viewBinding = true 10 | } 11 | } 12 | 13 | dependencies { 14 | implementation(projects.core.domain) 15 | implementation(projects.core.common) 16 | 17 | implementation(libs.androidx.core) 18 | implementation(libs.androidx.activity) 19 | implementation(libs.androidx.appcompat) 20 | 21 | implementation(libs.w2sv.androidutils.core) 22 | implementation(libs.w2sv.kotlinutils) 23 | implementation(libs.slimber) 24 | 25 | implementation(libs.google.guava) 26 | 27 | implementation(libs.w2sv.simplestorage) 28 | 29 | // ============== 30 | // Test 31 | // ============== 32 | 33 | testImplementation(projects.core.test) 34 | } 35 | -------------------------------------------------------------------------------- /core/navigator/src/main/kotlin/com/w2sv/navigator/FileNavigatorModule.kt: -------------------------------------------------------------------------------- 1 | package com.w2sv.navigator 2 | 3 | import android.content.Context 4 | import com.w2sv.androidutils.isServiceRunning 5 | import com.w2sv.navigator.moving.model.MediaIdWithMediaType 6 | import com.w2sv.navigator.moving.model.MoveResult 7 | import dagger.Module 8 | import dagger.Provides 9 | import dagger.hilt.InstallIn 10 | import dagger.hilt.android.qualifiers.ApplicationContext 11 | import dagger.hilt.components.SingletonComponent 12 | import javax.inject.Singleton 13 | import kotlinx.coroutines.channels.Channel 14 | import kotlinx.coroutines.flow.MutableSharedFlow 15 | import kotlinx.coroutines.flow.MutableStateFlow 16 | import kotlinx.coroutines.flow.SharedFlow 17 | import kotlinx.coroutines.flow.asSharedFlow 18 | 19 | internal typealias MoveResultChannel = Channel 20 | 21 | @InstallIn(SingletonComponent::class) 22 | @Module 23 | object FileNavigatorModule { 24 | 25 | @Singleton 26 | @Provides 27 | fun fileNavigatorIsRunning(@ApplicationContext context: Context): FileNavigator.IsRunning = 28 | FileNavigator.IsRunning(mutableStateFlow = MutableStateFlow(context.isServiceRunning())) 29 | 30 | @Singleton // TODO: ServiceScoped 31 | @Provides 32 | internal fun moveResultChannel(): MoveResultChannel = Channel(Channel.BUFFERED) 33 | 34 | @Singleton // TODO: ServiceScoped 35 | @Provides 36 | internal fun mutableBlacklistedMediaUriSharedFlow(): MutableSharedFlow = 37 | MutableSharedFlow() 38 | 39 | @Provides 40 | internal fun blacklistedMediaUriSharedFlow( 41 | mutableBlacklistedMediaUriSharedFlow: MutableSharedFlow 42 | ): SharedFlow = 43 | mutableBlacklistedMediaUriSharedFlow.asSharedFlow() 44 | } 45 | -------------------------------------------------------------------------------- /core/navigator/src/main/kotlin/com/w2sv/navigator/moving/MoveBroadcastReceiver.kt: -------------------------------------------------------------------------------- 1 | package com.w2sv.navigator.moving 2 | 3 | import android.content.BroadcastReceiver 4 | import android.content.Context 5 | import android.content.Intent 6 | import com.w2sv.common.di.AppDispatcher 7 | import com.w2sv.common.di.GlobalScope 8 | import com.w2sv.navigator.MoveResultChannel 9 | import com.w2sv.navigator.moving.model.AnyMoveBundle 10 | import com.w2sv.navigator.moving.model.MoveBundle 11 | import dagger.hilt.android.AndroidEntryPoint 12 | import javax.inject.Inject 13 | import kotlinx.coroutines.CoroutineScope 14 | import kotlinx.coroutines.launch 15 | 16 | @AndroidEntryPoint 17 | internal class MoveBroadcastReceiver : BroadcastReceiver() { 18 | 19 | @Inject 20 | lateinit var moveResultChannel: MoveResultChannel 21 | 22 | @Inject 23 | @GlobalScope(AppDispatcher.IO) 24 | lateinit var scope: CoroutineScope 25 | 26 | override fun onReceive(context: Context, intent: Intent) { 27 | val moveBundle = MoveBundle.fromIntent(intent) 28 | 29 | scope.launch { 30 | with(moveBundle) { 31 | file.moveTo(destination = destination, context = context) { result -> 32 | moveResultChannel.trySend( 33 | result bundleWith this 34 | ) 35 | } 36 | } 37 | } 38 | } 39 | 40 | companion object { 41 | fun sendBroadcast(moveBundle: AnyMoveBundle, context: Context) { 42 | context.sendBroadcast( 43 | getIntent( 44 | moveBundle = moveBundle, 45 | context = context 46 | ) 47 | ) 48 | } 49 | 50 | fun getIntent(moveBundle: AnyMoveBundle, context: Context): Intent = 51 | Intent(context, MoveBroadcastReceiver::class.java) 52 | .putExtra(MoveBundle.EXTRA, moveBundle) 53 | } 54 | } 55 | -------------------------------------------------------------------------------- /core/navigator/src/main/kotlin/com/w2sv/navigator/moving/api/activity/AbstractDestinationPickerActivity.kt: -------------------------------------------------------------------------------- 1 | package com.w2sv.navigator.moving.api.activity 2 | 3 | import android.content.ComponentName 4 | import android.content.Context 5 | import android.content.Intent 6 | import android.os.Bundle 7 | import android.os.Parcelable 8 | import androidx.annotation.CallSuper 9 | import com.w2sv.common.util.DocumentUri 10 | import com.w2sv.common.util.isExternalStorageManger 11 | import com.w2sv.navigator.moving.model.MoveResult 12 | 13 | internal abstract class AbstractDestinationPickerActivity : AbstractMoveActivity() { 14 | 15 | override fun onCreate(savedInstanceState: Bundle?) { 16 | super.onCreate(savedInstanceState) 17 | 18 | preemptiveMoveFailure()?.let { sendMoveResultBundleAndFinishAndRemoveTask(it) } ?: run { launchPicker() } 19 | } 20 | 21 | abstract fun launchPicker() 22 | 23 | @CallSuper 24 | protected open fun preemptiveMoveFailure(): MoveResult.Failure? = 25 | when { 26 | !isExternalStorageManger -> MoveResult.ManageAllFilesPermissionMissing 27 | else -> null 28 | } 29 | 30 | interface Args : Parcelable { 31 | val pickerStartDestination: DocumentUri? 32 | 33 | companion object { 34 | const val EXTRA = "com.w2sv.navigator.extra.AbstractDestinationPickerActivity.Args" 35 | } 36 | } 37 | 38 | companion object { 39 | inline fun makeRestartActivityIntent(args: Args, context: Context): Intent = 40 | Intent.makeRestartActivityTask( 41 | ComponentName( 42 | context, 43 | T::class.java 44 | ) 45 | ) 46 | .putExtra(Args.EXTRA, args) 47 | } 48 | } 49 | -------------------------------------------------------------------------------- /core/navigator/src/main/kotlin/com/w2sv/navigator/moving/api/activity/AbstractMoveActivity.kt: -------------------------------------------------------------------------------- 1 | package com.w2sv.navigator.moving.api.activity 2 | 3 | import com.w2sv.common.util.LoggingComponentActivity 4 | import com.w2sv.navigator.MoveResultChannel 5 | import com.w2sv.navigator.moving.model.MoveResult 6 | import com.w2sv.navigator.notifications.NotificationResources 7 | import dagger.hilt.android.AndroidEntryPoint 8 | import javax.inject.Inject 9 | 10 | @AndroidEntryPoint 11 | internal abstract class AbstractMoveActivity : LoggingComponentActivity() { 12 | 13 | @Inject 14 | lateinit var moveResultChannel: MoveResultChannel 15 | 16 | protected fun sendMoveResultBundleAndFinishAndRemoveTask( 17 | moveFailure: MoveResult.Failure, 18 | notificationResources: NotificationResources? = null 19 | ) { 20 | moveResultChannel.trySend(moveFailure bundleWith notificationResources) 21 | finishAndRemoveTask() 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /core/navigator/src/main/kotlin/com/w2sv/navigator/moving/batch/CancelBatchMoveBroadcastReceiver.kt: -------------------------------------------------------------------------------- 1 | package com.w2sv.navigator.moving.batch 2 | 3 | import android.content.Context 4 | import android.content.Intent 5 | import com.w2sv.common.util.LoggingBroadcastReceiver 6 | import dagger.hilt.android.AndroidEntryPoint 7 | import javax.inject.Inject 8 | 9 | @AndroidEntryPoint 10 | internal class CancelBatchMoveBroadcastReceiver : LoggingBroadcastReceiver() { 11 | 12 | @Inject 13 | lateinit var batchMoveJobHolder: BatchMoveBroadcastReceiver.JobHolder 14 | 15 | override fun onReceive(context: Context, intent: Intent) { 16 | super.onReceive(context, intent) 17 | batchMoveJobHolder.job?.cancel() 18 | } 19 | 20 | companion object { 21 | fun intent(context: Context): Intent = 22 | Intent(context, CancelBatchMoveBroadcastReceiver::class.java) 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /core/navigator/src/main/kotlin/com/w2sv/navigator/moving/model/DestinationSelectionManner.kt: -------------------------------------------------------------------------------- 1 | package com.w2sv.navigator.moving.model 2 | 3 | import android.os.Parcelable 4 | import com.w2sv.navigator.notifications.NotificationResources 5 | import kotlinx.parcelize.Parcelize 6 | 7 | @Parcelize 8 | internal sealed interface DestinationSelectionManner : Parcelable { 9 | 10 | sealed interface NotificationBased : DestinationSelectionManner { 11 | val notificationResources: NotificationResources 12 | } 13 | 14 | @Parcelize 15 | data class Picked( 16 | override val notificationResources: NotificationResources 17 | ) : NotificationBased 18 | 19 | @Parcelize 20 | data class Quick( 21 | override val notificationResources: NotificationResources 22 | ) : NotificationBased 23 | 24 | @Parcelize 25 | data object Auto : DestinationSelectionManner 26 | 27 | val isPicked: Boolean 28 | get() = this is Picked 29 | 30 | val isAuto: Boolean 31 | get() = this is Auto 32 | } 33 | -------------------------------------------------------------------------------- /core/navigator/src/main/kotlin/com/w2sv/navigator/moving/model/MediaIdWithMediaType.kt: -------------------------------------------------------------------------------- 1 | package com.w2sv.navigator.moving.model 2 | 3 | import com.anggrayudi.storage.media.MediaType 4 | import com.w2sv.common.util.MediaId 5 | 6 | data class MediaIdWithMediaType(val mediaId: MediaId, val mediaType: MediaType) 7 | -------------------------------------------------------------------------------- /core/navigator/src/main/kotlin/com/w2sv/navigator/moving/model/MoveFile.kt: -------------------------------------------------------------------------------- 1 | package com.w2sv.navigator.moving.model 2 | 3 | import android.content.Context 4 | import android.content.Intent 5 | import android.os.Parcelable 6 | import com.anggrayudi.storage.media.MediaFile 7 | import com.anggrayudi.storage.media.MediaStoreCompat 8 | import com.w2sv.androidutils.os.getParcelableCompat 9 | import com.w2sv.common.util.MediaUri 10 | import com.w2sv.domain.model.filetype.FileAndSourceType 11 | import com.w2sv.domain.model.filetype.FileType 12 | import com.w2sv.domain.model.filetype.PresetFileType 13 | import com.w2sv.domain.model.filetype.SourceType 14 | import com.w2sv.navigator.observing.model.MediaStoreFileData 15 | import kotlinx.parcelize.Parcelize 16 | 17 | @Parcelize 18 | internal data class MoveFile( 19 | val mediaUri: MediaUri, 20 | val mediaStoreFileData: MediaStoreFileData, 21 | val fileAndSourceType: FileAndSourceType 22 | ) : Parcelable { 23 | 24 | fun simpleStorageMediaFile(context: Context): MediaFile? = 25 | MediaStoreCompat.fromMediaId( 26 | context = context, 27 | mediaType = fileType.mediaType, 28 | id = mediaStoreFileData.rowId 29 | ) 30 | 31 | val fileType: FileType 32 | get() = fileAndSourceType.fileType 33 | 34 | val sourceType: SourceType 35 | get() = fileAndSourceType.sourceType 36 | 37 | val isGif: Boolean 38 | get() = fileType.wrappedPresetTypeOrNull is PresetFileType.Image && mediaStoreFileData.extension.lowercase() == "gif" 39 | 40 | companion object { 41 | const val EXTRA = "com.w2sv.filenavigator.extra.MoveFile" 42 | 43 | fun fromIntent(intent: Intent): MoveFile = 44 | intent.getParcelableCompat(EXTRA)!! 45 | } 46 | } 47 | -------------------------------------------------------------------------------- /core/navigator/src/main/kotlin/com/w2sv/navigator/notifications/CleanupNotificationResourcesBroadcastReceiver.kt: -------------------------------------------------------------------------------- 1 | package com.w2sv.navigator.notifications 2 | 3 | import android.content.Context 4 | import android.content.Intent 5 | import com.w2sv.common.util.LoggingBroadcastReceiver 6 | import com.w2sv.common.util.log 7 | import com.w2sv.navigator.notifications.api.MultiInstanceNotificationManager 8 | import com.w2sv.navigator.shared.plus 9 | import dagger.hilt.android.AndroidEntryPoint 10 | import javax.inject.Inject 11 | 12 | @AndroidEntryPoint 13 | internal class CleanupNotificationResourcesBroadcastReceiver : LoggingBroadcastReceiver() { 14 | 15 | @Inject 16 | @JvmSuppressWildcards 17 | lateinit var multiInstanceAppNotificationManagers: Set> 18 | 19 | override fun onReceive(context: Context, intent: Intent) { 20 | super.onReceive(context, intent) 21 | 22 | NotificationResources.Companion.optionalFromIntent(intent) 23 | ?.let { resources -> 24 | multiInstanceAppNotificationManagers 25 | .first { notificationManager -> 26 | resources.managerClassName == notificationManager.resourcesIdentifier 27 | } 28 | .log { "Cleaning up ${it.resourcesIdentifier} resources" } 29 | .cancelNotification(resources.id) 30 | } 31 | } 32 | 33 | companion object { 34 | fun getIntent(context: Context, notificationResources: NotificationResources): Intent = 35 | Intent( 36 | context, 37 | CleanupNotificationResourcesBroadcastReceiver::class.java 38 | ) + notificationResources 39 | 40 | fun start(context: Context, notificationResources: NotificationResources) { 41 | context.sendBroadcast(getIntent(context, notificationResources)) 42 | } 43 | } 44 | } 45 | -------------------------------------------------------------------------------- /core/navigator/src/main/kotlin/com/w2sv/navigator/notifications/NotificationModule.kt: -------------------------------------------------------------------------------- 1 | package com.w2sv.navigator.notifications 2 | 3 | import android.app.NotificationManager 4 | import android.content.Context 5 | import com.w2sv.androidutils.getNotificationManager 6 | import com.w2sv.navigator.notifications.api.MultiInstanceNotificationManager 7 | import com.w2sv.navigator.notifications.appnotifications.AutoMoveDestinationInvalidNotificationManager 8 | import com.w2sv.navigator.notifications.appnotifications.movefile.MoveFileNotificationManager 9 | import dagger.Module 10 | import dagger.Provides 11 | import dagger.hilt.InstallIn 12 | import dagger.hilt.android.qualifiers.ApplicationContext 13 | import dagger.hilt.components.SingletonComponent 14 | import dagger.multibindings.ElementsIntoSet 15 | import javax.inject.Singleton 16 | 17 | @InstallIn(SingletonComponent::class) 18 | @Module 19 | internal object NotificationModule { 20 | 21 | @Singleton 22 | @Provides 23 | fun notificationManager(@ApplicationContext context: Context): NotificationManager = 24 | context.getNotificationManager() 25 | 26 | @Singleton 27 | @Provides 28 | @ElementsIntoSet 29 | fun multiInstanceAppNotificationManagers( 30 | moveFileNotificationManager: MoveFileNotificationManager, 31 | autoMoveDestinationInvalidNotificationManager: AutoMoveDestinationInvalidNotificationManager 32 | ): Set> = 33 | setOf(moveFileNotificationManager, autoMoveDestinationInvalidNotificationManager) 34 | } 35 | -------------------------------------------------------------------------------- /core/navigator/src/main/kotlin/com/w2sv/navigator/notifications/NotificationResources.kt: -------------------------------------------------------------------------------- 1 | package com.w2sv.navigator.notifications 2 | 3 | import android.content.Context 4 | import android.content.Intent 5 | import android.os.Parcelable 6 | import com.w2sv.androidutils.os.getParcelableCompat 7 | import kotlinx.parcelize.Parcelize 8 | 9 | @Parcelize 10 | internal data class NotificationResources( 11 | val id: Int, 12 | val managerClassName: String 13 | ) : Parcelable { 14 | 15 | fun pendingIntentRequestCodes(count: Int): List = 16 | (id until id + count).toList() 17 | 18 | fun cancelNotification(context: Context) { 19 | CleanupNotificationResourcesBroadcastReceiver.start( 20 | context = context, 21 | notificationResources = this 22 | ) 23 | } 24 | 25 | companion object { 26 | const val EXTRA = "com.w2sv.filenavigator.extra.NotificationResources" 27 | 28 | fun optionalFromIntent(intent: Intent): NotificationResources? = 29 | intent.getParcelableCompat(EXTRA) 30 | 31 | fun fromIntent(intent: Intent): NotificationResources = 32 | optionalFromIntent(intent)!! 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /core/navigator/src/main/kotlin/com/w2sv/navigator/notifications/api/AppNotificationManager.kt: -------------------------------------------------------------------------------- 1 | package com.w2sv.navigator.notifications.api 2 | 3 | import android.app.Notification 4 | import android.app.NotificationChannel 5 | import android.app.NotificationManager 6 | import android.content.Context 7 | import androidx.annotation.CallSuper 8 | import androidx.core.app.NotificationCompat 9 | import com.w2sv.core.common.R 10 | import com.w2sv.navigator.notifications.appnotifications.AppNotificationChannel 11 | 12 | internal abstract class AppNotificationManager( 13 | appNotificationChannel: AppNotificationChannel, 14 | protected val notificationManager: NotificationManager, 15 | protected val context: Context 16 | ) { 17 | protected val notificationChannel: NotificationChannel = 18 | appNotificationChannel.getNotificationChannel(context) 19 | 20 | init { 21 | notificationManager.createNotificationChannel(notificationChannel) 22 | } 23 | 24 | open inner class Builder : NotificationCompat.Builder(context, notificationChannel.id) { 25 | 26 | @CallSuper 27 | override fun build(): Notification { 28 | setSmallIcon(R.drawable.ic_app_logo_24) 29 | 30 | priority = NotificationCompat.PRIORITY_DEFAULT 31 | 32 | return super.build() 33 | } 34 | } 35 | 36 | protected fun buildAndPostNotification(id: Int, args: Args) { 37 | notificationManager.notify(id, buildNotification(args)) 38 | } 39 | 40 | fun buildNotification(args: Args): Notification = 41 | getBuilder(args).build() 42 | 43 | protected abstract fun getBuilder(args: Args): Builder 44 | } 45 | -------------------------------------------------------------------------------- /core/navigator/src/main/kotlin/com/w2sv/navigator/notifications/api/SingleInstanceNotificationManager.kt: -------------------------------------------------------------------------------- 1 | package com.w2sv.navigator.notifications.api 2 | 3 | import android.app.NotificationManager 4 | import android.content.Context 5 | import com.w2sv.navigator.notifications.appnotifications.AppNotificationChannel 6 | import com.w2sv.navigator.notifications.appnotifications.AppNotificationId 7 | 8 | /** 9 | * Manager for notifications of which only a single instance may be active at a time. 10 | */ 11 | internal abstract class SingleInstanceNotificationManager( 12 | appNotificationChannel: AppNotificationChannel, 13 | notificationManager: NotificationManager, 14 | context: Context, 15 | private val appNotificationId: AppNotificationId 16 | ) : AppNotificationManager( 17 | appNotificationChannel = appNotificationChannel, 18 | notificationManager = notificationManager, 19 | context = context 20 | ) { 21 | fun buildAndPostNotification(args: Args) { 22 | buildAndPostNotification(appNotificationId.id, args) 23 | } 24 | 25 | fun cancelNotification() { 26 | notificationManager.cancel(appNotificationId.id) 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /core/navigator/src/main/kotlin/com/w2sv/navigator/notifications/appnotifications/AppNotificationChannel.kt: -------------------------------------------------------------------------------- 1 | package com.w2sv.navigator.notifications.appnotifications 2 | 3 | import android.app.NotificationChannel 4 | import android.app.NotificationManager 5 | import android.content.Context 6 | import androidx.annotation.StringRes 7 | import com.w2sv.core.common.R 8 | 9 | /** 10 | * Enum assures required id-uniqueness of resulting [NotificationChannel]. 11 | */ 12 | internal enum class AppNotificationChannel(@StringRes val nameRes: Int) { 13 | FileNavigatorIsRunning(R.string.file_navigator_is_running), 14 | NewNavigatableFile(R.string.new_navigatable_file), 15 | AutoMoveDestinationInvalid(R.string.auto_move_destination_invalid), 16 | MoveProgress(R.string.move_progress); 17 | 18 | fun getNotificationChannel(context: Context, importance: Int = NotificationManager.IMPORTANCE_DEFAULT): NotificationChannel = 19 | NotificationChannel( 20 | name, 21 | context.getString(nameRes), 22 | importance 23 | ) 24 | } 25 | -------------------------------------------------------------------------------- /core/navigator/src/main/kotlin/com/w2sv/navigator/notifications/appnotifications/AppNotificationId.kt: -------------------------------------------------------------------------------- 1 | package com.w2sv.navigator.notifications.appnotifications 2 | 3 | /** 4 | * Enum assures required [id]-uniqueness. 5 | */ 6 | internal enum class AppNotificationId { 7 | FileNavigatorIsRunning, 8 | NewNavigatableFile, 9 | AutoMoveDestinationInvalid, 10 | BatchMoveFiles, 11 | MoveProgress; 12 | 13 | val id: Int by lazy { 14 | ordinal + 1 // 0 is an invalid notification ID 15 | } 16 | 17 | val multiInstanceIdBase: Int by lazy { 18 | id * 1000 19 | } 20 | 21 | val summaryId: Int by lazy { 22 | multiInstanceIdBase + 999 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /core/navigator/src/main/kotlin/com/w2sv/navigator/notifications/appnotifications/Shared.kt: -------------------------------------------------------------------------------- 1 | package com.w2sv.navigator.notifications.appnotifications 2 | 3 | import android.content.Context 4 | import android.graphics.Bitmap 5 | import androidx.annotation.ColorInt 6 | import androidx.annotation.DrawableRes 7 | import androidx.appcompat.content.res.AppCompatResources 8 | import androidx.core.graphics.drawable.toBitmap 9 | import com.w2sv.domain.model.filetype.FileAndSourceType 10 | 11 | internal fun FileAndSourceType.iconBitmap(context: Context, colored: Boolean = false): Bitmap? = 12 | context.drawableBitmap( 13 | drawable = iconRes, 14 | tint = if (colored) fileType.colorInt else null 15 | ) 16 | 17 | internal fun Context.drawableBitmap(@DrawableRes drawable: Int, @ColorInt tint: Int? = null): Bitmap? = 18 | AppCompatResources.getDrawable(this, drawable) 19 | ?.apply { tint?.let { setTint(it) } } 20 | ?.toBitmap() 21 | -------------------------------------------------------------------------------- /core/navigator/src/main/kotlin/com/w2sv/navigator/observing/FileObserverModule.kt: -------------------------------------------------------------------------------- 1 | package com.w2sv.navigator.observing 2 | 3 | import android.os.HandlerThread 4 | import com.w2sv.common.util.log 5 | import dagger.Module 6 | import dagger.Provides 7 | import dagger.hilt.InstallIn 8 | import dagger.hilt.android.components.ServiceComponent 9 | import dagger.hilt.android.scopes.ServiceScoped 10 | import javax.inject.Qualifier 11 | 12 | @Qualifier 13 | @Retention(AnnotationRetention.BINARY) 14 | annotation class FileObserverHandlerThread 15 | 16 | @InstallIn(ServiceComponent::class) 17 | @Module 18 | internal object FileObserverModule { 19 | 20 | @Provides 21 | @ServiceScoped 22 | @FileObserverHandlerThread 23 | fun fileObserverHandlerThread(): HandlerThread = 24 | HandlerThread("com.w2sv.filenavigator.FileObserverHandlerThread") 25 | .apply { start() } 26 | .log { "Initialized ${it.name}" } 27 | } 28 | -------------------------------------------------------------------------------- /core/navigator/src/main/kotlin/com/w2sv/navigator/shared/AlertDialog.kt: -------------------------------------------------------------------------------- 1 | package com.w2sv.navigator.shared 2 | 3 | import android.content.Context 4 | import android.view.LayoutInflater 5 | import androidx.annotation.DrawableRes 6 | import androidx.appcompat.app.AlertDialog 7 | import com.w2sv.core.navigator.R 8 | import com.w2sv.core.navigator.databinding.DialogHeaderBinding 9 | 10 | internal fun AlertDialog.Builder.setIconHeader(@DrawableRes iconRes: Int): AlertDialog.Builder = 11 | apply { 12 | setCustomTitle( 13 | DialogHeaderBinding 14 | .inflate(LayoutInflater.from(context)) 15 | .apply { icon.setImageResource(iconRes) } 16 | .root 17 | ) 18 | } 19 | 20 | internal fun roundedCornersAlertDialogBuilder(context: Context): AlertDialog.Builder = 21 | AlertDialog 22 | .Builder(context, R.style.RoundedCornersAlertDialog) 23 | -------------------------------------------------------------------------------- /core/navigator/src/main/kotlin/com/w2sv/navigator/shared/DialogHostingActivity.kt: -------------------------------------------------------------------------------- 1 | package com.w2sv.navigator.shared 2 | 3 | import android.app.Dialog 4 | import androidx.appcompat.app.AlertDialog 5 | import com.w2sv.navigator.moving.api.activity.AbstractMoveActivity 6 | 7 | internal abstract class DialogHostingActivity : AbstractMoveActivity() { 8 | 9 | protected var dialog: Dialog? = null 10 | 11 | protected fun showDialog(builder: AlertDialog.Builder) { 12 | dialog = builder.create().also { it.show() } 13 | } 14 | 15 | override fun onDestroy() { 16 | super.onDestroy() 17 | 18 | // Prevents 'android.view.WindowLeaked: Activity ... has 19 | // leaked window DecorView@f70b286[QuickMoveDestinationPermissionQueryOverlayDialogActivity] that was originally added here' 20 | dialog?.dismiss() 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /core/navigator/src/main/kotlin/com/w2sv/navigator/shared/Intent.kt: -------------------------------------------------------------------------------- 1 | package com.w2sv.navigator.shared 2 | 3 | import android.app.PendingIntent 4 | import android.content.ComponentName 5 | import android.content.Context 6 | import android.content.Intent 7 | import com.w2sv.navigator.moving.model.MoveFile 8 | import com.w2sv.navigator.notifications.NotificationResources 9 | 10 | internal fun mainActivityPendingIntent(context: Context): PendingIntent = 11 | PendingIntent.getActivity( 12 | context, 13 | 1, 14 | mainActivityIntent(context), 15 | PendingIntent.FLAG_IMMUTABLE 16 | ) 17 | 18 | internal fun mainActivityIntent(context: Context): Intent = 19 | Intent.makeRestartActivityTask( 20 | ComponentName(context, "com.w2sv.filenavigator.MainActivity") 21 | ) 22 | 23 | internal operator fun Intent.plus(notificationResources: NotificationResources?): Intent = 24 | putExtra(NotificationResources.EXTRA, notificationResources) 25 | 26 | internal operator fun Intent.plus(moveFile: MoveFile): Intent = 27 | putExtra(MoveFile.EXTRA, moveFile) 28 | -------------------------------------------------------------------------------- /core/navigator/src/main/kotlin/com/w2sv/navigator/shared/Logging.kt: -------------------------------------------------------------------------------- 1 | package com.w2sv.navigator.shared 2 | 3 | import slimber.log.i 4 | 5 | internal fun emitDiscardedLog(reason: () -> String) { 6 | i { "DISCARDED: ${reason()}" } 7 | } 8 | -------------------------------------------------------------------------------- /core/navigator/src/main/kotlin/com/w2sv/navigator/system_broadcastreceiver/BootCompletedReceiver.kt: -------------------------------------------------------------------------------- 1 | package com.w2sv.navigator.system_broadcastreceiver 2 | 3 | import android.content.Context 4 | import android.content.Intent 5 | import com.w2sv.navigator.FileNavigator 6 | import slimber.log.i 7 | 8 | internal class BootCompletedReceiver : SystemBroadcastReceiver(Intent.ACTION_BOOT_COMPLETED) { 9 | 10 | override fun onReceiveMatchingIntent(context: Context, intent: Intent) { 11 | i { "BootCompletedReceiver.onReceive" } 12 | 13 | FileNavigator.start(context) 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /core/navigator/src/main/kotlin/com/w2sv/navigator/system_broadcastreceiver/PowerSaveModeChangedReceiver.kt: -------------------------------------------------------------------------------- 1 | package com.w2sv.navigator.system_broadcastreceiver 2 | 3 | import android.content.Context 4 | import android.content.Intent 5 | import android.os.PowerManager 6 | import com.w2sv.navigator.FileNavigator 7 | import dagger.hilt.android.AndroidEntryPoint 8 | import javax.inject.Inject 9 | import slimber.log.i 10 | 11 | @AndroidEntryPoint 12 | internal class PowerSaveModeChangedReceiver : 13 | SystemBroadcastReceiver(PowerManager.ACTION_POWER_SAVE_MODE_CHANGED) { 14 | 15 | @Inject 16 | internal lateinit var powerManager: PowerManager 17 | 18 | override fun onReceiveMatchingIntent(context: Context, intent: Intent) { 19 | if (powerManager.isPowerSaveMode) { 20 | i { "Stopping FileNavigator due to power save mode" } 21 | FileNavigator.stop(context) 22 | } 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /core/navigator/src/main/kotlin/com/w2sv/navigator/system_broadcastreceiver/SystemBroadcastReceiver.kt: -------------------------------------------------------------------------------- 1 | package com.w2sv.navigator.system_broadcastreceiver 2 | 3 | import android.content.Context 4 | import android.content.Intent 5 | import android.content.IntentFilter 6 | import com.w2sv.common.util.LoggingBroadcastReceiver 7 | import com.w2sv.common.util.logIdentifier 8 | import slimber.log.i 9 | 10 | abstract class SystemBroadcastReceiver(private val action: String) : LoggingBroadcastReceiver() { 11 | 12 | override fun onReceive(context: Context, intent: Intent) { 13 | super.onReceive(context, intent) 14 | 15 | if (intent.action != action) return 16 | 17 | onReceiveMatchingIntent(context, intent) 18 | } 19 | 20 | protected abstract fun onReceiveMatchingIntent(context: Context, intent: Intent) 21 | 22 | fun toggle(register: Boolean, context: Context) { 23 | try { 24 | if (register) { 25 | register(context) 26 | } else { 27 | unregister(context) 28 | } 29 | } catch (_: IllegalArgumentException) { // Thrown upon attempting to unregister unregistered receiver 30 | } 31 | } 32 | 33 | fun register(context: Context) { 34 | context.registerReceiver( 35 | this, 36 | IntentFilter() 37 | .apply { 38 | addAction(action) 39 | } 40 | ) 41 | i { "Registered $logIdentifier" } 42 | } 43 | 44 | fun unregister(context: Context) { 45 | context.unregisterReceiver(this) 46 | i { "Unregistered $logIdentifier" } 47 | } 48 | } 49 | -------------------------------------------------------------------------------- /core/navigator/src/main/kotlin/com/w2sv/navigator/system_broadcastreceiver/di/SystemBroadcastReceiverBinderModule.kt: -------------------------------------------------------------------------------- 1 | package com.w2sv.navigator.system_broadcastreceiver.di 2 | 3 | import com.w2sv.navigator.system_broadcastreceiver.manager.NavigatorConfigControlledSystemBroadcastReceiverManager 4 | import com.w2sv.navigator.system_broadcastreceiver.manager.NavigatorConfigControlledSystemBroadcastReceiverManagerImpl 5 | import dagger.Binds 6 | import dagger.Module 7 | import dagger.hilt.InstallIn 8 | import dagger.hilt.components.SingletonComponent 9 | 10 | @InstallIn(SingletonComponent::class) 11 | @Module 12 | internal interface SystemBroadcastReceiverBinderModule { 13 | 14 | @Binds 15 | fun navigatorConfigControlledSystemBroadcastReceiverManager( 16 | impl: NavigatorConfigControlledSystemBroadcastReceiverManagerImpl 17 | ): NavigatorConfigControlledSystemBroadcastReceiverManager 18 | } 19 | -------------------------------------------------------------------------------- /core/navigator/src/main/kotlin/com/w2sv/navigator/system_broadcastreceiver/di/SystemBroadcastReceiverModule.kt: -------------------------------------------------------------------------------- 1 | package com.w2sv.navigator.system_broadcastreceiver.di 2 | 3 | import com.w2sv.navigator.system_broadcastreceiver.BootCompletedReceiver 4 | import com.w2sv.navigator.system_broadcastreceiver.PowerSaveModeChangedReceiver 5 | import dagger.Module 6 | import dagger.Provides 7 | import dagger.hilt.InstallIn 8 | import dagger.hilt.components.SingletonComponent 9 | 10 | @InstallIn(SingletonComponent::class) 11 | @Module 12 | internal object SystemBroadcastReceiverModule { 13 | 14 | @Provides 15 | fun bootCompletedReceiver(): BootCompletedReceiver = 16 | BootCompletedReceiver() 17 | 18 | @Provides 19 | fun powerSaveModeChangedReceiver(): PowerSaveModeChangedReceiver = 20 | PowerSaveModeChangedReceiver() 21 | } 22 | -------------------------------------------------------------------------------- /core/navigator/src/main/kotlin/com/w2sv/navigator/system_broadcastreceiver/manager/NavigatorConfigControlledSystemBroadcastReceiverManager.kt: -------------------------------------------------------------------------------- 1 | package com.w2sv.navigator.system_broadcastreceiver.manager 2 | 3 | import android.content.Context 4 | import kotlinx.coroutines.CoroutineScope 5 | 6 | interface NavigatorConfigControlledSystemBroadcastReceiverManager { 7 | fun toggleReceiversOnStatusChange(collectionScope: CoroutineScope, context: Context) 8 | } 9 | -------------------------------------------------------------------------------- /core/navigator/src/main/kotlin/com/w2sv/navigator/system_broadcastreceiver/manager/NavigatorConfigControlledSystemBroadcastReceiverManagerImpl.kt: -------------------------------------------------------------------------------- 1 | package com.w2sv.navigator.system_broadcastreceiver.manager 2 | 3 | import android.content.Context 4 | import com.w2sv.domain.repository.NavigatorConfigDataSource 5 | import com.w2sv.kotlinutils.coroutines.collectFromFlow 6 | import com.w2sv.navigator.system_broadcastreceiver.BootCompletedReceiver 7 | import com.w2sv.navigator.system_broadcastreceiver.PowerSaveModeChangedReceiver 8 | import javax.inject.Inject 9 | import kotlinx.coroutines.CoroutineScope 10 | import kotlinx.coroutines.flow.distinctUntilChanged 11 | import kotlinx.coroutines.flow.map 12 | import slimber.log.i 13 | 14 | internal class NavigatorConfigControlledSystemBroadcastReceiverManagerImpl @Inject constructor( 15 | navigatorConfigDataSource: NavigatorConfigDataSource, 16 | private val bootCompletedReceiver: BootCompletedReceiver, 17 | private val powerSaveModeChangedReceiver: PowerSaveModeChangedReceiver 18 | ) : NavigatorConfigControlledSystemBroadcastReceiverManager { 19 | 20 | override fun toggleReceiversOnStatusChange(collectionScope: CoroutineScope, context: Context) { 21 | with(collectionScope) { 22 | collectFromFlow(disabledOnLowBatteryDistinctUntilChanged) { 23 | i { "Collected disableOnLowBattery=$it" } 24 | powerSaveModeChangedReceiver.toggle(it, context) 25 | } 26 | collectFromFlow(startOnBootDistinctUntilChanged) { 27 | i { "Collected startOnBootCompleted=$it" } 28 | bootCompletedReceiver.toggle(it, context) 29 | } 30 | } 31 | } 32 | 33 | private val disabledOnLowBatteryDistinctUntilChanged = 34 | navigatorConfigDataSource.navigatorConfig.map { it.disableOnLowBattery } 35 | .distinctUntilChanged() 36 | 37 | private val startOnBootDistinctUntilChanged = 38 | navigatorConfigDataSource.navigatorConfig.map { it.startOnBoot } 39 | .distinctUntilChanged() 40 | } 41 | -------------------------------------------------------------------------------- /core/navigator/src/main/res/layout/dialog_header.xml: -------------------------------------------------------------------------------- 1 | 2 | 7 | 8 | 16 | 17 | -------------------------------------------------------------------------------- /core/navigator/src/main/res/layout/tile_dialog.xml: -------------------------------------------------------------------------------- 1 | 2 | 11 | 12 | 19 | 20 | 23 | 24 | 30 | 31 | 34 | 35 | 39 | 40 | 41 | -------------------------------------------------------------------------------- /core/navigator/src/main/res/values/styles.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 12 | -------------------------------------------------------------------------------- /core/navigator/src/test/kotlin/com/w2sv/navigator/moving/model/DestinationSelectionMannerTest.kt: -------------------------------------------------------------------------------- 1 | package com.w2sv.navigator.moving.model 2 | 3 | import com.w2sv.navigator.notifications.NotificationResources 4 | import com.w2sv.test.testParceling 5 | import org.junit.Test 6 | import org.junit.runner.RunWith 7 | import org.robolectric.RobolectricTestRunner 8 | 9 | @RunWith(RobolectricTestRunner::class) 10 | internal class DestinationSelectionMannerTest { 11 | 12 | @Test 13 | fun testParceling() { 14 | DestinationSelectionManner 15 | .Picked(NotificationResources(12, "manager")) 16 | .testParceling() 17 | 18 | DestinationSelectionManner 19 | .Quick(NotificationResources(19, "manager")) 20 | .testParceling() 21 | 22 | DestinationSelectionManner.Auto.testParceling() 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /core/navigator/src/test/kotlin/com/w2sv/navigator/moving/model/MoveBundleTest.kt: -------------------------------------------------------------------------------- 1 | package com.w2sv.navigator.moving.model 2 | 3 | import com.w2sv.navigator.notifications.NotificationResources 4 | import com.w2sv.test.testParceling 5 | import org.junit.Test 6 | import org.junit.runner.RunWith 7 | import org.robolectric.RobolectricTestRunner 8 | import util.TestInstance 9 | 10 | @RunWith(RobolectricTestRunner::class) 11 | internal class MoveBundleTest { 12 | 13 | @Test 14 | fun testParceling() { 15 | MoveBundle.DirectoryDestinationPicked( 16 | file = TestInstance.moveFile(), 17 | destination = NavigatorMoveDestination.Directory.parse("lkasjdflkajhlk"), 18 | destinationSelectionManner = DestinationSelectionManner.Picked( 19 | NotificationResources( 20 | 7, 21 | "MoveFileNotificationManager" 22 | ) 23 | ), 24 | batched = true 25 | ) 26 | .testParceling() 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /core/navigator/src/test/kotlin/com/w2sv/navigator/moving/model/MoveFileTest.kt: -------------------------------------------------------------------------------- 1 | package com.w2sv.navigator.moving.model 2 | 3 | import com.w2sv.test.testParceling 4 | import org.junit.Test 5 | import org.junit.runner.RunWith 6 | import org.robolectric.RobolectricTestRunner 7 | import util.TestInstance 8 | 9 | @RunWith(RobolectricTestRunner::class) 10 | internal class MoveFileTest { 11 | 12 | @Test 13 | fun testParceling() { 14 | TestInstance 15 | .moveFile() 16 | .testParceling() 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /core/navigator/src/test/kotlin/util/ResourceFileLoading.kt: -------------------------------------------------------------------------------- 1 | package util 2 | 3 | import java.io.File 4 | 5 | internal fun getResourceFile(fileName: String): File = 6 | File("src/test/resources/$fileName") 7 | 8 | internal val File.sizeInMb: Double 9 | get() = length().toDouble() / 1e6 10 | -------------------------------------------------------------------------------- /core/navigator/src/test/kotlin/util/TestInstance.kt: -------------------------------------------------------------------------------- 1 | package util 2 | 3 | import com.w2sv.common.util.MediaUri 4 | import com.w2sv.domain.model.filetype.FileAndSourceType 5 | import com.w2sv.domain.model.filetype.PresetFileType 6 | import com.w2sv.domain.model.filetype.SourceType 7 | import com.w2sv.navigator.moving.model.MoveFile 8 | import com.w2sv.navigator.observing.model.MediaStoreFileData 9 | 10 | internal object TestInstance { 11 | 12 | val mediaStoreFileData = MediaStoreFileData( 13 | rowId = "1000012597", 14 | absPath = "primary/0/DCIM/Screenshots/somepicture.jpg", 15 | volumeRelativeDirPath = "DCIM/Screenshots", 16 | size = 7862183L, 17 | isPending = false, 18 | isTrashed = false 19 | ) 20 | 21 | fun mediaStoreFileData( 22 | absPath: String, 23 | volumeRelativeDirPath: String, 24 | rowId: String = "1000012597", 25 | size: Long = 7862183L, 26 | isPending: Boolean = false, 27 | isTrashed: Boolean = false 28 | ): MediaStoreFileData = 29 | MediaStoreFileData( 30 | rowId = rowId, 31 | absPath = absPath, 32 | volumeRelativeDirPath = volumeRelativeDirPath, 33 | size = size, 34 | isPending = isPending, 35 | isTrashed = isTrashed 36 | ) 37 | 38 | fun moveFile( 39 | mediaUri: MediaUri = MediaUri.parse("content://media/external/images/media/1000012597"), 40 | mediaStoreFileData: MediaStoreFileData = this.mediaStoreFileData, 41 | fileAndSourceType: FileAndSourceType = FileAndSourceType( 42 | fileType = PresetFileType.Image.toFileType(), 43 | sourceType = SourceType.Screenshot 44 | ) 45 | ): MoveFile = 46 | MoveFile( 47 | mediaUri = mediaUri, 48 | mediaStoreFileData = mediaStoreFileData, 49 | fileAndSourceType = fileAndSourceType 50 | ) 51 | } 52 | -------------------------------------------------------------------------------- /core/navigator/src/test/resources/Kyuss_Welcome_to_Sky_Valley.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/w2sv/FileNavigator/60cc5c220659dca1cdade72c68d8eed742f8d528/core/navigator/src/test/resources/Kyuss_Welcome_to_Sky_Valley.jpg -------------------------------------------------------------------------------- /core/navigator/src/test/resources/Mandelbulb.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/w2sv/FileNavigator/60cc5c220659dca1cdade72c68d8eed742f8d528/core/navigator/src/test/resources/Mandelbulb.png -------------------------------------------------------------------------------- /core/navigator/src/test/resources/Sandro_Botticelli_-_La_Carte_de_l'Enfer.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/w2sv/FileNavigator/60cc5c220659dca1cdade72c68d8eed742f8d528/core/navigator/src/test/resources/Sandro_Botticelli_-_La_Carte_de_l'Enfer.jpg -------------------------------------------------------------------------------- /core/navigator/src/test/resources/empty.txt: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/w2sv/FileNavigator/60cc5c220659dca1cdade72c68d8eed742f8d528/core/navigator/src/test/resources/empty.txt -------------------------------------------------------------------------------- /core/navigator/src/test/resources/other_empty.txt: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/w2sv/FileNavigator/60cc5c220659dca1cdade72c68d8eed742f8d528/core/navigator/src/test/resources/other_empty.txt -------------------------------------------------------------------------------- /core/test/.gitignore: -------------------------------------------------------------------------------- 1 | /build -------------------------------------------------------------------------------- /core/test/build.gradle.kts: -------------------------------------------------------------------------------- 1 | plugins { 2 | alias(libs.plugins.filenavigator.library) 3 | alias(libs.plugins.kotlin.parcelize) 4 | } 5 | 6 | dependencies { 7 | implementation(libs.slimber) 8 | api(libs.bundles.unitTest) 9 | } 10 | -------------------------------------------------------------------------------- /core/test/src/main/kotlin/com/w2sv/test/Parcelable.kt: -------------------------------------------------------------------------------- 1 | package com.w2sv.test 2 | 3 | import android.os.Parcel 4 | import android.os.Parcelable 5 | import kotlinx.parcelize.parcelableCreator 6 | import org.junit.Assert.assertEquals 7 | 8 | /** 9 | * Parcels the receiver, recreates it from the parcel and asserts whether the original instance and the recreated one are equal. 10 | * 11 | * Requires `@RunWith(RobolectricTestRunner::class)` annotation on calling test class. 12 | */ 13 | inline fun T.testParceling(flags: Int = 0) { 14 | val parcel = Parcel.obtain() 15 | this.writeToParcel(parcel, flags) 16 | 17 | // Reset the parcel's position for reading 18 | parcel.setDataPosition(0) 19 | 20 | // Assert that the original and recreated objects are equal 21 | val recreated = parcelableCreator().createFromParcel(parcel) 22 | assertEquals(this, recreated) 23 | 24 | // Recycle the parcel to avoid memory leaks 25 | parcel.recycle() 26 | } 27 | -------------------------------------------------------------------------------- /core/test/src/main/kotlin/com/w2sv/test/TimberTestRule.kt: -------------------------------------------------------------------------------- 1 | package com.w2sv.test 2 | 3 | import org.junit.rules.TestRule 4 | import org.junit.runner.Description 5 | import org.junit.runners.model.Statement 6 | import timber.log.Timber 7 | 8 | /** 9 | * May be used to receive [Timber] logs during unit testing. 10 | */ 11 | class TimberTestRule : TestRule { 12 | override fun apply(base: Statement, description: Description): Statement = 13 | object : Statement() { 14 | override fun evaluate() { 15 | Timber.plant(TestDebugTree) 16 | try { 17 | base.evaluate() 18 | } finally { 19 | Timber.uprootAll() 20 | } 21 | } 22 | } 23 | } 24 | 25 | private object TestDebugTree : Timber.Tree() { 26 | override fun log( 27 | priority: Int, 28 | tag: String?, 29 | message: String, 30 | t: Throwable? 31 | ) { 32 | println( 33 | buildString { 34 | tag?.let { append("$tag: ") } 35 | append(message) 36 | } 37 | ) 38 | t?.printStackTrace(System.out) 39 | } 40 | } 41 | -------------------------------------------------------------------------------- /gradle.properties: -------------------------------------------------------------------------------- 1 | # ======= Kotlin ======= 2 | kotlin.code.style=official 3 | kotlin.daemon.jvmargs=-Dfile.encoding=UTF-8 -XX:+UseG1GC -XX:SoftRefLRUPolicyMSPerMB=1 -XX:ReservedCodeCacheSize=320m -XX:+HeapDumpOnOutOfMemoryError -Xmx4g -Xms4g 4 | # ======= Gradle ======= 5 | org.gradle.jvmargs=-Dfile.encoding=UTF-8 -XX:+UseG1GC -XX:SoftRefLRUPolicyMSPerMB=1 -XX:ReservedCodeCacheSize=256m -XX:+HeapDumpOnOutOfMemoryError -Xmx4g -Xms4g 6 | org.gradle.parallel=true 7 | org.gradle.caching=true 8 | org.gradle.configureondemand=true 9 | org.gradle.configuration-cache=true 10 | org.gradle.configuration-cache.parallel=true 11 | # ======= Android ======= 12 | android.useAndroidX=true 13 | android.nonTransitiveRClass=true 14 | android.uniquePackageNames=true 15 | android.nonFinalResIds=false 16 | # ======= Version ======= 17 | version=0.3.0.1 18 | versionCode=15 19 | -------------------------------------------------------------------------------- /gradle/wrapper/gradle-wrapper.jar: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/w2sv/FileNavigator/60cc5c220659dca1cdade72c68d8eed742f8d528/gradle/wrapper/gradle-wrapper.jar -------------------------------------------------------------------------------- /gradle/wrapper/gradle-wrapper.properties: -------------------------------------------------------------------------------- 1 | distributionBase=GRADLE_USER_HOME 2 | distributionPath=wrapper/dists 3 | distributionUrl=https\://services.gradle.org/distributions/gradle-8.14-bin.zip 4 | networkTimeout=10000 5 | validateDistributionUrl=true 6 | zipStoreBase=GRADLE_USER_HOME 7 | zipStorePath=wrapper/dists 8 | -------------------------------------------------------------------------------- /renovate.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://docs.renovatebot.com/renovate-schema.json", 3 | "extends": [ 4 | "config:base" 5 | ] 6 | } 7 | -------------------------------------------------------------------------------- /settings.gradle.kts: -------------------------------------------------------------------------------- 1 | pluginManagement { 2 | repositories { 3 | google() 4 | mavenCentral() 5 | gradlePluginPortal() 6 | includeBuild("build-logic") 7 | } 8 | } 9 | 10 | @Suppress("UnstableApiUsage") 11 | dependencyResolutionManagement { 12 | repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS) 13 | repositories { 14 | google() 15 | mavenCentral() 16 | maven(url = "https://jitpack.io") 17 | } 18 | } 19 | 20 | rootProject.name = "FileNavigator" 21 | 22 | include(":app") 23 | include(":benchmarking") 24 | include(":core:datastore") 25 | include(":core:database") 26 | include(":core:domain") 27 | include(":core:common") 28 | include(":core:navigator") 29 | include(":core:test") 30 | --------------------------------------------------------------------------------