Repository: amir1376/ab-download-manager Branch: master Commit: 5ee92ecf4b90 Files: 1054 Total size: 3.9 MB Directory structure: gitextract_5ho1xmu5/ ├── .gitattributes ├── .github/ │ ├── FUNDING.yml │ ├── ISSUE_TEMPLATE/ │ │ └── bug_report.yml │ └── workflows/ │ ├── brew-cask-auto-bump.yml │ ├── publish.yml │ └── winget.yml ├── .gitignore ├── CHANGELOG.md ├── CONTRIBUTING.md ├── DONATE.md ├── LICENSE ├── README.md ├── REST-API.yml ├── android/ │ └── app/ │ ├── .gitignore │ ├── build.gradle.kts │ ├── filetypes.txt │ └── src/ │ └── main/ │ ├── AndroidManifest.xml │ ├── kotlin/ │ │ └── com/ │ │ └── abdownloadmanager/ │ │ └── android/ │ │ ├── ABDMApp.kt │ │ ├── action/ │ │ │ └── Actions.kt │ │ ├── di/ │ │ │ └── Di.kt │ │ ├── pages/ │ │ │ ├── about/ │ │ │ │ └── AboutPage.kt │ │ │ ├── add/ │ │ │ │ ├── AddDownloadActivity.kt │ │ │ │ ├── multiple/ │ │ │ │ │ ├── AddMultiDownloadActivity.kt │ │ │ │ │ ├── AddMultiDownloadList.kt │ │ │ │ │ ├── AddMultiItemPage.kt │ │ │ │ │ └── AndroidAddMultiDownloadComponent.kt │ │ │ │ ├── shared/ │ │ │ │ │ ├── CategorySelect.kt │ │ │ │ │ ├── ExtraConfig.kt │ │ │ │ │ ├── LocationTextField.kt │ │ │ │ │ └── SelectQueue.kt │ │ │ │ └── single/ │ │ │ │ ├── AddSingleDownloadActivity.kt │ │ │ │ ├── AddSingleDownloadPage.kt │ │ │ │ └── AndroidAddSingleDownloadComponent.kt │ │ │ ├── batchdownload/ │ │ │ │ ├── AndroidBatchDownloadComponent.kt │ │ │ │ └── BatchDownloadPage.kt │ │ │ ├── browser/ │ │ │ │ ├── BrowserActivity.kt │ │ │ │ ├── BrowserComponent.kt │ │ │ │ ├── BrowserUi.kt │ │ │ │ ├── DownloadInterceptor.kt │ │ │ │ ├── SearchEngines.kt │ │ │ │ ├── WebView.kt │ │ │ │ ├── WebViewHolder.kt │ │ │ │ └── bookmark/ │ │ │ │ ├── Bookmarks.kt │ │ │ │ └── EditBookmark.kt │ │ │ ├── category/ │ │ │ │ ├── CategorySheet.kt │ │ │ │ └── NewCategory.kt │ │ │ ├── checksum/ │ │ │ │ ├── AndroidFileChecksumComponent.kt │ │ │ │ └── FileChecksumPage.kt │ │ │ ├── crashreport/ │ │ │ │ ├── CrashReportActivity.kt │ │ │ │ ├── ErrorUi.kt │ │ │ │ └── ThrowableData.kt │ │ │ ├── credits/ │ │ │ │ ├── thirdpartylibraries/ │ │ │ │ │ ├── ExternalLibsPage.kt │ │ │ │ │ └── LibraryDialog.kt │ │ │ │ └── translators/ │ │ │ │ └── TranslatorsPage.kt │ │ │ ├── directorypicker/ │ │ │ │ ├── ComposeExtension.kt │ │ │ │ ├── DirectoryPickerActivity.kt │ │ │ │ └── DirectoryPickerPage.kt │ │ │ ├── editdownload/ │ │ │ │ ├── AndroidEditDownloadComponent.kt │ │ │ │ └── EditDownload.kt │ │ │ ├── enterurl/ │ │ │ │ ├── AndroidEnterNewURLComponent.kt │ │ │ │ └── EnterURLPage.kt │ │ │ ├── home/ │ │ │ │ ├── AndroidDownloadActions.kt │ │ │ │ ├── BottomNavigation.kt │ │ │ │ ├── DownloadList.kt │ │ │ │ ├── FilterStatusIndicator.kt │ │ │ │ ├── HomeComponent.kt │ │ │ │ ├── HomePage.kt │ │ │ │ ├── HomePageStateToPersist.kt │ │ │ │ ├── Prompts.kt │ │ │ │ ├── RenderAddMenu.kt │ │ │ │ ├── RenderDownloadItem.kt │ │ │ │ ├── RenderMainMenu.kt │ │ │ │ ├── RenderStatusFilterMenu.kt │ │ │ │ ├── SelectedQueueItemsOption.kt │ │ │ │ ├── Selection.kt │ │ │ │ ├── SimplePager.kt │ │ │ │ └── sections/ │ │ │ │ ├── Categories.kt │ │ │ │ ├── queues/ │ │ │ │ │ └── Queues.kt │ │ │ │ └── sort/ │ │ │ │ ├── DownloadSortBy.kt │ │ │ │ └── RenderSortMenu.kt │ │ │ ├── newqueue/ │ │ │ │ └── NewQueue.kt │ │ │ ├── onboarding/ │ │ │ │ ├── StartUpPageTemplate.kt │ │ │ │ ├── initialsetup/ │ │ │ │ │ ├── InitialSetupComponent.kt │ │ │ │ │ └── InitialSetupPage.kt │ │ │ │ └── permissions/ │ │ │ │ ├── ABDMPermissions.kt │ │ │ │ ├── AppPermission.kt │ │ │ │ ├── BatteryOptimizationUtil.kt │ │ │ │ ├── CustomPermissions.kt │ │ │ │ ├── PermissionComponent.kt │ │ │ │ ├── PermissionManager.kt │ │ │ │ ├── PermissionsPage.kt │ │ │ │ └── StoragePermissionUtil.kt │ │ │ ├── perhostsettings/ │ │ │ │ ├── AndroidPerHostSettingsComponent.kt │ │ │ │ └── PerHostSettingsPage.kt │ │ │ ├── queue/ │ │ │ │ ├── QueueConfigurationComponent.kt │ │ │ │ └── QueuesSheet.kt │ │ │ ├── settings/ │ │ │ │ ├── AndroidSettings.kt │ │ │ │ ├── AndroidSettingsComponent.kt │ │ │ │ └── SettingsPage.kt │ │ │ ├── singledownload/ │ │ │ │ ├── CompletedDownloadPage.kt │ │ │ │ ├── DesktopSingleDownloadPageComponent.kt │ │ │ │ ├── ProgressDownloadPage.kt │ │ │ │ ├── ShowDownloadDialogs.kt │ │ │ │ └── SingleDownloadPageActivity.kt │ │ │ └── updater/ │ │ │ ├── NewUpdatePage.kt │ │ │ └── UpdaterDialog.kt │ │ ├── receiver/ │ │ │ └── StartOnBootBroadcastReceiver.kt │ │ ├── repository/ │ │ │ └── AppRepository.kt │ │ ├── service/ │ │ │ ├── DownloadSystemService.kt │ │ │ └── KeepAliveServiceReason.kt │ │ ├── storage/ │ │ │ ├── AndroidExtraDownloadItemSettings.kt │ │ │ ├── AndroidExtraQueueSettings.kt │ │ │ ├── AndroidOnBoardingStorage.kt │ │ │ ├── AppSettingsStorage.kt │ │ │ ├── BrowserBookmarksStorage.kt │ │ │ └── HomePageStorage.kt │ │ ├── ui/ │ │ │ ├── ABDownloadManagerApplicationContent.kt │ │ │ ├── MainActivity.kt │ │ │ ├── MainComponent.kt │ │ │ ├── MainContent.kt │ │ │ ├── SelectionControls.kt │ │ │ ├── SheetUI.kt │ │ │ ├── configurable/ │ │ │ │ ├── AndroidConfigurableUtils.kt │ │ │ │ ├── ConfigurableSheet.kt │ │ │ │ ├── SheetInput.kt │ │ │ │ ├── SheetSpinner.kt │ │ │ │ ├── android/ │ │ │ │ │ ├── AndroidConfigurableRenderers.kt │ │ │ │ │ ├── item/ │ │ │ │ │ │ └── PermissionConfigurable.kt │ │ │ │ │ └── renderer/ │ │ │ │ │ └── PermissionConfigurableRenderer.kt │ │ │ │ └── comon/ │ │ │ │ ├── CommonConfigurableRenderersForAndroid.kt │ │ │ │ ├── ConfigurableRenderersForAndroid.kt │ │ │ │ └── renderer/ │ │ │ │ ├── BooleanConfigurableRenderer.kt │ │ │ │ ├── DayOfWeekConfigurableRenderer.kt │ │ │ │ ├── EnumConfigurableRenderer.kt │ │ │ │ ├── FileChecksumConfigurableRenderer.kt │ │ │ │ ├── FloatConfigurableRenderer.kt │ │ │ │ ├── FolderConfigurableRenderer.kt │ │ │ │ ├── IntConfigurableRenderer.kt │ │ │ │ ├── LongConfigurableRenderer.kt │ │ │ │ ├── NavigatableConfigurableRenderer.kt │ │ │ │ ├── ProxyConfigurableRenderer.kt │ │ │ │ ├── SpeedLimitConfigurableRenderer.kt │ │ │ │ ├── StringConfigurableRenderer.kt │ │ │ │ ├── ThemeConfigurableRenderer.kt │ │ │ │ └── TimeConfigurableRenderer.kt │ │ │ ├── menu/ │ │ │ │ ├── Menu.kt │ │ │ │ ├── RenderMenuInSheet.kt │ │ │ │ ├── RenderMenuInSinglePage.kt │ │ │ │ └── StackedMenu.kt │ │ │ ├── myCombinedClickable.kt │ │ │ ├── page/ │ │ │ │ ├── PageUI.kt │ │ │ │ └── PageUtils.kt │ │ │ └── widget/ │ │ │ └── ComposeWebView.kt │ │ └── util/ │ │ ├── ABDMAppManager.kt │ │ ├── ABDMServiceNotificationManager.kt │ │ ├── AndroidConstants.kt │ │ ├── AndroidDefinedPaths.kt │ │ ├── AndroidDownloadItemOpener.kt │ │ ├── AndroidGlobalExceptionHandler.kt │ │ ├── AndroidIntentUtils.kt │ │ ├── AndroidUi.kt │ │ ├── AppInfo.kt │ │ ├── ApplicationBackgroundTracker.kt │ │ ├── HeadlessComposeRuntime.kt │ │ ├── activity/ │ │ │ ├── ABDMActivity.kt │ │ │ ├── ActivityActions.kt │ │ │ ├── RetainedComponentContainer.kt │ │ │ └── SerializableExtra.kt │ │ ├── compose/ │ │ │ ├── ObserveUiVisibility.kt │ │ │ └── useBack.kt │ │ ├── notification/ │ │ │ └── playNotificationSoundIfAllowed.kt │ │ └── pagemanager/ │ │ ├── BrowserPageManager.kt │ │ └── PermissionsPageManager.kt │ └── res/ │ ├── drawable/ │ │ ├── ic_launcher_background.xml │ │ ├── ic_launcher_foreground.xml │ │ ├── ic_launcher_monochrome.xml │ │ └── ic_monochrome.xml │ ├── mipmap-anydpi-v26/ │ │ ├── ic_launcher.xml │ │ └── ic_launcher_round.xml │ ├── values/ │ │ ├── strings.xml │ │ └── theme.xml │ └── xml/ │ └── provider_paths.xml ├── build.gradle.kts ├── buildSrc/ │ ├── build.gradle.kts │ ├── settings.gradle.kts │ └── src/ │ └── main/ │ └── kotlin/ │ ├── Plugins.kt │ ├── buildlogic/ │ │ ├── CiDirs.kt │ │ ├── CiUtils.kt │ │ ├── HashUtils.kt │ │ └── versioning/ │ │ └── VersionUtil.kt │ └── myPlugins/ │ ├── composeAndroid.gradle.kts │ ├── composeBase.gradle.kts │ ├── composeDesktop.gradle.kts │ ├── kotlin.gradle.kts │ ├── kotlinAndroid.gradle.kts │ ├── kotlinMultiplatform.gradle.kts │ └── proguardDesktop.gradle.kts ├── compositeBuilds/ │ ├── plugins/ │ │ ├── common-android/ │ │ │ ├── build.gradle.kts │ │ │ └── src/ │ │ │ └── main/ │ │ │ ├── kotlin/ │ │ │ │ └── ir/ │ │ │ │ └── amirab/ │ │ │ │ └── plugin/ │ │ │ │ └── common_android/ │ │ │ │ └── task/ │ │ │ │ ├── EnableFileTypesGeneratorForManifest.kt │ │ │ │ ├── FileTypesIntentFilterGenerator.kt │ │ │ │ └── SignApkTask.kt │ │ │ └── resources/ │ │ │ └── ir/ │ │ │ └── amirab/ │ │ │ └── plugin/ │ │ │ └── common_android/ │ │ │ └── AndroidManifest.xml.hbs │ │ ├── git-version-plugin/ │ │ │ ├── build.gradle.kts │ │ │ └── src/ │ │ │ └── main/ │ │ │ └── kotlin/ │ │ │ └── ir/ │ │ │ └── amirab/ │ │ │ └── git_version/ │ │ │ ├── GitVersionPlugin.kt │ │ │ └── core/ │ │ │ ├── CiReferenceProvider.kt │ │ │ ├── GitStatus.kt │ │ │ ├── SemanticVersionSelector.kt │ │ │ ├── TagSelector.kt │ │ │ ├── Utils.kt │ │ │ └── extension.kt │ │ ├── installer-plugin/ │ │ │ ├── build.gradle.kts │ │ │ └── src/ │ │ │ └── main/ │ │ │ └── kotlin/ │ │ │ └── ir/ │ │ │ └── amirab/ │ │ │ └── installer/ │ │ │ ├── InstallerPlugin.kt │ │ │ ├── InstallerTargetFormat.kt │ │ │ ├── extensiion/ │ │ │ │ └── InstallerPluginExtension.kt │ │ │ ├── tasks/ │ │ │ │ ├── macos/ │ │ │ │ │ └── CreateDmgTask.kt │ │ │ │ └── windows/ │ │ │ │ └── NsisTask.kt │ │ │ └── utils/ │ │ │ └── Contants.kt │ │ └── settings.gradle.kts │ └── shared/ │ ├── README.md │ ├── build.gradle.kts │ ├── platform/ │ │ ├── build.gradle.kts │ │ └── src/ │ │ └── commonMain/ │ │ └── kotlin/ │ │ └── ir/ │ │ └── amirab/ │ │ └── util/ │ │ └── platform/ │ │ ├── Arch.kt │ │ └── Platform.kt │ └── settings.gradle.kts ├── crowdin.yml ├── desktop/ │ ├── app/ │ │ ├── build.gradle.kts │ │ ├── gradle.properties │ │ ├── icons/ │ │ │ └── icon.icns │ │ ├── proguard/ │ │ │ ├── decompose.pro │ │ │ ├── main.pro │ │ │ └── okhttp.pro │ │ ├── resources/ │ │ │ ├── common/ │ │ │ │ └── app.properties │ │ │ └── installer/ │ │ │ └── nsis-script-template.nsi │ │ └── src/ │ │ └── main/ │ │ ├── kotlin/ │ │ │ └── com/ │ │ │ └── abdownloadmanager/ │ │ │ └── desktop/ │ │ │ ├── App.kt │ │ │ ├── AppArguments.kt │ │ │ ├── AppComponent.kt │ │ │ ├── SingleInstanceServerInitializer.kt │ │ │ ├── actions/ │ │ │ │ ├── DesktopActionFactories.kt │ │ │ │ ├── dev.kt │ │ │ │ ├── main.kt │ │ │ │ └── onevennts/ │ │ │ │ ├── CleanExtraSettingsOnDownloadFinish.kt │ │ │ │ ├── getOnDownloadCompletionAction.kt │ │ │ │ └── getOnQueueEventActions.kt │ │ │ ├── di/ │ │ │ │ └── Di.kt │ │ │ ├── integration/ │ │ │ │ └── IntegrationHandlerImp.kt │ │ │ ├── pages/ │ │ │ │ ├── about/ │ │ │ │ │ ├── AboutDialog.kt │ │ │ │ │ └── AboutPage.kt │ │ │ │ ├── addDownload/ │ │ │ │ │ ├── ShowAddDownloadDialogs.kt │ │ │ │ │ ├── multiple/ │ │ │ │ │ │ ├── AddMultiItemPage.kt │ │ │ │ │ │ ├── AddMultiItemTable.kt │ │ │ │ │ │ └── DesktopAddMultiDownloadComponent.kt │ │ │ │ │ ├── shared/ │ │ │ │ │ │ ├── CategorySelect.kt │ │ │ │ │ │ ├── DialogDropDown.kt │ │ │ │ │ │ ├── ExtraConfig.kt │ │ │ │ │ │ ├── LocationTextField.kt │ │ │ │ │ │ └── SelectQueue.kt │ │ │ │ │ └── single/ │ │ │ │ │ ├── AddDownloadPage.kt │ │ │ │ │ └── DesktopAddSingleDownloadComponent.kt │ │ │ │ ├── batchdownload/ │ │ │ │ │ ├── BatchDownloadWindow.kt │ │ │ │ │ ├── BatchDownnload.kt │ │ │ │ │ └── DesktopBatchDownloadComponent.kt │ │ │ │ ├── category/ │ │ │ │ │ ├── DesktopCategoryDialogManager.kt │ │ │ │ │ ├── NewCategoryPage.kt │ │ │ │ │ └── ShowCategoryDialogs.kt │ │ │ │ ├── checksum/ │ │ │ │ │ ├── DesktopFileChecksumComponent.kt │ │ │ │ │ ├── FileChecksumPage.kt │ │ │ │ │ └── FileChecksumWindow.kt │ │ │ │ ├── confirmexit/ │ │ │ │ │ └── ConfirmExit.kt │ │ │ │ ├── credits/ │ │ │ │ │ └── translators/ │ │ │ │ │ ├── TranslatorsPage.kt │ │ │ │ │ ├── TranslatorsTable.kt │ │ │ │ │ └── TranslatorsWindow.kt │ │ │ │ ├── editdownload/ │ │ │ │ │ ├── DesktopEditDownloadComponent.kt │ │ │ │ │ └── EditDownload.kt │ │ │ │ ├── enterurl/ │ │ │ │ │ ├── DesktopEnterNewURLComponent.kt │ │ │ │ │ ├── EnterNewDownloadWindow.kt │ │ │ │ │ └── EnterNewURLPage.kt │ │ │ │ ├── extenallibs/ │ │ │ │ │ ├── ExternalLibsPage.kt │ │ │ │ │ ├── ExternalLibsWindow.kt │ │ │ │ │ ├── LibraryDialog.kt │ │ │ │ │ └── OpenSourceLibraryTable.kt │ │ │ │ ├── home/ │ │ │ │ │ ├── Actions.kt │ │ │ │ │ ├── DesktopDownloadActions.kt │ │ │ │ │ ├── DownloadItemListDataFlavor.kt │ │ │ │ │ ├── HomeComponent.kt │ │ │ │ │ ├── HomePage.kt │ │ │ │ │ ├── HomePersistedState.kt │ │ │ │ │ ├── HomeWindow.kt │ │ │ │ │ ├── dropDownloadItemsHere.kt │ │ │ │ │ └── sections/ │ │ │ │ │ ├── DownloadList.kt │ │ │ │ │ ├── Filters.kt │ │ │ │ │ ├── TableDownloadItem.kt │ │ │ │ │ ├── category/ │ │ │ │ │ │ └── Categories.kt │ │ │ │ │ └── queue/ │ │ │ │ │ └── Queues.kt │ │ │ │ ├── newQueue/ │ │ │ │ │ ├── NewQueueDialog.kt │ │ │ │ │ └── NewQueuePage.kt │ │ │ │ ├── perhostsettings/ │ │ │ │ │ ├── DesktopPerHostSettingsComponent.kt │ │ │ │ │ ├── PerHostSettingsPage.kt │ │ │ │ │ └── PerHostSettingsWindow.kt │ │ │ │ ├── poweractionalert/ │ │ │ │ │ ├── PowerActionAlertWindow.kt │ │ │ │ │ └── PowerActionComponent.kt │ │ │ │ ├── queue/ │ │ │ │ │ ├── QueueInfoComponent.kt │ │ │ │ │ ├── QueueWindow.kt │ │ │ │ │ ├── QueuesComponent.kt │ │ │ │ │ └── QueuesPage.kt │ │ │ │ ├── settings/ │ │ │ │ │ ├── DesktopSettings.kt │ │ │ │ │ ├── DesktopSettingsComponent.kt │ │ │ │ │ ├── FontManager.kt │ │ │ │ │ ├── SettingPageStateToPersist.kt │ │ │ │ │ ├── SettingWindow.kt │ │ │ │ │ └── SettingsPage.kt │ │ │ │ ├── singleDownloadPage/ │ │ │ │ │ ├── CompletedDownloadPage.kt │ │ │ │ │ ├── DesktopSingleDownloadPageComponent.kt │ │ │ │ │ ├── ProgressDownloadPage.kt │ │ │ │ │ ├── ShowDownloadDialogs.kt │ │ │ │ │ └── SingleDownloadPageStateToPersist.kt │ │ │ │ └── updater/ │ │ │ │ ├── NewUpdatePage.kt │ │ │ │ └── UpdaterDialog.kt │ │ │ ├── repository/ │ │ │ │ └── AppRepository.kt │ │ │ ├── storage/ │ │ │ │ ├── AppSettingsStorage.kt │ │ │ │ ├── DesktopDefinedPaths.kt │ │ │ │ ├── DesktopExtraDownloadItemSettings.kt │ │ │ │ ├── DesktopExtraQueueSettings.kt │ │ │ │ └── PageStatesStorage.kt │ │ │ ├── ui/ │ │ │ │ ├── Ui.kt │ │ │ │ ├── configurable/ │ │ │ │ │ ├── DesktopConfigurableRendererUtils.kt │ │ │ │ │ ├── comon/ │ │ │ │ │ │ ├── CommonConfigurableRenderersForDesktop.kt │ │ │ │ │ │ └── renderer/ │ │ │ │ │ │ ├── BooleanConfigurableRenderer.kt │ │ │ │ │ │ ├── DayOfWeekConfigurableRenderer.kt │ │ │ │ │ │ ├── EnumConfigurableRenderer.kt │ │ │ │ │ │ ├── FileChecksumConfigurableRenderer.kt │ │ │ │ │ │ ├── FloatConfigurableRenderer.kt │ │ │ │ │ │ ├── FolderConfigurableRenderer.kt │ │ │ │ │ │ ├── IntConfigurableRenderer.kt │ │ │ │ │ │ ├── LongConfigurableRenderer.kt │ │ │ │ │ │ ├── PerHostSettingsConfigurableRenderer.kt │ │ │ │ │ │ ├── ProxyConfigurableRenderer.kt │ │ │ │ │ │ ├── SpeedLimitConfigurableRenderer.kt │ │ │ │ │ │ ├── StringConfigurableRenderer.kt │ │ │ │ │ │ ├── ThemeConfigurableRenderer.kt │ │ │ │ │ │ └── TimeConfigurableRenderer.kt │ │ │ │ │ └── platform/ │ │ │ │ │ ├── DesktopConfigurableRenderers.kt │ │ │ │ │ ├── item/ │ │ │ │ │ │ └── FontConfigurable.kt │ │ │ │ │ └── renderer/ │ │ │ │ │ └── FontConfigurableRenderer.kt │ │ │ │ ├── error/ │ │ │ │ │ └── ErrorUi.kt │ │ │ │ ├── util/ │ │ │ │ │ └── FilePickerUtils.kt │ │ │ │ └── widget/ │ │ │ │ ├── ConfirmDialog.kt │ │ │ │ ├── MessageDialogModel.kt │ │ │ │ └── Tray.kt │ │ │ └── utils/ │ │ │ ├── AppInfo.kt │ │ │ ├── AppProperties.kt │ │ │ ├── DebugboardUtils.kt │ │ │ ├── DesktopEntryCreator.kt │ │ │ ├── DesktopShortcutManager.kt │ │ │ ├── GlobalAppExceptionHandler.kt │ │ │ ├── IntegrationUtil.kt │ │ │ ├── KeepAwakeManager.kt │ │ │ ├── OSFileIconProvider.kt │ │ │ ├── PortableUtil.kt │ │ │ ├── native_messaging/ │ │ │ │ ├── NativeMessaging.kt │ │ │ │ └── NativeMessagingManifestApplier.kt │ │ │ ├── proxy/ │ │ │ │ ├── AutoConfigurableProxyProviderForDesktop.kt │ │ │ │ ├── DesktopSystemProxySelectorProvider.kt │ │ │ │ └── ProxyCachingConfig.kt │ │ │ ├── renderapi/ │ │ │ │ └── RenderApi.kt │ │ │ └── singleInstance/ │ │ │ ├── Comunication.kt │ │ │ ├── MutableSingleInstanceServerHandler.kt │ │ │ ├── SingleAppInstanceLocker.kt │ │ │ ├── SingleInstanceServer.kt │ │ │ ├── SingleInstanceServerHandler.kt │ │ │ └── SingleInstanceUtil.kt │ │ └── resources/ │ │ └── configs/ │ │ └── app_default.properties │ ├── app-utils/ │ │ ├── build.gradle.kts │ │ └── src/ │ │ └── main/ │ │ └── kotlin/ │ │ └── com/ │ │ └── abdownloadmanager/ │ │ └── desktop/ │ │ └── window/ │ │ ├── BrowserUtil.kt │ │ ├── custom/ │ │ │ ├── CustomWindow.kt │ │ │ ├── DropDownTooltip.kt │ │ │ ├── OptionsDialog.kt │ │ │ ├── titlebar/ │ │ │ │ ├── SystemButtonPositionProvider.kt │ │ │ │ ├── TitleBar.kt │ │ │ │ ├── TitleBarShared.kt │ │ │ │ ├── linux/ │ │ │ │ │ ├── LinuxSystemButtonsProvider.kt │ │ │ │ │ ├── LinuxTitleBar.kt │ │ │ │ │ └── SystemButtons.Linux.kt │ │ │ │ ├── mac/ │ │ │ │ │ ├── MacTitleBar.kt │ │ │ │ │ └── SystemButtons.MacOS.kt │ │ │ │ └── windows/ │ │ │ │ ├── SystemButtons.Windows.kt │ │ │ │ └── WindowsTitleBar.kt │ │ │ └── utils.kt │ │ └── moveSafe.kt │ ├── mac_utils/ │ │ ├── build.gradle.kts │ │ └── src/ │ │ └── main/ │ │ └── kotlin/ │ │ └── ir/ │ │ └── amirab/ │ │ └── util/ │ │ └── desktop/ │ │ └── mac/ │ │ └── event/ │ │ └── MacEventHandler.kt │ └── shared/ │ ├── build.gradle.kts │ └── src/ │ └── main/ │ └── kotlin/ │ └── ir/ │ └── amirab/ │ └── util/ │ └── desktop/ │ ├── DesktopUtils.kt │ ├── GlobalKeyboardModifiers.kt │ ├── OsUtils.kt │ ├── PlatformAppActivator.kt │ ├── PlatformDockToggler.kt │ ├── WindowsRegistry.kt │ ├── activator/ │ │ └── mac/ │ │ └── MacAppActivator.kt │ ├── dock/ │ │ └── mac/ │ │ └── MacDockToggler.kt │ ├── keepawake/ │ │ ├── KeepAwake.kt │ │ ├── MacKeepAwake.kt │ │ └── WindowsKeepAwake.kt │ ├── poweraction/ │ │ ├── PowerAction.kt │ │ ├── PowerActionConfig.kt │ │ ├── PowerActionLinux.kt │ │ ├── PowerActionMac.kt │ │ └── PowerActionWindows.kt │ ├── screen/ │ │ └── DesktopScreen.kt │ └── utils/ │ ├── linux/ │ │ └── LinuxUtils.kt │ ├── mac/ │ │ ├── FoundationLibrary.kt │ │ └── MacOSUtils.kt │ └── windows/ │ └── WindowsUtils.kt ├── downloader/ │ ├── core/ │ │ ├── build.gradle.kts │ │ └── src/ │ │ ├── androidMain/ │ │ │ └── kotlin/ │ │ │ └── ir/ │ │ │ └── amirab/ │ │ │ └── downloader/ │ │ │ └── utils/ │ │ │ └── SparseFile.android.kt │ │ ├── commonMain/ │ │ │ └── kotlin/ │ │ │ └── ir/ │ │ │ └── amirab/ │ │ │ └── downloader/ │ │ │ ├── DownloadManager.kt │ │ │ ├── DownloadManagerMinimalControl.kt │ │ │ ├── DownloadSettings.kt │ │ │ ├── Downloader.kt │ │ │ ├── DownloaderRegistry.kt │ │ │ ├── NewDownloadItemProps.kt │ │ │ ├── anntation/ │ │ │ │ └── Markers.kt │ │ │ ├── connection/ │ │ │ │ ├── Connection.kt │ │ │ │ ├── HttpDownloaderClient.kt │ │ │ │ ├── IResponseInfo.kt │ │ │ │ ├── OkHttpHttpDownloaderClient.kt │ │ │ │ ├── UserAgent.kt │ │ │ │ ├── UserAgentProvider.kt │ │ │ │ ├── proxy/ │ │ │ │ │ ├── AutoConfigurableProxyProvider.kt │ │ │ │ │ ├── Proxy.kt │ │ │ │ │ ├── ProxyStrategy.kt │ │ │ │ │ ├── ProxyStrategyProvider.kt │ │ │ │ │ ├── ProxyType.kt │ │ │ │ │ └── SystemProxySelectorProvider.kt │ │ │ │ └── response/ │ │ │ │ ├── HttpResponseInfo.kt │ │ │ │ └── headers/ │ │ │ │ ├── RangeHeaderExtractor.kt │ │ │ │ └── fileNameExtractor.kt │ │ │ ├── db/ │ │ │ │ ├── DownloadListFileStorage.kt │ │ │ │ ├── DownloadQueuePersistedDataAccess.kt │ │ │ │ ├── IDownloadListDb.kt │ │ │ │ ├── IDownloadPartListDb.kt │ │ │ │ ├── MemoryDownloadListDB.kt │ │ │ │ ├── MemoryDownloadPartStatesDB.kt │ │ │ │ ├── PartListFileStorage.kt │ │ │ │ ├── QueueFileStorage.kt │ │ │ │ └── TransactionalFileSaver.kt │ │ │ ├── destination/ │ │ │ │ ├── DestWriter.kt │ │ │ │ ├── DownloadDestination.kt │ │ │ │ ├── IncompleteFileUtil.kt │ │ │ │ ├── SegmentedDownloadDestination.kt │ │ │ │ └── SimpleDownloadDestination.kt │ │ │ ├── downloaditem/ │ │ │ │ ├── DownloadItemContext.kt │ │ │ │ ├── DownloadJob.kt │ │ │ │ ├── DownloadJobExtraConfig.kt │ │ │ │ ├── DownloadJobStatus.kt │ │ │ │ ├── DownloadStatus.kt │ │ │ │ ├── IDownloadCredentials.kt │ │ │ │ ├── IDownloadItem.kt │ │ │ │ ├── contexts/ │ │ │ │ │ └── DefaultContexts.kt │ │ │ │ ├── hls/ │ │ │ │ │ ├── HLSDownloadCredentials.kt │ │ │ │ │ ├── HLSDownloadItem.kt │ │ │ │ │ ├── HLSDownloadJob.kt │ │ │ │ │ ├── HLSDownloadJobExtraConfig.kt │ │ │ │ │ ├── HLSDownloader.kt │ │ │ │ │ ├── HLSPartDownloader.kt │ │ │ │ │ ├── HLSResponseInfo.kt │ │ │ │ │ └── IHLSCredentials.kt │ │ │ │ └── http/ │ │ │ │ ├── HttpDownloadCredentials.kt │ │ │ │ ├── HttpDownloadItem.kt │ │ │ │ ├── HttpDownloadJob.kt │ │ │ │ ├── HttpDownloader.kt │ │ │ │ └── IHttpBasedDownloadCredentials.kt │ │ │ ├── exception/ │ │ │ │ ├── DownloadNotSuccessFullException.kt │ │ │ │ ├── DownloadValidationException.kt │ │ │ │ ├── FileChangedException.kt │ │ │ │ ├── NoSpaceInStorageException.kt │ │ │ │ ├── PartTooManyErrorException.kt │ │ │ │ ├── PrepareDestinationFailedException.kt │ │ │ │ ├── ServerPartIsNotTheSameAsWeExpectException.kt │ │ │ │ ├── ServerResumeSupportChangeException.kt │ │ │ │ └── TooManyErrorException.kt │ │ │ ├── part/ │ │ │ │ ├── DownloadPart.kt │ │ │ │ ├── HttpPartDownloader.kt │ │ │ │ ├── MediaSegment.kt │ │ │ │ ├── PartDownloadStatus.kt │ │ │ │ ├── PartDownloader.kt │ │ │ │ ├── PartSplitSupport.kt │ │ │ │ ├── Parts.kt │ │ │ │ └── RangedPart.kt │ │ │ ├── queue/ │ │ │ │ ├── DownloadQueue.kt │ │ │ │ ├── ManualDownloadQueue.kt │ │ │ │ ├── QueueEvent.kt │ │ │ │ ├── QueueManager.kt │ │ │ │ └── ScheduleTimes.kt │ │ │ └── utils/ │ │ │ ├── CallAwait.kt │ │ │ ├── CollectionUtils.kt │ │ │ ├── DuplicateFilter.kt │ │ │ ├── EmptyFileCreator.kt │ │ │ ├── ExceptionUtils.kt │ │ │ ├── FileNameUtil.kt │ │ │ ├── FlowUtils.kt │ │ │ ├── IDistStat.kt │ │ │ ├── LockList.kt │ │ │ ├── Logger.kt │ │ │ ├── NumUtil.kt │ │ │ ├── OnDuplicateStrategy.kt │ │ │ ├── SparseFile.kt │ │ │ ├── SplitToRange.kt │ │ │ └── TimeUtils.kt │ │ └── desktopMain/ │ │ └── kotlin/ │ │ └── ir/ │ │ └── amirab/ │ │ └── downloader/ │ │ └── utils/ │ │ └── SparseFile.desktop.kt │ └── monitor/ │ ├── build.gradle.kts │ └── src/ │ └── commonMain/ │ └── kotlin/ │ └── ir/ │ └── amirab/ │ └── downloader/ │ └── monitor/ │ ├── CompletedDownloadItemState.kt │ ├── DownloadItemStateFactory.kt │ ├── DownloadMonitor.kt │ ├── DownloadStateUtil.kt │ ├── IDownloadItemState.kt │ ├── IDownloadMonitor.kt │ ├── ProcessingDownloadItemState.kt │ └── UiPart.kt ├── gradle/ │ ├── libs.versions.toml │ └── wrapper/ │ ├── gradle-wrapper.jar │ └── gradle-wrapper.properties ├── gradle.properties ├── gradlew ├── gradlew.bat ├── integration/ │ └── server/ │ ├── build.gradle.kts │ └── src/ │ └── main/ │ ├── kotlin/ │ │ └── com/ │ │ └── abdownloadmanager/ │ │ └── integration/ │ │ ├── ApiQueueModel.kt │ │ ├── DownloadCredentialsFromIntegration.kt │ │ ├── Integration.kt │ │ ├── IntegrationHandler.kt │ │ ├── MyRequestAndResponse.kt │ │ ├── MyServer.kt │ │ ├── MySunHttpServer.kt │ │ ├── NewDownloadTask.kt │ │ └── http4k/ │ │ └── MyHttp4KServer.kt │ └── resources/ │ ├── logback.xml │ └── rules.pro ├── scripts/ │ ├── install.sh │ └── uninstall.sh ├── settings.gradle.kts └── shared/ ├── app/ │ ├── build.gradle.kts │ └── src/ │ ├── androidMain/ │ │ └── kotlin/ │ │ └── com/ │ │ └── abdownloadmanager/ │ │ └── shared/ │ │ ├── ui/ │ │ │ ├── modifier/ │ │ │ │ └── PointerHoverIcon.android.kt │ │ │ ├── theme/ │ │ │ │ └── PlatformThemeDefinitions.android.kt │ │ │ └── widget/ │ │ │ ├── Tooltip.android.kt │ │ │ └── menu/ │ │ │ └── custom/ │ │ │ ├── Option.android.kt │ │ │ └── WithContextMenu.android.kt │ │ └── util/ │ │ ├── ClipboardUtil.android.kt │ │ ├── DesktopDiskStat.android.kt │ │ ├── PlatformThemeDetector.android.kt │ │ ├── downloadlocation/ │ │ │ └── PlatformDownloadLocationProvider.android.kt │ │ └── ui/ │ │ └── widget/ │ │ └── MPBackHandler.android.kt │ ├── commonMain/ │ │ └── kotlin/ │ │ └── com/ │ │ └── abdownloadmanager/ │ │ └── shared/ │ │ ├── action/ │ │ │ ├── CommonActionFactories.kt │ │ │ └── DevActionFactories.kt │ │ ├── downloaderinui/ │ │ │ ├── BasicDownloadItem.kt │ │ │ ├── CredentialAndItemMapper.kt │ │ │ ├── DownloadSize.kt │ │ │ ├── DownloadUiChecker.kt │ │ │ ├── DownloaderInUi.kt │ │ │ ├── DownloaderInUiRegistry.kt │ │ │ ├── LinkChecker.kt │ │ │ ├── LinkCheckerFactory.kt │ │ │ ├── add/ │ │ │ │ ├── AddDownloadChecker.kt │ │ │ │ ├── CanAddResult.kt │ │ │ │ ├── NewDownloadInputs.kt │ │ │ │ └── NewDownloadInputsFactory.kt │ │ │ ├── edit/ │ │ │ │ ├── CanEditDownloadResult.kt │ │ │ │ ├── CanEditWarnings.kt │ │ │ │ ├── DownloadConflictDetector.kt │ │ │ │ ├── EditDownloadCheckerFactory.kt │ │ │ │ ├── EditDownloadInputs.kt │ │ │ │ └── EditDownloadInputsFactory.kt │ │ │ ├── hls/ │ │ │ │ ├── HLSDownloaderInUi.kt │ │ │ │ ├── HLSLinkChecker.kt │ │ │ │ ├── HlsItemToCredentialMapper.kt │ │ │ │ ├── UiProcessingItemForHSLFactory.kt │ │ │ │ ├── add/ │ │ │ │ │ ├── HLSDownloadUIChecker.kt │ │ │ │ │ └── HLSNewDownloadInputs.kt │ │ │ │ └── edit/ │ │ │ │ ├── HLSEditDownloadChecker.kt │ │ │ │ └── HLSEditDownloadInputs.kt │ │ │ └── http/ │ │ │ ├── HttpCredentialsToItemMapper.kt │ │ │ ├── HttpDownloaderInUi.kt │ │ │ ├── add/ │ │ │ │ ├── HttpDownloadUiChecker.kt │ │ │ │ ├── HttpLinkChecker.kt │ │ │ │ └── HttpNewDownloadInputs.kt │ │ │ ├── applyHostSettingsToExtraConfig.kt │ │ │ └── edit/ │ │ │ ├── HttpEditDownloadChecker.kt │ │ │ └── HttpEditDownloadInputs.kt │ │ ├── pagemanager/ │ │ │ ├── AboutPageManager.kt │ │ │ ├── AddDownloadDialogManager.kt │ │ │ ├── BatchDownloadPageManager.kt │ │ │ ├── CategoryDialogManager.kt │ │ │ ├── DownloadDialogManager.kt │ │ │ ├── EditDownloadDialogManager.kt │ │ │ ├── EnterNewURLDialogManager.kt │ │ │ ├── ExitApplicationRequestManager.kt │ │ │ ├── FileChecksumDialogManager.kt │ │ │ ├── NotificationSender.kt │ │ │ ├── OpenSourceLibrariesPageManager.kt │ │ │ ├── PerHostSettingsPageManager.kt │ │ │ ├── QueuePageManager.kt │ │ │ ├── SettingsPageManager.kt │ │ │ └── TranslatorsPageManager.kt │ │ ├── pages/ │ │ │ ├── adddownload/ │ │ │ │ ├── AddDownloadComponent.kt │ │ │ │ ├── AddDownloadConfig.kt │ │ │ │ ├── AddDownloadCredentialsInUiProps.kt │ │ │ │ ├── ImportOptions.kt │ │ │ │ ├── multiple/ │ │ │ │ │ ├── BaseAddMultiDownloadComponent.kt │ │ │ │ │ └── OnRequestAdd.kt │ │ │ │ └── single/ │ │ │ │ └── BaseAddSingleDownloadComponent.kt │ │ │ ├── batchdownload/ │ │ │ │ └── BaseBatchDownloadComponent.kt │ │ │ ├── category/ │ │ │ │ └── CategoryComponent.kt │ │ │ ├── checksum/ │ │ │ │ └── BaseFileChecksumComponent.kt │ │ │ ├── credits/ │ │ │ │ └── translators/ │ │ │ │ └── LanguageTranslationInfo.kt │ │ │ ├── editdownload/ │ │ │ │ └── BaseEditDownloadComponent.kt │ │ │ ├── enterurl/ │ │ │ │ ├── BaseEnterNewURLComponent.kt │ │ │ │ └── DownloaderSelection.kt │ │ │ ├── home/ │ │ │ │ ├── AbstractDownloadActions.kt │ │ │ │ ├── BaseHomeComponent.kt │ │ │ │ ├── CategoryActions.kt │ │ │ │ ├── FilterState.kt │ │ │ │ ├── PromptStates.kt │ │ │ │ ├── category/ │ │ │ │ │ ├── DefinedStatusCategories.kt │ │ │ │ │ ├── DownloadStatusCategoryFilter.kt │ │ │ │ │ └── DownloadStatusCategoryFilterByList.kt │ │ │ │ └── queue/ │ │ │ │ └── QueueActions.kt │ │ │ ├── perhostsettings/ │ │ │ │ └── PerHostSettingsComponent.kt │ │ │ └── updater/ │ │ │ ├── RenderUpdateNotifications.kt │ │ │ └── UpdateComponent.kt │ │ ├── repository/ │ │ │ └── BaseAppRepository.kt │ │ ├── settings/ │ │ │ ├── BaseSettingsComponent.kt │ │ │ └── CommonSettings.kt │ │ ├── singledownloadpage/ │ │ │ ├── BaseSingleDownloadComponent.kt │ │ │ ├── DownloadStatusStringSource.kt │ │ │ └── SingleDownloadPagePropertyItem.kt │ │ ├── storage/ │ │ │ ├── BaseAppSettingsStorage.kt │ │ │ ├── ExtraDownloadSettingsStorage.kt │ │ │ ├── ExtraQueueSettingsStorage.kt │ │ │ ├── IExtraDownloadSettingsStorage.kt │ │ │ ├── IExtraQueueSettingsStorage.kt │ │ │ ├── ILastSavedLocationsStorage.kt │ │ │ ├── PerHostSettingsDatastoreStorage.kt │ │ │ ├── ProxyDatastoreStorage.kt │ │ │ ├── SupportedSizeUnits.kt │ │ │ └── impl/ │ │ │ └── LastSavedLocationStorage.kt │ │ ├── ui/ │ │ │ ├── Bootstrap.kt │ │ │ ├── configurable/ │ │ │ │ ├── BaseEnumConfigurable.kt │ │ │ │ ├── BaseLongConfigurable.kt │ │ │ │ ├── CommonConfigurableRenderers.kt │ │ │ │ ├── Configurable.kt │ │ │ │ ├── ConfigurableGroup.kt │ │ │ │ ├── ConfigurableRendererRegistry.kt │ │ │ │ ├── RenderConfigurable.kt │ │ │ │ ├── RenderConfigurableGroup.kt │ │ │ │ ├── Shared.kt │ │ │ │ └── item/ │ │ │ │ ├── BooleanConfigurable.kt │ │ │ │ ├── DayOfWeekConfigurable.kt │ │ │ │ ├── EnumConfigurable.kt │ │ │ │ ├── FileChecksumConfigurable.kt │ │ │ │ ├── FloatConfigurable.kt │ │ │ │ ├── FolderConfigurable.kt │ │ │ │ ├── IntConfigurable.kt │ │ │ │ ├── LongConfigurable.kt │ │ │ │ ├── NavigatableConfigurable.kt │ │ │ │ ├── ProxyConfigurable.kt │ │ │ │ ├── SpeedLimitConfigurable.kt │ │ │ │ ├── StringConfigurable.kt │ │ │ │ ├── ThemeConfigurable.kt │ │ │ │ └── TimeConfigurable.kt │ │ │ ├── modifier/ │ │ │ │ └── PointerHoverIcon.kt │ │ │ ├── theme/ │ │ │ │ ├── ABDownloaderTheme.kt │ │ │ │ ├── DefaultThemes.kt │ │ │ │ ├── Markdown.kt │ │ │ │ ├── PlatformThemeDefinitions.kt │ │ │ │ ├── ThemeManager.kt │ │ │ │ └── ThemeSettingsStorage.kt │ │ │ └── widget/ │ │ │ ├── ActionButton.kt │ │ │ ├── ActionContainer.kt │ │ │ ├── AddUrlButton.kt │ │ │ ├── CheckBox.kt │ │ │ ├── DashedBorder.kt │ │ │ ├── ExpandableItem.kt │ │ │ ├── Handle.kt │ │ │ ├── Help.kt │ │ │ ├── IconPick.kt │ │ │ ├── Language.kt │ │ │ ├── LinkText.kt │ │ │ ├── LoadingIndicator.kt │ │ │ ├── MainActionButton.kt │ │ │ ├── MessageDialogType.kt │ │ │ ├── Multiselect.kt │ │ │ ├── MyIconButton.kt │ │ │ ├── MyTextField.kt │ │ │ ├── MyTextFieldWithIcons.kt │ │ │ ├── NavigateableItem.kt │ │ │ ├── Notification.kt │ │ │ ├── NumberTextField.kt │ │ │ ├── Popup.kt │ │ │ ├── RadioButton.kt │ │ │ ├── Switch.kt │ │ │ ├── Tabs.kt │ │ │ ├── Text.kt │ │ │ ├── Tooltip.kt │ │ │ ├── menu/ │ │ │ │ └── custom/ │ │ │ │ ├── DropDown.kt │ │ │ │ ├── MenuColumn.kt │ │ │ │ ├── Option.kt │ │ │ │ ├── SiblingMenuPositionProvider.kt │ │ │ │ ├── SubMenu.kt │ │ │ │ └── WithContextMenu.kt │ │ │ └── sort/ │ │ │ ├── ComparatorProvider.kt │ │ │ ├── Sort.kt │ │ │ ├── SortIndicatorMode.kt │ │ │ └── sorted.kt │ │ ├── updater/ │ │ │ └── UpdateDownloaderViaDownloadSystem.kt │ │ └── util/ │ │ ├── AppHostNameVerifier.kt │ │ ├── AppSSLFactoryProvider.kt │ │ ├── AppVersion.kt │ │ ├── AutoStartManager.kt │ │ ├── BaseComponent.kt │ │ ├── BaseConstants.kt │ │ ├── BaseSettings.kt │ │ ├── BottomSheet.kt │ │ ├── BrowserIntegrationModel.kt │ │ ├── ClipboardUtil.kt │ │ ├── ColorUtils.kt │ │ ├── ContainsShortcuts.kt │ │ ├── CoroutineUtils.kt │ │ ├── DefinedPaths.kt │ │ ├── DesktopDiskStat.kt │ │ ├── DownloadFoldersRegistry.kt │ │ ├── DownloadItemOpener.kt │ │ ├── DownloadSystem.kt │ │ ├── DurationUtil.kt │ │ ├── ExceptionToString.kt │ │ ├── FileIconProvider.kt │ │ ├── FilenameFixer.kt │ │ ├── HashUtil.kt │ │ ├── IPUtils.kt │ │ ├── InstanceCheckUtils.kt │ │ ├── LimitationsInUI.kt │ │ ├── Platform.kt │ │ ├── PlatformKeyStroke.kt │ │ ├── PlatformThemeDetector.kt │ │ ├── PopUpContainer.kt │ │ ├── RememberDotLoading.kt │ │ ├── Responsive.kt │ │ ├── SharedConstants.kt │ │ ├── ShortcutManager.kt │ │ ├── ShouldValidate.kt │ │ ├── SizeAndSpeedUnitProvider.kt │ │ ├── SizeUtil.kt │ │ ├── StateUtils.kt │ │ ├── StringUtils.kt │ │ ├── SystemDownloadLocationProvider.kt │ │ ├── TimeUtil.kt │ │ ├── UiConstants.kt │ │ ├── UserAgentProviderFromSettings.kt │ │ ├── ValueUtils.kt │ │ ├── appinfo/ │ │ │ └── PreviousVersion.kt │ │ ├── autoremove/ │ │ │ └── RemovedDownloadsFromDiskTracker.kt │ │ ├── category/ │ │ │ ├── Category.kt │ │ │ ├── CategoryFileStorage.kt │ │ │ ├── CategoryItem.kt │ │ │ ├── CategoryManager.kt │ │ │ ├── CategoryManagerExtensions.kt │ │ │ ├── CategorySelectionMode.kt │ │ │ ├── CategoryStorage.kt │ │ │ ├── DefaultCategories.kt │ │ │ ├── DownloadManagerCategoryItemProvider.kt │ │ │ ├── ICategoryItemProvider.kt │ │ │ └── InMemoryCategoryStorage.kt │ │ ├── downloadlocation/ │ │ │ └── PlatformDownloadLocationProvider.kt │ │ ├── extractors/ │ │ │ ├── Extractor.kt │ │ │ └── linkextractor/ │ │ │ ├── DownloadCredentialExtractor.kt │ │ │ ├── DownloadCredentialsFromCurl.kt │ │ │ └── URLExtractors.kt │ │ ├── mvi/ │ │ │ ├── ContainsEffects.kt │ │ │ ├── ContainsScreenState.kt │ │ │ └── EventHandler.kt │ │ ├── ondownloadcompletion/ │ │ │ ├── OnDownloadCompletionAction.kt │ │ │ ├── OnDownloadCompletionActionProvider.kt │ │ │ └── OnDownloadCompletionActionRunner.kt │ │ ├── onqueuecompletion/ │ │ │ ├── OnQueueCompletionActionProvider.kt │ │ │ ├── OnQueueEventAction.kt │ │ │ └── OnQueueEventActionRunner.kt │ │ ├── perhostsettings/ │ │ │ ├── PerHostSettingsItem.kt │ │ │ ├── PerHostSettingsManager.kt │ │ │ └── PerHostSettingsStorage.kt │ │ ├── proxy/ │ │ │ ├── IProxyStorage.kt │ │ │ ├── Proxy.kt │ │ │ └── ProxyManager.kt │ │ └── ui/ │ │ ├── BaseMyColors.kt │ │ ├── IMyIcons.kt │ │ ├── LocalIsDebugMode.kt │ │ ├── LocalProviders.kt │ │ ├── LocalTitleBarDirection.kt │ │ ├── MyColors.kt │ │ ├── ScrollbableContent.kt │ │ ├── Scrollbar.kt │ │ ├── icon/ │ │ │ └── MyIcons.kt │ │ ├── theme/ │ │ │ ├── ISystemThemeDetector.kt │ │ │ ├── MaterialRipple.kt │ │ │ ├── MyShapes.kt │ │ │ └── Sizing.kt │ │ └── widget/ │ │ ├── Icon.kt │ │ ├── MPBackHandler.kt │ │ ├── ScreenSurface.kt │ │ └── ScrolFade.kt │ └── desktopMain/ │ └── kotlin/ │ └── com/ │ └── abdownloadmanager/ │ └── shared/ │ ├── ui/ │ │ ├── modifier/ │ │ │ └── PointerHoverIcon.desktop.kt │ │ ├── theme/ │ │ │ ├── MyContextMenuRepresentation.kt │ │ │ └── PlatformThemeDefinitions.desktop.kt │ │ ├── util/ │ │ │ └── LocalWindow.desktop.kt │ │ └── widget/ │ │ ├── Tooltip.desktop.kt │ │ ├── menu/ │ │ │ ├── custom/ │ │ │ │ ├── MenuBar.kt │ │ │ │ ├── Option.desktop.kt │ │ │ │ └── WithContextMenu.desktop.kt │ │ │ └── native/ │ │ │ └── NativeMenuBar.kt │ │ └── table/ │ │ └── customtable/ │ │ ├── CellPadding.kt │ │ ├── Table.kt │ │ ├── TableUtils.kt │ │ └── styled/ │ │ └── MyStyledHeader.kt │ └── util/ │ ├── ClipboardUtil.desktop.kt │ ├── DesktopDiskStat.desktop.kt │ ├── PlatformThemeDetector.desktop.kt │ ├── downloadlocation/ │ │ ├── DesktopDownloadLocationProvider.kt │ │ ├── LinuxDownloadLocationProvider.kt │ │ ├── MacDownloadLocationProvider.kt │ │ ├── PlatformDownloadLocationProvider.desktop.kt │ │ └── WindowsDownloadLocationProvider.kt │ └── ui/ │ └── widget/ │ └── MPBackHandler.desktop.kt ├── auto-start/ │ ├── build.gradle.kts │ └── src/ │ ├── androidMain/ │ │ └── kotlin/ │ │ └── ir/ │ │ └── amirab/ │ │ └── util/ │ │ └── startup/ │ │ ├── AndroidStartupManager.kt │ │ └── Startup.android.kt │ ├── commonMain/ │ │ └── kotlin/ │ │ └── ir/ │ │ └── amirab/ │ │ └── util/ │ │ └── startup/ │ │ ├── AbstractStartupManager.kt │ │ └── Startup.kt │ └── desktopMain/ │ └── kotlin/ │ └── ir/ │ └── amirab/ │ └── util/ │ └── startup/ │ ├── AbstractDesktopStartupManager.kt │ ├── HeadlessStartupDesktop.kt │ ├── MacOSStartupDesktop.kt │ ├── Startup.kt │ ├── UnixXDGStartupDesktop.kt │ ├── Utils.kt │ └── WindowsStartupDesktop.kt ├── compose-utils/ │ ├── build.gradle.kts │ └── src/ │ └── commonMain/ │ └── kotlin/ │ └── ir/ │ └── amirab/ │ └── util/ │ └── compose/ │ ├── Helpers.kt │ ├── IIconResolver.kt │ ├── IconSource.kt │ ├── StringSource.kt │ ├── action/ │ │ ├── AnAction.kt │ │ ├── Extensions.kt │ │ ├── MenuDsl.kt │ │ └── MenuItem.kt │ ├── contants/ │ │ └── Constants.kt │ ├── layout/ │ │ └── RelativeAlignment.kt │ ├── localizationmanager/ │ │ ├── ILanguageNameProvider.kt │ │ ├── LanguageManager.kt │ │ ├── LanguageSourceProvider.kt │ │ ├── LanguageStorage.kt │ │ ├── LocalLanguageManager.kt │ │ ├── MyLocale.kt │ │ └── StringVariableReplacer.kt │ ├── modifiers/ │ │ ├── AutoMirror.kt │ │ └── Clickables.kt │ └── resources/ │ └── Resources.kt ├── config/ │ ├── build.gradle.kts │ └── src/ │ └── main/ │ └── kotlin/ │ └── ir/ │ └── amirab/ │ └── util/ │ └── config/ │ ├── Config.kt │ ├── ConfigKeyWithPrimitiveType.kt │ ├── ConfigToJson.kt │ ├── JsonMapper.kt │ ├── NestedCreator.kt │ ├── datastore/ │ │ ├── KotlinSerializationDataStore.kt │ │ └── MapConfigDataStore.kt │ └── extensions.kt ├── nanohttp4k/ │ ├── build.gradle.kts │ └── src/ │ └── main/ │ ├── kotlin/ │ │ └── ir/ │ │ └── amirab/ │ │ └── util/ │ │ └── http4k/ │ │ └── NanoHttpServer.kt │ └── resources/ │ └── rules.pro ├── resources/ │ ├── build.gradle.kts │ ├── contracts/ │ │ ├── build.gradle.kts │ │ └── src/ │ │ └── commonMain/ │ │ └── kotlin/ │ │ └── ir/ │ │ └── amirab/ │ │ └── resources/ │ │ └── contracts/ │ │ ├── MyLanguageResource.kt │ │ └── MyStringResource.kt │ └── src/ │ └── commonMain/ │ ├── kotlin/ │ │ └── com/ │ │ └── abdownloadmanager/ │ │ └── resources/ │ │ ├── ABDMLanguageResources.kt │ │ └── icons/ │ │ ├── ABDMIcons.kt │ │ ├── AddLink.kt │ │ ├── Alphabet.kt │ │ ├── AppIcon.kt │ │ ├── Back.kt │ │ ├── BrowserGoogleChrome.kt │ │ ├── BrowserMicrosoftEdge.kt │ │ ├── BrowserMozillaFirefox.kt │ │ ├── BrowserOpera.kt │ │ ├── Check.kt │ │ ├── Clear.kt │ │ ├── Clipboard.kt │ │ ├── Clock.kt │ │ ├── Colors.kt │ │ ├── Copy.kt │ │ ├── Data.kt │ │ ├── Delete.kt │ │ ├── Down.kt │ │ ├── DownSpeed.kt │ │ ├── DragAndDrop.kt │ │ ├── Earth.kt │ │ ├── Edit.kt │ │ ├── Exit.kt │ │ ├── ExternalLink.kt │ │ ├── Fast.kt │ │ ├── File.kt │ │ ├── FileApplication.kt │ │ ├── FileDocument.kt │ │ ├── FileMusic.kt │ │ ├── FilePicture.kt │ │ ├── FileUnknown.kt │ │ ├── FileVideo.kt │ │ ├── FileZip.kt │ │ ├── Flag.kt │ │ ├── Folder.kt │ │ ├── FolderFinished.kt │ │ ├── FolderUnfinished.kt │ │ ├── Grip.kt │ │ ├── Group.kt │ │ ├── Hearth.kt │ │ ├── Info.kt │ │ ├── Language.kt │ │ ├── List.kt │ │ ├── Lock.kt │ │ ├── Menu.kt │ │ ├── Minus.kt │ │ ├── Network.kt │ │ ├── Next.kt │ │ ├── OpenSource.kt │ │ ├── Pause.kt │ │ ├── Permission.kt │ │ ├── Plus.kt │ │ ├── QuestionMark.kt │ │ ├── Queue.kt │ │ ├── QueueStart.kt │ │ ├── QueueStop.kt │ │ ├── Refresh.kt │ │ ├── Resume.kt │ │ ├── Scheduler.kt │ │ ├── Search.kt │ │ ├── SelectAll.kt │ │ ├── SelectInside.kt │ │ ├── SelectInvert.kt │ │ ├── Settings.kt │ │ ├── Share.kt │ │ ├── Sort123.kt │ │ ├── Sort321.kt │ │ ├── Speaker.kt │ │ ├── SpeedLimiter.kt │ │ ├── Stop.kt │ │ ├── StopAll.kt │ │ ├── Telegram.kt │ │ ├── Theme.kt │ │ ├── Undo.kt │ │ ├── Up.kt │ │ ├── VerticalDirection.kt │ │ ├── WindowClose.kt │ │ ├── WindowFloating.kt │ │ ├── WindowMaximize.kt │ │ └── WindowMinimize.kt │ └── resources/ │ └── com/ │ └── abdownloadmanager/ │ └── resources/ │ ├── credits/ │ │ └── translators.json │ └── locales/ │ ├── ar_SA.properties │ ├── bn_BD.properties │ ├── bqi_IR.properties │ ├── ckb_IR.properties │ ├── de_DE.properties │ ├── en_US.properties │ ├── es_ES.properties │ ├── fa_IR.properties │ ├── fi_FI.properties │ ├── fr_FR.properties │ ├── hu_HU.properties │ ├── id_ID.properties │ ├── it_IT.properties │ ├── ja_JP.properties │ ├── ka_GE.properties │ ├── ko_KR.properties │ ├── lt_LT.properties │ ├── pl_PL.properties │ ├── pt_BR.properties │ ├── ru_RU.properties │ ├── sq_AL.properties │ ├── th_TH.properties │ ├── tr_TR.properties │ ├── uk_UA.properties │ ├── vi_VN.properties │ ├── zh_CN.properties │ └── zh_TW.properties ├── updater/ │ ├── build.gradle.kts │ └── src/ │ ├── androidMain/ │ │ └── kotlin/ │ │ ├── AndroidDirectLinkUpdateApplier.kt │ │ └── ApkInstaller.kt │ ├── commonMain/ │ │ └── kotlin/ │ │ └── com/ │ │ └── abdownloadmanager/ │ │ ├── ArtifactUtil.kt │ │ ├── UpdateDownloadLocationProvider.kt │ │ ├── UpdateManager.kt │ │ ├── github/ │ │ │ └── githubapi.kt │ │ ├── updateapplier/ │ │ │ ├── BaseUpdateApplier.kt │ │ │ ├── UpdateApplier.kt │ │ │ ├── UpdateDownloader.kt │ │ │ └── UpdateInstaller.kt │ │ └── updatechecker/ │ │ ├── DummyUpdateChecker.kt │ │ ├── GithubUpdateChecker.kt │ │ ├── UpdateChecker.kt │ │ └── UpdateInfo.kt │ └── desktopMain/ │ ├── kotlin/ │ │ └── com/ │ │ └── abdownloadmanager/ │ │ └── updateapplier/ │ │ ├── DesktopDirectLinkUpdateApplier.kt │ │ ├── UpdateInstallerByWindowsExecutable.kt │ │ └── UpdateInstallerFromArchiveFile.kt │ └── resources/ │ └── com/ │ └── abdownloadmanager/ │ └── updater/ │ ├── updater_linux.sh │ ├── updater_macos.sh │ └── updater_windows.bat └── utils/ ├── build.gradle.kts └── src/ ├── androidMain/ │ └── kotlin/ │ └── ir/ │ └── amirab/ │ └── util/ │ ├── openUrl.android.kt │ └── osfileutil/ │ ├── AndroidFileUtil.kt │ └── FileUtils.android.kt ├── commonMain/ │ └── kotlin/ │ └── ir/ │ └── amirab/ │ ├── SelectionUtil.kt │ └── util/ │ ├── AppVersionTracker.kt │ ├── CallAwait.kt │ ├── CollectionUtils.kt │ ├── Exec.kt │ ├── FileExtensions.kt │ ├── FileNameValidator.kt │ ├── FilenameDecoder.kt │ ├── GuardedEntry.kt │ ├── HttpUrlUtils.kt │ ├── IfThen.kt │ ├── NullCheck.kt │ ├── OkioUtils.kt │ ├── PathValidator.kt │ ├── StringUtil.kt │ ├── ValueHolder.kt │ ├── coroutines/ │ │ ├── CombineFlows.kt │ │ ├── CoroutineUtils.kt │ │ └── debounce.kt │ ├── datasize/ │ │ ├── BaseSize.kt │ │ ├── CommonSizeConvertConfigs.kt │ │ ├── CommonSizeUnits.kt │ │ ├── ConvertSizeConfig.kt │ │ ├── SizeConverter.kt │ │ ├── SizeFactors.kt │ │ ├── SizeUnit.kt │ │ ├── SizeWithUnit.kt │ │ └── extensions.kt │ ├── enumValueOrNull.kt │ ├── flow/ │ │ ├── FlowOperators.kt │ │ ├── FlowUtils.kt │ │ └── StateFlowUtil.kt │ ├── lock.kt │ ├── openUrl.kt │ └── osfileutil/ │ ├── FileUtils.kt │ └── FileUtilsBase.kt └── desktopMain/ └── kotlin/ └── ir/ └── amirab/ └── util/ ├── openUrl.desktop.kt └── osfileutil/ ├── DesktopFileUtils.kt ├── FileUtils.desktop.kt ├── JVMFileUtils.kt ├── LinuxFileUtils.kt ├── MacOsFileUtils.kt └── WindowsFileUtils.kt ================================================ FILE CONTENTS ================================================ ================================================ FILE: .gitattributes ================================================ # # https://help.github.com/articles/dealing-with-line-endings/ # # Linux start script should use lf /gradlew text eol=lf # These are Windows script files and should use crlf *.bat text eol=crlf ================================================ FILE: .github/FUNDING.yml ================================================ # These are supported funding model platforms github: # Replace with up to 4 GitHub Sponsors-enabled usernames e.g., [user1, user2] patreon: # Replace with a single Patreon username open_collective: # Replace with a single Open Collective username ko_fi: # Replace with a single Ko-fi username tidelift: # Replace with a single Tidelift platform-name/package-name e.g., npm/babel community_bridge: # Replace with a single Community Bridge project-name e.g., cloud-foundry liberapay: # Replace with a single Liberapay username issuehunt: # Replace with a single IssueHunt username lfx_crowdfunding: # Replace with a single LFX Crowdfunding project-name e.g., cloud-foundry polar: # Replace with a single Polar username buy_me_a_coffee: # Replace with a single Buy Me a Coffee username thanks_dev: # Replace with a single thanks.dev username custom: ["https://github.com/amir1376/ab-download-manager/blob/master/DONATE.md"] # Replace with up to 4 custom sponsorship URLs e.g., ['link1', 'link2'] ================================================ FILE: .github/ISSUE_TEMPLATE/bug_report.yml ================================================ name: 🐞 Bug Report description: Report an issue or unexpected behavior in the app title: "[Bug] Brief summary (keep it short)" labels: [] body: - type: markdown attributes: value: | Thank you for taking the time to report a bug! **Before submitting, please [search the existing issues](./issues) to make sure this is not a duplicate.** - type: textarea id: description attributes: label: 📝 Description description: What happened? What were you trying to do? Please provide a clear and concise description of the problem. placeholder: Describe the bug you encountered. validations: required: true - type: input id: app-version attributes: label: 🏷️ App Version description: e.g., 1.2.3 placeholder: "1.2.3" validations: required: true - type: input id: platform attributes: label: 💻 Platform description: e.g., Windows 11, macOS 14.2, Ubuntu 24.04 placeholder: "Windows 11" validations: required: true - type: input id: installation-type attributes: label: 📦 Installation Type (optional) description: e.g., .exe installer, dmg file, package manager, installation script placeholder: ".exe installer" validations: required: false - type: input id: system-device-details attributes: label: ⚙️ System/Device Details (optional) description: e.g., CPU, RAM, device model placeholder: "CPU: i7-12700K; RAM: 32GB DDR5" validations: required: false - type: textarea id: steps-to-reproduce attributes: label: 🔁 Steps to Reproduce description: List all necessary steps to reproduce the bug. placeholder: | 1. Go to '...' 2. Click on '....' 3. Scroll down to '....' 4. See error validations: required: true - type: textarea id: expected-behavior attributes: label: ✅ Expected Behavior description: What did you expect to happen? placeholder: Describe the expected behavior. validations: required: true - type: textarea id: screenshots attributes: label: 📷 Screenshots or Recordings (optional) description: Drag and drop, paste images, or attach screen recordings here. placeholder: Attach screenshots or screen recordings if possible. validations: required: false - type: textarea id: additional-info attributes: label: 🗒️ Additional Information (optional) description: Any additional info, logs, context, or links to related issues. placeholder: Add any other context about the problem here. validations: required: false - type: textarea id: possible-solution attributes: label: 💡 Possible Solution (optional) description: Suggest a fix or reason for the bug, if you have one. placeholder: Suggest a possible solution or reason for the bug. validations: required: false ================================================ FILE: .github/workflows/brew-cask-auto-bump.yml ================================================ name: Auto bump ab-download-manager homebrew cask on: release: types: [released] workflow_dispatch: {} jobs: bump: runs-on: macos-latest env: HOMEBREW_GITHUB_API_TOKEN: ${{ secrets.HOMEBREW_GITHUB_API_TOKEN }} CASK_NAME: ab-download-manager TAP_NAME: amir1376/tap GH_REPO: amir1376/homebrew-tap steps: - name: Tap homebrew cask repo run: | brew tap ${{ env.TAP_NAME }} - name: Extract version id: ver run: | TAG_RAW="${{ github.event.release.tag_name }}" TAG_VER="${TAG_RAW#v}" if [ -n "$TAG_VER" ]; then SRC="release tag" V="$TAG_VER" else SRC="livecheck" OUT="$(brew livecheck --cask --json ${{ env.TAP_NAME }}/${{ env.CASK_NAME }} || true)" V="$(ruby -rjson -e ' j = JSON.parse(STDIN.read) rescue [] if j.is_a?(Array) && j[0] && j[0]["version"] v = j[0]["version"] if v["latest"] && v["current"] && v["latest"] != v["current"] puts v["latest"] end end ' <<< "$OUT" | tr -d "[:space:]")" fi echo "Source: $SRC" echo "Version: ${V:-}" echo "version=$V" >> "$GITHUB_OUTPUT" - name: Bump cask if: ${{ steps.ver.outputs.version != '' }} run: | V="${{ steps.ver.outputs.version }}" echo "Bumping ${{ env.CASK_NAME }} to $V ..." brew bump-cask-pr --no-browse --version "$V" "${{ env.TAP_NAME }}/${{ env.CASK_NAME }}" - name: Skip (No new version) if: ${{ steps.ver.outputs.version == '' }} run: echo "No newer version detected; nothing to bump." ================================================ FILE: .github/workflows/publish.yml ================================================ on: push: tags: - "v[0-9]+.[0-9]+.[0-9]+*" workflow_dispatch: permissions: contents: write jobs: create-packages: strategy: matrix: os: [ "ubuntu-latest", "ubuntu-24.04-arm", "windows-2022", "windows-11-arm", "macos-15-intel", "macos-15" ] runs-on: ${{ matrix.os }} steps: - name: Checkout uses: actions/checkout@v3 - name: Set up JBR uses: actions/setup-java@v4 with: distribution: "jetbrains" java-package: "jdk" java-version: "21" check-latest: true env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - name: Cache Gradle Dependencies uses: actions/cache@v4 with: path: | ~/.gradle/caches ~/.gradle/wrapper key: ${{ matrix.os }}-gradle enableCrossOsArchive: true - name: Skip Android build (build android only on Ubuntu) if: matrix.os != 'ubuntu-latest' shell: "bash" run: | echo "SKIP_ANDROID_BUILD=true" >> $GITHUB_ENV - name: Use Android Secrets if: matrix.os == 'ubuntu-latest' run: | echo "ABDM_KEYSTORE_FILE=${{ secrets.ABDM_KEYSTORE_FILE }}" >> $GITHUB_ENV echo "ABDM_KEYSTORE_FILE_PASSWORD=${{ secrets.ABDM_KEYSTORE_FILE_PASSWORD }}" >> $GITHUB_ENV echo "ABDM_KEYSTORE_KEY_ALIAS=${{ secrets.ABDM_KEYSTORE_KEY_ALIAS }}" >> $GITHUB_ENV echo "ABDM_KEYSTORE_KEY_PASSWORD=${{ secrets.ABDM_KEYSTORE_KEY_PASSWORD }}" >> $GITHUB_ENV # Steps specific to macOS (to create DMG for macOS) - name: Install create-dmg (for macOS) if: startsWith(matrix.os, 'macos-') run: | brew install create-dmg - name: Gradle run: | ./gradlew shell: "bash" - name: Build package for current OS using gradle shell: bash run: | ./gradlew createReleaseFolderForCi - name: Release Gradle to unlock cache files shell: bash run: | ./gradlew -stop - name: Upload output to artifacts uses: actions/upload-artifact@v4 with: path: ./build/ci-release name: app-${{ matrix.os }} release: runs-on: "ubuntu-latest" needs: [ "create-packages" ] steps: - uses: "actions/download-artifact@v4" name: "Download All Artifacts Into One Directory" with: path: release pattern: app-* merge-multiple: true - name: Version Info id: version uses: nowsprinting/check-version-format-action@v3 with: prefix: "v" - name: "Show the output tree of release" run: | tree . - uses: softprops/action-gh-release@v2 with: prerelease: ${{ !steps.version.outputs.is_stable }} make_latest: legacy draft: true files: | release/binaries/* body_path: release/release-notes.md - name: "Remove artifacts to free space" uses: geekyeggo/delete-artifact@v5 with: name: app-* ================================================ FILE: .github/workflows/winget.yml ================================================ name: Publish to Winget on: release: types: [released] jobs: publish: runs-on: ubuntu-latest steps: - uses: vedantmgoyal9/winget-releaser@main with: identifier: amir1376.ABDownloadManager token: ${{ secrets.WINGET_TOKEN }} ================================================ FILE: .gitignore ================================================ # Ignore Gradle project-specific cache directory .gradle .kotlin # Ignore Gradle build output directory build .idea local.properties # Ignore android keystores *.jks ================================================ FILE: CHANGELOG.md ================================================ # Changelog ## Unreleased ### Added ### Changed ### Deprecated ### Removed ### Fixed ### Security ## 1.8.7 ### Added - Ability to change the storage root when selecting the download location on Android (e.g., save to SD card) (#1101) - Option to choose the render API on Desktop (#1103) ### Changed - Default render API set to `SOFTWARE` on Linux ### Improved - Updated translations - Increment/decrement buttons in number fields are now more accessible (#1104) - Minor UI improvements ## 1.8.6 ### Added - Linux ARM support (#1081) - An option to set max concurrent downloads for manually resumed downloads (#1085) ### Fixed - Do not reset the download if storage is not mounted yet (#1087) - Error when assembling HLS media if destination folder was not created yet (#1089) - Do not reset the download if server changes status code from 206 to 200 (#1088) ### Improved - Updated translations - Queue logic improvements (#1086) - Linux Installation Script updated to support ARM devices (#1090) ## 1.8.5 ### Added - Windows ARM support (#1055) - In-app browser bookmark feature on Android (#1072) - In-app browser can be added to the launcher (App Menu) - In-app browser can be used as the default browser ### Fixed - System tray crash on some Linux environments (#1060) - Black screen issue on some Linux environments (#1066) - Notification sound playing even when notification sounds are muted on Android (#1064) ### Improved - Updated translations - In-app browser UI/UX improvements on Android - System tray now uses native UI on Windows (#1060) - Use a default User-Agent when no value is provided by the user (#1071) - Small UI improvements ## 1.8.4 ### Added - In-app browser for Android - The Android service now tells the user why it is running - The Add-Multi-Download page can now filter downloads using search and wildcards ### Fixed - Random app crashes on some Android devices caused by service-related issues - Issues with the in-app update feature on some Android devices ### Improved - Updated translations - The Android Foreground Service is now only used when necessary (active downloads, active queues, scheduled queues) and automatically stops after inactivity - Add-Multi-Download page UI/UX improvements ## 1.8.3 ### Added - Ability to sort and remove queue items on Android (#996) - A new shortcut to open the download list from the download progress dialog (#1001) ### Fixed - Download table state not saved properly on desktop (#999) - Update related notifications appearing repeatedly on android (#998) ### Improved - Updated translations - Settings Page UI improvements on android (#990) ## 1.8.2 ### Fixed - Resolved issues with the In-App update feature on some android devices - Disabled notification badges on the launcher icon on android - The application crashes on some devices (desktops) because of an issue in system theme detection logic ### Improved - Updated translations - Added tooltips for action buttons - Display selected count in the "Add Multi Download" page on desktop (#970) - Reduced battery consumption - Various UI/UX enhancements ## 1.8.1 ### Fixed - Android 10 storage access issue that caused download errors (#977) ### Improved - Updated translations - Better support for adaptive icons on Android (#978) - Improved directory picker on Android (#979) - Slightly reduced application size ## 1.8.0 ### Added - Android Support - macOS users can now use homebrew to install/update the application ### Fixed - Some HLS streams are not recognized properly ### Improved - Updated translations - UI improvements ## 1.7.1 ### Added - Support for custom data directory (#895) ### Fixed - System shortcuts not working on the main page (#885) - Segment info display issues - Suggested file names from browsers are now automatically corrected before use (#896) ### Improved - Translations updated - Download list UI improvements (#897) - Extra icon sizes added to the Windows `.ico` file ## 1.7.0 ### Added - Support for downloading media files: audio, video, non encrypted HLS streams from the browser (browser extension needs to be updated) - Option to customize each item individually in the “Add Multiple Downloads” page (by right-clicking on each item) ( #866) - “Download Page” and “Custom User-Agent” options are now available in the “Add Download” > "Configs" dialog - Ability to remove recently used save locations (#873) ### Changed - Browser integration API updated; updating the browser extension is required to support new features ### Improved - Updated translations - The download creation time is now set to when the “Add Download” dialog is opened (#846) ## 1.6.14 ### Fixed - An issue causing slow download speeds on some websites ### Improved - Updated translations - Download Engine improvements - Minor UI improvements ## 1.6.13 ### Fixed - **Access Denied** error could sometimes happen when adding a list of downloads (#826) ### Improved - Updated translations - Download engine improvements (#828) - **Customize Table Columns** popup now supports drag to reorder (#830) ## 1.6.12 ### Added - **Per Host Settings** — save username, password, thread count, user-agent, and more for specific hosts (#820) - Support for using **Move** action by holding **Shift** during drag & drop (#821) ### Changed - UI scale is now relative to the system scale instead of using a fixed value (#814) ### Fixed - Encoding issue with the default download folder on Linux (#810) ### Improved - Updated translations - Enhanced multi-display support (#814) - Main window now remembers its maximized state (#815) ## 1.6.11 ### Added - Option to change the download size unit (#804) ### Fixed - "Permission denied" error when starting a new download (#795) ### Improved - Updated translations - Improved settings page (#805) - Automatically fix illegal characters in server-provided filenames (#781) - Better handling of filenames received from the server (#780) - Use the OS default download location on first launch (#789) ## 1.6.10 ### Added - New Black theme (#767) ### Fixed - Restored missing executable permissions for files inside archives (macOS & Linux) (#765) - Eliminated flickering on the "New Update" page (#770) ### Improved - Updated translations - Hovering between menus now works without closing the open one (#766) - Better item selection and new keyboard shortcuts on the queue items page (#769) - Small UI improvements ## 1.6.9 ### Fixed - "Keep System Awake" was not properly cancelled on Windows (#755) - "Create Desktop Entry" had issue if the path contains spaces on Linux (#733) - Application crash on systems that have invalid font names (#737) - Some settings statuses were not updating correctly (#732) - "Download Dialog" position shifted when multiple dialogs were open simultaneously (#758) - "Add Download Dialog" position shifted when multiple dialogs were open simultaneously (#761) ### Improved - Translations updated - Better handling of filenames received from the server (#759) ## 1.6.8 ### Fixed - Can't change Auto Shutdown option if it was enabled ## 1.6.7 ### Added - Lithuanian language support - Kurdish (Sorani) language support - Option to automatically shut down the system when downloads or queues complete (#726) - Option to delete partial files on download cancellation (#724) ### Changed - App icon is now hidden from the Dock at runtime on macOS when the system tray is used (#710) - Default max download retry count changed to 3 ### Fixed - Removable storages are no longer monitored on Windows (#705) ### Improved - Translations updated - System stays awake while downloads are in progress (#725) - Confirm dialogs and buttons now have better focus behavior - Dropdowns are now searchable (#706) - IO Operations improvements ## 1.6.6 ### Added - Option to create desktop entry on Linux (#698) ### Changed - Renamed Linux desktop and autostart files for better compatibility (#699) ### Fixed - Removed duplicate UI Scale option in the Settings (#697) ### Improved - Updated translations - Pressing "Stop All" now closes all active download windows (#700) ## 1.6.5 ### Added - New themes (Deep Ocean, new Dark, new Light) - Category accepted file types are now optional (#690) - Option to change the app font (#692) - Option to switch between relative and absolute date/time formats (#694) - Option to clear all items in the queue at once ### Changed - Renamed the previous Dark theme to **Obsidian** - Renamed the previous Light theme to **Light Gray** ### Fixed - Issue where the app wouldn’t start for some Windows users (#695) ### Improved - Updated translations - General UI Improvements - Automatically scroll to new downloads on the main page (#672) - Improved path validation for new downloads (#693) ## 1.6.4 ### Added - Queues are now visible on the home page, next to the categories (#661) - In-app update is now supported on macOS (#627) - New option to enable the native menu bar on macOS (#646) ### Fixed - macOS: Window now activates properly when "Show Downloads" is clicked from the system tray (#632) - Linux: Startup desktop entry now includes an icon (#634) - An issue where the "Edit Download" page could unintentionally change the download status (#641) - Queue status not updated properly sometimes (#663) ### Improved - Translations updated - Minor UI improvements ## 1.6.3 ### Added - Korean Language - An option to append ".part" extension to incomplete downloads (disabled by default) ### Fixed - Prevent freeze when opening a file or folder - Some websites close the connection if we ask for resume support - Some non-standard links not captured correctly - Crash when opening browser integration links on macOS - Multiselect with Meta key not working as expected on macOS - Multiselect not stopped properly after window focus lost ### Improved - Translations updated - Minor UI/UX improvements ## 1.6.2 ### Added - Thai Language ### Fixed - System Tray crashes sometimes in Linux - Icons not rendered properly sometimes in Linux - System Tray icon color in macOS - Quit handler in macOS ### Improved - Translations updated - Respect user defined position of system buttons in Linux ## 1.6.1 ### Fixed - Application shortcut in Windows have no icon ### Improved - Translations updated ## 1.6.0 ### Added - macOS support - Polish Language - Hungarian Language - Luri Bakhtiari Language - Silent Download option in the Browser Integration - Donate button in the app to support the project ### Fixed - Overriding an existing download sometimes didn't work as expected. - "Start Queue" checkbox sometimes did not work as expected. ### Improved - Translations updated - Custom Window decorations - Window dragging on Linux is now handled by the OS - Each platform now uses its own system button style - JVM updated to version 21 ## 1.5.8 ### Added - An option to allow update existing download from "Add Download" page if a duplicate is detected ### Fixed - Crash when opening "Items" section of "Queues" page ### Improved - Translations updated - Duplicate download detection - Minor UI/UX improvements ## 1.5.7 ### Added - Drag and Drop files to other categories or external applications ### Fixed - "Parts Info" section in the "Download Progress" window does not expand for the first time ### Improved - Translations Updated - Improved UI rendering on Windows, resulting in higher FPS. - Minor UI/UX Improvements ## 1.5.6 ### Added - Finnish Language Support - An option to make the start time of queues optional - An ability to edit saved checksums on the "File Checksum Checker" page' ### Changed - The "Close" button in the "Download Progress" window has been renamed to "Cancel" (this stops the download and closes the window). To close the window without stopping the download, use the "X" button. ### Fixed - An issue where filenames in email attachments were not captured correctly - The updater wouldn't resume after the download was stopped - "Open Folder" doesn’t work properly on Linux when the file name contains special characters. - Changing settings in the 'Download Progress' window also affects other download items! ### Improved - Translations updated - "Download Progress" and "Queues" windows UI improvements - Pressing "Download Browser Integration" the download page will be opened in the corresponding browser ## 1.5.5 ### Added - Japanese Language - An option to automatically "Retry Failed Downloads" (Disabled by default for now) - An option to Import/Export download credentials as curl command ### Fixed - The download progress sometimes shows incorrect speeds and ETAs - Some unverified hostnames can't be used when the "Ignore SSL Certificate" is enabled - Startup on Boot issue in macOS - Drag And Drop of links issue in macOS - Some shortcuts didn't work properly in macOS - System Tray didn't work in macOS ### Improved - Translations updated - Minor UI improvements - App Icon size in macOS - Override "About" dialog in macOS ## 1.5.4 ### Added - The app now supports full portability by creating an empty `.abdm` directory in the installation folder. - An option to delete user data (configuration files) when using Windows Uninstaller. ### Fixed - Unchecked "Use Category" didn't work as expected. ### Improved - Download engine improvements. - Translation updated. ## 1.5.3 ### Added - Vietnamese language. - An option to "Select Queue" dialog to start the queue immediately. - An option to allow user set custom User-agent in the settings. - An option to not "Use Category" by default. - An option to disable SSL Certificate Verification. - An option to show/hide icon labels in the main toolbar (you can hover over them to see their labels). - An option to not use System Tray. ### Fixed - Sometimes Thread count not applied correctly. - The download completion dialog appeared even when its option is disabled. - Some servers return 256 Bytes instead of full size ### Improved - Translations updated - Minor UI improvements - Use system language as default language - Proxy Settings page improved ## 1.5.2 ### Added - An ability to validate downloads with File Checksum - System Proxy support - Proxy Auto Configuration (pac) support ### Changed - Maximum allowed thread count has been increased ### Fixed - Fixed the incorrect System Tray name on Linux. ### Improved - Translations Updated - Settings window size will be remembered now ## 1.5.1 ### Added - Italian Language - German Language - Georgian Language - Indonesian Language - An option to change download speed unit - An ability to start new download using Rest-Api ### Fixed - System tray in Linux now has correct icon and native option menu - App crashes when changing theme in Linux - Open file/folder action fails sometimes in Windows ### Improved - Translations updated - Split category and location configuration options in multi download page - Home page minor UI improvements ## 1.5.0 ### Added - In App Update feature. - An option to track deleted files on disk and remove them from the download list (either manually or automatically). - Delete option to the Main toolbar. ### Changed - UI Scale maximum value increased to 3x. ### Fixed - When you change "Default download Folder", the "Download Location" of categories also updated (if they are inside " Default Download Location"). - Issue on the "Add Multiple Download" page where the "Save Mode" set to "All in Same Location" was not functioning as expected. - App does not start on boot when installation path contains space. ### Improved - Redesigned "About" Page. - Translations updated. - "Extra Config" section on the "Add Download" page was not displaying correctly in some languages. ## 1.4.4 ### Added - UI Scale option ### Changed - The "Selection" cell in download list table can be hidden (optional) ### Fixed - Improved "Open Folder" in Windows - Support third party file managers in Windows - "Download Progress" Window shows up even if the "Auto Show Progress Window" option is disabled - Improved "Confirm Delete Download" UX - Improved the readability of shortcut text in menus - Improved sort of download list by status - Resize handle moves in opposite direction for RTL languages - Improved "Home" page - Improved "About" page - Updated translations ## 1.4.3 ### Added - "Download Completion" window - "Exit Confirmation" dialog when there is active download - An Option to automatically show "Download Completion" window (Optional) - An Option to automatically show "Download Progress" window when user presses on "Resume" (Optional) ### Changed - Default thread count is now 8 - Shape of filename of release binaries changed (added arch name after platform name) ### Fixed - "Delete entire list" task does not remove all downloads - Filename not detected correctly from some download servers - Rename download changes state to paused if it was finished - Improved installation script for linux - Improved "Settings" page - Translations updated ## 1.4.2 ### Added - Edit Download Page (Rename, Refresh links/credentials etc…) - Translators Credit Page - Traditional Chinese Language - Spanish Language - French Language - Turkish Language ### Changed - Updated translations ## 1.4.1 ### Added - Portuguese (Brazilian) Language ### Changed - Updated some languages ### Fixed - Language names not shown by their native names - Selected language not saved properly - Wrong text for "Close" button in "Batch Download" page ## 1.4.0 ### Added - Localization Support - Persian Language - Arabic Language - Chinese (Simplified) Language - Ukrainian Language - Russian Language - Albanian Language - Bengali Language ### Changed - Category Download Location is now optional ### Fixed - A bug in Download Engine - "Add Queue" page will be shown properly when opened from "Import List" page ## 1.3.0 ### Added - Proxy Support - Categories now can have URL patterns ### Fixed - Application freezes a while when we drag(and drop) a large file on it - Improved category section in the home screen ## 1.2.0 - in this version we replaced Wix installer with Nsis for better customization and more control over the installation process in Windows. - if you use Windows in order to install this version please first uninstall the previous msi version (your settings and downloads will be safe) ### Added - You can now create and customize categories - Pause/Resume in header actions ### Changed - Change installer in Windows from Wix to Nsis - Improved import link page ## 1.1.0 ### Added - Added Batch Download - Added an option to merge TitleBar with MenuBar (disabled by default) - Added two cli options --version, --exit ### Fixed - Fixed Opening downloaded file creates a subprocess in Windows - Fixed that some non-standard links not imported correctly - Improved window custom decoration logic and title bar position - Improved settings page ## 1.0.10 ### Fixed - Improve home page - Improve download page - Opening Directory Picker cause the app to crash in Linux - Folder opened two times when clicking on open folder in Linux ## 1.0.9 ### Added - Sparse File Allocation ### Fixed - Improve directory picker - Improve show help UX in settings - Improve Download Engine ## 1.0.8 ### Added - Use server Last-Modified time option in settings - Show Open File button if new download already exists and completed ### Fixed - Improved custom window decoration in linux - When we click on open folder in linux it opens the file instead! - Some URLEncoded filenames are not decoded properly ## 1.0.7 ### Added - support follow system Dark/Light mode - auto paste link (if any) from clipboard when opening add url page ### Fixed - App is now open in center of screen - Some settings doesn't persist after app restart - Download speed shows a high value incorrectly when we reopen the app window after a while - Some files not downloaded correctly now fixed ## 1.0.6 ### Added - Add Community and Browser Integration links to the app menu ### Changed - Change default download folder ### Fixed - Exception will not throw anymore if System Tray is not supported by the OS ## 1.0.5 - Improve Download Engine ## 1.0.4 - Improved UI/UX in Download Page ## 1.0.3 - Download Info Page now show users that a download file supports resuming or not ### Fixed - Download links that does not support resume now handled correctly - Some Web pages does not download correctly ## 1.0.2 - Error messages improvements ### Fixed - handle some webservers does not respect requested range at first place ## 1.0.1 - UI improvements ### Changed - repository url updated ## 1.0.0 - This is the first release of the app ### Added - Multi Connection File Download - Speed limiter - Download Queues - Download Scheduler - DownloadManager Browser Integration Support - Dark/Light themes ================================================ FILE: CONTRIBUTING.md ================================================ # Contributing to AB Download Manager > ❌ **Important Notice:** The entire codebase is being completely rewritten. Pull requests are **not accepted** at this > time, as incoming changes may be lost or conflict heavily with ongoing refactoring. Please wait until the refactor is > complete before submitting any PRs. Thank you for your interest in contributing to AB Download Manager! I appreciate any help you can offer. ## What Contributions Are Accepted? I welcome the following types of contributions: - **Bug Reports**: If you find a bug, please report it by opening an issue with details about the problem. - **Feature Requests**: Have an idea for a new feature? Let me know by opening an issue or starting a discussion. - **Translations**: You can translate AB Download Manager into other languages on Crowdin. See the Translations section below for more information. - **Pull Requests**: If you’d like to contribute code, feel free to submit a pull request. Just make sure to read the guidelines below before you start. ## Translations If you’d like to help translate AB Download Manager into another language, or improve existing translations, you can do so on Crowdin. Here’s how: - Visit the project in [Crowdin](https://crowdin.com/project/ab-download-manager) - Please DO NOT submit translations via pull requests. - If you want to add a new language, please see [here](https://github.com/amir1376/ab-download-manager/issues/144) ## Pull Requests If you're ready to contribute code, that's awesome! Before you start, here’s what you need to know about creating a pull request (PR): - **Discuss First**: Before you start working on a PR, please open an issue or discussion to explain what you want to do. This helps me understand your idea and make sure it's something that can be merged. It also saves time if changes are needed. - **Fork the Repo**: Fork this repository to your own GitHub account. This creates your own copy where you can make changes. - **Create a Branch**: In your forked repository, create a new branch for your changes. Give it a descriptive name, like feature/add-some-feature or fix/some-error. - **Make Your Changes**: Now you can start coding! Make sure your changes follow the project’s coding standards. - **Submit the PR**: Once you're happy with your changes, push your branch to your fork and submit a pull request. In the PR description, explain what changes you made and why. - **Review & Feedback**: I’ll review your PR as soon as I can. There might be some feedback or requests for changes, so be ready to make adjustments if needed. - **Merging**: If everything looks good, I’ll merge your PR into the master branch. ================================================ FILE: DONATE.md ================================================ # ❤️ Donate Want to support the project? You can make a donation using these crypto addresses: ## TON Address (TON): `UQAAPTagY3Y9XWJc9IMYGFYdVHugoBV_Xa3OjdsBHax69eYg` ## USDT Address (TRC-20): `TK8hMh24yGZGUAYwuSf8rRXncm6s9LJmAx` If you make a contribution, please text me in [Telegram](https://t.me/Amir_Ai), so I can thank you personally. Thank you for your support! ================================================ FILE: LICENSE ================================================ Apache License Version 2.0, January 2004 http://www.apache.org/licenses/ TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION 1. Definitions. "License" shall mean the terms and conditions for use, reproduction, and distribution as defined by Sections 1 through 9 of this document. "Licensor" shall mean the copyright owner or entity authorized by the copyright owner that is granting the License. "Legal Entity" shall mean the union of the acting entity and all other entities that control, are controlled by, or are under common control with that entity. For the purposes of this definition, "control" means (i) the power, direct or indirect, to cause the direction or management of such entity, whether by contract or otherwise, or (ii) ownership of fifty percent (50%) or more of the outstanding shares, or (iii) beneficial ownership of such entity. "You" (or "Your") shall mean an individual or Legal Entity exercising permissions granted by this License. "Source" form shall mean the preferred form for making modifications, including but not limited to software source code, documentation source, and configuration files. "Object" form shall mean any form resulting from mechanical transformation or translation of a Source form, including but not limited to compiled object code, generated documentation, and conversions to other media types. "Work" shall mean the work of authorship, whether in Source or Object form, made available under the License, as indicated by a copyright notice that is included in or attached to the work (an example is provided in the Appendix below). "Derivative Works" shall mean any work, whether in Source or Object form, that is based on (or derived from) the Work and for which the editorial revisions, annotations, elaborations, or other modifications represent, as a whole, an original work of authorship. For the purposes of this License, Derivative Works shall not include works that remain separable from, or merely link (or bind by name) to the interfaces of, the Work and Derivative Works thereof. "Contribution" shall mean any work of authorship, including the original version of the Work and any modifications or additions to that Work or Derivative Works thereof, that is intentionally submitted to Licensor for inclusion in the Work by the copyright owner or by an individual or Legal Entity authorized to submit on behalf of the copyright owner. For the purposes of this definition, "submitted" means any form of electronic, verbal, or written communication sent to the Licensor or its representatives, including but not limited to communication on electronic mailing lists, source code control systems, and issue tracking systems that are managed by, or on behalf of, the Licensor for the purpose of discussing and improving the Work, but excluding communication that is conspicuously marked or otherwise designated in writing by the copyright owner as "Not a Contribution." "Contributor" shall mean Licensor and any individual or Legal Entity on behalf of whom a Contribution has been received by Licensor and subsequently incorporated within the Work. 2. Grant of Copyright License. Subject to the terms and conditions of this License, each Contributor hereby grants to You a perpetual, worldwide, non-exclusive, no-charge, royalty-free, irrevocable copyright license to reproduce, prepare Derivative Works of, publicly display, publicly perform, sublicense, and distribute the Work and such Derivative Works in Source or Object form. 3. Grant of Patent License. Subject to the terms and conditions of this License, each Contributor hereby grants to You a perpetual, worldwide, non-exclusive, no-charge, royalty-free, irrevocable (except as stated in this section) patent license to make, have made, use, offer to sell, sell, import, and otherwise transfer the Work, where such license applies only to those patent claims licensable by such Contributor that are necessarily infringed by their Contribution(s) alone or by combination of their Contribution(s) with the Work to which such Contribution(s) was submitted. If You institute patent litigation against any entity (including a cross-claim or counterclaim in a lawsuit) alleging that the Work or a Contribution incorporated within the Work constitutes direct or contributory patent infringement, then any patent licenses granted to You under this License for that Work shall terminate as of the date such litigation is filed. 4. Redistribution. You may reproduce and distribute copies of the Work or Derivative Works thereof in any medium, with or without modifications, and in Source or Object form, provided that You meet the following conditions: (a) You must give any other recipients of the Work or Derivative Works a copy of this License; and (b) You must cause any modified files to carry prominent notices stating that You changed the files; and (c) You must retain, in the Source form of any Derivative Works that You distribute, all copyright, patent, trademark, and attribution notices from the Source form of the Work, excluding those notices that do not pertain to any part of the Derivative Works; and (d) If the Work includes a "NOTICE" text file as part of its distribution, then any Derivative Works that You distribute must include a readable copy of the attribution notices contained within such NOTICE file, excluding those notices that do not pertain to any part of the Derivative Works, in at least one of the following places: within a NOTICE text file distributed as part of the Derivative Works; within the Source form or documentation, if provided along with the Derivative Works; or, within a display generated by the Derivative Works, if and wherever such third-party notices normally appear. The contents of the NOTICE file are for informational purposes only and do not modify the License. You may add Your own attribution notices within Derivative Works that You distribute, alongside or as an addendum to the NOTICE text from the Work, provided that such additional attribution notices cannot be construed as modifying the License. You may add Your own copyright statement to Your modifications and may provide additional or different license terms and conditions for use, reproduction, or distribution of Your modifications, or for any such Derivative Works as a whole, provided Your use, reproduction, and distribution of the Work otherwise complies with the conditions stated in this License. 5. Submission of Contributions. Unless You explicitly state otherwise, any Contribution intentionally submitted for inclusion in the Work by You to the Licensor shall be under the terms and conditions of this License, without any additional terms or conditions. Notwithstanding the above, nothing herein shall supersede or modify the terms of any separate license agreement you may have executed with Licensor regarding such Contributions. 6. Trademarks. This License does not grant permission to use the trade names, trademarks, service marks, or product names of the Licensor, except as required for reasonable and customary use in describing the origin of the Work and reproducing the content of the NOTICE file. 7. Disclaimer of Warranty. Unless required by applicable law or agreed to in writing, Licensor provides the Work (and each Contributor provides its Contributions) on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied, including, without limitation, any warranties or conditions of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A PARTICULAR PURPOSE. You are solely responsible for determining the appropriateness of using or redistributing the Work and assume any risks associated with Your exercise of permissions under this License. 8. Limitation of Liability. In no event and under no legal theory, whether in tort (including negligence), contract, or otherwise, unless required by applicable law (such as deliberate and grossly negligent acts) or agreed to in writing, shall any Contributor be liable to You for damages, including any direct, indirect, special, incidental, or consequential damages of any character arising as a result of this License or out of the use or inability to use the Work (including but not limited to damages for loss of goodwill, work stoppage, computer failure or malfunction, or any and all other commercial damages or losses), even if such Contributor has been advised of the possibility of such damages. 9. Accepting Warranty or Additional Liability. While redistributing the Work or Derivative Works thereof, You may choose to offer, and charge a fee for, acceptance of support, warranty, indemnity, or other liability obligations and/or rights consistent with this License. However, in accepting such obligations, You may act only on Your own behalf and on Your sole responsibility, not on behalf of any other Contributor, and only if You agree to indemnify, defend, and hold each Contributor harmless for any liability incurred by, or claims asserted against, such Contributor by reason of your accepting any such warranty or additional liability. END OF TERMS AND CONDITIONS APPENDIX: How to apply the Apache License to your work. To apply the Apache License to your work, attach the following boilerplate notice, with the fields enclosed by brackets "[]" replaced with your own identifying information. (Don't include the brackets!) The text should be enclosed in the appropriate comment syntax for the file format. We also recommend that a file or class name and description of purpose be included on the same "printed page" as the copyright notice for easier identification within third-party archives. Copyright [yyyy] [name of copyright owner] Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. You may obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0 Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License. ================================================ FILE: README.md ================================================
AB Download Manager Logo

AB Download Manager

GitHub Release AB Download Manager Website Telegram Group Telegram Channel Crowdin

AB Download Manager Banner ## Description [AB Download Manager](https://abdownloadmanager.com) is a desktop app that helps you manage and organize your downloads more efficiently than ever before. ## Features - ⚡️ Faster Download Speed - ⏰ Queues and Schedulers - 🌐 Browser Extensions - 💻 Multiplatform (Android / Windows / Linux / Mac) - 🌙 Multiple Themes (Dark/Light/Black and more) with modern UI - ❤️ Free and Open Source Please visit [Project Website](https://abdownloadmanager.com) for more info. ## Installation ### Download and Install the App Official Website GitHub Releases #### Installation script (Linux) ```bash bash <(curl -fsSL https://raw.githubusercontent.com/amir1376/ab-download-manager/master/scripts/install.sh) ``` #### Winget or Scoop (for Windows) **winget**: ```bash winget install amir1376.ABDownloadManager ``` **scoop**: ```bash scoop install extras/abdownloadmanager ``` #### Homebrew (for macOS & Linux) ```bash brew tap amir1376/tap && brew install --cask ab-download-manager ``` > ⚠️ **Warning:** This software is NOT on Google Play or other app stores unless listed here. Any version **claiming to be or related to this project** should be considered SCAM and UNSAFE. For alternative installation methods, uninstallation instructions, and more details, please refer to the [wiki](https://github.com/amir1376/ab-download-manager/wiki/) page. ### Browser Extensions You can download the browser extension to integrate the app with your browser.

Chrome Extension Chrome Extension

## Screenshots
App Home Section App Download Section
## Project Status & Feedback Please keep in mind that this project is in the beginning of its journey. **Lots of features** are on the way! **But**, in the meantime you may face **Bugs or Problems**. If you do, please report them to me via the [Community chat](#community) or through `GitHub Issues`, and I'll do my best to fix them ASAP. ## Community You can join our [Telegram Group](https://t.me/abdownloadmanager_discussion) to: - Report problems - Suggest features - Get help with the app ## Repositories And Source Code There are multiple repositories related to the **AB Download Manager** project: | Repository | Description | |--------------------------------------------------------------------------------------------|-------------------------------------------------------------------------------| | [Main Application](https://github.com/amir1376/ab-download-manager) (You are here) | Contains the **Application** that runs on your **device** | | [Browser Integration](https://github.com/amir1376/ab-download-manager-browser-integration) | Contains the **Browser Extension** to be installed on your **browser** | | [Website](https://github.com/amir1376/ab-download-manager-website) | Contains the **AB Download Manager** [website](https://abdownloadmanager.com) | I've spent a lot of time to create this project. If you like my work, please consider giving it a ⭐ — thanks! ❤️ ## Bug Report If you notice any bugs in the source code, please report them via the `GitHub Issues` section. ## Build From Source To compile and test the desktop app on your local machine, follow these steps: 1. Clone the project. 2. Download and extract the [JBR](https://github.com/JetBrains/JetBrainsRuntime/releases), and make it available by either: - Adding it to your `PATH`, or - Setting the `JAVA_HOME` environment variable to its installation path. 3. Navigate to the project directory, open your terminal and execute the following command: ```bash ./gradlew createReleaseFolderForCi ``` 4. The output will be available at: ``` /build/ci-release ``` > **Note**. This project is compiled and published by GitHub actions [here](./.github/workflows/publish.yml), so if you > faced any problem you can check that too. ## Translations If you’d like to help translate AB Download Manager into another language, or improve existing translations, you can do so on Crowdin. Here’s how: - Visit the project in [Crowdin](https://crowdin.com/project/ab-download-manager) - Please DO NOT submit translations via pull requests. - If you want to add a new language, please see [this](https://github.com/amir1376/ab-download-manager/issues/144). ## Contribution If you want to contribute to this project, please read [Contributing Guide](CONTRIBUTING.md) first. ## Support the Project If you'd like to support the project, you can find details on how to donate in the [DONATE.md](DONATE.md) file. ================================================ FILE: REST-API.yml ================================================ openapi: 3.0.0 info: title: Download Service API version: 1.0.0 description: API for managing download tasks and queues. servers: - url: http://localhost:15151 description: Default server running on port 15151 paths: /add: post: summary: Add a new download source requestBody: description: Data for adding a download source content: application/json: schema: type: array items: type: object properties: link: type: string description: The link to the download source headers: type: object additionalProperties: type: string description: Optional headers for the request downloadPage: type: string description: Optional download page URL responses: "200": description: Successfully added the download content: plain/text: schema: type: string description: OK on success /queues: get: summary: Get list of download queues responses: "200": description: List of download queues content: application/json: schema: type: array items: type: object properties: id: type: integer description: The unique ID of the queue name: type: string description: The name of the queue /start-headless-download: post: summary: Add a new download task requestBody: description: Data for adding a download task content: application/json: schema: type: object properties: downloadSource: type: object properties: link: type: string description: The link to the download source headers: type: object additionalProperties: type: string description: Optional headers for the request downloadPage: type: string description: Optional download page URL folder: type: string description: Optional folder to save the download (Unix style path) name: type: string description: Optional name for the download task queueId: type: integer description: Optional queue ID to associate the task with responses: "200": description: Successfully added the download task content: plain/text: schema: type: string description: OK on success ================================================ FILE: android/app/.gitignore ================================================ release ================================================ FILE: android/app/build.gradle.kts ================================================ import buildlogic.CiDirs import buildlogic.CiUtils import buildlogic.versioning.convertToVersionCode import buildlogic.versioning.getAppName import buildlogic.versioning.getAppVersion import buildlogic.versioning.getAppVersionString import buildlogic.versioning.getApplicationPackageName import com.android.build.api.artifact.SingleArtifact import ir.amirab.installer.InstallerTargetFormat import ir.amirab.plugin.common_android.task.SignApkTask import ir.amirab.plugin.common_android.task.androidEnableFileTypesGeneratorForManifest import org.gradle.kotlin.dsl.registering import org.gradle.kotlin.dsl.support.uppercaseFirstChar import org.jetbrains.kotlin.gradle.dsl.JvmTarget import java.util.Properties plugins { id(Plugins.Android.application) id(MyPlugins.kotlinAndroid) id(MyPlugins.composeAndroid) id(Plugins.ksp) id(Plugins.Kotlin.serialization) id(Plugins.aboutLibraries) id(Plugins.aboutLibrariesAndroid) } android { defaultConfig { minSdk = 26 targetSdk = 36 applicationId = getApplicationPackageName() versionCode = getAppVersion().convertToVersionCode() versionName = getAppVersionString() } compileSdk = 36 namespace = "com.abdownloadmanager.android" buildTypes { debug { applicationIdSuffix = ".debug" resValue("string", "app_short_name", "AB DM - Debug") } } buildFeatures { compose = true buildConfig = true } compileOptions { sourceCompatibility = JavaVersion.VERSION_21 targetCompatibility = JavaVersion.VERSION_21 } } kotlin.compilerOptions { jvmTarget = JvmTarget.JVM_21 } dependencies { implementation(libs.compose.runtime) implementation(libs.compose.foundation) implementation(libs.androidx.activity.compose) implementation(libs.decompose.jbCompose) implementation(libs.aboutLibraries.core) implementation(project(":shared:app")) ksp(libs.arrow.opticKsp) } androidEnableFileTypesGeneratorForManifest( targetActivityClass = ".pages.add.AddDownloadActivity", fileTypesFile = project.layout.projectDirectory.file("filetypes.txt") ) // ======= begin of GitHub action stuff val ciDir = CiUtils.getCiDir(project) androidComponents.onVariants { variant -> tasks.register( "createReleaseSignedBinary${variant.name.uppercaseFirstChar()}", SignApkTask::class ) { inputDir.set(variant.artifacts.get(SingleArtifact.APK)) outputDIr.set(project.layout.buildDirectory.dir("generatedSignedApks")) platformToolsVersion.set("36.1.0") keystoreUri.set(provider { getFromEnvOrProperties("ABDM_KEYSTORE_FILE") }) keystorePassword.set(provider { getFromEnvOrProperties("ABDM_KEYSTORE_FILE_PASSWORD") }) keyPassword.set(provider { getFromEnvOrProperties("ABDM_KEYSTORE_KEY_PASSWORD") }) keyAlias.set(provider { getFromEnvOrProperties("ABDM_KEYSTORE_KEY_ALIAS") }) } } val androidBinaries by tasks.registering { val signedApks = tasks.named("createReleaseSignedBinaryRelease") .map { task -> task.outputs.files.singleFile } inputs.dir(signedApks) outputs.dir(ciDir.binariesDir) doLast { // at the moment we only have one apk // if I decided to add multiple targets (arm64 x64 etc..) // ... I need to extract arch and use forEach instead of first val signedApk = signedApks.get().listFiles() .first { it.name.endsWith(".apk") } val outputFileName = CiUtils.getTargetFileName( getAppName(), getAppVersion(), InstallerTargetFormat.Apk, null, ) CiUtils.copyAndHashToDestination( src = signedApk, destinationFolder = ciDir.binariesDir.get().asFile, name = outputFileName, ) } } tasks.register(CiUtils.getCreateBinaryFolderForCiTaskName()) { dependsOn(androidBinaries) } private val localProperties by lazy { val file = project.rootProject.projectDir.resolve("local.properties") file.inputStream().use { Properties().apply { load(it) } } } fun getFromEnvOrProperties(key: String): String? { val string = (System.getenv(key)?.takeIf { it.isNotEmpty() } ?: localProperties.getProperty(key)) return string } ================================================ FILE: android/app/filetypes.txt ================================================ # music / audio mp3 aac m4a wav flac ogg wma amr opus mid # video mp4 mkv avi mov 3gp webm wmv flv mpeg mpg m4v ts # image jpg jpeg png gif bmp webp svg tiff ico heic raw # documents / text formats pdf txt doc docx xls xlsx ppt pptx rtf odt ods odp csv json xml yaml yml ini cfg md log # compressed zip rar 7z tar gz bz2 xz iso # executables exe msi apk jar bat cmd sh deb rpm # fonts ttf otf woff woff2 # ebooks epub mobi azw3 djvu # subtitles srt ass vtt # design / creative psd ai eps # miscellaneous dat bin ================================================ FILE: android/app/src/main/AndroidManifest.xml ================================================ ================================================ FILE: android/app/src/main/kotlin/com/abdownloadmanager/android/ABDMApp.kt ================================================ package com.abdownloadmanager.android import android.app.Application import com.abdownloadmanager.android.di.Di import com.abdownloadmanager.android.pages.onboarding.permissions.PermissionManager import com.abdownloadmanager.android.util.ABDMAppManager import com.abdownloadmanager.android.util.AndroidGlobalExceptionHandler import com.abdownloadmanager.android.util.AppInfo import com.abdownloadmanager.android.util.ApplicationBackgroundTracker import com.abdownloadmanager.shared.repository.BaseAppRepository import com.abdownloadmanager.shared.util.appinfo.PreviousVersion import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.launch import org.koin.core.component.KoinComponent import org.koin.core.component.inject class ABDMApp : Application(), KoinComponent { val TAG_NAME = ABDMApp::class.simpleName!! val appManager: ABDMAppManager by inject() val appRepository: BaseAppRepository by inject() val previousVersion: PreviousVersion by inject() val scope: CoroutineScope by inject() override fun onCreate() { super.onCreate() AppInfo.init(this) Di.boot(this) ApplicationBackgroundTracker.startTracking(this) appRepository.boot() previousVersion.boot() Thread.setDefaultUncaughtExceptionHandler( AndroidGlobalExceptionHandler( this, Thread.getDefaultUncaughtExceptionHandler(), ) ) appManager.boot() } } ================================================ FILE: android/app/src/main/kotlin/com/abdownloadmanager/android/action/Actions.kt ================================================ package com.abdownloadmanager.android.action import com.abdownloadmanager.android.util.pagemanager.IBrowserPageManager import com.abdownloadmanager.resources.Res import com.abdownloadmanager.shared.util.ui.icon.MyIcons import ir.amirab.util.compose.action.AnAction import ir.amirab.util.compose.action.simpleAction import ir.amirab.util.compose.asStringSource fun createOpenBrowserAction( browserPageManager: IBrowserPageManager, ): AnAction { return simpleAction( Res.string.browser.asStringSource(), MyIcons.earth, ) { browserPageManager.openBrowser(null) } } ================================================ FILE: android/app/src/main/kotlin/com/abdownloadmanager/android/di/Di.kt ================================================ package com.abdownloadmanager.android.di import AndroidDirectLinkUpdateApplier import android.app.Application import android.content.Context import com.abdownloadmanager.github.GithubApi import com.abdownloadmanager.UpdateDownloadLocationProvider import com.abdownloadmanager.UpdateManager import com.abdownloadmanager.android.ABDMApp import com.abdownloadmanager.android.pages.home.HomePageStateToPersist import com.abdownloadmanager.android.pages.onboarding.permissions.ABDMPermissions import com.abdownloadmanager.android.pages.onboarding.permissions.PermissionManager import com.abdownloadmanager.android.receiver.StartOnBootBroadcastReceiver import com.abdownloadmanager.android.repository.AppRepository import com.abdownloadmanager.android.storage.AndroidExtraDownloadItemSettings import com.abdownloadmanager.android.storage.AndroidExtraQueueSettings import com.abdownloadmanager.android.storage.AndroidOnBoardingStorage import com.abdownloadmanager.android.storage.AppSettingsStorage import com.abdownloadmanager.android.storage.BrowserBookmarksStorage import com.abdownloadmanager.android.storage.HomePageStorage import com.abdownloadmanager.android.storage.OnBoardingData import com.abdownloadmanager.android.util.ABDMAppManager import com.abdownloadmanager.android.util.ABDMServiceNotificationManager import com.abdownloadmanager.android.util.AndroidDefinedPaths import com.abdownloadmanager.android.util.AndroidDownloadItemOpener import com.abdownloadmanager.android.util.AppInfo import com.abdownloadmanager.shared.util.SharedConstants import com.abdownloadmanager.shared.ui.theme.ThemeManager import ir.amirab.downloader.queue.QueueManager import com.abdownloadmanager.shared.util.ui.icon.MyIcons import com.abdownloadmanager.shared.util.ui.theme.ISystemThemeDetector import ir.amirab.downloader.DownloadManagerMinimalControl import ir.amirab.downloader.DownloadSettings import ir.amirab.downloader.connection.HttpDownloaderClient import ir.amirab.downloader.connection.OkHttpHttpDownloaderClient import ir.amirab.downloader.db.* import ir.amirab.downloader.monitor.DownloadMonitor import ir.amirab.downloader.utils.IDiskStat import com.abdownloadmanager.resources.ABDMLanguageResources import com.abdownloadmanager.shared.downloaderinui.DownloaderInUiRegistry import com.abdownloadmanager.shared.downloaderinui.hls.HLSDownloaderInUi import com.abdownloadmanager.shared.downloaderinui.http.HttpDownloaderInUi import com.abdownloadmanager.shared.repository.BaseAppRepository import com.abdownloadmanager.shared.storage.BaseAppSettingsStorage import com.abdownloadmanager.shared.storage.ExtraDownloadSettingsStorage import com.abdownloadmanager.shared.storage.ExtraQueueSettingsStorage import com.abdownloadmanager.shared.storage.IExtraDownloadSettingsStorage import com.abdownloadmanager.shared.storage.IExtraQueueSettingsStorage import com.abdownloadmanager.shared.storage.ILastSavedLocationsStorage import com.abdownloadmanager.shared.storage.PerHostSettingsDatastoreStorage import com.abdownloadmanager.shared.storage.ProxyDatastoreStorage import com.abdownloadmanager.shared.storage.impl.LastSavedLocationStorage import com.abdownloadmanager.shared.ui.theme.ThemeSettingsStorage import com.abdownloadmanager.shared.ui.widget.NotificationManager import com.abdownloadmanager.shared.updater.UpdateDownloaderViaDownloadSystem import com.abdownloadmanager.shared.util.AndroidDiskStat import com.abdownloadmanager.shared.util.AndroidSystemThemeDetector import com.abdownloadmanager.shared.util.AppVersion import com.abdownloadmanager.shared.util.DefinedPaths import com.abdownloadmanager.shared.util.SizeAndSpeedUnitProvider import com.abdownloadmanager.shared.util.UserAgentProviderFromSettings import com.abdownloadmanager.shared.util.* import com.abdownloadmanager.updateapplier.UpdateApplier import ir.amirab.downloader.DownloadManager import ir.amirab.util.config.datastore.createMapConfigDatastore import kotlinx.coroutines.* import kotlinx.serialization.json.Json import okhttp3.Dispatcher import okhttp3.OkHttpClient import org.koin.core.component.KoinComponent import org.koin.core.context.startKoin import org.koin.dsl.bind import org.koin.dsl.module import com.abdownloadmanager.updatechecker.GithubUpdateChecker import com.abdownloadmanager.updatechecker.UpdateChecker import ir.amirab.util.AppVersionTracker import com.abdownloadmanager.shared.util.appinfo.PreviousVersion import com.abdownloadmanager.shared.util.autoremove.RemovedDownloadsFromDiskTracker import com.abdownloadmanager.shared.util.category.* import com.abdownloadmanager.shared.util.ondownloadcompletion.NoOpOnDownloadCompletionActionProvider import com.abdownloadmanager.shared.util.ondownloadcompletion.OnDownloadCompletionActionProvider import com.abdownloadmanager.shared.util.ondownloadcompletion.OnDownloadCompletionActionRunner import com.abdownloadmanager.shared.util.onqueuecompletion.NoopOnQueueCompletionActionProvider import com.abdownloadmanager.shared.util.onqueuecompletion.OnQueueEventActionRunner import com.abdownloadmanager.shared.util.onqueuecompletion.OnQueueCompletionActionProvider import com.abdownloadmanager.shared.util.perhostsettings.IPerHostSettingsStorage import com.abdownloadmanager.shared.util.perhostsettings.PerHostSettingsItem import com.abdownloadmanager.shared.util.perhostsettings.PerHostSettingsManager import com.abdownloadmanager.shared.util.ui.IMyIcons import com.abdownloadmanager.shared.util.proxy.IProxyStorage import com.abdownloadmanager.shared.util.proxy.ProxyData import com.abdownloadmanager.shared.util.proxy.ProxyManager import ir.amirab.downloader.DownloaderRegistry import ir.amirab.downloader.connection.UserAgentProvider import ir.amirab.downloader.connection.proxy.AutoConfigurableProxyProvider import ir.amirab.downloader.connection.proxy.NoopSystemProxySelectorProvider import ir.amirab.downloader.connection.proxy.ProxyStrategyProvider import ir.amirab.downloader.connection.proxy.SystemProxySelectorProvider import ir.amirab.downloader.downloaditem.DownloadJob import ir.amirab.downloader.downloaditem.IDownloadCredentials import ir.amirab.downloader.downloaditem.IDownloadItem import ir.amirab.downloader.downloaditem.hls.HLSDownloader import ir.amirab.downloader.downloaditem.http.HttpDownloadCredentials import ir.amirab.downloader.downloaditem.http.HttpDownloadItem import ir.amirab.downloader.downloaditem.http.HttpDownloader import ir.amirab.downloader.monitor.DownloadItemStateFactory import ir.amirab.downloader.monitor.IDownloadMonitor import ir.amirab.downloader.queue.ManualDownloadQueue import ir.amirab.downloader.utils.EmptyFileCreator import ir.amirab.util.compose.IIconResolver import ir.amirab.util.compose.localizationmanager.LanguageManager import ir.amirab.util.compose.localizationmanager.LanguageSourceProvider import ir.amirab.util.compose.localizationmanager.LanguageStorage import ir.amirab.util.config.datastore.kotlinxSerializationDataStore import ir.amirab.util.startup.AbstractStartupManager import ir.amirab.util.startup.Startup import kotlinx.serialization.modules.SerializersModule import kotlinx.serialization.modules.polymorphic import okhttp3.Protocol import okhttp3.internal.tls.OkHostnameVerifier val downloaderModule = module { single { val definedPaths = get() DownloadQueueFileStorageDatabase( queueFolder = get().registerAndGet( definedPaths.queuesDir ), fileSaver = get(), ) } single { val definedPaths = get() DownloadListFileStorage( downloadListFolder = get().registerAndGet( definedPaths.downloadListDir ), fileSaver = get(), ) } single { TransactionalFileSaver(get()) } single { val definedPaths = get() PartListFileStorage( get().registerAndGet( definedPaths.partsDir ), get() ) } single { AndroidDiskStat() } single { AndroidSystemThemeDetector(get()) } single { QueueManager(get(), get()) } single { DownloadFoldersRegistry() } single { DownloadSettings( 8, ) } single { ProxyManager( get() ) }.bind() single { NoopSystemProxySelectorProvider() } single { AutoConfigurableProxyProvider.NoOp() } single { UserAgentProviderFromSettings(get()) } single { OkHttpHttpDownloaderClient( get(), get(), get(), get(), get(), ) } single { val downloadSettings: DownloadSettings = get() EmptyFileCreator( diskStat = get(), useSparseFile = { downloadSettings.useSparseFileAllocation } ) } single { HLSDownloader(inject()) } single { HLSDownloaderInUi(get(), get()) } single { HttpDownloader(inject()) } single { HttpDownloaderInUi(get(), get()) } single { DownloaderInUiRegistry().apply { add(get()) add(get()) } }.bind>() single { DownloaderRegistry().apply { add(get()) add(get()) } } single { val definedPaths = get() DownloadManager( get(), get(), get(), get(), get(), get().registerAndGet( definedPaths.downloadDataDir ) ) }.bind(DownloadManagerMinimalControl::class) single { ManualDownloadQueue(get(), get()) } single { DownloadMonitor( downloadManager = get(), manualDownloadQueue = get(), downloadItemStateFactory = inject(), ) } } val downloadSystemModule = module { single { val definedPaths = get() get().registerAndGet(definedPaths.categoriesDir) CategoryFileStorage( file = definedPaths.categoriesFile.toFile(), fileSaver = get() ) }.bind() single { FileIconProviderUsingCategoryIcons( get(), get(), get(), get(), ) }.bind() single { DefaultCategories( icons = get(), getDefaultDownloadFolder = { get().defaultDownloadFolder.value } ) } single { DownloadManagerCategoryItemProvider(get()) }.bind() single { CategoryManager( categoryStorage = get(), scope = get(), defaultCategoriesFactory = get(), categoryItemProvider = get(), ) } single { DownloadSystem( get(), get(), get(), get(), get(), get(), get(), get(), get(), get(), get(), get(), ) } single { val definedPaths = get() val extraDownloadSettingsStorageFolder = get().registerAndGet( definedPaths.extraDownloadSettings ) ExtraDownloadSettingsStorage( extraDownloadSettingsStorageFolder, get(), AndroidExtraDownloadItemSettings ) }.bind>() single { val definedPaths = get() val extraQueueSettingsStorageFolder = get().registerAndGet( definedPaths.extraQueueSettings ) ExtraQueueSettingsStorage( extraQueueSettingsStorageFolder, get(), AndroidExtraQueueSettings ) }.apply { bind>() } single { NoOpOnDownloadCompletionActionProvider() } single { NoopOnQueueCompletionActionProvider() } single { OnDownloadCompletionActionRunner( downloadManagerMinimalControl = get(), scope = get(), onDownloadCompletionActionProvider = get(), ) } single { OnQueueEventActionRunner( queueManager = get(), scope = get(), onQueueCompletionActionProvider = get(), ) } single { PermissionManager( ABDMPermissions.importantPermissions, get(), ) } } val coroutineModule = module { single { CoroutineScope(SupervisorJob()) } } val jsonModule = module { single { val downloaderRegistry: DownloaderRegistry by inject() Json { this.encodeDefaults = true this.prettyPrint = true this.ignoreUnknownKeys = true this.serializersModule = SerializersModule { polymorphic(IDownloadItem::class) { downloaderRegistry.getAll().forEach { subclass(it.downloadItemClass, it.downloadItemSerializer) } defaultDeserializer { HttpDownloadItem.serializer() } } polymorphic(IDownloadCredentials::class) { downloaderRegistry.getAll().forEach { subclass(it.downloadCredentialsClass, it.downloadCredentialsSerializer) } defaultDeserializer { HttpDownloadCredentials.serializer() } } } } } } val updaterModule = module { single { val definedPaths = get() UpdateDownloadLocationProvider { definedPaths.updateDownloadLocation.toFile() } } single { val definedPaths = get() definedPaths.updateDownloadLocation AndroidDirectLinkUpdateApplier( updateDownloader = UpdateDownloaderViaDownloadSystem( get(), get(), ), ) } single { GithubUpdateChecker( AppVersion.get(), githubApi = GithubApi( owner = SharedConstants.projectGithubOwner, repo = SharedConstants.projectGithubRepo, client = OkHttpClient .Builder() .build() ) ) } single { UpdateManager( updateChecker = get(), updateApplier = get(), appVersionTracker = get(), ) } } val startUpModule = module { single { Startup.getStartUpManager(get(), StartOnBootBroadcastReceiver::class.java) }.apply { bind() } } fun getAppModule(context: ABDMApp) = module { includes(downloaderModule) includes(downloadSystemModule) includes(coroutineModule) includes(jsonModule) includes(updaterModule) includes(startUpModule) // single { // NetworkChecker(get()) // } single { AppInfo.definedPaths }.apply { bind() bind() } single { AppRepository( get(), get(), get(), get(), get(), get(), get(), ) }.apply { bind() bind() } single { ThemeManager(get(), get(), get()) } // single { // FontManager(get()) // } single { LanguageManager( get(), LanguageSourceProvider( ABDMLanguageResources.defaultLanguageResource, ABDMLanguageResources.languages, ) ) } single { MyIcons }.apply { bind() bind() } single { val definedPaths = get() ProxyDatastoreStorage( kotlinxSerializationDataStore( definedPaths.proxySettingsFile.toFile(), get(), ProxyData::default, ) ) }.bind() single { val definedPaths = get() AppSettingsStorage( createMapConfigDatastore( definedPaths.appSettingsFile.toFile(), get(), ) ) }.apply { bind() bind() bind() } single { RemovedDownloadsFromDiskTracker( get(), get(), get(), ) } single { val definedPaths = get() PreviousVersion( systemPath = definedPaths.systemDir.toFile(), currentVersion = AppVersion.get(), ) } single { AppVersionTracker( previousVersion = { // it MUST be booted first get().get() }, currentVersion = AppVersion.get(), ) } single { val appSettingsStorage: BaseAppSettingsStorage = get() AppSSLFactoryProvider( ignoreSSLCertificates = appSettingsStorage.ignoreSSLCertificates ) } single { val appSettingsStorage: BaseAppSettingsStorage = get() AppHostNameVerifier( delegateHostnameVerifier = OkHostnameVerifier, ignoreHostNameVerification = appSettingsStorage.ignoreSSLCertificates ) } single { val appSSLFactoryProvider: AppSSLFactoryProvider = get() val appHostNameVerifier: AppHostNameVerifier = get() OkHttpClient .Builder() .protocols(listOf(Protocol.HTTP_1_1)) .dispatcher(Dispatcher().apply { //bypass limit on concurrent connections! maxRequests = Int.MAX_VALUE maxRequestsPerHost = Int.MAX_VALUE }) .sslSocketFactory( appSSLFactoryProvider.createSSLSocketFactory(), appSSLFactoryProvider.trustManager, ) .hostnameVerifier(appHostNameVerifier) .build() } single { val definedPaths = get() LastSavedLocationStorage( kotlinxSerializationDataStore>( definedPaths.lastSavedLocationFile.toFile(), get(), ::emptyList, ) ) } single { val definedPaths = get() PerHostSettingsDatastoreStorage( kotlinxSerializationDataStore>( definedPaths.perHostSettingsFile.toFile(), get(), ::emptyList, ) ) } single { PerHostSettingsManager(get()) } single { context }.apply { bind() bind() bind() } single { ABDMAppManager(get(), get(), get(), get(), get(), get(), get()) } single { ABDMServiceNotificationManager(get(), get(), get(), get(), get()) } single { AndroidDownloadItemOpener(get()) }.apply { bind() } single { NotificationManager() } single { val paths = get() AndroidOnBoardingStorage( kotlinxSerializationDataStore( paths.onboardingFile.toFile(), get(), ::OnBoardingData, ) ) } single { val paths = get() HomePageStorage( kotlinxSerializationDataStore( paths.homePageFile.toFile(), get(), ::HomePageStateToPersist, ) ) } single { val paths = get() BrowserBookmarksStorage( kotlinxSerializationDataStore( paths.browserBookmarksFile.toFile(), get(), ::emptyList, ) ) } } object Di : KoinComponent { fun boot(applicationContext: ABDMApp) { startKoin { modules(getAppModule(applicationContext)) } } } ================================================ FILE: android/app/src/main/kotlin/com/abdownloadmanager/android/pages/about/AboutPage.kt ================================================ package com.abdownloadmanager.android.pages.about import androidx.compose.runtime.Composable import androidx.compose.foundation.* import com.abdownloadmanager.shared.util.ui.icon.MyIcons import com.abdownloadmanager.shared.util.ui.myColors import com.abdownloadmanager.shared.util.ui.theme.myTextSizes import com.abdownloadmanager.shared.ui.widget.Text import com.abdownloadmanager.shared.util.ui.WithContentAlpha import androidx.compose.foundation.layout.* import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.runtime.* import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.* import androidx.compose.ui.graphics.Brush import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.platform.LocalUriHandler import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp import com.abdownloadmanager.android.ui.page.PageFooter import com.abdownloadmanager.android.ui.page.PageHeader import com.abdownloadmanager.android.ui.page.PageTitle import com.abdownloadmanager.android.ui.page.PageUi import com.abdownloadmanager.android.ui.page.createAlphaForHeader import com.abdownloadmanager.android.ui.page.rememberHeaderAlpha import com.abdownloadmanager.android.util.compose.useBack import com.abdownloadmanager.shared.util.SharedConstants import com.abdownloadmanager.shared.util.ui.widget.MyIcon import com.abdownloadmanager.shared.ui.widget.IconActionButton import com.abdownloadmanager.shared.ui.widget.Tooltip import com.abdownloadmanager.shared.util.div import com.abdownloadmanager.resources.Res import com.abdownloadmanager.shared.ui.widget.ActionButton import com.abdownloadmanager.shared.ui.widget.TransparentIconActionButton import com.abdownloadmanager.shared.util.AppVersion import com.abdownloadmanager.shared.util.ui.theme.myShapes import com.abdownloadmanager.shared.util.ui.theme.mySpacings import ir.amirab.util.URLOpener import ir.amirab.util.HttpUrlUtils import ir.amirab.util.compose.IconSource import ir.amirab.util.compose.StringSource import ir.amirab.util.compose.asStringSource import ir.amirab.util.compose.dpToPx import ir.amirab.util.compose.resources.myStringResource @Composable fun AboutPage( onRequestShowOpenSourceLibraries: () -> Unit, onRequestShowTranslators: () -> Unit, ) { val state = rememberScrollState() var paddings by remember { mutableStateOf(PaddingValues.Zero) } val headerAlpha = createAlphaForHeader(state.value.toFloat(), paddings.calculateTopPadding().dpToPx(LocalDensity.current)) val shape = myShapes.defaultRounded PageUi( header = { val onBack = useBack() PageHeader( leadingIcon = { TransparentIconActionButton( icon = MyIcons.back, contentDescription = Res.string.back.asStringSource() ) { onBack?.onBackPressed() } }, headerTitle = { PageTitle(myStringResource(Res.string.about)) }, modifier = Modifier .background( myColors.background.copy( alpha = headerAlpha * 0.75f ) ) .statusBarsPadding(), ) }, footer = { PageFooter { Column( Modifier .fillMaxWidth() .navigationBarsPadding() .padding(horizontal = mySpacings.largeSpace) .padding(bottom = mySpacings.largeSpace) .border(1.dp, myColors.onBackground / 0.15f, shape) .clip(shape) .background(myColors.surface) ) { Spacer(Modifier.height(mySpacings.largeSpace)) DevelopedWithLove( Modifier .fillMaxWidth() .wrapContentWidth() ) Spacer(Modifier.height(mySpacings.mediumSpace)) SocialAndLinks( Modifier .fillMaxWidth() .wrapContentWidth(), horizontalPadding = 8.dp, ) Spacer(Modifier.height(mySpacings.mediumSpace)) Spacer( Modifier .fillMaxWidth() .background(myColors.onBackground / 0.05f) .height(1.dp) ) MainWebsite(Modifier) } } } ) { paddings = it.paddingValues Column( Modifier .fillMaxSize() .verticalScroll(state) .padding(it.paddingValues), verticalArrangement = Arrangement.SpaceBetween, ) { Column( Modifier .fillMaxWidth() .padding(horizontal = mySpacings.largeSpace), ) { AppIconAndVersion( Modifier .fillMaxWidth() .padding(vertical = 32.dp) ) } CreditsSection( modifier = Modifier .fillMaxWidth(), onRequestShowOpenSourceLibraries = onRequestShowOpenSourceLibraries, onRequestShowTranslators = onRequestShowTranslators, ) } } } @Composable private fun AppIconAndVersion( modifier: Modifier, ) { Column( horizontalAlignment = Alignment.CenterHorizontally, modifier = modifier.padding( horizontal = 24.dp, vertical = 8.dp, ) ) { val shape = RoundedCornerShape(16.dp) Image( MyIcons.appIcon.rememberPainter(), null, Modifier .shadow(12.dp, shape, spotColor = myColors.primary) .clip(shape) .border( 1.dp, Brush.linearGradient( listOf(myColors.primary, myColors.secondary) ), shape ) .background(myColors.surface) .padding(16.dp) .size(52.dp) ) Spacer(Modifier.size(16.dp)) Column( horizontalAlignment = Alignment.CenterHorizontally ) { Text( SharedConstants.appDisplayName, fontSize = myTextSizes.lg, fontWeight = FontWeight.Bold, ) Spacer(Modifier.height(2.dp)) WithContentAlpha(0.75f) { Text( myStringResource( Res.string.version_n, Res.string.version_n_createArgs( value = AppVersion.get().toString(), ) ), fontSize = myTextSizes.base, ) } } } } @Composable fun MainWebsite( modifier: Modifier ) { val uriHandler = LocalUriHandler.current val websiteUrl = SharedConstants.projectWebsite val websiteDisplayName = remember(websiteUrl) { HttpUrlUtils.getHost(websiteUrl) ?: websiteUrl } Column( modifier .fillMaxWidth() .clickable { uriHandler.openUri(websiteUrl) } .padding( mySpacings.largeSpace ), horizontalAlignment = Alignment.CenterHorizontally, ) { Text( text = websiteDisplayName, color = myColors.info, ) } } @Composable fun DevelopedWithLove(modifier: Modifier) { Column( modifier, horizontalAlignment = Alignment.CenterHorizontally, ) { Text( myStringResource(Res.string.developed_with_love_for_you), Modifier .fillMaxWidth() .wrapContentWidth() ) Spacer(Modifier.height(mySpacings.largeSpace)) DonateButton(Modifier) } } @Composable private fun SocialAndLinks( modifier: Modifier = Modifier, horizontalPadding: Dp, ) { Row( horizontalArrangement = Arrangement.spacedBy(8.dp), modifier = modifier .padding( horizontal = horizontalPadding, ) ) { SocialSmallButton( MyIcons.earth, Res.string.visit_the_project_website.asStringSource(), onClick = { URLOpener.openUrl(SharedConstants.projectWebsite) } ) SocialSmallButton( MyIcons.openSource, Res.string.view_the_source_code.asStringSource(), onClick = { URLOpener.openUrl(SharedConstants.projectSourceCode) } ) SocialSmallButton( MyIcons.speaker, Res.string.channel.asStringSource(), onClick = { URLOpener.openUrl(SharedConstants.telegramChannelUrl) } ) SocialSmallButton( MyIcons.group, Res.string.group.asStringSource(), onClick = { URLOpener.openUrl(SharedConstants.telegramGroupUrl) } ) SocialSmallButton( MyIcons.language, Res.string.translators_contribute_title.asStringSource(), onClick = { URLOpener.openUrl(SharedConstants.projectTranslations) } ) } } @Composable private fun CreditsSection( modifier: Modifier = Modifier, onRequestShowOpenSourceLibraries: () -> Unit, onRequestShowTranslators: () -> Unit, ) { Column( modifier .padding(horizontal = 16.dp) .padding(vertical = 8.dp), verticalArrangement = Arrangement.spacedBy(8.dp) ) { val itemModifier = Modifier.fillMaxWidth() AboutPageListItemButton( itemModifier, icon = MyIcons.hearth, title = Res.string.this_is_a_free_and_open_source_software.asStringSource(), description = Res.string.view_the_source_code.asStringSource(), onClick = { URLOpener.openUrl(SharedConstants.projectSourceCode) } ) AboutPageListItemButton( itemModifier, icon = MyIcons.openSource, title = Res.string.powered_by_open_source_software.asStringSource(), description = Res.string.view_the_open_source_licenses.asStringSource(), onClick = { onRequestShowOpenSourceLibraries() } ) AboutPageListItemButton( itemModifier, icon = MyIcons.language, title = Res.string.localized_by_translators.asStringSource(), description = Res.string.meet_the_translators.asStringSource(), onClick = { onRequestShowTranslators() } ) } } @Composable private fun SocialSmallButton( icon: IconSource, title: StringSource, onClick: () -> Unit, ) { Tooltip(title) { IconActionButton( icon, contentDescription = title, onClick = onClick, ) } } @Composable private fun AboutPageListItemButton( modifier: Modifier, icon: IconSource, title: StringSource, description: StringSource, onClick: () -> Unit, ) { val shape = myShapes.defaultRounded Row( modifier .border(1.dp, myColors.onBackground / 0.15f, shape) .clip(shape) .clickable(onClick = onClick) .background(myColors.surface) .padding( horizontal = 8.dp, vertical = 8.dp, ), verticalAlignment = Alignment.CenterVertically, ) { MyIcon( icon = icon, contentDescription = null, modifier = Modifier.size(24.dp) ) Spacer(Modifier.width(8.dp)) Column { Text( title.rememberString(), fontSize = myTextSizes.base, fontWeight = FontWeight.Bold, ) Spacer(Modifier.height(4.dp)) WithContentAlpha(0.75f) { Text(description.rememberString()) } } } } @Composable private fun DonateButton( modifier: Modifier, ) { ActionButton( modifier = modifier, start = { MyIcon( MyIcons.hearth, null, modifier = Modifier.size(24.dp), tint = myColors.error, ) Spacer(Modifier.width(8.dp)) }, text = myStringResource(Res.string.donate), onClick = { URLOpener.openUrl(SharedConstants.donateLink) } ) } ================================================ FILE: android/app/src/main/kotlin/com/abdownloadmanager/android/pages/add/AddDownloadActivity.kt ================================================ package com.abdownloadmanager.android.pages.add import android.content.Intent import android.os.Bundle import arrow.core.firstOrNone import arrow.core.getOrElse import com.abdownloadmanager.android.pages.add.multiple.AddMultiDownloadActivity import com.abdownloadmanager.android.pages.add.single.AddSingleDownloadActivity import com.abdownloadmanager.android.pages.onboarding.permissions.PermissionManager import com.abdownloadmanager.android.ui.MainActivity import com.abdownloadmanager.android.util.activity.ABDMActivity import com.abdownloadmanager.shared.pages.adddownload.AddDownloadConfig import com.abdownloadmanager.shared.pages.adddownload.AddDownloadCredentialsInUiProps import com.abdownloadmanager.shared.pages.adddownload.ImportOptions import com.abdownloadmanager.shared.util.extractors.linkextractor.DownloadCredentialFromStringExtractor import ir.amirab.downloader.downloaditem.IDownloadCredentials import ir.amirab.downloader.downloaditem.http.HttpDownloadCredentials import kotlinx.serialization.json.Json import org.koin.core.component.inject class AddDownloadActivity : ABDMActivity() { val json: Json by inject() val permissionManager: PermissionManager by inject() private fun createDownloaderInUiProps( credentials: IDownloadCredentials ): AddDownloadCredentialsInUiProps { return AddDownloadCredentialsInUiProps( credentials, AddDownloadCredentialsInUiProps.Configs(), ) } override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) if (!permissionManager.isReady()) { // user not opened the app at least once. we must redirect it to the permission page first val intent = Intent(this, MainActivity::class.java) startActivity(intent) finish() return } val credentials = getDownloadCredentialsFromIntent(intent) val intent = if (credentials.size > 1) { AddMultiDownloadActivity.createIntent( this, AddDownloadConfig.MultipleAddConfig( newDownloads = credentials.map(::createDownloaderInUiProps), importOptions = ImportOptions(), ), json = json, ) } else { AddSingleDownloadActivity.createIntent( this, AddDownloadConfig.SingleAddConfig( newDownload = credentials .firstOrNone() .getOrElse { HttpDownloadCredentials("") } .let(::createDownloaderInUiProps), importOptions = ImportOptions(), ), json = json, ) } startActivity(intent) finish() } private fun getDownloadCredentialsFromIntent(intent: Intent): List { val links = when (intent.action) { Intent.ACTION_SEND -> { intent.getStringExtra(Intent.EXTRA_TEXT).orEmpty() } else -> { // action view etc... intent.data?.toString().orEmpty() } } return DownloadCredentialFromStringExtractor .extract(links) .distinctBy { it.link } } } ================================================ FILE: android/app/src/main/kotlin/com/abdownloadmanager/android/pages/add/multiple/AddMultiDownloadActivity.kt ================================================ package com.abdownloadmanager.android.pages.add.multiple import android.content.Context import android.content.Intent import android.os.Bundle import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.collectAsState import com.abdownloadmanager.android.pages.category.CategorySheet import com.abdownloadmanager.android.pages.newqueue.NewQueueSheet import com.abdownloadmanager.android.util.ABDMAppManager import com.abdownloadmanager.android.util.activity.ABDMActivity import com.abdownloadmanager.android.util.activity.HandleActivityEffects import com.abdownloadmanager.android.util.activity.getSerializedExtra import com.abdownloadmanager.android.util.activity.putSerializedExtra import com.abdownloadmanager.shared.downloaderinui.DownloaderInUiRegistry import com.abdownloadmanager.shared.pages.adddownload.AddDownloadConfig import com.abdownloadmanager.shared.storage.ILastSavedLocationsStorage import com.abdownloadmanager.shared.util.DownloadSystem import com.abdownloadmanager.shared.util.FileIconProvider import com.abdownloadmanager.shared.util.OnFullyDismissed import com.abdownloadmanager.shared.util.ResponsiveDialog import com.abdownloadmanager.shared.util.category.CategoryManager import com.abdownloadmanager.shared.util.rememberChild import com.abdownloadmanager.shared.util.rememberResponsiveDialogState import ir.amirab.downloader.queue.QueueManager import kotlinx.coroutines.delay import kotlinx.serialization.json.Json import org.koin.core.component.inject class AddMultiDownloadActivity : ABDMActivity() { private val json: Json by inject() private val downloadSystem: DownloadSystem by inject() private val appManager: ABDMAppManager by inject() private val downloaderInUiRegistry: DownloaderInUiRegistry by inject() private val lastSavedLocationsStorage: ILastSavedLocationsStorage by inject() private val queueManager: QueueManager by inject() private val categoryManager: CategoryManager by inject() private val iconProvider: FileIconProvider by inject() override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) val myRetainedComponent = myRetainedComponent { val config = getComponentConfig(intent) val appManager = appManager val closeAddDownloadDialog = { this@myRetainedComponent.finishActivityAction() } AndroidAddMultiDownloadComponent( ctx = it, onRequestClose = closeAddDownloadDialog, lastSavedLocationsStorage = lastSavedLocationsStorage, id = config.id, queueManager = queueManager, categoryManager = categoryManager, downloadSystem = downloadSystem, onRequestAdd = { items, queueId, categorySelectionMode -> appManager.addDownloads( items = items, categorySelectionMode = categorySelectionMode, queueId = queueId, ) }, perHostSettingsManager = perHostSettingsManager, fileIconProvider = iconProvider, appRepository = appRepository, downloaderInUiRegistry = downloaderInUiRegistry, ).apply { addItems(config.newDownloads) } } val addDownloadComponent = myRetainedComponent.component setABDMContent { myRetainedComponent.HandleActivityEffects() AddMultiItemPage(addDownloadComponent) CategorySheet( categoryComponent = addDownloadComponent.categorySlot.rememberChild(), onDismiss = addDownloadComponent::closeCategoryDialog ) NewQueueSheet( onQueueCreate = addDownloadComponent::createQueueWithName, isOpened = addDownloadComponent.showAddQueue.collectAsState().value, onCloseRequest = { addDownloadComponent.setShowAddQueue(false) }, ) } } private fun getComponentConfig(intent: Intent): AddDownloadConfig.MultipleAddConfig { runCatching { with(json) { intent.getSerializedExtra(COMPONENT_CONFIG_KEY) } }.onFailure { it.printStackTrace() }.getOrNull()?.let { return it } return AddDownloadConfig.MultipleAddConfig() } companion object { const val COMPONENT_CONFIG_KEY = "ComponentConfig" fun createIntent( context: Context, multipleAddConfig: AddDownloadConfig.MultipleAddConfig, json: Json, ): Intent { val intent = Intent( context, AddMultiDownloadActivity::class.java, ) with(json) { intent.putSerializedExtra(COMPONENT_CONFIG_KEY, multipleAddConfig) } return intent } } } ================================================ FILE: android/app/src/main/kotlin/com/abdownloadmanager/android/pages/add/multiple/AddMultiDownloadList.kt ================================================ package com.abdownloadmanager.android.pages.add.multiple import androidx.compose.foundation.background import androidx.compose.foundation.basicMarquee import androidx.compose.foundation.combinedClickable import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.heightIn import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.width import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.itemsIndexed import androidx.compose.runtime.Composable import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue import androidx.compose.runtime.remember import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.alpha import androidx.compose.ui.draw.drawBehind import androidx.compose.ui.geometry.Offset import androidx.compose.ui.graphics.Brush import androidx.compose.ui.graphics.Color import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp import com.abdownloadmanager.resources.Res import com.abdownloadmanager.shared.pages.adddownload.multiple.NewMultiDownloadState import com.abdownloadmanager.shared.ui.widget.CheckBox import com.abdownloadmanager.shared.ui.widget.Text import com.abdownloadmanager.shared.util.FileIconProvider import com.abdownloadmanager.shared.util.div import com.abdownloadmanager.shared.util.ui.LocalContentColor import com.abdownloadmanager.shared.util.ui.myColors import com.abdownloadmanager.shared.util.ui.theme.mySpacings import com.abdownloadmanager.shared.util.ui.widget.MyIcon import ir.amirab.util.compose.resources.myStringResource import ir.amirab.util.ifThen @Composable fun AddMultiDownloadList( modifier: Modifier, component: AndroidAddMultiDownloadComponent, itePaddingValues: PaddingValues, ) { val dividerColor = myColors.onBackground / 0.5f val listState by component.filteredList.collectAsState() LazyColumn(modifier) { itemsIndexed( items = listState, ) { index, item -> val isSelected = remember(item, component.selectionList) { component.isSelected(item.id) } val isFirstItem = index == 0 RenderAddDownloadItem( state = item, iconProvider = component.fileIconProvider, onSelectionChange = { selected -> component.setSelect(item.id, selected) }, onLongPress = { component.openConfigurableList( item.id ) }, isSelected = isSelected, itemPadding = itePaddingValues, modifier = Modifier.ifThen(!isFirstItem) { drawBehind { drawLine( brush = Brush.horizontalGradient( listOf( Color.Transparent, dividerColor, Color.Transparent, ) ), start = Offset.Zero, end = Offset(size.width, 0f) ) } } ) } } } @Composable private fun RenderAddDownloadItem( state: NewMultiDownloadState, iconProvider: FileIconProvider, isSelected: Boolean, onLongPress: () -> Unit, onSelectionChange: (Boolean) -> Unit, itemPadding: PaddingValues, modifier: Modifier, ) { val name = state.name val icon = iconProvider.rememberIcon(name) Row( verticalAlignment = Alignment.CenterVertically, modifier = modifier .fillMaxWidth() .heightIn(mySpacings.thumbSize) .ifThen(isSelected) { background( myColors.selectionGradient(1f, 0.5f) ) } .combinedClickable( onClick = { onSelectionChange(!isSelected) }, onLongClick = { onLongPress() } ) .padding(itemPadding) ) { Column { Text( text = state.link, color = LocalContentColor.current / 0.75f, maxLines = 1, overflow = TextOverflow.MiddleEllipsis, ) Spacer(Modifier.height(mySpacings.mediumSpace)) Text( text = name.takeIf { it.isNotEmpty() } ?: "...", maxLines = 1, modifier = Modifier.basicMarquee(), ) Spacer(Modifier.height(mySpacings.mediumSpace)) val sizeTitle = myStringResource(Res.string.size) val sizeValue = state.sizeString.rememberString() Row( verticalAlignment = Alignment.CenterVertically, ) { CheckBox( value = isSelected, onValueChange = { onSelectionChange(it) }, size = 24.dp, ) Spacer(Modifier.width(mySpacings.largeSpace)) MyIcon( icon = icon, contentDescription = null, modifier = Modifier .size(24.dp) .alpha(0.75f) ) Spacer(Modifier.width(mySpacings.largeSpace)) Text( text = "$sizeTitle: $sizeValue", maxLines = 1, ) } } } } ================================================ FILE: android/app/src/main/kotlin/com/abdownloadmanager/android/pages/add/multiple/AddMultiItemPage.kt ================================================ package com.abdownloadmanager.android.pages.add.multiple import androidx.activity.compose.BackHandler import androidx.activity.compose.LocalOnBackPressedDispatcherOwner import androidx.compose.animation.AnimatedVisibility import androidx.compose.foundation.background import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.* import androidx.compose.runtime.* import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.unit.dp import com.abdownloadmanager.android.pages.add.shared.CategoryAddButton import com.abdownloadmanager.android.pages.add.shared.CategorySelect import com.abdownloadmanager.android.pages.add.shared.ExtraConfig import com.abdownloadmanager.android.pages.add.shared.LocationTextField import com.abdownloadmanager.android.pages.add.shared.ShowAddToQueueDialog import com.abdownloadmanager.android.ui.RenderControlSelections import com.abdownloadmanager.android.ui.SelectionControlButton import com.abdownloadmanager.android.ui.page.PageHeader import com.abdownloadmanager.android.ui.page.PageTitleWithDescription import com.abdownloadmanager.android.ui.page.PageUi import com.abdownloadmanager.shared.ui.widget.* import com.abdownloadmanager.shared.util.ui.myColors import com.abdownloadmanager.shared.util.div import com.abdownloadmanager.resources.Res import com.abdownloadmanager.shared.util.category.Category import com.abdownloadmanager.shared.util.ui.icon.MyIcons import com.abdownloadmanager.shared.util.ui.theme.mySpacings import ir.amirab.util.compose.asStringSource import ir.amirab.util.compose.resources.myStringResource @Composable fun AddMultiItemPage( addMultiDownloadComponent: AndroidAddMultiDownloadComponent, ) { val hasSelection = addMultiDownloadComponent.selectionList.isNotEmpty() BackHandler(hasSelection) { addMultiDownloadComponent.selectAll(false) } val pageHorizontalPadding = 16.dp PageUi( modifier = Modifier .background(myColors.background) .statusBarsPadding(), header = { val backDispatcher = LocalOnBackPressedDispatcherOwner.current?.onBackPressedDispatcher PageHeader( leadingIcon = { TransparentIconActionButton( MyIcons.back, contentDescription = Res.string.back.asStringSource() ) { backDispatcher?.onBackPressed() } }, headerTitle = { PageTitleWithDescription( title = myStringResource( Res.string.add_download ), description = myStringResource( Res.string.add_multi_download_page_header ) ) } ) }, footer = { Footer( Modifier, addMultiDownloadComponent, ) }, ) { Column( Modifier .fillMaxWidth() .background(myColors.background) .padding(it.paddingValues) ) { AddMultiDownloadList( Modifier.weight(1f), addMultiDownloadComponent, itePaddingValues = PaddingValues( horizontal = pageHorizontalPadding, vertical = 16.dp, ) ) } } val currentDownloadConfigurableList by addMultiDownloadComponent.currentDownloadConfigurableList.collectAsState() currentDownloadConfigurableList?.let { ExtraConfig( onDismiss = { addMultiDownloadComponent.openConfigurableList(null) }, configurables = it, isOpened = true, ) } ShowAddToQueueDialog( queueList = addMultiDownloadComponent.queueList.collectAsState().value, onQueueSelected = { queue, startQueue -> addMultiDownloadComponent.requestAddDownloads( queue, startQueue ) }, onClose = { addMultiDownloadComponent.closeAddToQueue() }, isOpened = addMultiDownloadComponent.showAddToQueue, newQueueAction = addMultiDownloadComponent.newQueueAction, ) } @Composable fun Footer( modifier: Modifier = Modifier, component: AndroidAddMultiDownloadComponent, ) { Column( modifier .fillMaxWidth() .background(myColors.surface) .navigationBarsPadding() .imePadding() ) { Spacer( Modifier .fillMaxWidth() .height(1.dp) .background(myColors.onSurface / 0.15f) ) Column( Modifier .padding(horizontal = 16.dp) .padding(vertical = 16.dp), ) { val total = component.totalList.size val showMoreOptions by component.showMoreOptions.collectAsState() RenderControlSelections( onRequestSelectAll = { component.selectAll(true) }, onRequestSelectInside = { component.toggleSelectInside() }, onRequestInvertSelection = { component.inverseSelection() }, total = total, selectionCount = component.selectionList.size, ) { SelectionControlButton( icon = if (showMoreOptions) { MyIcons.down } else { MyIcons.up }, contentDescription = Res.string.more_options.asStringSource(), onClick = { component.setShowMoreOptions(!showMoreOptions) } ) } AnimatedVisibility( showMoreOptions ) { Column { Spacer(Modifier.height(8.dp)) SaveSettings( modifier = Modifier .fillMaxWidth(), component = component, ) val text = component.filterText.collectAsState().value Spacer(Modifier.height(8.dp)) MyTextFieldWithIcons( modifier = Modifier .fillMaxWidth(), text = text, onTextChange = component::setFilterText, placeholder = myStringResource(Res.string.search), end = { MyTextFieldIcon( MyIcons.clear, enabled = text.isNotEmpty(), ) { component.setFilterText("") } } ) Spacer(Modifier.height(8.dp)) } } Spacer(Modifier.height(8.dp)) Row( Modifier ) { val buttonModifier = Modifier.weight(1f) PrimaryMainActionButton( text = myStringResource(Res.string.add), onClick = { component.openAddToQueueDialog() }, enabled = component.canClickAdd, modifier = buttonModifier, ) // ActionButton( // text = myStringResource(Res.string.cancel), // onClick = { // component.requestClose() // }, // modifier = buttonModifier, // ) } } } } @Composable private fun SaveSettings( modifier: Modifier, component: AndroidAddMultiDownloadComponent, ) { val selectedCategory by component.selectedCategory.collectAsState() val folder by component.folder.collectAsState() Column(modifier) { Text("${myStringResource(Res.string.save_to)}:") Spacer(Modifier.height(8.dp)) Column(Modifier.fillMaxWidth()) { CategorySaveOption(selectedCategory, component) Spacer(Modifier.height(8.dp)) LocationSaveOption(component, folder) Spacer(Modifier) } } } @Composable private fun LocationSaveOption( component: AndroidAddMultiDownloadComponent, folder: String ) { val allItemsInSameLocation by component.allInSameLocation.collectAsState() SaveOption( title = myStringResource(Res.string.all_items_in_one_Location), selectedHelp = myStringResource(Res.string.all_items_in_one_Location_description), unselectedHelp = myStringResource(Res.string.unselected_all_items_in_specific_location_description), selected = allItemsInSameLocation, onSelectedChange = { component.setAllItemsInSameLocation(it) }, selectedContent = { LocationTextField( text = folder, setText = { component.setFolder(it) }, modifier = Modifier.fillMaxWidth(), lastUsedLocations = component.lastUsedLocations.collectAsState().value, onRequestRemoveSaveLocation = component::removeFromLastDownloadLocation ) } ) } @Composable private fun CategorySaveOption( selectedCategory: Category?, component: AndroidAddMultiDownloadComponent ) { SaveOption( title = myStringResource(Res.string.all_items_in_one_category), selectedHelp = myStringResource(Res.string.all_items_in_one_category_description), unselectedHelp = myStringResource(Res.string.each_item_on_its_own_category_description), selected = selectedCategory != null, onSelectedChange = { if (it) { component.setSelectedCategory(component.categories.value.firstOrNull()) } else { component.setSelectedCategory(null) } component.setAlsoAutoCategorize(!it) }, selectedContent = { Row( Modifier .height(IntrinsicSize.Max) .fillMaxWidth(), verticalAlignment = Alignment.CenterVertically, ) { CategorySelect( categories = component.categories.collectAsState().value, modifier = Modifier.weight(1f), selectedCategory = component.selectedCategory.collectAsState().value, onCategorySelected = { component.setSelectedCategory(it) } ) Spacer(Modifier.width(8.dp)) CategoryAddButton( Modifier.fillMaxHeight(), enabled = true, onClick = { component.onRequestAddCategory() }, ) } } ) } @Composable private fun SaveOption( title: String, selectedHelp: String, unselectedHelp: String, selected: Boolean, onSelectedChange: (Boolean) -> Unit, selectedContent: @Composable () -> Unit ) { ExpandableItem( modifier = Modifier .fillMaxWidth(), isExpanded = selected, header = { Row( modifier = Modifier .heightIn(mySpacings.thumbSize) .clickable { onSelectedChange(!selected) }, verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(8.dp) ) { CheckBox( value = selected, onValueChange = onSelectedChange, size = 24.dp ) Text(title) Help(if (selected) selectedHelp else unselectedHelp) } }, body = { Column { Spacer(Modifier.height(8.dp)) selectedContent() } } ) } ================================================ FILE: android/app/src/main/kotlin/com/abdownloadmanager/android/pages/add/multiple/AndroidAddMultiDownloadComponent.kt ================================================ package com.abdownloadmanager.android.pages.add.multiple import com.abdownloadmanager.shared.action.createNewQueueAction import com.abdownloadmanager.shared.util.DownloadSystem import com.abdownloadmanager.shared.downloaderinui.DownloaderInUiRegistry import com.abdownloadmanager.shared.pagemanager.CategoryDialogManager import com.abdownloadmanager.shared.pagemanager.NewQueuePageManager import com.abdownloadmanager.shared.pages.adddownload.multiple.BaseAddMultiDownloadComponent import com.abdownloadmanager.shared.pages.adddownload.multiple.OnRequestAdd import com.abdownloadmanager.shared.pages.category.CategoryComponent import com.abdownloadmanager.shared.repository.BaseAppRepository import com.abdownloadmanager.shared.storage.ILastSavedLocationsStorage import com.abdownloadmanager.shared.util.FileIconProvider import com.abdownloadmanager.shared.util.category.CategoryManager import com.abdownloadmanager.shared.util.perhostsettings.PerHostSettingsManager import com.abdownloadmanager.shared.util.subscribeAsStateFlow import com.arkivanov.decompose.ComponentContext import com.arkivanov.decompose.router.slot.SlotNavigation import com.arkivanov.decompose.router.slot.activate import com.arkivanov.decompose.router.slot.childSlot import com.arkivanov.decompose.router.slot.dismiss import ir.amirab.downloader.queue.QueueManager import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.launch import kotlinx.serialization.builtins.serializer class AndroidAddMultiDownloadComponent( ctx: ComponentContext, id: String, onRequestClose: () -> Unit, onRequestAdd: OnRequestAdd, lastSavedLocationsStorage: ILastSavedLocationsStorage, perHostSettingsManager: PerHostSettingsManager, downloadSystem: DownloadSystem, fileIconProvider: FileIconProvider, appRepository: BaseAppRepository, downloaderInUiRegistry: DownloaderInUiRegistry, queueManager: QueueManager, categoryManager: CategoryManager, ) : BaseAddMultiDownloadComponent( ctx = ctx, id = id, lastSavedLocationsStorage = lastSavedLocationsStorage, onRequestAdd = onRequestAdd, onRequestClose = onRequestClose, perHostSettingsManager = perHostSettingsManager, downloadSystem = downloadSystem, appRepository = appRepository, fileIconProvider = fileIconProvider, downloaderInUiRegistry = downloaderInUiRegistry, queueManager = queueManager, categoryManager = categoryManager, ), NewQueuePageManager, CategoryDialogManager { val categoryComponentNavigation = SlotNavigation() val categorySlot = childSlot( source = categoryComponentNavigation, childFactory = { config, ctx -> CategoryComponent( ctx = ctx, id = config, close = ::closeCategoryDialog, submit = { submittedCategory -> if (submittedCategory.id < 0) { categoryManager.addCustomCategory(submittedCategory) } else { categoryManager.updateCategory( submittedCategory.id ) { submittedCategory.copy( items = it.items ) } } closeCategoryDialog() }, ) }, serializer = Long.serializer(), ).subscribeAsStateFlow() val newQueueAction = createNewQueueAction( scope, this, ) override fun openCategoryDialog(categoryId: Long) { scope.launch { categoryComponentNavigation.activate(categoryId) } } override fun closeCategoryDialog() { scope.launch { categoryComponentNavigation.dismiss() } } override fun getCategoryPageManager(): CategoryDialogManager { return this } private val _showMoreInputs = MutableStateFlow(false) val showMoreOptions = _showMoreInputs.asStateFlow() fun setShowMoreOptions(value: Boolean) { _showMoreInputs.value = value } private val _showAddQueue = MutableStateFlow(false) val showAddQueue = _showAddQueue.asStateFlow() fun setShowAddQueue(value: Boolean) { _showAddQueue.value = value } fun createQueueWithName(name: String) { scope.launch { queueManager.addQueue(name) } setShowAddQueue(false) } override fun closeNewQueueDialog() { setShowAddQueue(false) } override fun openNewQueueDialog() { setShowAddQueue(true) } } ================================================ FILE: android/app/src/main/kotlin/com/abdownloadmanager/android/pages/add/shared/CategorySelect.kt ================================================ package com.abdownloadmanager.android.pages.add.shared import androidx.compose.foundation.background import androidx.compose.foundation.border import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.* import androidx.compose.runtime.* import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.alpha import androidx.compose.ui.draw.clip import androidx.compose.ui.unit.* import com.abdownloadmanager.android.ui.configurable.RenderSpinnerInSheet import com.abdownloadmanager.resources.Res import com.abdownloadmanager.shared.ui.widget.IconActionButton import com.abdownloadmanager.shared.util.ui.icon.MyIcons import com.abdownloadmanager.shared.util.ui.myColors import ir.amirab.util.ifThen import com.abdownloadmanager.shared.ui.widget.Text import com.abdownloadmanager.shared.util.div import com.abdownloadmanager.shared.util.category.Category import com.abdownloadmanager.shared.util.category.rememberIconPainter import com.abdownloadmanager.shared.util.ui.theme.myShapes import com.abdownloadmanager.shared.util.ui.theme.mySpacings import com.abdownloadmanager.shared.util.ui.widget.MyIcon import ir.amirab.util.compose.asStringSource import ir.amirab.util.compose.resources.myStringResource @Composable fun CategorySelect( modifier: Modifier = Modifier, enabled: Boolean = true, categories: List, selectedCategory: Category?, onCategorySelected: (Category) -> Unit, ) { var isSelectionOpen by remember { mutableStateOf(false) } val closeDialog = { isSelectionOpen = false } RenderSelectedCategory( modifier = modifier, item = selectedCategory, enabled = enabled, onClick = { isSelectionOpen = true }, renderItem = { RenderCategory( category = it, modifier = Modifier, ) } ) selectedCategory?.let { RenderSpinnerInSheet( title = Res.string.categories.asStringSource(), isOpened = isSelectionOpen, onDismiss = closeDialog, possibleValues = categories, render = { RenderCategory( category = it, modifier = Modifier, ) }, value = selectedCategory, onSelect = { onCategorySelected(it) }, // renderEmpty = { // Column( // modifier = Modifier.fillMaxSize().wrapContentSize(), // horizontalAlignment = Alignment.CenterHorizontally, // ) { // MyIcon(MyIcons.info, null, Modifier.size(64.dp)) // Spacer(Modifier.height(16.dp)) // Text( // myStringResource(Res.string.no_categories_found), // fontWeight = FontWeight.Bold, // fontSize = myTextSizes.lg, // ) // } // } ) } } @Composable private fun RenderCategory( modifier: Modifier, category: Category, ) { Row( modifier, verticalAlignment = Alignment.CenterVertically, ) { val icon = category.rememberIconPainter() val iconModifier = Modifier.size(16.dp) if (icon != null) { MyIcon( icon, null, iconModifier, ) } else { Spacer(iconModifier) } Spacer(Modifier.width(8.dp)) Text( category.name, softWrap = false, maxLines = 1, modifier = Modifier.weight(1f) ) } } @Composable fun CategoryAddButton( modifier: Modifier, enabled: Boolean = true, onClick: () -> Unit, ) { IconActionButton( modifier = modifier, icon = MyIcons.add, contentDescription = Res.string.add_category.asStringSource(), enabled = enabled, onClick = onClick, ) } @Composable private fun RenderSelectedCategory( item: T?, enabled: Boolean, onClick: () -> Unit, modifier: Modifier, renderItem: @Composable (T) -> Unit, ) { val borderColor = myColors.onBackground / 0.1f // val background = myColors.surface / 50 val shape = myShapes.defaultRounded Row( modifier .height(IntrinsicSize.Max) .heightIn(mySpacings.thumbSize) .clip(shape) .ifThen(!enabled) { alpha(0.5f) } .border(1.dp, borderColor, shape) // .background(background) .clickable( enabled = enabled ) { onClick() } .padding(horizontal = 8.dp), verticalAlignment = Alignment.CenterVertically, ) { val contentModifier = Modifier .padding(vertical = 8.dp) .weight(1f) if (item != null) { Box(contentModifier) { renderItem(item) } } else { Text( myStringResource(Res.string.no_category_selected), contentModifier ) } Spacer( Modifier .padding(horizontal = 8.dp) .fillMaxHeight() .padding(vertical = 1.dp) .width(1.dp) .background(borderColor) ) MyIcon( MyIcons.down, null, Modifier .align(Alignment.CenterVertically) .size(16.dp), ) } } ================================================ FILE: android/app/src/main/kotlin/com/abdownloadmanager/android/pages/add/shared/ExtraConfig.kt ================================================ package com.abdownloadmanager.android.pages.add.shared import com.abdownloadmanager.shared.ui.configurable.RenderConfigurable import com.abdownloadmanager.shared.util.ui.myColors import com.abdownloadmanager.shared.util.div import androidx.compose.foundation.* import androidx.compose.foundation.layout.* import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip import androidx.compose.ui.graphics.Brush import androidx.compose.ui.unit.DpOffset import androidx.compose.ui.unit.DpSize import androidx.compose.ui.unit.dp import com.abdownloadmanager.android.ui.SheetHeader import com.abdownloadmanager.android.ui.SheetTitle import com.abdownloadmanager.android.ui.SheetUI import com.abdownloadmanager.shared.ui.configurable.Configurable import com.abdownloadmanager.shared.ui.configurable.ConfigurableUiProps import com.abdownloadmanager.shared.util.OnFullyDismissed import com.abdownloadmanager.shared.util.ResponsiveDialog import com.abdownloadmanager.shared.util.rememberResponsiveDialogState import com.abdownloadmanager.shared.util.ui.MultiplatformVerticalScrollbar import com.abdownloadmanager.shared.util.ui.theme.LocalUiScale import com.abdownloadmanager.shared.util.ui.theme.myShapes import io.github.oikvpqya.compose.fastscroller.rememberScrollbarAdapter @Composable fun ExtraConfig( isOpened: Boolean, onDismiss: () -> Unit, configurables: List>, ) { val dialogState = rememberResponsiveDialogState(false) LaunchedEffect(isOpened) { if (isOpened) { dialogState.show() } else { dialogState.hide() } } dialogState.OnFullyDismissed { onDismiss() } ResponsiveDialog( state = dialogState, onDismiss = { dialogState.hide() } ) { SheetUI( header = { SheetHeader( headerTitle = { SheetTitle("Extra Config") }, headerActions = {} ) } ) { Box { val scrollState = rememberScrollState() Column( Modifier.verticalScroll(scrollState) ) { for ((index, cfg) in configurables.withIndex()) { RenderConfigurable( cfg, ConfigurableUiProps( itemPaddingValues = PaddingValues(vertical = 8.dp, horizontal = 32.dp) ) ) if (index != configurables.lastIndex) { Divider() } } } MultiplatformVerticalScrollbar( rememberScrollbarAdapter(scrollState), Modifier .matchParentSize() .wrapContentWidth() .align(Alignment.CenterEnd) ) } } } } @Composable private fun Divider() { Spacer( Modifier .fillMaxWidth() .height(1.dp) .background(myColors.onBackground / 10), ) } ================================================ FILE: android/app/src/main/kotlin/com/abdownloadmanager/android/pages/add/shared/LocationTextField.kt ================================================ package com.abdownloadmanager.android.pages.add.shared import com.abdownloadmanager.shared.ui.widget.MyTextFieldWithIcons import com.abdownloadmanager.shared.ui.widget.MyTextFieldIcon import com.abdownloadmanager.shared.util.ui.icon.MyIcons import com.abdownloadmanager.shared.util.ui.myColors import com.abdownloadmanager.shared.util.ui.theme.myTextSizes import com.abdownloadmanager.shared.ui.widget.Text import com.abdownloadmanager.shared.ui.widget.menu.custom.MyDropDown import androidx.compose.foundation.background import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.* import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.verticalScroll import androidx.compose.runtime.* import androidx.compose.ui.Modifier import androidx.compose.ui.draw.alpha import androidx.compose.ui.draw.clip import androidx.compose.ui.layout.onGloballyPositioned import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp import com.abdownloadmanager.android.pages.directorypicker.rememberAndroidDirectoryPickerLauncher import com.abdownloadmanager.resources.Res import com.abdownloadmanager.shared.util.ui.theme.myShapes import com.abdownloadmanager.shared.util.ui.widget.MyIcon import ir.amirab.util.compose.resources.myStringResource import ir.amirab.util.compose.asStringSource import java.io.File @Composable fun LocationTextField( modifier: Modifier, text: String, setText: (String) -> Unit, errorText: String? = null, lastUsedLocations: List = emptyList(), onRequestRemoveSaveLocation: (String) -> Unit, ) { var showLastUsedLocations by remember { mutableStateOf(false) } val downloadLauncherFolderPickerLauncher = rememberAndroidDirectoryPickerLauncher( title = Res.string.download_location.asStringSource(), initialDirectory = remember(text) { runCatching { File(text).canonicalPath }.getOrNull() }, ) { directory -> directory?.let(setText) } var widthForDropDown by remember { mutableStateOf(0.dp) } val density = LocalDensity.current Box(modifier) { MyTextFieldWithIcons( text, setText, myStringResource(Res.string.location), modifier = Modifier .fillMaxWidth() .onGloballyPositioned { widthForDropDown = with(density) { it.size.width.toDp() } }, errorText = errorText, end = { Row { MyTextFieldIcon(MyIcons.folder) { downloadLauncherFolderPickerLauncher.launch() } MyTextFieldIcon(MyIcons.down) { showLastUsedLocations = !showLastUsedLocations } } } ) if (showLastUsedLocations) { ShowSuggestions( width = { widthForDropDown }, suggestions = lastUsedLocations, onSuggestionSelected = { setText(it) showLastUsedLocations = false }, onDismiss = { showLastUsedLocations = false }, onRequestRemove = onRequestRemoveSaveLocation ) } } } @Composable private fun ShowSuggestions( width: () -> Dp, suggestions: List, onRequestRemove: (String) -> Unit, onSuggestionSelected: (String) -> Unit, onDismiss: () -> Unit, ) { MyDropDown(onDismiss) { Column( Modifier .width(width()) .clip(myShapes.defaultRounded) .background(myColors.surface) .verticalScroll(rememberScrollState()) ) { for (l in suggestions) { Row( Modifier.height(IntrinsicSize.Max) ) { Text( text = l, modifier = Modifier .weight(1f) .clickable { onSuggestionSelected(l) } .padding(vertical = 4.dp, horizontal = 4.dp), fontSize = myTextSizes.sm ) MyIcon( MyIcons.clear, null, Modifier .fillMaxHeight() .clickable { onRequestRemove(l) } .wrapContentHeight() .padding(horizontal = 2.dp) .size(12.dp) .alpha(0.25f) ) } } } } } ================================================ FILE: android/app/src/main/kotlin/com/abdownloadmanager/android/pages/add/shared/SelectQueue.kt ================================================ package com.abdownloadmanager.android.pages.add.shared import com.abdownloadmanager.shared.util.ui.icon.MyIcons import com.abdownloadmanager.shared.util.ui.myColors import com.abdownloadmanager.shared.util.ui.theme.myTextSizes import com.abdownloadmanager.shared.ui.widget.ActionButton import com.abdownloadmanager.shared.ui.widget.IconActionButton import com.abdownloadmanager.shared.ui.widget.Text import com.abdownloadmanager.shared.util.ui.WithContentColor import com.abdownloadmanager.shared.util.div import androidx.compose.foundation.* import androidx.compose.foundation.layout.* import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.runtime.* import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip import androidx.compose.ui.graphics.Brush import androidx.compose.ui.unit.DpOffset import androidx.compose.ui.unit.DpSize import androidx.compose.ui.unit.dp import com.abdownloadmanager.android.ui.SheetHeader import com.abdownloadmanager.android.ui.SheetTitle import com.abdownloadmanager.android.ui.SheetUI import com.abdownloadmanager.shared.util.ui.theme.LocalUiScale import com.abdownloadmanager.resources.Res import com.abdownloadmanager.shared.ui.widget.CheckBox import com.abdownloadmanager.shared.util.OnFullyDismissed import com.abdownloadmanager.shared.util.ResponsiveDialog import com.abdownloadmanager.shared.util.rememberResponsiveDialogState import com.abdownloadmanager.shared.util.ui.MultiplatformVerticalScrollbar import com.abdownloadmanager.shared.util.ui.VerticalScrollableContent import com.abdownloadmanager.shared.util.ui.theme.myShapes import com.abdownloadmanager.shared.util.ui.theme.mySpacings import io.github.oikvpqya.compose.fastscroller.rememberScrollbarAdapter import ir.amirab.util.compose.resources.myStringResource import ir.amirab.downloader.queue.DownloadQueue import ir.amirab.util.compose.action.AnAction import ir.amirab.util.compose.asStringSource @Composable fun ShowAddToQueueDialog( queueList: List, isOpened: Boolean, onQueueSelected: (Long?, Boolean) -> Unit, newQueueAction: AnAction, onClose: () -> Unit, ) { val state = rememberResponsiveDialogState(false) LaunchedEffect(isOpened) { if (isOpened) { state.show() } else { state.hide() } } state.OnFullyDismissed { onClose() } val (startQueue, setStartQueue) = remember { mutableStateOf(false) } ResponsiveDialog( onDismiss = state::hide, state = state, ) { SheetUI( header = { SheetHeader( headerTitle = { SheetTitle( myStringResource(Res.string.select_queue) ) } ) } ) { WithContentColor(myColors.onBackground) { Column( Modifier.fillMaxWidth() ) { Column( Modifier .padding(horizontal = 8.dp) .padding(bottom = 8.dp) ) { val addToQueueModifier = Modifier.fillMaxWidth() Spacer(Modifier.height(8.dp)) val scrollState = rememberScrollState() VerticalScrollableContent( scrollState, Modifier .border(1.dp, myColors.onBackground / 5, myShapes.defaultRounded) .padding(1.dp), ) { Column( modifier = Modifier .verticalScroll(scrollState) ) { for (q in queueList) { key(q.id) { val queueModel by q.queueModel.collectAsState() QueueItemToSelect( modifier = addToQueueModifier, name = queueModel.name, onSelect = { onQueueSelected(queueModel.id, startQueue) } ) } } } } Spacer(Modifier.height(8.dp)) Row( verticalAlignment = Alignment.CenterVertically, modifier = Modifier .clickable { setStartQueue(!startQueue) } .padding(vertical = 4.dp) .padding(start = 2.dp) ) { CheckBox( size = 24.dp, value = startQueue, onValueChange = setStartQueue ) Spacer(Modifier.width(mySpacings.mediumSpace)) Text(myStringResource(Res.string.start_queue)) } Row( modifier = Modifier .fillMaxWidth() .padding(vertical = 8.dp), verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.SpaceBetween ) { IconActionButton( MyIcons.add, contentDescription = Res.string.add_new_queue.asStringSource(), onClick = newQueueAction ) Spacer(Modifier.width(mySpacings.mediumSpace)) ActionButton( text = myStringResource(Res.string.without_queue), modifier = Modifier.weight(1f), onClick = { onQueueSelected(null, startQueue) } ) } } } } } } } @Composable fun QueueItemToSelect( modifier: Modifier, name: String, onSelect: () -> Unit, ) { Row( modifier .clickable(onClick = onSelect) .heightIn(mySpacings.thumbSize) .padding(vertical = 4.dp) .padding(horizontal = 4.dp), verticalAlignment = Alignment.CenterVertically, ) { Text( name, fontSize = myTextSizes.base, ) } } @Composable private fun Divider() { Spacer( Modifier .fillMaxWidth() .height(1.dp) .background(myColors.onBackground / 10), ) } ================================================ FILE: android/app/src/main/kotlin/com/abdownloadmanager/android/pages/add/single/AddSingleDownloadActivity.kt ================================================ package com.abdownloadmanager.android.pages.add.single import android.content.Context import android.content.Intent import android.os.Bundle import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.collectAsState import com.abdownloadmanager.android.pages.browser.BrowserActivity import com.abdownloadmanager.android.pages.category.CategorySheet import com.abdownloadmanager.android.pages.newqueue.NewQueueSheet import com.abdownloadmanager.android.pages.singledownload.SingleDownloadPageActivity import com.abdownloadmanager.android.util.ABDMAppManager import com.abdownloadmanager.android.util.AndroidDownloadItemOpener import com.abdownloadmanager.android.util.activity.ABDMActivity import com.abdownloadmanager.android.util.activity.HandleActivityEffects import com.abdownloadmanager.android.util.activity.getSerializedExtra import com.abdownloadmanager.android.util.activity.putSerializedExtra import com.abdownloadmanager.shared.downloaderinui.DownloaderInUiRegistry import com.abdownloadmanager.shared.pages.adddownload.AddDownloadConfig import com.abdownloadmanager.shared.pages.adddownload.AddDownloadCredentialsInUiProps import com.abdownloadmanager.shared.pages.adddownload.single.BaseAddSingleDownloadComponent import com.abdownloadmanager.shared.storage.ILastSavedLocationsStorage import com.abdownloadmanager.shared.util.DownloadSystem import com.abdownloadmanager.shared.util.FileIconProvider import com.abdownloadmanager.shared.util.OnFullyDismissed import com.abdownloadmanager.shared.util.ResponsiveDialog import com.abdownloadmanager.shared.util.category.CategoryManager import com.abdownloadmanager.shared.util.mvi.HandleEffects import com.abdownloadmanager.shared.util.rememberChild import com.abdownloadmanager.shared.util.rememberResponsiveDialogState import ir.amirab.downloader.downloaditem.http.HttpDownloadCredentials import ir.amirab.downloader.queue.QueueManager import kotlinx.coroutines.delay import kotlinx.coroutines.launch import kotlinx.serialization.json.Json import org.koin.core.component.inject class AddSingleDownloadActivity : ABDMActivity() { private val json: Json by inject() private val downloadSystem: DownloadSystem by inject() private val appManager: ABDMAppManager by inject() private val downloadItemOpener: AndroidDownloadItemOpener by inject() private val downloaderInUiRegistry: DownloaderInUiRegistry by inject() private val lastSavedLocationsStorage: ILastSavedLocationsStorage by inject() private val queueManager: QueueManager by inject() private val categoryManager: CategoryManager by inject() private val iconProvider: FileIconProvider by inject() private val appContext: Context by inject() override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) val myRetainedComponent = myRetainedComponent { // TODO consider use a factory to create AndroidAddSingleDownloadComponent // we may create memory leaks if we accidentally pass Activity::this into the component lambdas val config = getComponentConfig(intent) val appManager = appManager val appContext = this@AddSingleDownloadActivity.appContext val scope = applicationScope val downloadItemOpener = downloadItemOpener val appSettingsStorage = appSettingsStorage val downloadSystem = downloadSystem val closeAddDownloadDialog = { this@myRetainedComponent.finishActivityAction() } AndroidAddSingleDownloadComponent( ctx = it, onRequestClose = closeAddDownloadDialog, onRequestDownload = { item, categoryId -> scope.launch { val id = appManager.startNewDownload(item, categoryId).await() if (appSettingsStorage.showDownloadProgressDialog.value) { runCatching { appContext.startActivity( SingleDownloadPageActivity.createIntent( appContext, id, true, ).addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) ) }.onFailure { it.printStackTrace() } } } }, onRequestAddToQueue = { item, queue, category -> appManager.addDownload(item, queue, category) }, openExistingDownload = { scope.launch { downloadItemOpener.openDownloadItem(it) } }, updateExistingDownloadCredentials = { id, newCredentials, downloadJobExtraConfig -> scope.launch { downloadSystem.downloadManager.updateDownloadItem( id = id, downloadJobExtraConfig = downloadJobExtraConfig, updater = { it.withCredentials(newCredentials) } ) // openDownloadDialog(id) } }, downloadItemOpener = downloadItemOpener, lastSavedLocationsStorage = lastSavedLocationsStorage, importOptions = config.importOptions, id = config.id, downloaderInUi = downloaderInUiRegistry.getDownloaderOf(config.newDownload.credentials)!!, initialCredentials = config.newDownload, queueManager = queueManager, categoryManager = categoryManager, downloadSystem = downloadSystem, appSettings = appSettingsStorage, iconProvider = iconProvider, appScope = applicationScope, appRepository = appRepository, perHostSettingsManager = perHostSettingsManager, ) } val addDownloadComponent = myRetainedComponent.component setABDMContent { myRetainedComponent.HandleActivityEffects() HandleEffects(addDownloadComponent) { if (it is AndroidAddSingleDownloadComponent.Effects.OpenInBrowser) { startActivity( BrowserActivity.createIntent(this, it.link) ) finish() } } val dialogState = rememberResponsiveDialogState(false) dialogState.OnFullyDismissed { addDownloadComponent.onRequestClose() } LaunchedEffect(Unit) { // animate open after activity becomes fully open // is there a better way? delay(10) dialogState.show() } val onDismiss = { dialogState.hide() } ResponsiveDialog( dialogState, onDismiss ) { AddSingleDownloadPage(addDownloadComponent, onDismiss) } CategorySheet( categoryComponent = addDownloadComponent.categorySlot.rememberChild(), onDismiss = addDownloadComponent::closeCategoryDialog ) NewQueueSheet( onQueueCreate = addDownloadComponent::createQueueWithName, isOpened = addDownloadComponent.showAddQueue.collectAsState().value, onCloseRequest = { addDownloadComponent.setShowAddQueue(false) }, ) } } private fun getComponentConfig(intent: Intent): AddDownloadConfig.SingleAddConfig { runCatching { with(json) { intent.getSerializedExtra(COMPONENT_CONFIG_KEY) } }.onFailure { it.printStackTrace() }.getOrNull()?.let { return it } val link = intent.data?.toString().orEmpty() return AddDownloadConfig.SingleAddConfig( newDownload = AddDownloadCredentialsInUiProps( credentials = HttpDownloadCredentials( link = link, ) ) ) } companion object { const val COMPONENT_CONFIG_KEY = "ComponentConfig" const val LINK_KEY = "link" fun createIntent( context: Context, singleAddConfig: AddDownloadConfig.SingleAddConfig, json: Json, ): Intent { val intent = Intent( context, AddSingleDownloadActivity::class.java, ) with(json) { intent.putSerializedExtra(COMPONENT_CONFIG_KEY, singleAddConfig) } return intent } } } ================================================ FILE: android/app/src/main/kotlin/com/abdownloadmanager/android/pages/add/single/AddSingleDownloadPage.kt ================================================ package com.abdownloadmanager.android.pages.add.single import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.runtime.Composable import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue import androidx.compose.ui.Modifier import arrow.core.Some import com.abdownloadmanager.android.pages.add.shared.CategoryAddButton import com.abdownloadmanager.android.pages.add.shared.CategorySelect import com.abdownloadmanager.android.pages.add.shared.ExtraConfig import com.abdownloadmanager.android.pages.add.shared.LocationTextField import com.abdownloadmanager.android.pages.add.shared.ShowAddToQueueDialog import com.abdownloadmanager.android.ui.SheetHeader import com.abdownloadmanager.android.ui.SheetTitle import com.abdownloadmanager.android.ui.SheetTitleWithDescription import com.abdownloadmanager.android.ui.SheetUI import com.abdownloadmanager.resources.Res import com.abdownloadmanager.shared.ui.widget.ActionButton import com.abdownloadmanager.shared.ui.widget.IconActionButton import com.abdownloadmanager.shared.ui.widget.MyTextFieldWithIcons import com.abdownloadmanager.shared.ui.widget.Text import com.abdownloadmanager.shared.util.ResponsiveDialog import com.abdownloadmanager.shared.util.mvi.HandleEffects import com.abdownloadmanager.shared.util.rememberResponsiveDialogState import com.abdownloadmanager.shared.util.ui.icon.MyIcons import ir.amirab.util.compose.resources.myStringResource import com.abdownloadmanager.shared.util.ui.WithContentAlpha import com.abdownloadmanager.shared.util.ui.widget.MyIcon import com.abdownloadmanager.shared.util.ui.myColors import com.abdownloadmanager.shared.util.ui.theme.myTextSizes import androidx.compose.animation.* import androidx.compose.foundation.* import androidx.compose.foundation.layout.* import androidx.compose.runtime.LaunchedEffect import androidx.compose.ui.Alignment import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.* import com.abdownloadmanager.shared.ui.widget.* import com.abdownloadmanager.shared.downloaderinui.add.CanAddResult import com.abdownloadmanager.shared.pages.adddownload.single.BaseAddSingleDownloadComponent import com.abdownloadmanager.shared.util.ClipboardUtil import com.abdownloadmanager.shared.util.OnFullyDismissed import com.abdownloadmanager.shared.util.ResponsiveDialogScope import com.abdownloadmanager.shared.util.div import com.abdownloadmanager.shared.util.ui.theme.mySpacings import ir.amirab.downloader.utils.OnDuplicateStrategy import ir.amirab.util.compose.asStringSource @Composable fun ResponsiveDialogScope.AddSingleDownloadPage( component: AndroidAddSingleDownloadComponent, onDismiss: () -> Unit, ) { SheetUI( header = { SheetHeader( headerTitle = { SheetTitle( myStringResource(Res.string.new_download) ) }, headerActions = { TransparentIconActionButton( MyIcons.close, contentDescription = Res.string.close.asStringSource(), onClick = onDismiss, ) } ) } ) { val onDuplicateStrategy by component.onDuplicateStrategy.collectAsState() Column( Modifier .padding(horizontal = mySpacings.mediumSpace) ) { Column( Modifier .weight(1f, false) .verticalScroll(rememberScrollState()) ) { val credentials by component.credentials.collectAsState() fun setLink(link: String) { component.setCredentials( credentials.copy(link = Some(link)) ) } val showMoreInputs by component.showMoreInputs.collectAsState() HandleEffects(component) { when (it) { is BaseAddSingleDownloadComponent.Effects.Common -> { when (it) { is BaseAddSingleDownloadComponent.Effects.Common.SuggestUrl -> { setLink(it.link) } } } is BaseAddSingleDownloadComponent.Effects.Platform -> { // } } } val canAddResult by component.canAddResult.collectAsState() Column { UrlTextField( text = credentials.link, setText = { setLink(it) }, modifier = Modifier ) AnimatedVisibility(showMoreInputs) { Column { Space() val useCategory by component.useCategory.collectAsState() Column { Row( verticalAlignment = Alignment.CenterVertically, modifier = Modifier .clickable { component.setUseCategory(!useCategory) } .padding(vertical = 4.dp) ) { CheckBox( size = 16.dp, value = useCategory, onValueChange = { component.setUseCategory(it) } ) Spacer(Modifier.width(8.dp)) Text(myStringResource(Res.string.use_category)) } Space() Row { CategorySelect( modifier = Modifier.weight(1f), enabled = useCategory, categories = component.categories.collectAsState().value, selectedCategory = component.selectedCategory.collectAsState().value, onCategorySelected = { component.setSelectedCategory(it) }, ) Spacer(Modifier.width(8.dp)) CategoryAddButton( enabled = useCategory, modifier = Modifier, onClick = { component.addNewCategory() }, ) } } Spacer(Modifier.size(8.dp)) LocationTextField( modifier = Modifier.fillMaxWidth(), text = component.folder.collectAsState().value, setText = { component.setFolder(it) }, errorText = when (canAddResult) { CanAddResult.CantWriteInThisFolder -> myStringResource(Res.string.cant_write_to_this_folder) else -> null }, lastUsedLocations = component.lastUsedLocations.collectAsState().value, onRequestRemoveSaveLocation = component::removeFromLastDownloadLocation, ) } } val name by component.name.collectAsState() Spacer(Modifier.size(8.dp)) NameTextField( text = name, setText = { component.setName(it) }, errorText = when (canAddResult) { is CanAddResult.DownloadAlreadyExists -> { if (onDuplicateStrategy == null) { myStringResource(Res.string.download_already_exists) } else { null } } CanAddResult.InvalidFileName -> myStringResource(Res.string.invalid_file_name) else -> null }.takeIf { name.isNotEmpty() } ) } } Column { Space() Row( verticalAlignment = Alignment.CenterVertically, ) { RenderFileTypeAndSize(component) RenderResumeSupport(component, Modifier.weight(1f)) ConfigActionsButtons(component) } Space() MainActionButtons(component) ShowSolutionsOnDuplicateDownload(component) ShowAddToQueueDialog( isOpened = component.shouldShowAddToQueue, queueList = component.queues.collectAsState().value, onClose = { component.shouldShowAddToQueue = false }, onQueueSelected = { queue, startQueue -> component.onRequestAddToQueue(queue, startQueue) }, newQueueAction = component.newQueuesAction ) ExtraConfig( isOpened = component.showMoreSettings, onDismiss = { component.showMoreSettings = false }, configurables = component.configurables, ) } } } } @Composable private fun Space() { Spacer(Modifier.size(mySpacings.mediumSpace)) } @Composable private fun ShowSolutionsOnDuplicateDownload( component: AndroidAddSingleDownloadComponent, ) { val state = rememberResponsiveDialogState(false) val isOpen = component.showSolutionsOnDuplicateDownloadUi val onRequestClose = { component.showSolutionsOnDuplicateDownloadUi = false } state.OnFullyDismissed(onRequestClose) LaunchedEffect(isOpen) { if (isOpen) { state.show() } else { state.hide() } } val onDuplicateStrategy by component.onDuplicateStrategy.collectAsState() ResponsiveDialog( onDismiss = state::hide, state = state, ) { SheetUI( header = { SheetHeader( headerTitle = { SheetTitleWithDescription( myStringResource(Res.string.select_a_solution), myStringResource(Res.string.select_download_strategy_description), ) } ) }, content = { Column( Modifier .padding(horizontal = 8.dp) .padding(bottom = 8.dp) ) { Spacer(Modifier.height(4.dp)) Divider() Spacer(Modifier.height(4.dp)) Column { OnDuplicateStrategySolutionItem( isSelected = onDuplicateStrategy == OnDuplicateStrategy.AddNumbered, title = myStringResource(Res.string.download_strategy_add_a_numbered_file), description = myStringResource(Res.string.download_strategy_add_a_numbered_file_description), ) { component.setOnDuplicateStrategy(OnDuplicateStrategy.AddNumbered) onRequestClose() } OnDuplicateStrategySolutionItem( isSelected = onDuplicateStrategy == OnDuplicateStrategy.OverrideDownload, title = myStringResource(Res.string.download_strategy_override_existing_file), description = myStringResource(Res.string.download_strategy_override_existing_file_description), ) { component.setOnDuplicateStrategy(OnDuplicateStrategy.OverrideDownload) onRequestClose() } OnDuplicateStrategySolutionItem( isSelected = null, title = myStringResource(Res.string.download_strategy_update_download_link), description = myStringResource(Res.string.download_strategy_update_download_link_description), ) { component.updateDownloadCredentialsOfOriginalDownload() onRequestClose() } OnDuplicateStrategySolutionItem( isSelected = null, title = myStringResource(Res.string.download_strategy_show_downloaded_file), description = myStringResource(Res.string.download_strategy_show_downloaded_file_description), ) { component.openDownloadFileForCurrentLink() onRequestClose() } } } } ) } } @Composable private fun OnDuplicateStrategySolutionItem( title: String, description: String, isSelected: Boolean?, onClick: () -> Unit, ) { Row( verticalAlignment = Alignment.CenterVertically, modifier = Modifier .fillMaxWidth() .clickable(onClick = onClick) .padding(8.dp) ) { isSelected?.let { CheckBox(isSelected, { onClick() }, size = 12.dp) } Spacer(Modifier.width(8.dp)) Column { Text( title, fontSize = myTextSizes.base, fontWeight = FontWeight.Bold ) Spacer(Modifier.height(4.dp)) WithContentAlpha(0.7f) { Text( text = description, fontSize = myTextSizes.sm, modifier = Modifier ) } } } } @Composable private fun Divider() { Spacer( Modifier .fillMaxWidth() .height(1.dp) .background(myColors.onBackground / 10), ) } @Composable fun RenderResumeSupport( component: AndroidAddSingleDownloadComponent, modifier: Modifier, ) { val fileInfo by component.linkResponseInfo.collectAsState() Row( verticalAlignment = Alignment.CenterVertically, modifier = modifier .height(16.dp) .padding(horizontal = 8.dp) ) { val lineModifier = Modifier .weight(1f) .height(1.dp) .background(myColors.onBackground / 10) Box(lineModifier) val canAddToDownloads by component.canAddToDownloads.collectAsState() AnimatedVisibility( visible = canAddToDownloads && fileInfo != null, ) { fileInfo?.let { fileInfo -> if (fileInfo.resumeSupport) { val iconModifier = Modifier .padding(horizontal = 8.dp) .size(16.dp) if (fileInfo.resumeSupport) { MyIcon( icon = MyIcons.check, contentDescription = null, modifier = iconModifier, tint = myColors.success ) } else { MyIcon( icon = MyIcons.clear, contentDescription = null, modifier = iconModifier, tint = myColors.error, ) } } } } Box(lineModifier) } } @Composable private fun MainConfigActionButton( text: String, modifier: Modifier, enabled: Boolean = true, onClick: () -> Unit, ) { ActionButton(text, modifier, enabled, onClick) } @Composable fun ConfigActionsButtons(component: AndroidAddSingleDownloadComponent) { val responseInfo by component.linkResponseInfo.collectAsState() Row { IconActionButton(MyIcons.refresh, Res.string.refresh.asStringSource()) { component.refresh() } Spacer(Modifier.width(6.dp)) val showMoreInputs by component.showMoreInputs.collectAsState() IconActionButton( if (showMoreInputs) { MyIcons.up } else { MyIcons.down }, Res.string.more_options.asStringSource(), ) { component.setShowMoreInputs(!showMoreInputs) } Spacer(Modifier.width(6.dp)) IconActionButton( MyIcons.settings, Res.string.settings.asStringSource(), indicateActive = component.showMoreSettings, requiresAttention = responseInfo?.requireBasicAuth ?: false ) { component.showMoreSettings = true } } } @Composable private fun MainActionButtons(component: AndroidAddSingleDownloadComponent) { val onDuplicateStrategy by component.onDuplicateStrategy.collectAsState() val canAddResult by component.canAddResult.collectAsState() if (canAddResult is CanAddResult.DownloadAlreadyExists && onDuplicateStrategy == null) { Row { val buttonModifier = Modifier.weight(1f) MainConfigActionButton( text = myStringResource(Res.string.show_solutions), modifier = buttonModifier, onClick = { component.showSolutionsOnDuplicateDownloadUi = true }, ) if (component.shouldShowOpenFile.collectAsState().value) { Spacer(Modifier.width(8.dp)) MainConfigActionButton( text = myStringResource(Res.string.open_file), modifier = buttonModifier, onClick = { component.openExistingFile() }, ) } } } else { val canAddToDownloads by component.canAddToDownloads.collectAsState() Column { if (onDuplicateStrategy != null) { MainConfigActionButton( text = myStringResource(Res.string.change_solution), modifier = Modifier.fillMaxWidth(), onClick = { component.showSolutionsOnDuplicateDownloadUi = true }, ) Space() } val isWebPage by component.isWebPage.collectAsState() if (isWebPage) { Row { MainConfigActionButton( text = myStringResource(Res.string.open_in_browser), modifier = Modifier.fillMaxWidth(), enabled = canAddToDownloads, onClick = { component.onRequestOpenLinkInBrowser() }, ) } } else { Row { val buttonModifier = Modifier.weight(1f) MainConfigActionButton( text = myStringResource(Res.string.add), modifier = buttonModifier, enabled = canAddToDownloads, onClick = { component.shouldShowAddToQueue = true }, ) Spacer(Modifier.width(8.dp)) PrimaryMainActionButton( text = myStringResource(Res.string.download), modifier = buttonModifier, enabled = canAddToDownloads, onClick = { component.onRequestDownload() }, ) } } } } } @Composable fun RenderFileTypeAndSize( component: AndroidAddSingleDownloadComponent, ) { val isLinkLoading by component.isLinkLoading.collectAsState() val fileInfo by component.linkResponseInfo.collectAsState() val fileIconProvider = component.iconProvider val iconModifier = Modifier.size(mySpacings.iconSize) Box( contentAlignment = Alignment.Center, ) { AnimatedContent( targetState = isLinkLoading, transitionSpec = { fadeIn() togetherWith fadeOut() } ) { loading -> if (loading) { LoadingIndicator(iconModifier) } else { // val extension = getExtension(fileInfo?.fileName ?: usersSetFileName) ?: "unknown" val downloadItem by component.downloadItem.collectAsState() val icon = fileIconProvider.rememberIcon(downloadItem.name) AnimatedContent( fileInfo, ) { fileInfo -> Row( verticalAlignment = Alignment.CenterVertically, ) { WithContentAlpha(1f) { if (fileInfo != null) { if (fileInfo.requiresAuth) { MyIcon( MyIcons.lock, null, iconModifier, tint = myColors.error ) } MyIcon( icon, null, iconModifier ) val size = component.getLengthString() Spacer(Modifier.width(8.dp)) Text( size.rememberString(), fontSize = myTextSizes.sm, ) } else { MyIcon( icon = MyIcons.question, contentDescription = null, modifier = iconModifier, ) } } } } } } } } @Composable private fun UrlTextField( text: String, setText: (String) -> Unit, errorText: String? = null, modifier: Modifier = Modifier, ) { MyTextFieldWithIcons( text, setText, myStringResource(Res.string.download_link), modifier = modifier.fillMaxWidth(), end = { MyTextFieldIcon(MyIcons.paste) { setText( ClipboardUtil.read() .orEmpty() ) } }, errorText = errorText ) } @Composable private fun NameTextField( text: String, setText: (String) -> Unit, errorText: String? = null, ) { MyTextFieldWithIcons( text, setText, myStringResource(Res.string.name), modifier = Modifier.fillMaxWidth(), errorText = errorText, ) } ================================================ FILE: android/app/src/main/kotlin/com/abdownloadmanager/android/pages/add/single/AndroidAddSingleDownloadComponent.kt ================================================ package com.abdownloadmanager.android.pages.add.single import com.abdownloadmanager.shared.action.createNewQueueAction import com.abdownloadmanager.shared.downloaderinui.DownloaderInUi import com.abdownloadmanager.shared.pagemanager.CategoryDialogManager import com.abdownloadmanager.shared.pagemanager.NewQueuePageManager import com.abdownloadmanager.shared.pages.adddownload.AddDownloadCredentialsInUiProps import com.abdownloadmanager.shared.pages.adddownload.ImportOptions import com.abdownloadmanager.shared.pages.adddownload.single.BaseAddSingleDownloadComponent import com.abdownloadmanager.shared.pages.adddownload.single.OnRequestAddSingleItem import com.abdownloadmanager.shared.pages.adddownload.single.OnRequestDownloadSingleItem import com.abdownloadmanager.shared.pages.category.CategoryComponent import com.abdownloadmanager.shared.repository.BaseAppRepository import com.abdownloadmanager.shared.storage.BaseAppSettingsStorage import com.abdownloadmanager.shared.storage.ILastSavedLocationsStorage import com.abdownloadmanager.shared.util.DownloadItemOpener import com.abdownloadmanager.shared.util.DownloadSystem import com.abdownloadmanager.shared.util.FileIconProvider import com.abdownloadmanager.shared.util.category.CategoryManager import com.abdownloadmanager.shared.util.perhostsettings.PerHostSettingsManager import com.abdownloadmanager.shared.util.subscribeAsStateFlow import com.arkivanov.decompose.ComponentContext import com.arkivanov.decompose.router.slot.SlotNavigation import com.arkivanov.decompose.router.slot.activate import com.arkivanov.decompose.router.slot.childSlot import com.arkivanov.decompose.router.slot.dismiss import ir.amirab.downloader.downloaditem.DownloadJobExtraConfig import ir.amirab.downloader.downloaditem.IDownloadCredentials import ir.amirab.downloader.queue.QueueManager import ir.amirab.util.flow.mapStateFlow import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.launch import kotlinx.serialization.builtins.serializer class AndroidAddSingleDownloadComponent( ctx: ComponentContext, onRequestClose: () -> Unit, onRequestDownload: OnRequestDownloadSingleItem, onRequestAddToQueue: OnRequestAddSingleItem, openExistingDownload: (Long) -> Unit, updateExistingDownloadCredentials: (Long, IDownloadCredentials, DownloadJobExtraConfig?) -> Unit, downloadItemOpener: DownloadItemOpener, lastSavedLocationsStorage: ILastSavedLocationsStorage, queueManager: QueueManager, categoryManager: CategoryManager, downloadSystem: DownloadSystem, appSettings: BaseAppSettingsStorage, iconProvider: FileIconProvider, appScope: CoroutineScope, appRepository: BaseAppRepository, perHostSettingsManager: PerHostSettingsManager, importOptions: ImportOptions, id: String, downloaderInUi: DownloaderInUi, initialCredentials: AddDownloadCredentialsInUiProps, ) : BaseAddSingleDownloadComponent( ctx = ctx, onRequestClose = onRequestClose, onRequestDownload = onRequestDownload, onRequestAddToQueue = onRequestAddToQueue, openExistingDownload = openExistingDownload, updateExistingDownloadCredentials = updateExistingDownloadCredentials, downloadItemOpener = downloadItemOpener, lastSavedLocationsStorage = lastSavedLocationsStorage, importOptions = importOptions, id = id, downloaderInUi = downloaderInUi, initialCredentials = initialCredentials, queueManager = queueManager, categoryManager = categoryManager, downloadSystem = downloadSystem, appSettings = appSettings, iconProvider = iconProvider, appScope = appScope, appRepository = appRepository, perHostSettingsManager = perHostSettingsManager, ), CategoryDialogManager, NewQueuePageManager { val categoryComponentNavigation = SlotNavigation() val categorySlot = childSlot( source = categoryComponentNavigation, childFactory = { config, ctx -> CategoryComponent( ctx = ctx, id = config, close = ::closeCategoryDialog, submit = { submittedCategory -> if (submittedCategory.id < 0) { categoryManager.addCustomCategory(submittedCategory) } else { categoryManager.updateCategory( submittedCategory.id ) { submittedCategory.copy( items = it.items ) } } closeCategoryDialog() }, ) }, serializer = Long.serializer(), ).subscribeAsStateFlow() val newQueuesAction = createNewQueueAction( appScope, this, ) override fun openCategoryDialog(categoryId: Long) { scope.launch { categoryComponentNavigation.activate(categoryId) } } override fun closeCategoryDialog() { scope.launch { categoryComponentNavigation.dismiss() } } override fun getCategoryPageManager(): CategoryDialogManager { return this } private val _showMoreInputs = MutableStateFlow(false) val showMoreInputs = _showMoreInputs.asStateFlow() fun setShowMoreInputs(value: Boolean) { _showMoreInputs.value = value } private val _showAddQueue = MutableStateFlow(false) val showAddQueue = _showAddQueue.asStateFlow() fun setShowAddQueue(value: Boolean) { _showAddQueue.value = value } val isWebPage = downloadChecker .responseInfo .mapStateFlow { it?.isWebPage ?: false } fun createQueueWithName(name: String) { scope.launch { queueManager.addQueue(name) } setShowAddQueue(false) } override fun closeNewQueueDialog() { setShowAddQueue(false) } override fun openNewQueueDialog() { setShowAddQueue(true) } fun onRequestOpenLinkInBrowser() { sendEffect( Effects.OpenInBrowser( downloadChecker.credentials.value.link ) ) } sealed interface Effects : BaseAddSingleDownloadComponent.Effects.Platform { data class OpenInBrowser(val link: String) : Effects } } ================================================ FILE: android/app/src/main/kotlin/com/abdownloadmanager/android/pages/batchdownload/AndroidBatchDownloadComponent.kt ================================================ package com.abdownloadmanager.android.pages.batchdownload import com.abdownloadmanager.shared.pages.batchdownload.BaseBatchDownloadComponent import com.arkivanov.decompose.ComponentContext class AndroidBatchDownloadComponent( ctx: ComponentContext, onClose: () -> Unit, importLinks: (List) -> Unit, ) : BaseBatchDownloadComponent( ctx = ctx, onClose = onClose, importLinks = importLinks ) { sealed interface Effects : BaseBatchDownloadComponent.Effects.PlatformEffects { // nothing for now } } ================================================ FILE: android/app/src/main/kotlin/com/abdownloadmanager/android/pages/batchdownload/BatchDownloadPage.kt ================================================ package com.abdownloadmanager.android.pages.batchdownload import androidx.compose.animation.AnimatedVisibility import androidx.compose.foundation.* import androidx.compose.foundation.layout.* import androidx.compose.foundation.text.KeyboardOptions import androidx.compose.runtime.* import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip import androidx.compose.ui.focus.FocusRequester import androidx.compose.ui.focus.focusRequester import androidx.compose.ui.unit.dp import com.abdownloadmanager.android.ui.SheetHeader import com.abdownloadmanager.android.ui.SheetTitle import com.abdownloadmanager.android.ui.SheetUI import com.abdownloadmanager.shared.util.ui.icon.MyIcons import com.abdownloadmanager.shared.ui.widget.* import com.abdownloadmanager.shared.util.ui.myColors import com.abdownloadmanager.shared.util.ClipboardUtil import com.abdownloadmanager.shared.util.div import com.abdownloadmanager.resources.Res import com.abdownloadmanager.shared.pages.batchdownload.BatchDownloadValidationResult import com.abdownloadmanager.shared.pages.batchdownload.WildcardLength import com.abdownloadmanager.shared.util.OnFullyDismissed import com.abdownloadmanager.shared.util.ResponsiveDialog import com.abdownloadmanager.shared.util.ResponsiveDialogScope import com.abdownloadmanager.shared.util.rememberResponsiveDialogState import com.abdownloadmanager.shared.util.ui.LocalContentColor import com.abdownloadmanager.shared.util.ui.VerticalScrollableContent import com.abdownloadmanager.shared.util.ui.theme.myShapes import ir.amirab.util.compose.resources.myStringResource import ir.amirab.util.compose.StringSource import ir.amirab.util.compose.asStringSource @Composable fun BatchDownloadSheet( component: AndroidBatchDownloadComponent?, onDismiss: () -> Unit, ) { val state = rememberResponsiveDialogState(false) state.OnFullyDismissed(onDismiss) LaunchedEffect(component) { if (component == null) { state.hide() } else { state.show() } } ResponsiveDialog(state, state::hide) { component?.let { BatchDownloadPage(component, state::hide) } } } @Composable private fun ResponsiveDialogScope.BatchDownloadPage( component: AndroidBatchDownloadComponent, onDismiss: () -> Unit, ) { val link by component.link.collectAsState() val setLink = component::setLink val start by component.start.collectAsState() val setStart = component::setStart val end by component.end.collectAsState() val setEnd = component::setEnd val scrollState = rememberScrollState() val validationResult by component.validationResult.collectAsState() val linkFocusRequester = remember { FocusRequester() } LaunchedEffect(Unit) { linkFocusRequester.requestFocus() } SheetUI( header = { SheetHeader( headerTitle = { SheetTitle( myStringResource(Res.string.batch_download) ) } ) } ) { Column( Modifier .padding(16.dp) ) { VerticalScrollableContent( scrollState, modifier = Modifier.weight(1f, false), ) { Column( modifier = Modifier .verticalScroll(scrollState) ) { LabeledContent( label = { Text(myStringResource(Res.string.batch_download_link_help)) }, content = { MyTextFieldWithIcons( text = link, onTextChange = setLink, placeholder = "https://example.com/photo-*.png", modifier = Modifier .focusRequester(linkFocusRequester) .fillMaxWidth(), start = { MyTextFieldIcon(MyIcons.link) }, end = { MyTextFieldIcon(MyIcons.paste, onClick = { val v = ClipboardUtil.read() if (v != null) { setLink(v) } }) }, errorText = when (val v = validationResult) { BatchDownloadValidationResult.URLInvalid -> { myStringResource(Res.string.invalid_url) } is BatchDownloadValidationResult.MaxRangeExceed -> myStringResource( Res.string.list_is_too_large_maximum_n_items_allowed, Res.string.list_is_too_large_maximum_n_items_allowed_createArgs( count = v.allowed.toString() ) ) BatchDownloadValidationResult.Others -> null BatchDownloadValidationResult.Ok -> null } ) } ) Spacer(Modifier.height(8.dp)) LabeledContent( label = { Text(myStringResource(Res.string.enter_range)) }, content = { Row( verticalAlignment = Alignment.CenterVertically ) { MyTextFieldWithIcons( text = start, onTextChange = setStart, placeholder = "", modifier = Modifier.width(90.dp), start = { Text( "${myStringResource(Res.string.range_from)}:", Modifier.padding(horizontal = 8.dp) ) } ) Spacer(Modifier.width(8.dp)) Text("...") Spacer(Modifier.width(8.dp)) MyTextFieldWithIcons( text = end, onTextChange = setEnd, placeholder = "", modifier = Modifier.width(90.dp), start = { Text( "${myStringResource(Res.string.range_to)}:", Modifier.padding(horizontal = 8.dp) ) } ) } } ) Spacer(Modifier.height(8.dp)) LabeledContent( label = { Text(myStringResource(Res.string.batch_download_wildcard_length)) }, content = { WildcardLengthUi( component.wildcardLength.collectAsState().value, component::setWildCardLength ) } ) Spacer(Modifier.height(8.dp)) Row( verticalAlignment = Alignment.CenterVertically ) { val lineModifier = Modifier .height(1.dp) .padding(horizontal = 5.dp) .background(LocalContentColor.current.copy(0.05f)) Spacer( Modifier .padding(vertical = 4.dp) .fillMaxWidth() .then(lineModifier) ) } Spacer(Modifier.height(8.dp)) LabeledContent( label = { Text(myStringResource(Res.string.first_link)) }, content = { LinkPreview(component.startLinkResult.collectAsState().value) } ) Spacer(Modifier.height(8.dp)) LabeledContent( label = { Text(myStringResource(Res.string.last_link)) }, content = { LinkPreview(component.endLinkResult.collectAsState().value) } ) } } Spacer(Modifier.height(8.dp)) Row( modifier = Modifier .fillMaxWidth() ) { val buttonModifier = Modifier.weight(1f) ActionButton( myStringResource(Res.string.close), onClick = onDismiss, modifier = buttonModifier, ) Spacer(Modifier.width(8.dp)) ActionButton( text = myStringResource(Res.string.ok), enabled = component.canConfirm.collectAsState().value, onClick = component::confirm, modifier = buttonModifier, ) } } } } @Composable fun LinkPreview(link: String) { Text( link, Modifier .fillMaxWidth() .clip(myShapes.defaultRounded) .border(1.dp, myColors.onSurface / 0.1f, myShapes.defaultRounded) // .background(myColors.surface) .padding(vertical = 4.dp, horizontal = 6.dp) ) } enum class WildcardSelect( val text: StringSource, ) { Auto(Res.string.auto.asStringSource()), Unspecified(Res.string.unspecified.asStringSource()), Custom(Res.string.custom.asStringSource()); companion object { fun fromWildcardLength(wildcardLength: WildcardLength): WildcardSelect { return when (wildcardLength) { WildcardLength.Auto -> Auto is WildcardLength.Custom -> Custom WildcardLength.Unspecified -> Unspecified } } } } @Composable private fun WildcardLengthUi( wildcardLength: WildcardLength, onChangeWildcardLength: (WildcardLength) -> Unit, ) { var customLength by remember { mutableIntStateOf(2) } FlowRow( itemVerticalAlignment = Alignment.CenterVertically ) { Multiselect( selections = WildcardSelect.entries, selectedItem = WildcardSelect.fromWildcardLength(wildcardLength), onSelectionChange = { onChangeWildcardLength( when (it) { WildcardSelect.Auto -> WildcardLength.Auto WildcardSelect.Unspecified -> WildcardLength.Unspecified WildcardSelect.Custom -> WildcardLength.Custom(customLength) } ) }, render = { Text(it.text.rememberString()) } ) AnimatedVisibility(wildcardLength is WildcardLength.Custom) { Row { Spacer(Modifier.width(8.dp)) IntTextField( value = customLength, onValueChange = { customLength = it onChangeWildcardLength( WildcardLength.Custom(it) ) }, range = 1..10, keyboardOptions = KeyboardOptions.Default, modifier = Modifier.width(96.dp) ) } } } } @Composable private fun LabeledContent( label: @Composable () -> Unit, content: @Composable () -> Unit, ) { Column { label() Spacer(Modifier.height(8.dp)) content() } } ================================================ FILE: android/app/src/main/kotlin/com/abdownloadmanager/android/pages/browser/BrowserActivity.kt ================================================ package com.abdownloadmanager.android.pages.browser import android.content.ComponentName import android.content.Context import android.content.Intent import android.content.pm.PackageManager import android.os.Bundle import com.abdownloadmanager.android.util.AndroidIntentUtils import com.abdownloadmanager.android.util.activity.ABDMActivity import com.abdownloadmanager.shared.util.mvi.HandleEffects import com.arkivanov.decompose.defaultComponentContext import ir.amirab.util.HttpUrlUtils import kotlinx.serialization.json.Json import org.koin.core.component.KoinComponent import org.koin.core.component.inject import androidx.core.net.toUri import com.abdownloadmanager.android.storage.BrowserBookmarksStorage class BrowserActivity : ABDMActivity() { private val browserBookmarksStorage: BrowserBookmarksStorage by inject() private val json: Json by inject() val component by lazy { BrowserComponent( componentContext = defaultComponentContext(), context = applicationContext, json = json, browserBookmarksStorage = browserBookmarksStorage, ) } override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) setABDMContent { HandleEffects(component) { when (it) { is BrowserComponent.Effects.StartActivity -> { startActivity(it.intent) } is BrowserComponent.Effects.ShareText -> { AndroidIntentUtils.shareText(this, it.text) } } } BrowserPage(component) } } override fun handleIntent(intent: Intent) { if (intent.action == Intent.ACTION_VIEW) { val url = intent.data?.toString() if (url != null && HttpUrlUtils.isValidUrl(url)) { component.newTab(url) } } } companion object { fun createIntent( context: Context, url: String? = null, ): Intent { return Intent(context, BrowserActivity::class.java).apply { action = Intent.ACTION_VIEW data = url?.toUri() } } object Launcher : KoinComponent { private val context: Context by inject() private val browserLauncherActivityAliasName by lazy { "com.abdownloadmanager.browser.BrowserIconInLauncher" } fun setEnabled( isEnabled: Boolean, ) { val newState = if (isEnabled) { PackageManager.COMPONENT_ENABLED_STATE_ENABLED } else { PackageManager.COMPONENT_ENABLED_STATE_DISABLED } context.packageManager.setComponentEnabledSetting( ComponentName(context, browserLauncherActivityAliasName), newState, PackageManager.DONT_KILL_APP ) } fun isEnabled(): Boolean { return context.packageManager.getComponentEnabledSetting( ComponentName(context, browserLauncherActivityAliasName), ) == PackageManager.COMPONENT_ENABLED_STATE_ENABLED } } } } ================================================ FILE: android/app/src/main/kotlin/com/abdownloadmanager/android/pages/browser/BrowserComponent.kt ================================================ package com.abdownloadmanager.android.pages.browser import android.content.Context import android.content.Intent import androidx.compose.runtime.Stable import com.abdownloadmanager.android.pages.add.multiple.AddMultiDownloadActivity import com.abdownloadmanager.android.pages.add.single.AddSingleDownloadActivity import com.abdownloadmanager.android.pages.browser.bookmark.EditBookmarkState import com.abdownloadmanager.android.storage.BrowserBookmark import com.abdownloadmanager.android.storage.BrowserBookmarksStorage import com.abdownloadmanager.android.ui.widget.WebContent import com.abdownloadmanager.android.ui.widget.WebViewState import com.abdownloadmanager.resources.Res import com.abdownloadmanager.shared.pages.adddownload.AddDownloadConfig import com.abdownloadmanager.shared.util.BaseComponent import com.abdownloadmanager.shared.util.ClipboardUtil import com.abdownloadmanager.shared.util.mvi.ContainsEffects import com.abdownloadmanager.shared.util.mvi.supportEffects import com.abdownloadmanager.shared.util.ui.icon.MyIcons import com.arkivanov.decompose.ComponentContext import ir.amirab.util.HttpUrlUtils import ir.amirab.util.compose.action.AnAction import ir.amirab.util.compose.action.MenuItem import ir.amirab.util.compose.action.buildMenu import ir.amirab.util.compose.action.simpleAction import ir.amirab.util.compose.asStringSource import ir.amirab.util.ifThen import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.update import kotlinx.serialization.json.Json import java.util.UUID import kotlin.text.orEmpty class BrowserComponent( componentContext: ComponentContext, private val context: Context, private val json: Json, private val browserBookmarksStorage: BrowserBookmarksStorage, ) : BaseComponent( componentContext, ), ContainsEffects by supportEffects() { val downloadInterceptor = DownloadInterceptor( scope, { val intent = when (it.size) { 0 -> null 1 -> AddSingleDownloadActivity.createIntent( context, AddDownloadConfig.SingleAddConfig(it.first()), json, ) else -> AddMultiDownloadActivity.createIntent( context, AddDownloadConfig.MultipleAddConfig( it ), json ) } intent?.let { intent -> sendEffect(Effects.StartActivity(intent)) } } ) private val currentSearchEngine = MutableStateFlow( SearchEngines.DuckDuckGo ) val tabs = MutableStateFlow( ABDMTabs.createDefault() ) val bookmarks = browserBookmarksStorage.bookmarksFlow private val _mainMenu: MutableStateFlow = MutableStateFlow(null) val mainMenu = _mainMenu.asStateFlow() fun openMainMenu() { val tab = tabs.value.activeTab val url = tab?.tabState?.lastLoadedUrl val title = tab?.tabState?.pageTitle _mainMenu.value = MenuItem.SubMenu( title = title?.asStringSource() ?: Res.string.menu.asStringSource(), items = buildMenu { +createNewTabAction() separator() +createShowBookmarksAction() if (url != null) { if (isBookmarked(url)) { +createRemoveFromBookmarkAction(url) } else { +createAddToBookmarkAction(url, title) } } tab?.let { separator() +createCloseTabAction(it) } } ) } fun closeMainMenu() { _mainMenu.value = null } fun newTab( url: String? = ABDMBrowserTab.blankPage, switch: Boolean = true, id: String = UUID.randomUUID().toString(), openedBy: ABDMBrowserTabId? = null, ): ABDMBrowserTab { val browserTab = ABDMBrowserTab( tabId = id, tabState = WebViewState(WebContent.fromNullableUrl(url)), ) tabs.update { currentTabState -> val newTabPosition = openedBy?.let { // index of openedBy + 1 or null if not found currentTabState.tabs .indexOfFirst { it.tabId == openedBy } .takeIf { it >= 0 } ?.plus(1) } ?: currentTabState.tabs.size val newItems = buildList { addAll(currentTabState.tabs) add(newTabPosition, browserTab) } val newIndex = if (switch) { newTabPosition } else { currentTabState.activeTabIndex } currentTabState.copy( tabs = newItems, activeTabIndex = newIndex ) } return browserTab } fun closeTab(tabId: ABDMBrowserTabId) { tabs.update { val newItems = it.tabs.filterNot { it.tabId == tabId } it.copy( tabs = newItems, activeTabIndex = runCatching { it.activeTabIndex.coerceIn(newItems.indices) }.getOrElse { -1 }, ) } } fun addToBookmarks( bookmark: BrowserBookmark, replaceWith: BrowserBookmark?, ) { browserBookmarksStorage.bookmarksFlow.update { currentBookmarks -> if (replaceWith != null) { currentBookmarks.map { item -> item.ifThen(item == replaceWith) { bookmark } } } else { currentBookmarks.plus(bookmark) } } } private val _showBookmarkList: MutableStateFlow = MutableStateFlow(false) val showBookmarkList = _showBookmarkList.asStateFlow() fun setShowBookmarkList(show: Boolean) { _showBookmarkList.value = show } private val _editBookmarkState = MutableStateFlow(null) val editBookmarkState = _editBookmarkState.asStateFlow() fun promptAddBookmark( bookmark: BrowserBookmark, ) { _editBookmarkState.value = EditBookmarkState( initialValue = bookmark, editMode = false, ) } fun promptEditBookmark( bookmark: BrowserBookmark ) { _editBookmarkState.value = EditBookmarkState( initialValue = bookmark, editMode = true, ) } fun dismissEditBookmark() { _editBookmarkState.value = null } fun removeBookmark(url: String) { browserBookmarksStorage.bookmarksFlow.update { it.filterNot { bookmark -> bookmark.url == url } } } fun clearBookmarks() { browserBookmarksStorage.bookmarksFlow.value = emptyList() } fun isBookmarked(url: String): Boolean { return browserBookmarksStorage.bookmarksFlow.value.find { it.url == url } != null } fun switchTab(tabId: ABDMBrowserTabId) { tabs.update { val tabIndex = it.tabs.indexOfFirst { it.tabId == tabId } val newIndex = if (tabIndex < 0) { it.activeTabIndex } else { tabIndex } it.copy( activeTabIndex = newIndex, ) } } private val websiteAndTLD by lazy { """^[\w.-]+\.[a-zA-Z]{2,}(:\d{1,5})?$""".toRegex() } fun createNewUrlFor(urlOrSearch: String): String { val value = urlOrSearch.trim() if (value.contains(' ')) { return createSearchEngineUrl(value) } if (HttpUrlUtils.isValidUrl(value)) { return value } if (websiteAndTLD.matches(value)) { val withHttpScheme = "https://$value" if (HttpUrlUtils.isValidUrl(withHttpScheme)) { return withHttpScheme } } return createSearchEngineUrl(value) } private fun createSearchEngineUrl(searchText: String): String { return currentSearchEngine.value.createSearchUrl(searchText) } val contextMenu: MutableStateFlow = MutableStateFlow(null) fun closeContextMenu() { contextMenu.value = null } fun onLinkSelected( link: String, tab: ABDMBrowserTab, ) { contextMenu.value = MenuItem.SubMenu( title = link.asStringSource(), items = buildMenu { +simpleAction( Res.string.browser_open_in_new_tab.asStringSource(), MyIcons.file, ) { newTab( url = link, switch = true, openedBy = tab.tabId, ) } +simpleAction( Res.string.browser_open_in_new_background_tab.asStringSource(), MyIcons.file, ) { newTab( url = link, switch = false, openedBy = tab.tabId, ) } +simpleAction( Res.string.share.asStringSource(), MyIcons.share, ) { sendEffect(Effects.ShareText(link)) } +simpleAction( Res.string.copy.asStringSource(), MyIcons.copy, ) { ClipboardUtil.copy(link) } +simpleAction( Res.string.download.asStringSource(), MyIcons.download, ) { downloadInterceptor.onDownloadStart( url = link, userAgent = null, page = null, tab = tab, ) } if (isBookmarked(link)) { +createRemoveFromBookmarkAction(link) } else { +createAddToBookmarkAction(link, null) } } ) } fun createNewTabAction(): AnAction { return simpleAction( title = Res.string.browser_new_tab.asStringSource(), icon = MyIcons.file, ) { newTab( url = null, switch = true, ) } } fun createAddToBookmarkAction( url: String, title: String?, ): AnAction { return simpleAction( Res.string.browser_add_to_bookmarks.asStringSource(), MyIcons.add, ) { promptAddBookmark( BrowserBookmark( url = url, title = title.orEmpty(), ) ) } } fun createRemoveFromBookmarkAction( url: String, ): AnAction { return simpleAction( Res.string.browser_remove_from_bookmarks.asStringSource(), MyIcons.remove, ) { removeBookmark(url) } } fun createCloseTabAction(tab: ABDMBrowserTab): AnAction { return simpleAction( title = Res.string.browser_close_tab.asStringSource(), icon = MyIcons.close, ) { closeTab(tab.tabId) } } fun createShowBookmarksAction(): AnAction { return simpleAction( title = Res.string.browser_bookmarks.asStringSource(), icon = MyIcons.hearth, ) { setShowBookmarkList(true) } } sealed interface Effects { data class StartActivity( val intent: Intent ) : Effects data class ShareText( val text: String, ) : Effects } } typealias ABDMBrowserTabId = String @Stable data class ABDMBrowserTab( val tabId: ABDMBrowserTabId, val tabState: WebViewState, ) { companion object { fun createDefaultTab( page: String = blankPage ) = ABDMBrowserTab( tabId = UUID.randomUUID().toString(), tabState = WebViewState(WebContent.Url(page)), ) val blankPage = "about:blank" } } @Stable data class ABDMTabs( val tabs: List, val activeTabIndex: Int, ) { val tabsSize = tabs.size val activeTab get() = if (activeTabIndex == -1) null else tabs[activeTabIndex] companion object { fun createDefault(): ABDMTabs = ABDMTabs( listOf(), -1, ) } } ================================================ FILE: android/app/src/main/kotlin/com/abdownloadmanager/android/pages/browser/BrowserUi.kt ================================================ package com.abdownloadmanager.android.pages.browser import androidx.activity.compose.BackHandler import androidx.compose.foundation.Image import androidx.compose.foundation.background import androidx.compose.foundation.border import androidx.compose.foundation.clickable import androidx.compose.foundation.interaction.MutableInteractionSource import androidx.compose.foundation.interaction.collectIsFocusedAsState import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxHeight import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.heightIn import androidx.compose.foundation.layout.imePadding import androidx.compose.foundation.layout.navigationBarsPadding import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.sizeIn import androidx.compose.foundation.layout.statusBarsPadding import androidx.compose.foundation.layout.width import androidx.compose.foundation.layout.wrapContentHeight import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.items import androidx.compose.foundation.text.KeyboardActions import androidx.compose.foundation.text.KeyboardOptions import androidx.compose.runtime.Composable import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip import androidx.compose.ui.focus.FocusRequester import androidx.compose.ui.focus.focusRequester import androidx.compose.ui.graphics.ImageBitmap import androidx.compose.ui.graphics.asImageBitmap import androidx.compose.ui.semantics.Role import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.input.ImeAction import androidx.compose.ui.text.input.KeyboardType import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp import com.abdownloadmanager.android.pages.browser.bookmark.BookmarkList import com.abdownloadmanager.android.pages.browser.bookmark.EditBookmarkSheet import com.abdownloadmanager.android.storage.BrowserBookmark import com.abdownloadmanager.android.ui.SheetHeader import com.abdownloadmanager.android.ui.SheetTitle import com.abdownloadmanager.android.ui.SheetUI import com.abdownloadmanager.android.ui.menu.RenderMenuInSheet import com.abdownloadmanager.android.ui.page.PageFooter import com.abdownloadmanager.android.ui.page.PageHeader import com.abdownloadmanager.android.ui.page.PageTitle import com.abdownloadmanager.android.ui.page.PageUi import com.abdownloadmanager.android.ui.widget.LoadingState import com.abdownloadmanager.resources.Res import com.abdownloadmanager.shared.ui.widget.ActionButton import com.abdownloadmanager.shared.ui.widget.MyTextField import com.abdownloadmanager.shared.ui.widget.Text import com.abdownloadmanager.shared.ui.widget.TransparentIconActionButton import com.abdownloadmanager.shared.util.ClipboardUtil import com.abdownloadmanager.shared.util.ResponsiveDialog import com.abdownloadmanager.shared.util.div import com.abdownloadmanager.shared.util.rememberResponsiveDialogState import com.abdownloadmanager.shared.util.ui.WithContentAlpha import com.abdownloadmanager.shared.util.ui.WithContentColor import com.abdownloadmanager.shared.util.ui.icon.MyIcons import com.abdownloadmanager.shared.util.ui.myColors import com.abdownloadmanager.shared.util.ui.theme.myShapes import com.abdownloadmanager.shared.util.ui.theme.mySpacings import com.abdownloadmanager.shared.util.ui.widget.MyIcon import ir.amirab.util.compose.asStringSource import ir.amirab.util.compose.resources.myStringResource import ir.amirab.util.ifThen @Composable fun BrowserPage( browserComponent: BrowserComponent, ) { val scope = rememberCoroutineScope() val viewRegistry = remember { WebViewRegistry(scope, browserComponent) } DisposableEffect(viewRegistry) { onDispose { viewRegistry.disposeAll() } } val tabs by browserComponent.tabs.collectAsState() val tab = tabs.activeTab val tabWebViewHolder = remember(tab?.tabId) { tab?.let { viewRegistry.getWebViewHolder(it) } } BackHandler(tabs.tabsSize > 1) { tab?.let { browserComponent.closeTab(tab.tabId) } } BackHandler(tabWebViewHolder?.navigator?.canGoBack ?: false) { tabWebViewHolder?.webView?.goBack() } LaunchedEffect(tabs) { viewRegistry.onTabsUpdated(tabs) } PageUi( header = { PageHeader( leadingIcon = { MyIcon( MyIcons.earth, null, Modifier.size(mySpacings.iconSize) ) }, headerTitle = { PageTitle( myStringResource(Res.string.browser) ) }, modifier = Modifier .statusBarsPadding() .padding(horizontal = mySpacings.largeSpace), ) }, footer = { PageFooter { Column( Modifier .background(myColors.surface) .navigationBarsPadding() .imePadding() ) { Spacer( Modifier .height(1.dp) .fillMaxWidth() .background(myColors.onSurface / 0.1f) ) tab?.tabState?.loadingState?.let { if (it is LoadingState.Loading) { Box( Modifier.fillMaxWidth(), contentAlignment = Alignment.CenterStart ) { Box( Modifier .height(1.dp) .fillMaxWidth(it.progress) .background(myColors.info), ) } } } WithContentColor(myColors.onSurface) { AddressBar( browserComponent = browserComponent, currentWebViewHolder = tabWebViewHolder, tabs = tabs, modifier = Modifier, ) } } } } ) { if (tabWebViewHolder != null) { ABDMWebView( modifier = Modifier .fillMaxSize() .background(myColors.background) .padding(it.paddingValues), webViewHolder = tabWebViewHolder, ) } else { EmptyPage( Modifier .fillMaxSize() .background(myColors.background) .padding(it.paddingValues), onRequestOpenUrlFromClipboard = { ClipboardUtil.read()?.let { browserComponent.newTab( browserComponent.createNewUrlFor(it) ) } }, onRequestOpenBookmarks = { browserComponent.setShowBookmarkList(true) } ) } } RenderMenuInSheet( browserComponent.contextMenu.collectAsState().value, browserComponent::closeContextMenu ) BookmarkList( visible = browserComponent.showBookmarkList.collectAsState().value, onDismissRequest = { browserComponent.setShowBookmarkList(false) }, onRemoveBookmarkRequest = { browserComponent.removeBookmark(it.url) }, onBookmarkClick = { browserComponent.setShowBookmarkList(false) val newLink = browserComponent.createNewUrlFor(it.url) tabWebViewHolder ?.navigator ?.loadUrl(newLink) ?: browserComponent.newTab(newLink) }, bookmarks = browserComponent.bookmarks.collectAsState().value, onRequestEditBookmark = browserComponent::promptEditBookmark, onRequestNewBookmark = { browserComponent.promptAddBookmark((BrowserBookmark("", ""))) }, ) val editBookmarkState by browserComponent.editBookmarkState.collectAsState() editBookmarkState?.let { s -> EditBookmarkSheet( state = s, onSave = { browserComponent.addToBookmarks( it, if (s.editMode) { s.initialValue } else { null }, ) browserComponent.dismissEditBookmark() }, onCancel = { browserComponent.dismissEditBookmark() } ) } RenderMenuInSheet( browserComponent.mainMenu.collectAsState().value, browserComponent::closeMainMenu, ) } @Composable fun EmptyPage( modifier: Modifier, onRequestOpenUrlFromClipboard: () -> Unit, onRequestOpenBookmarks: () -> Unit, ) { Box(modifier) { Column( modifier = Modifier.align(Alignment.Center), horizontalAlignment = Alignment.CenterHorizontally, ) { Text( myStringResource(Res.string.browser_no_tab_open), maxLines = 1, ) Spacer(Modifier.height(mySpacings.largeSpace)) ActionButton( text = myStringResource(Res.string.browser_paste_and_go), onClick = onRequestOpenUrlFromClipboard, start = { MyIcon( MyIcons.paste, null, Modifier.size(mySpacings.iconSize) ) Spacer(Modifier.width(mySpacings.mediumSpace)) } ) Spacer(Modifier.height(mySpacings.largeSpace)) ActionButton( text = myStringResource(Res.string.browser_bookmarks), onClick = onRequestOpenBookmarks, start = { MyIcon( MyIcons.hearth, null, Modifier.size(mySpacings.iconSize) ) Spacer(Modifier.width(mySpacings.mediumSpace)) } ) } } } @Composable fun AddressBar( browserComponent: BrowserComponent, currentWebViewHolder: WebViewHolder?, tabs: ABDMTabs, modifier: Modifier, ) { val webViewState = currentWebViewHolder?.tab?.tabState val navigator = currentWebViewHolder?.navigator val canGoBack = navigator?.canGoBack ?: false val canGoForward = navigator?.canGoForward ?: false val currentURL = webViewState?.lastLoadedUrl val currentTitle = webViewState?.pageTitle var isTabListVisible by remember { mutableStateOf(false) } Column( modifier = modifier .padding(horizontal = mySpacings.mediumSpace) .padding(vertical = mySpacings.mediumSpace) ) { AddressField( currentPageURL = currentURL, currentPageTitle = currentTitle, currentPageIcon = remember(webViewState?.pageIcon) { webViewState?.pageIcon?.asImageBitmap() }, onNewPageRequested = { it?.let { text -> val newLink = browserComponent.createNewUrlFor(text) navigator ?.loadUrl(newLink) ?: browserComponent.newTab(newLink) } } ) Spacer(Modifier.height(mySpacings.mediumSpace)) Row { TransparentIconActionButton( enabled = canGoBack, icon = MyIcons.back, contentDescription = Res.string.back.asStringSource() ) { navigator?.navigateBack() } TransparentIconActionButton( enabled = canGoForward, icon = MyIcons.next, contentDescription = Res.string.next.asStringSource() ) { navigator?.navigateForward() } Spacer(Modifier.width(16.dp)) webViewState?.let { TransparentIconActionButton( icon = if (webViewState.isLoading) { MyIcons.close } else { MyIcons.refresh }, contentDescription = Res.string.next.asStringSource() ) { if (webViewState.isLoading) { navigator?.stopLoading() } else { navigator?.reload() } } } Spacer(Modifier.weight(1f)) val shape = myShapes.defaultRounded Box( Modifier .sizeIn(mySpacings.thumbSize, mySpacings.thumbSize) .clip(shape) .border( 1.dp, myColors.onBackground / 0.1f, shape ) .clickable( role = Role.Button, onClick = { isTabListVisible = !isTabListVisible }, ) .padding(vertical = 8.dp, horizontal = 16.dp), contentAlignment = Alignment.Center, ) { Text( text = "${tabs.tabsSize}", maxLines = 1, fontWeight = FontWeight.Bold, ) } TransparentIconActionButton( MyIcons.menu, contentDescription = Res.string.menu.asStringSource() ) { browserComponent.openMainMenu() } } } TabList( visible = isTabListVisible, onDismissRequest = { isTabListVisible = false }, onCloseTabRequest = { browserComponent.closeTab(it.tabId) }, onTabClick = { isTabListVisible = false browserComponent.switchTab(it.tabId) }, onRequestNewTab = { requestedUrl -> isTabListVisible = false browserComponent.newTab( requestedUrl?.let { browserComponent.createNewUrlFor(it) } ) }, tabs = tabs, currentTabId = currentWebViewHolder?.tab?.tabId, ) } @Composable private fun TabList( visible: Boolean, onDismissRequest: () -> Unit, tabs: ABDMTabs, onRequestNewTab: (String?) -> Unit, onTabClick: (ABDMBrowserTab) -> Unit, onCloseTabRequest: (ABDMBrowserTab) -> Unit, currentTabId: String?, ) { val responsiveState = rememberResponsiveDialogState(visible) LaunchedEffect(visible) { if (visible) { responsiveState.show() } else { responsiveState.hide() } } ResponsiveDialog( state = responsiveState, onDismiss = onDismissRequest ) { SheetUI( header = { SheetHeader( headerTitle = { SheetTitle( myStringResource(Res.string.browser_tabs), ) }, headerActions = { TransparentIconActionButton( MyIcons.paste, Res.string.paste.asStringSource(), ) { onRequestNewTab( ClipboardUtil.read() ) } TransparentIconActionButton( MyIcons.add, Res.string.add.asStringSource(), ) { onRequestNewTab(null) } TransparentIconActionButton( MyIcons.close, Res.string.close.asStringSource(), ) { onDismissRequest() } } ) } ) { LazyColumn { items(tabs.tabs) { tabItem -> val isSelected = tabItem.tabId == currentTabId Row( modifier = Modifier .heightIn(mySpacings.thumbSize) .ifThen(isSelected) { background(myColors.onBackground / 0.1f) } .clickable { onTabClick(tabItem) } .padding(vertical = 8.dp, horizontal = 16.dp), verticalAlignment = Alignment.CenterVertically, ) { val websiteIconBitmap = remember(tabItem.tabState.pageIcon) { tabItem.tabState.pageIcon?.asImageBitmap() } val modifier = Modifier.size(24.dp) if (websiteIconBitmap != null) { Image( bitmap = websiteIconBitmap, contentDescription = null, modifier = modifier, ) } else { MyIcon( MyIcons.earth, contentDescription = null, modifier = modifier, ) } Spacer(Modifier.width(16.dp)) Text( text = tabItem.tabState.let { it.pageTitle ?: it.lastLoadedUrl }.orEmpty(), modifier = Modifier .weight(1f), maxLines = 1, overflow = TextOverflow.Ellipsis, ) WithContentAlpha(0.5f) { TransparentIconActionButton( MyIcons.close, Res.string.close.asStringSource(), ) { onCloseTabRequest(tabItem) } } } } } } } } @Composable fun AddressField( currentPageIcon: ImageBitmap?, currentPageURL: String?, currentPageTitle: String?, onNewPageRequested: (String?) -> Unit, ) { val title = currentPageTitle ?: currentPageURL ?: "Blank" val url = currentPageURL ?: "" val isSecure = remember(url) { url.startsWith("https://") } var isEditing by remember { mutableStateOf(false) } BackHandler(enabled = isEditing) { isEditing = false } val textFieldInteractionSource = remember { MutableInteractionSource() } val isFocused by textFieldInteractionSource.collectIsFocusedAsState() LaunchedEffect(isFocused) { isEditing = isFocused } if (isEditing) { val fr = remember { FocusRequester() } LaunchedEffect(Unit) { fr.requestFocus() } var editingText by remember { mutableStateOf(url) } MyTextField( text = editingText, onTextChange = { editingText = it }, interactionSource = textFieldInteractionSource, placeholder = "URL", modifier = Modifier .fillMaxWidth() .focusRequester(fr), keyboardOptions = KeyboardOptions( keyboardType = KeyboardType.Uri, imeAction = ImeAction.Go, ), keyboardActions = KeyboardActions( onGo = { isEditing = false onNewPageRequested(editingText) }, ), end = { MyIcon( MyIcons.paste, contentDescription = myStringResource(Res.string.paste), modifier = Modifier .clickable { ClipboardUtil.read()?.let { editingText = it } } .fillMaxHeight() .padding(horizontal = 10.dp), ) MyIcon( MyIcons.clear, contentDescription = null, modifier = Modifier .clickable { if (editingText.isNotEmpty()) { editingText = "" } else { isEditing = false } } .fillMaxHeight() .padding(horizontal = 10.dp), ) }, ) } else { Row( modifier = Modifier .fillMaxWidth() .heightIn(mySpacings.thumbSize) .clip(myShapes.defaultRounded) .background(myColors.onSurface / 0.05f) .clickable { isEditing = true } .padding(horizontal = 16.dp) .padding(vertical = 8.dp), verticalAlignment = Alignment.CenterVertically, ) { if (currentPageIcon != null) { Image( bitmap = currentPageIcon, contentDescription = null, modifier = Modifier.size(24.dp), ) Spacer(Modifier.width(16.dp)) } Text( title, modifier = Modifier .weight(1f) .wrapContentHeight(), maxLines = 1, overflow = TextOverflow.Ellipsis, ) if (isSecure) { Spacer(Modifier.width(8.dp)) MyIcon( MyIcons.lock, "HTTPS", modifier = Modifier.size(24.dp), tint = myColors.success, ) } } } } ================================================ FILE: android/app/src/main/kotlin/com/abdownloadmanager/android/pages/browser/DownloadInterceptor.kt ================================================ package com.abdownloadmanager.android.pages.browser import android.webkit.CookieManager import com.abdownloadmanager.android.ui.widget.WebViewState import com.abdownloadmanager.shared.pages.adddownload.AddDownloadCredentialsInUiProps import ir.amirab.downloader.downloaditem.http.HttpDownloadCredentials import ir.amirab.util.HttpUrlUtils import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.delay import kotlinx.coroutines.launch typealias ABDMWebRequestId = String data class ABDMWebRequest( val url: String, val headers: Map, val page: String?, ) { val id: ABDMWebRequestId = url } interface RequestInterceptor { fun interceptRequest(request: ABDMWebRequest) } class DownloadInterceptor( private val scope: CoroutineScope, private val onNewDownload: (newDownloads: List) -> Unit, ) : RequestInterceptor { private val requests = mutableMapOf() fun onDownloadStart( url: String?, userAgent: String?, page: String?, tab: ABDMBrowserTab, ) { if (url == null) { return } if (!HttpUrlUtils.isValidUrl(url)) { return } val webRequest = getWebRequestOrDefault( url = url, userAgent = userAgent, page = page, webViewState = tab.tabState, ) onNewDownload( listOf( AddDownloadCredentialsInUiProps( HttpDownloadCredentials( link = webRequest.url, headers = webRequest.headers, downloadPage = webRequest.page, ), AddDownloadCredentialsInUiProps.Configs() ) ) ) } override fun interceptRequest( request: ABDMWebRequest, ) { addToHeaders(request) } private fun addToHeaders(request: ABDMWebRequest) { requests[request.id] = request scope.launch { delay(REMOVE_REQUESTS_DELAY) requests.remove(request.id) } } private fun getWebRequestOrDefault( url: String, userAgent: String?, page: String?, webViewState: WebViewState, ): ABDMWebRequest { var request = requests[url] if (request == null) { request = ABDMWebRequest( url = url, headers = emptyMap(), page = getPageUrl(webViewState) ?: page, ) } return request .withUserAgent(userAgent) .withCookieManagerCookies() } private fun ABDMWebRequest.withUserAgent(userAgent: String?): ABDMWebRequest { val request = this if (userAgent == null) { return request } val userAgentKey = "User-Agent" if (request.headers.containsKey(userAgentKey)) { return request } return request.copy( headers = request.headers.plus( userAgentKey to userAgent ) ) } private fun ABDMWebRequest.withCookieManagerCookies(): ABDMWebRequest { val request = this val cookieFromCookieManager = CookieManager.getInstance().getCookie(url)?.takeIf { it.isNotBlank() } ?: return request val cookieKey = "Cookie" val currentCookie = request.headers[cookieKey]?.takeIf { it.isNotBlank() } return request.copy( headers = request.headers.plus( cookieKey to if (currentCookie != null) { "$currentCookie; $cookieFromCookieManager" } else { cookieFromCookieManager } ) ) } private fun getPageUrl(state: WebViewState): String? { return state.lastLoadedUrl } companion object { private const val REMOVE_REQUESTS_DELAY = 20_000L } } ================================================ FILE: android/app/src/main/kotlin/com/abdownloadmanager/android/pages/browser/SearchEngines.kt ================================================ package com.abdownloadmanager.android.pages.browser import java.net.URLEncoder import java.nio.charset.StandardCharsets sealed class SearchEngines( val baseUrl: String, val query: String, val home: String = baseUrl, ) { fun createSearchUrl(textToSearch: String): String { return buildSearchUrl(baseUrl, query, textToSearch) } data object DuckDuckGo : SearchEngines( baseUrl = "https://duckduckgo.com/", query = "q", ) data object Google : SearchEngines( baseUrl = "https://www.google.com/search", query = "q", home = "https://www.google.com", ) data object Bing : SearchEngines( baseUrl = "https://www.bing.com/search", query = "q", ) data object Brave : SearchEngines( baseUrl = "https://search.brave.com/search", query = "q", home = "https://search.brave.com", ) companion object { private fun buildSearchUrl( baseUrl: String, queryParam: String, query: String ): String { val encodedQuery = URLEncoder.encode(query, StandardCharsets.UTF_8.toString()) return "$baseUrl?$queryParam=$encodedQuery" } } } ================================================ FILE: android/app/src/main/kotlin/com/abdownloadmanager/android/pages/browser/WebView.kt ================================================ package com.abdownloadmanager.android.pages.browser import androidx.compose.runtime.Composable import androidx.compose.runtime.key import androidx.compose.ui.Modifier import com.abdownloadmanager.android.ui.widget.WebView @Composable fun ABDMWebView( modifier: Modifier = Modifier, webViewHolder: WebViewHolder, ) { val tab = webViewHolder.tab key(tab.tabId) { val wState = tab.tabState val navigator = webViewHolder.navigator WebView( state = wState, modifier = modifier, captureBackPresses = false, navigator = navigator, client = webViewHolder.client, chromeClient = webViewHolder.chromeClient, onDispose = { webViewHolder.deactivate() }, factory = { webViewHolder.activate(it) }, ) } } ================================================ FILE: android/app/src/main/kotlin/com/abdownloadmanager/android/pages/browser/WebViewHolder.kt ================================================ package com.abdownloadmanager.android.pages.browser import android.content.Context import android.content.Intent import android.net.Uri import android.os.Message import android.webkit.WebResourceRequest import android.webkit.WebResourceResponse import android.webkit.WebSettings import android.webkit.WebView import com.abdownloadmanager.android.ui.widget.AccompanistWebChromeClient import com.abdownloadmanager.android.ui.widget.AccompanistWebViewClient import com.abdownloadmanager.android.ui.widget.WebContent import com.abdownloadmanager.android.ui.widget.WebViewNavigator import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch import java.util.UUID class WebViewRegistry( private val scope: CoroutineScope, private val browserComponent: BrowserComponent, ) : WebViewFactory { val viewHolders = mutableMapOf() fun onTabsUpdated( webViewStates: ABDMTabs, ) { val webViewStateIds = webViewStates.tabs.map { it.tabId }.toSet() for (viewHolderKey in viewHolders.keys.toList()) { if (viewHolderKey !in webViewStateIds) { removeViewHolder(viewHolderKey) } } } fun getWebViewHolder( tab: ABDMBrowserTab ): WebViewHolder { return viewHolders.getOrPut(tab.tabId, { WebViewHolder( tab = tab, navigator = WebViewNavigator(scope), webView = null, client = ABDMWebViewClient(browserComponent.downloadInterceptor, scope), chromeClient = ABDMChromeClient(browserComponent, ::getWebViewHolder), webViewFactory = this, ) }) } fun removeViewHolder(id: String) { viewHolders.remove(id)?.release() } fun disposeAll() { viewHolders.forEach { (_, holder) -> holder.release() } viewHolders.clear() } override fun createWebView( context: Context, tab: ABDMBrowserTab, ): ABDMWebView { return ABDMWebView(context).apply { val webView = this webView.settings.javaScriptEnabled = true webView.settings.domStorageEnabled = true webView.settings.setSupportZoom(true) webView.settings.builtInZoomControls = false webView.settings.setSupportMultipleWindows(true) webView.settings.mixedContentMode = WebSettings.MIXED_CONTENT_COMPATIBILITY_MODE webView.isLongClickable = true webView.setOnLongClickListener { val hit = webView.hitTestResult if (hit.type == WebView.HitTestResult.SRC_ANCHOR_TYPE || hit.type == WebView.HitTestResult.SRC_IMAGE_ANCHOR_TYPE ) { val url = hit.extra ?: return@setOnLongClickListener false browserComponent.onLinkSelected( url, tab, ) true } else { false } } webView.setDownloadListener { url, userAgent, _, _, _ -> scope.launch(Dispatchers.Main) { if (!webView.canGoBack() && webView.originalUrl == null) { browserComponent.closeTab(tab.tabId) } browserComponent.downloadInterceptor.onDownloadStart( url, userAgent, webView.originalUrl ?: webView.openedBy, tab, ) } } webView.tabId = tab.tabId } } } data class WebViewHolder( val tab: ABDMBrowserTab, var webView: ABDMWebView? = null, val navigator: WebViewNavigator, val client: ABDMWebViewClient, val chromeClient: ABDMChromeClient, private val webViewFactory: WebViewFactory, ) { fun activate(context: Context): ABDMWebView { return if (webView != null) { (webView!!).also { it.onResume() } } else { webViewFactory.createWebView(context, tab).also { webView = it } } } fun deactivate() { webView?.onPause() // prevent reloading after activated again tab.tabState.content = WebContent.NavigatorOnly } fun release() { webView?.onPause() webView?.destroy() webView = null } } interface WebViewFactory { fun createWebView( context: Context, tab: ABDMBrowserTab, ): ABDMWebView } class ABDMWebViewClient( private val requestInterceptor: DownloadInterceptor, private val scope: CoroutineScope, ) : AccompanistWebViewClient() { override fun shouldInterceptRequest(view: WebView?, request: WebResourceRequest?): WebResourceResponse? { if (request != null) { scope.launch(Dispatchers.Main) { requestInterceptor.interceptRequest( ABDMWebRequest( url = request.url.toString(), headers = request.requestHeaders, page = view?.originalUrl ?: view?.url ) ) } } return super.shouldInterceptRequest(view, request) } override fun shouldOverrideUrlLoading( view: WebView, request: WebResourceRequest ): Boolean { val url = request.url.toString() // Let WebView load normal web pages if (url.startsWith("http://") || url.startsWith("https://")) { return false } // Handle intent:// URIs if (url.startsWith("intent://")) { try { val intent = Intent.parseUri(url, Intent.URI_INTENT_SCHEME) val pm = view.context.packageManager if (intent.resolveActivity(pm) != null) { view.context.startActivity(intent) } else { intent.getStringExtra("browser_fallback_url")?.let { view.loadUrl(it) } } } catch (e: Exception) { e.printStackTrace() } return true } // Handle ALL other schemes (deep links) try { val intent = Intent(Intent.ACTION_VIEW, Uri.parse(url)) val pm = view.context.packageManager if (intent.resolveActivity(pm) != null) { view.context.startActivity(intent) } } catch (e: Exception) { e.printStackTrace() } return true } } class ABDMChromeClient( private val browserComponent: BrowserComponent, private val createWebViewHolder: (tab: ABDMBrowserTab) -> WebViewHolder, ) : AccompanistWebChromeClient() { override fun onCreateWindow( view: WebView?, isDialog: Boolean, isUserGesture: Boolean, resultMsg: Message? ): Boolean { if (view == null) return false val transport = (resultMsg?.obj as? WebView.WebViewTransport) ?: return false val newTab = browserComponent.newTab( id = UUID.randomUUID().toString(), switch = true, url = null, openedBy = (view as? ABDMWebView)?.tabId ) val newWebView = createWebViewHolder(newTab).activate(view.context) newWebView.openedBy = view.originalUrl ?: view.url transport.webView = newWebView resultMsg.sendToTarget() return true } } class ABDMWebView( context: Context, ) : WebView(context) { var openedBy: String? = null var tabId: String? = null } ================================================ FILE: android/app/src/main/kotlin/com/abdownloadmanager/android/pages/browser/bookmark/Bookmarks.kt ================================================ package com.abdownloadmanager.android.pages.browser.bookmark import androidx.compose.foundation.combinedClickable import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.heightIn import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.width import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.items import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp import com.abdownloadmanager.android.storage.BrowserBookmark import com.abdownloadmanager.android.ui.SheetHeader import com.abdownloadmanager.android.ui.SheetTitle import com.abdownloadmanager.android.ui.SheetUI import com.abdownloadmanager.resources.Res import com.abdownloadmanager.shared.ui.widget.Text import com.abdownloadmanager.shared.ui.widget.TransparentIconActionButton import com.abdownloadmanager.shared.util.ResponsiveDialog import com.abdownloadmanager.shared.util.div import com.abdownloadmanager.shared.util.rememberResponsiveDialogState import com.abdownloadmanager.shared.util.ui.LocalContentColor import com.abdownloadmanager.shared.util.ui.WithContentAlpha import com.abdownloadmanager.shared.util.ui.icon.MyIcons import com.abdownloadmanager.shared.util.ui.theme.mySpacings import com.abdownloadmanager.shared.util.ui.theme.myTextSizes import com.abdownloadmanager.shared.util.ui.widget.MyIcon import ir.amirab.util.compose.asStringSource import ir.amirab.util.compose.resources.myStringResource @Composable fun BookmarkList( visible: Boolean, onDismissRequest: () -> Unit, bookmarks: List, onRequestNewBookmark: () -> Unit, onRequestEditBookmark: (BrowserBookmark) -> Unit, onBookmarkClick: (BrowserBookmark) -> Unit, onRemoveBookmarkRequest: (BrowserBookmark) -> Unit, ) { val responsiveState = rememberResponsiveDialogState(visible) LaunchedEffect(visible) { if (visible) { responsiveState.show() } else { responsiveState.hide() } } ResponsiveDialog( state = responsiveState, onDismiss = onDismissRequest, ) { SheetUI( header = { SheetHeader( headerTitle = { SheetTitle( myStringResource(Res.string.browser_bookmarks), ) }, headerActions = { TransparentIconActionButton( MyIcons.add, Res.string.add.asStringSource(), ) { onRequestNewBookmark() } TransparentIconActionButton( MyIcons.close, Res.string.close.asStringSource(), ) { onDismissRequest() } } ) } ) { LazyColumn { items(bookmarks) { bookmark -> Row( modifier = Modifier .heightIn(mySpacings.thumbSize) .combinedClickable( onLongClick = { onRequestEditBookmark(bookmark) }, onClick = { onBookmarkClick(bookmark) } ) .padding(vertical = 8.dp, horizontal = 16.dp), verticalAlignment = Alignment.CenterVertically, ) { val modifier = Modifier.size(24.dp) MyIcon( MyIcons.earth, contentDescription = null, modifier = modifier, ) Spacer(Modifier.width(16.dp)) Column( modifier = Modifier .weight(1f), ) { Text( text = bookmark.title, maxLines = 1, overflow = TextOverflow.Ellipsis, ) Text( text = bookmark.url, maxLines = 1, overflow = TextOverflow.Ellipsis, fontSize = myTextSizes.sm, color = LocalContentColor.current / 0.75f ) } WithContentAlpha(0.5f) { TransparentIconActionButton( MyIcons.remove, Res.string.remove.asStringSource(), ) { onRemoveBookmarkRequest(bookmark) } } } } } } } } ================================================ FILE: android/app/src/main/kotlin/com/abdownloadmanager/android/pages/browser/bookmark/EditBookmark.kt ================================================ package com.abdownloadmanager.android.pages.browser.bookmark import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.height import androidx.compose.foundation.text.KeyboardActions import androidx.compose.foundation.text.KeyboardOptions import androidx.compose.runtime.Composable import androidx.compose.runtime.Immutable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier import androidx.compose.ui.focus.FocusRequester import androidx.compose.ui.focus.focusRequester import androidx.compose.ui.text.input.ImeAction import androidx.compose.ui.text.input.KeyboardType import com.abdownloadmanager.android.storage.BrowserBookmark import com.abdownloadmanager.android.ui.configurable.SheetInput import com.abdownloadmanager.resources.Res import com.abdownloadmanager.shared.ui.widget.MyTextField import com.abdownloadmanager.shared.util.ui.theme.mySpacings import ir.amirab.util.compose.asStringSource import ir.amirab.util.compose.resources.myStringResource @Immutable data class EditBookmarkState( val initialValue: BrowserBookmark, val editMode: Boolean = false, ) @Composable fun EditBookmarkSheet( state: EditBookmarkState, onSave: (BrowserBookmark) -> Unit, onCancel: () -> Unit, ) { val editMode = state.editMode val initialValue = state.initialValue val sheetTitle = if (editMode) Res.string.browser_edit_bookmark else Res.string.browser_add_bookmark SheetInput( title = sheetTitle.asStringSource(), validate = { it.title.isNotEmpty() && it.url.isNotEmpty() }, isOpened = true, initialValue = { initialValue }, onDismiss = onCancel, onConfirm = onSave, inputContent = { inputParams -> var title by remember(state) { mutableStateOf(state.initialValue.title) } var url by remember(state) { mutableStateOf(state.initialValue.url) } LaunchedEffect(url, title) { inputParams.setEditingValue( BrowserBookmark( url = url, title = title, ) ) } Column( modifier = inputParams.modifier, ) { val (urlFR, titleFR) = remember { FocusRequester.createRefs() } LaunchedEffect(Unit) { when { url.isBlank() -> { urlFR.requestFocus() } title.isBlank() -> { titleFR.requestFocus() } } } val textFieldModifier = Modifier MyTextField( text = url, onTextChange = { url = it }, modifier = textFieldModifier .focusRequester(urlFR), keyboardOptions = KeyboardOptions( keyboardType = KeyboardType.Uri, imeAction = ImeAction.Next ), keyboardActions = KeyboardActions.Default, placeholder = "URL", ) Spacer(modifier = Modifier.height(mySpacings.mediumSpace)) MyTextField( text = title, onTextChange = { title = it }, modifier = textFieldModifier .focusRequester(titleFR), keyboardOptions = KeyboardOptions( keyboardType = KeyboardType.Text, imeAction = ImeAction.Done ), keyboardActions = inputParams.keyboardActions, placeholder = myStringResource(Res.string.name), ) } }, ) } ================================================ FILE: android/app/src/main/kotlin/com/abdownloadmanager/android/pages/category/CategorySheet.kt ================================================ package com.abdownloadmanager.android.pages.category import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import com.abdownloadmanager.android.ui.SheetHeader import com.abdownloadmanager.android.ui.SheetTitle import com.abdownloadmanager.android.ui.SheetUI import com.abdownloadmanager.shared.pages.category.CategoryComponent import com.abdownloadmanager.shared.util.OnFullyDismissed import com.abdownloadmanager.shared.util.ResponsiveDialog import com.abdownloadmanager.shared.util.rememberResponsiveDialogState import androidx.compose.foundation.* import androidx.compose.foundation.layout.* import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.runtime.* import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip import androidx.compose.ui.unit.dp import com.abdownloadmanager.android.pages.directorypicker.rememberAndroidDirectoryPickerLauncher import com.abdownloadmanager.shared.ui.widget.MyTextFieldIcon import com.abdownloadmanager.shared.util.ui.icon.MyIcons import com.abdownloadmanager.shared.ui.widget.* import com.abdownloadmanager.shared.util.ui.myColors import ir.amirab.util.ifThen import com.abdownloadmanager.shared.util.div import com.abdownloadmanager.resources.Res import com.abdownloadmanager.shared.util.ui.widget.MyIcon import ir.amirab.util.compose.IconSource import ir.amirab.util.compose.resources.myStringResource import com.abdownloadmanager.shared.util.ResponsiveDialogScope import com.abdownloadmanager.shared.util.ui.theme.mySpacings import ir.amirab.util.compose.asStringSource import java.io.File @Composable fun CategorySheet( categoryComponent: CategoryComponent?, onDismiss: () -> Unit, ) { val state = rememberResponsiveDialogState(false) LaunchedEffect( categoryComponent ) { if (categoryComponent != null) { state.show() } else { state.hide() } } state.OnFullyDismissed(onDismiss) ResponsiveDialog(state, onDismiss = state::hide) { categoryComponent?.let { CategorySheetUi(it) } } } @Composable private fun ResponsiveDialogScope.CategorySheetUi(categoryComponent: CategoryComponent) { SheetUI( header = { SheetHeader( headerTitle = { SheetTitle( myStringResource( if (categoryComponent.isEditMode) { Res.string.edit_category } else { Res.string.add_category } ) ) } ) } ) { Column( modifier = Modifier .padding(horizontal = mySpacings.mediumSpace) .padding(vertical = mySpacings.mediumSpace) ) { Column( Modifier .weight(1f, false) .verticalScroll(rememberScrollState()) ) { Row { CategoryIcon( iconSource = categoryComponent.icon.collectAsState().value, onChange = categoryComponent::setIcon ) Spacer(Modifier.width(16.dp)) CategoryName( modifier = Modifier.weight(1f), name = categoryComponent.name.collectAsState().value, onNameChanged = categoryComponent::setName ) } Spacer(Modifier.height(12.dp)) CategoryAutoTypes( types = categoryComponent.types.collectAsState().value, onTypesChanged = categoryComponent::setTypes, enabled = categoryComponent.typesEnabled.collectAsState().value, setEnabled = categoryComponent::setTypesEnabled ) Spacer(Modifier.height(12.dp)) CategoryAutoUrls( urlPatterns = categoryComponent.urlPatterns.collectAsState().value, onUrlPatternChanged = categoryComponent::setUrlPatterns, enabled = categoryComponent.urlPatternsEnabled.collectAsState().value, setEnabled = categoryComponent::setUrlPatternsEnabled ) Spacer(Modifier.height(12.dp)) CategoryDefaultPath( path = categoryComponent.path.collectAsState().value, onPathChanged = categoryComponent::setPath, defaultDownloadLocation = categoryComponent.defaultDownloadLocation.collectAsState().value, checked = categoryComponent.usePath.collectAsState().value, setChecked = categoryComponent::setUsePath ) } Spacer(Modifier.height(12.dp)) Row(Modifier .fillMaxWidth() .wrapContentWidth(Alignment.End)) { ActionButton( myStringResource( when (categoryComponent.isEditMode) { true -> Res.string.change false -> Res.string.add } ), enabled = categoryComponent.canSubmit.collectAsState().value, onClick = { categoryComponent.submit() }, modifier = Modifier.weight(1f) ) Spacer(Modifier.width(8.dp)) ActionButton( myStringResource(Res.string.cancel), onClick = { categoryComponent.close() }, modifier = Modifier.weight(1f) ) } } } } @Composable fun CategoryDefaultPath( defaultDownloadLocation: String, path: String, onPathChanged: (String) -> Unit, checked: Boolean, setChecked: (Boolean) -> Unit, ) { val initialDirectory = remember(path, defaultDownloadLocation) { path .takeIf { it.isNotBlank() } ?.let { runCatching { File(path).canonicalPath }.getOrNull() } ?: defaultDownloadLocation } val downloadFolderPickerLauncher = rememberAndroidDirectoryPickerLauncher( title = Res.string.category_download_location.asStringSource(), initialDirectory = initialDirectory, ) { directory -> directory?.let(onPathChanged) } OptionalWithLabel( label = myStringResource(Res.string.category_download_location), helpText = myStringResource(Res.string.category_download_location_description), enabled = checked, setEnabled = setChecked, ) { MyTextFieldWithIcons( text = path, onTextChange = onPathChanged, modifier = Modifier.fillMaxWidth(), enabled = checked, placeholder = "", errorText = null, end = { MyTextFieldIcon( MyIcons.folder, enabled = checked, ) { downloadFolderPickerLauncher.launch() } } ) } } @Composable fun CategoryAutoTypes( enabled: Boolean, setEnabled: (Boolean) -> Unit, types: String, onTypesChanged: (String) -> Unit, ) { OptionalWithLabel( label = myStringResource(Res.string.category_file_types), helpText = myStringResource(Res.string.category_file_types_description), enabled = enabled, setEnabled = setEnabled, ) { MyTextFieldWithIcons( text = types, onTextChange = onTypesChanged, modifier = Modifier.fillMaxWidth(), placeholder = "ext1 ext2 ext3", enabled = enabled, singleLine = false, ) } } @Composable fun CategoryAutoUrls( enabled: Boolean, setEnabled: (Boolean) -> Unit, urlPatterns: String, onUrlPatternChanged: (String) -> Unit, ) { OptionalWithLabel( label = myStringResource(Res.string.category_url_patterns), helpText = myStringResource(Res.string.category_url_patterns_description), enabled = enabled, setEnabled = setEnabled ) { MyTextFieldWithIcons( text = urlPatterns, onTextChange = onUrlPatternChanged, modifier = Modifier.fillMaxWidth(), placeholder = "dl.example.com/pics example.com/*/path", enabled = enabled, singleLine = false, ) } } @Composable fun CategoryName( name: String, onNameChanged: (String) -> Unit, modifier: Modifier = Modifier, ) { WithLabel( myStringResource(Res.string.category_name), modifier, ) { MyTextFieldWithIcons( text = name, onTextChange = onNameChanged, modifier = Modifier.fillMaxWidth(), placeholder = "Something...", ) } } @Composable private fun WithLabel( label: String, modifier: Modifier = Modifier, helpText: String? = null, content: @Composable () -> Unit, ) { Column(modifier) { Row(verticalAlignment = Alignment.CenterVertically) { Text(label) helpText?.let { Spacer(Modifier.width(8.dp)) Help(helpText) } } Spacer(Modifier.height(8.dp)) content() } } @Composable private fun OptionalWithLabel( label: String, modifier: Modifier = Modifier, enabled: Boolean, setEnabled: (Boolean) -> Unit, helpText: String? = null, content: @Composable () -> Unit, ) { Column(modifier) { Row(verticalAlignment = Alignment.CenterVertically) { Row( modifier = Modifier.clickable { setEnabled(!enabled) }, verticalAlignment = Alignment.CenterVertically, ) { CheckBox(enabled, setEnabled, size = 16.dp) Spacer(Modifier.width(8.dp)) Text(label) } helpText?.let { Spacer(Modifier.width(8.dp)) Help(helpText) } } Spacer(Modifier.height(8.dp)) content() } } @Composable private fun CategoryIcon( iconSource: IconSource?, onChange: (IconSource) -> Unit, ) { var showIconPicker by remember { mutableStateOf(false) } WithLabel( myStringResource(Res.string.icon) ) { RenderIcon( icon = iconSource, requiresAttention = iconSource == null, onClick = { showIconPicker = !showIconPicker } ) if (showIconPicker) { IconPick( selectedIcon = iconSource, icons = listOf( MyIcons.pictureFile, MyIcons.musicFile, MyIcons.zipFile, MyIcons.videoFile, MyIcons.applicationFile, MyIcons.documentFile, MyIcons.otherFile, MyIcons.file, MyIcons.folder, MyIcons.browserIntegration, MyIcons.appearance, MyIcons.settings, MyIcons.search, MyIcons.info, MyIcons.check, MyIcons.link, MyIcons.download, MyIcons.speaker, MyIcons.group, MyIcons.activeCount, MyIcons.speed, MyIcons.resume, MyIcons.pause, MyIcons.stop, MyIcons.queue, MyIcons.remove, MyIcons.clear, MyIcons.add, MyIcons.paste, MyIcons.copy, MyIcons.refresh, MyIcons.share, MyIcons.lock, MyIcons.question, MyIcons.verticalDirection, MyIcons.downloadEngine, MyIcons.network, MyIcons.externalLink, ), onSelected = { onChange(it) showIconPicker = false }, onCancel = { showIconPicker = false } ) } } } @Composable private fun RenderIcon( icon: IconSource?, indicateActive: Boolean = false, requiresAttention: Boolean = false, onClick: () -> Unit, ) { val shape = RoundedCornerShape(10.dp) Box( Modifier .border( 1.dp, myColors.onBackground / 10, shape ) .sizeIn(mySpacings.thumbSize, mySpacings.thumbSize) .ifThen(indicateActive || requiresAttention) { border( 1.dp, myColors.primary / if (indicateActive) 1f else alphaFlicker(), shape ) } .clip(shape) .background(myColors.surface) .clickable { onClick() } .padding(6.dp), contentAlignment = Alignment.Center, ) { val modifier = Modifier .size(20.dp) if (icon != null) { MyIcon( icon, null, modifier, ) } else { Spacer(modifier) } } } ================================================ FILE: android/app/src/main/kotlin/com/abdownloadmanager/android/pages/category/NewCategory.kt ================================================ ================================================ FILE: android/app/src/main/kotlin/com/abdownloadmanager/android/pages/checksum/AndroidFileChecksumComponent.kt ================================================ package com.abdownloadmanager.android.pages.checksum import com.abdownloadmanager.shared.pages.checksum.BaseFileChecksumComponent import com.abdownloadmanager.shared.util.DownloadSystem import com.abdownloadmanager.shared.util.FileIconProvider import com.arkivanov.decompose.ComponentContext import kotlinx.serialization.Serializable import java.util.UUID class AndroidFileChecksumComponent( ctx: ComponentContext, id: String, itemIds: List, closeComponent: () -> Unit, downloadSystem: DownloadSystem, val iconProvider: FileIconProvider, ) : BaseFileChecksumComponent( ctx = ctx, id = id, itemIds = itemIds, closeComponent = closeComponent, downloadSystem = downloadSystem, ) { @Serializable data class Config( val id: String = UUID.randomUUID().toString(), override val itemIds: List, ) : BaseFileChecksumComponent.Config sealed interface Effects : BaseFileChecksumComponent.Effects.Platform { data object BringToFront : Effects } } ================================================ FILE: android/app/src/main/kotlin/com/abdownloadmanager/android/pages/checksum/FileChecksumPage.kt ================================================ package com.abdownloadmanager.android.pages.checksum import androidx.compose.animation.core.* import androidx.compose.foundation.* import androidx.compose.foundation.layout.* import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.itemsIndexed import androidx.compose.foundation.shape.CircleShape import androidx.compose.runtime.* import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip import androidx.compose.ui.draw.drawBehind import androidx.compose.ui.geometry.Offset import androidx.compose.ui.graphics.Brush import androidx.compose.ui.graphics.Color import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp import com.abdownloadmanager.android.ui.configurable.SheetInput import com.abdownloadmanager.android.ui.page.PageTitle import com.abdownloadmanager.shared.ui.configurable.item.FileChecksumConfigurable import com.abdownloadmanager.shared.ui.configurable.RenderSpinner import com.abdownloadmanager.shared.util.ClipboardUtil import com.abdownloadmanager.shared.ui.configurable.RenderConfigurable import com.abdownloadmanager.resources.Res import com.abdownloadmanager.shared.pages.checksum.ChecksumStatus import com.abdownloadmanager.shared.pages.checksum.DownloadItemWithChecksum import com.abdownloadmanager.shared.ui.configurable.ConfigurableUiProps import com.abdownloadmanager.shared.ui.widget.ActionButton import com.abdownloadmanager.shared.ui.widget.Help import com.abdownloadmanager.shared.ui.widget.Text import com.abdownloadmanager.shared.ui.widget.TransparentIconActionButton import com.abdownloadmanager.shared.util.FileChecksum import com.abdownloadmanager.shared.util.FileChecksumAlgorithm import com.abdownloadmanager.shared.util.FileIconProvider import com.abdownloadmanager.shared.util.div import com.abdownloadmanager.shared.util.rememberDotLoading import com.abdownloadmanager.shared.util.ui.WithContentAlpha import com.abdownloadmanager.shared.util.ui.WithContentColor import com.abdownloadmanager.shared.util.ui.icon.MyIcons import com.abdownloadmanager.shared.util.ui.myColors import com.abdownloadmanager.shared.util.ui.theme.myShapes import com.abdownloadmanager.shared.util.ui.theme.mySpacings import com.abdownloadmanager.shared.util.ui.theme.myTextSizes import com.abdownloadmanager.shared.util.ui.widget.MyIcon import ir.amirab.util.compose.IconSource import ir.amirab.util.compose.StringSource import ir.amirab.util.compose.asStringSource import ir.amirab.util.compose.resources.myStringResource import ir.amirab.util.ifThen import kotlinx.coroutines.flow.MutableStateFlow @Composable fun FileChecksumPage(component: AndroidFileChecksumComponent) { val pageTitle = myStringResource(Res.string.file_checksum_page) val horizontalPadding = mySpacings.largeSpace Column( Modifier .background(myColors.background) .statusBarsPadding() ) { PageTitle(pageTitle) ItemsToBeChecked( Modifier .fillMaxWidth() .weight(1f) .padding(horizontal = horizontalPadding), component, ) Actions( Modifier, component, ) } } @Composable fun ItemsToBeChecked( modifier: Modifier = Modifier, component: AndroidFileChecksumComponent, ) { val dividerColor = myColors.onBackground / 0.5f val collectAsState by component.state.collectAsState() var currentEditingItem: DownloadItemWithChecksum? by remember { mutableStateOf(null) } LazyColumn( modifier = modifier ) { itemsIndexed(collectAsState.items) { index, item -> val isFirstItem = index == 0 RenderDownloadItemWithChecksum( item = item, iconProvider = component.iconProvider, onRequestUpdateChecksum = { currentEditingItem = item }, modifier = Modifier.ifThen(!isFirstItem) { drawBehind { drawLine( brush = Brush.horizontalGradient( listOf( Color.Transparent, dividerColor, Color.Transparent, ) ), start = Offset.Zero, end = Offset(size.width, 0f) ) } } ) } } currentEditingItem?.let { item -> FileChecksumTableCellRenderers.ChecksumEditSheet( item = item, onCloseRequest = { currentEditingItem = null }, onRequestSaveNewChecksum = { component.updateChecksum(item.downloadItem.id, it) currentEditingItem = null } ) } } @Composable private fun Actions( modifier: Modifier, component: AndroidFileChecksumComponent, ) { val uiState by component.state.collectAsState() Column( modifier .fillMaxWidth() .background(myColors.surface) .navigationBarsPadding() ) { Spacer( Modifier .fillMaxWidth() .height(1.dp) .background(myColors.onSurface / 0.15f) ) Column( Modifier .padding(horizontal = 16.dp) .padding(vertical = 16.dp), ) { Row( verticalAlignment = Alignment.CenterVertically, ) { Row( verticalAlignment = Alignment.CenterVertically, ) { Text( text = myStringResource(Res.string.file_checksum_page_file_checksum_default_algorithm) ) Spacer(Modifier.width(8.dp)) Help( myStringResource(Res.string.file_checksum_page_file_checksum_default_algorithm_help) ) } Spacer(Modifier.size(8.dp)) RenderSpinner( modifier = Modifier, possibleValues = FileChecksumAlgorithm.all(), value = uiState.defaultAlgorithm, enabled = !uiState.isChecking, onSelect = { component.onAlgorithmChange(it) }, render = { Text(it.algorithm) }) } Spacer(Modifier.height(8.dp)) Row { ActionButton( myStringResource(Res.string.start), onClick = component::onRequestStartCheck, enabled = !uiState.isChecking, modifier = Modifier.weight(1f) ) Spacer(Modifier.width(8.dp)) ActionButton( myStringResource(Res.string.close), onClick = component::onRequestClose, modifier = Modifier.weight(1f) ) } } } } private data object FileChecksumTableCellRenderers { @Composable fun RenderStatus(item: DownloadItemWithChecksum) { when (val status = item.checksumStatus) { is ChecksumStatus.Checking -> { RenderCheckingStatus(status.percent) } ChecksumStatus.Error.DownloadNotFinished -> { RenderErrorStatus(myStringResource(Res.string.download_not_finished)) } is ChecksumStatus.Error.Exception -> { RenderErrorStatus(status.t.localizedMessage ?: status.t::class.simpleName.orEmpty()) } ChecksumStatus.Error.FileNotFound -> { RenderErrorStatus(myStringResource(Res.string.file_not_found)) } is ChecksumStatus.Finished -> { RenderFinishedStatus( status = status, ) } ChecksumStatus.Waiting -> { RenderWaitingStatus() } } } @Composable fun RenderCalculatedChecksum(item: DownloadItemWithChecksum) { val calculatedChecksum = item.calculatedChecksum ColumnKeyValue( modifier = Modifier, keyContent = { RenderKey { Text(myStringResource(Res.string.calculated_checksum)) } }, valueContent = { if (calculatedChecksum != null) { SimpleText(calculatedChecksum) } else if (item.isProcessing) { //shimmer ShimmerEffect( centerColor = myColors.onBackground / 0.4f, surroundingColor = myColors.onBackground / 0.1f, modifier = Modifier .fillMaxWidth() .clip(myShapes.defaultRounded) .height(myTextSizes.base.value.dp) ) } else if (item.isError) { SimpleText("!") } }, actions = { TransparentIconActionButton( enabled = calculatedChecksum != null, icon = MyIcons.copy, contentDescription = Res.string.copy.asStringSource(), onClick = { item.calculatedChecksum ?.let { savedChecksum -> ClipboardUtil.copy(savedChecksum) } }, ) } ) } @Composable fun RenderSavedChecksum( item: DownloadItemWithChecksum, onRequestUpdateChecksum: () -> Unit, ) { ColumnKeyValue( modifier = Modifier, keyContent = { RenderKey { Text(myStringResource(Res.string.saved_checksum)) } }, valueContent = { Text(item.savedChecksum.orEmpty()) }, actions = { TransparentIconActionButton( icon = MyIcons.edit, contentDescription = Res.string.edit.asStringSource(), onClick = onRequestUpdateChecksum, ) TransparentIconActionButton( icon = MyIcons.copy, contentDescription = Res.string.copy.asStringSource(), enabled = item.savedChecksum != null, onClick = { item.savedChecksum ?.let { savedChecksum -> ClipboardUtil.copy(savedChecksum) } }, ) } ) } @Composable fun ChecksumEditSheet( item: DownloadItemWithChecksum, onCloseRequest: () -> Unit, onRequestSaveNewChecksum: (FileChecksum?) -> Unit, ) { val editChecksumFlow = remember(item) { MutableStateFlow(FileChecksum(item.algorithm, item.savedChecksum.orEmpty())) } val fileChecksumConfigurable = remember(item) { FileChecksumConfigurable( title = Res.string.download_item_settings_file_checksum.asStringSource(), description = Res.string.download_item_settings_file_checksum_description.asStringSource(), backedBy = editChecksumFlow, describe = { "".asStringSource() }, ) } SheetInput( configurable = fileChecksumConfigurable, isOpened = true, onDismiss = onCloseRequest, onConfirm = onRequestSaveNewChecksum, ) { RenderConfigurable( fileChecksumConfigurable, ConfigurableUiProps( modifier = it.modifier, ), ) } } @Composable private fun ShimmerEffect( modifier: Modifier = Modifier, centerColor: Color = Color.Gray, surroundingColor: Color = Color.Gray, ) { val transition = rememberInfiniteTransition() val translateAnim = transition.animateFloat( initialValue = 0f, targetValue = 1000f, animationSpec = infiniteRepeatable( animation = tween( durationMillis = 3000, easing = LinearEasing ) ) ) val brush = Brush.linearGradient( colors = listOf( surroundingColor, centerColor, surroundingColor, ), start = Offset(0f, 0f), end = Offset(translateAnim.value, 0f) ) Box( modifier = modifier .background(brush = brush) ) } @Composable private fun RenderErrorStatus(message: String) { IconWithText( icon = MyIcons.info, text = message, color = myColors.error, ) } @Composable private fun RenderFinishedStatus( status: ChecksumStatus.Finished, ) { val text: StringSource val color: Color val icon: IconSource when (status) { ChecksumStatus.Finished.Done -> { text = Res.string.done.asStringSource() icon = MyIcons.check color = myColors.info } ChecksumStatus.Finished.Matches -> { text = Res.string.matches.asStringSource() icon = MyIcons.check color = myColors.success } ChecksumStatus.Finished.NotMatches -> { text = Res.string.not_matches.asStringSource() icon = MyIcons.info color = myColors.warning } } IconWithText( icon = icon, text = text.rememberString(), color = color, ) } @Composable private fun IconWithText( icon: IconSource, text: String, color: Color, ) { WithContentColor(color) { Row( verticalAlignment = Alignment.CenterVertically, ) { MyIcon( icon, modifier = Modifier.size(16.dp), contentDescription = null, ) Spacer(Modifier.width(2.dp)) SimpleText(text) } } } @Composable private fun RenderCheckingStatus(percent: Int) { Column { ProgressStatus(percent, myColors.primaryGradient) } } @Composable private fun RenderWaitingStatus() { Row { SimpleText("${myStringResource(Res.string.waiting)} ${rememberDotLoading()}") } } @Composable private fun ProgressStatus( percent: Int?, background: Brush = myColors.primaryGradient, ) { Box( Modifier .fillMaxWidth() .clip(CircleShape) .background(myColors.surface) ) { if (percent != null) { val w = (percent / 100f).coerceIn(0f..1f) Spacer( Modifier .height(5.dp) .fillMaxWidth( animateFloatAsState( w, tween(100) ).value ) .background(background) ) } } } @Composable private fun SimpleText(string: String, modifier: Modifier = Modifier) { Text( string, modifier = modifier, maxLines = 1, overflow = TextOverflow.MiddleEllipsis, ) } @Composable fun ColumnKeyValue( modifier: Modifier, keyContent: @Composable () -> Unit, valueContent: @Composable () -> Unit, actions: @Composable () -> Unit, ) { Row(modifier) { Column(Modifier.weight(1f)) { RenderKey { keyContent() } Space() RenderValue { valueContent() } } Space() actions() } } @Composable fun Space() { Spacer(Modifier.size(mySpacings.mediumSpace)) } @Composable fun RenderKey( content: @Composable () -> Unit ) { WithContentAlpha(0.5f) { content() } } @Composable fun RenderValue( content: @Composable () -> Unit ) { WithContentAlpha(1f) { content() } } @Composable fun RowKeyValue( key: String, value: String ) { Row { RenderKey { Text(key) } Space() RenderValue { Text(value) } } } } @Composable private fun RenderDownloadItemWithChecksum( item: DownloadItemWithChecksum, iconProvider: FileIconProvider, onRequestUpdateChecksum: () -> Unit, modifier: Modifier, ) { Column( modifier.padding( mySpacings.largeSpace, ) ) { Row( verticalAlignment = Alignment.CenterVertically ) { MyIcon( iconProvider.rememberIcon(item.downloadItem.name), modifier = Modifier.size(24.dp), contentDescription = null, ) Spacer(Modifier.width(mySpacings.mediumSpace)) Column { Text(item.downloadItem.name) Spacer(Modifier.height(mySpacings.mediumSpace)) FileChecksumTableCellRenderers.RowKeyValue( key = myStringResource(Res.string.checksum_algorithm), value = item.algorithm ) } } Spacer(Modifier.height(mySpacings.mediumSpace)) FileChecksumTableCellRenderers.RenderSavedChecksum( item = item, onRequestUpdateChecksum = onRequestUpdateChecksum ) Spacer(Modifier.height(mySpacings.mediumSpace)) FileChecksumTableCellRenderers.RenderCalculatedChecksum(item) Spacer(Modifier.height(mySpacings.mediumSpace)) FileChecksumTableCellRenderers.RenderStatus(item) } } ================================================ FILE: android/app/src/main/kotlin/com/abdownloadmanager/android/pages/crashreport/CrashReportActivity.kt ================================================ package com.abdownloadmanager.android.pages.crashreport import android.content.Context import android.content.Intent import android.os.Bundle import com.abdownloadmanager.android.util.activity.ABDMActivity class CrashReportActivity : ABDMActivity() { override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) val throwableData = getExceptionData(intent) setABDMContent { ErrorWindow(throwableData) { finish() } } } private fun getExceptionData(intent: Intent): ThrowableData { return ThrowableData( intent.getStringExtra(TITLE_KEY).orEmpty(), intent.getStringExtra(STACKTRACE_KEY).orEmpty(), ) } companion object { private const val TITLE_KEY = "title" private const val STACKTRACE_KEY = "stacktrace" fun createIntent( context: Context, throwable: Throwable ): Intent { val throwableData = ThrowableData.fromThrowable(throwable) return Intent( context, CrashReportActivity::class.java ).apply { putExtra(TITLE_KEY, throwableData.title) putExtra(STACKTRACE_KEY, throwableData.stacktrace) } } } } ================================================ FILE: android/app/src/main/kotlin/com/abdownloadmanager/android/pages/crashreport/ErrorUi.kt ================================================ package com.abdownloadmanager.android.pages.crashreport import android.os.Build import com.abdownloadmanager.shared.util.ui.myColors import com.abdownloadmanager.shared.util.ui.theme.myTextSizes import com.abdownloadmanager.shared.ui.widget.ActionButton import com.abdownloadmanager.shared.util.ClipboardUtil import androidx.compose.foundation.background import androidx.compose.foundation.horizontalScroll import androidx.compose.foundation.layout.* import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.text.selection.SelectionContainer import androidx.compose.foundation.verticalScroll import com.abdownloadmanager.shared.ui.widget.Text import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip import androidx.compose.ui.unit.dp import com.abdownloadmanager.android.ui.SheetHeader import com.abdownloadmanager.android.ui.SheetTitle import com.abdownloadmanager.android.ui.SheetUI import com.abdownloadmanager.android.util.AppInfo import com.abdownloadmanager.resources.Res import com.abdownloadmanager.shared.util.OnFullyDismissed import com.abdownloadmanager.shared.util.ResponsiveDialog import com.abdownloadmanager.shared.util.rememberResponsiveDialogState import com.abdownloadmanager.shared.util.ui.theme.myShapes import com.abdownloadmanager.shared.util.ui.theme.mySpacings import ir.amirab.util.compose.resources.myStringResource @Composable fun ErrorWindow( throwable: ThrowableData, close: () -> Unit, ) { val state = rememberResponsiveDialogState(true) state.OnFullyDismissed(close) ResponsiveDialog(state, state::hide) { SheetUI( header = { SheetHeader( headerTitle = { SheetTitle("Application Crash") } ) } ) { ErrorUi(throwable, state::hide) } } } @Composable private fun ErrorUi( e: ThrowableData, close: () -> Unit, ) { Column( modifier = Modifier .padding(horizontal = mySpacings.largeSpace) .padding(bottom = mySpacings.largeSpace), ) { Header( modifier = Modifier .fillMaxWidth(), e ) Spacer(Modifier.height(8.dp)) RenderException( modifier = Modifier .fillMaxWidth() .heightIn(max = 380.dp) .weight(1f, false), e = e ) Spacer(Modifier.height(8.dp)) Actions( modifier = Modifier .fillMaxWidth(), close = close, copyInformation = { ClipboardUtil.copy(createInformation(e)) } ) Spacer(Modifier.height(8.dp)) } } fun createInformation( exceptionString: ThrowableData, ): String { val version = AppInfo.version val platform = AppInfo.platform.name return """ ### Application Runtime Error ###### App Info ``` appVersion = $version platform = $platform Brand: ${Build.BRAND} Manufacturer: ${Build.MANUFACTURER} Model: ${Build.MODEL} Android Version: ${Build.VERSION.RELEASE} SDK: ${Build.VERSION.SDK_INT} ``` ###### Exception ``` $exceptionString ``` """.trimIndent() } @Composable private fun Header(modifier: Modifier = Modifier, e: ThrowableData) { Text( text = "We got an error in the application (\"${e.title}\")", modifier = modifier, fontSize = myTextSizes.xl ) } @Composable private fun RenderException(modifier: Modifier, e: ThrowableData) { val errorText = e.stacktrace Box( modifier = modifier .background(myColors.background) .clip(myShapes.defaultRounded) .horizontalScroll(rememberScrollState()) .verticalScroll(rememberScrollState()) .padding(8.dp) ) { SelectionContainer { Text( text = errorText, color = myColors.error, fontSize = myTextSizes.base, ) } } } @Composable private fun Actions( modifier: Modifier = Modifier, close: () -> Unit, copyInformation: () -> Unit, ) { Row( modifier = modifier, horizontalArrangement = Arrangement.End, ) { ActionButton( text = myStringResource(Res.string.close), onClick = close, modifier = Modifier.weight(1f) ) Spacer(Modifier.width(mySpacings.mediumSpace)) ActionButton( text = myStringResource(Res.string.copy), onClick = copyInformation, modifier = Modifier.weight(1f) ) } } ================================================ FILE: android/app/src/main/kotlin/com/abdownloadmanager/android/pages/crashreport/ThrowableData.kt ================================================ package com.abdownloadmanager.android.pages.crashreport import kotlinx.serialization.Serializable @Serializable data class ThrowableData( val title: String, val stacktrace: String, ) { companion object { fun fromThrowable(throwable: Throwable): ThrowableData { val title = throwable.localizedMessage ?: throwable.javaClass.simpleName ?: "Unknown error" val stacktrace = throwable.stackTraceToString().replace("\t", " ") return ThrowableData(title, stacktrace) } } } ================================================ FILE: android/app/src/main/kotlin/com/abdownloadmanager/android/pages/credits/thirdpartylibraries/ExternalLibsPage.kt ================================================ package com.abdownloadmanager.android.pages.credits.thirdpartylibraries import androidx.activity.OnBackPressedDispatcher import androidx.activity.compose.LocalOnBackPressedDispatcherOwner import androidx.compose.foundation.background import com.abdownloadmanager.shared.util.ui.ProvideTextStyle import com.abdownloadmanager.shared.util.ui.theme.myTextSizes import com.abdownloadmanager.shared.util.ui.WithContentAlpha import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.* import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.LazyListState import androidx.compose.foundation.lazy.itemsIndexed import androidx.compose.foundation.lazy.rememberLazyListState import com.abdownloadmanager.shared.ui.widget.Text import androidx.compose.runtime.* import androidx.compose.ui.Modifier import androidx.compose.ui.draw.drawBehind import androidx.compose.ui.geometry.Offset import androidx.compose.ui.graphics.Brush import androidx.compose.ui.graphics.Color import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.platform.LocalResources import androidx.compose.ui.text.TextStyle import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp import com.abdownloadmanager.android.ui.page.FooterFade import com.abdownloadmanager.android.ui.page.HeaderFade import com.abdownloadmanager.android.ui.page.PageHeader import com.abdownloadmanager.resources.Res import com.mikepenz.aboutlibraries.Libs import com.mikepenz.aboutlibraries.entity.Library import com.abdownloadmanager.android.ui.page.PageTitle import com.abdownloadmanager.android.ui.page.PageTitleWithDescription import com.abdownloadmanager.android.ui.page.PageUi import com.abdownloadmanager.android.ui.page.rememberHeaderAlpha import com.abdownloadmanager.android.util.compose.useBack import com.abdownloadmanager.shared.ui.widget.TransparentIconActionButton import com.abdownloadmanager.shared.util.div import com.abdownloadmanager.shared.util.ui.icon.MyIcons import com.abdownloadmanager.shared.util.ui.myColors import com.abdownloadmanager.shared.util.ui.theme.mySpacings import ir.amirab.util.compose.asStringSource import ir.amirab.util.compose.dpToPx import ir.amirab.util.compose.resources.myStringResource import ir.amirab.util.ifThen @Composable fun ThirdPartyLibrariesPage() { val pageTitle = myStringResource(Res.string.third_party_libraries) val pageDescription = myStringResource(Res.string.powered_by_open_source_software) val onBack = useBack() var contentPadding by remember { mutableStateOf(PaddingValues.Zero) } val topPadding = contentPadding.calculateTopPadding() val bottomPadding = contentPadding.calculateBottomPadding() val density = LocalDensity.current val listState = rememberLazyListState() val headerAlpha by rememberHeaderAlpha(listState, topPadding.dpToPx(density)) PageUi( header = { PageHeader( leadingIcon = { TransparentIconActionButton( MyIcons.back, Res.string.back.asStringSource() ) { onBack?.onBackPressed() } }, headerTitle = { PageTitleWithDescription(pageTitle, pageDescription) }, modifier = Modifier .background( myColors.background.copy( alpha = headerAlpha * 0.75f, ) ) .statusBarsPadding() ) }, footer = { Spacer(Modifier.navigationBarsPadding()) } ) { contentPadding = it.paddingValues Box( Modifier .fillMaxSize() ) { OpenSourceLibraries( libs = rememberLibs(), modifier = Modifier, state = listState, contentPadding = it.paddingValues, ) FooterFade(bottomPadding) } } } @Composable private fun OpenSourceLibraries( libs: Libs, modifier: Modifier, state: LazyListState, contentPadding: PaddingValues, ) { val dividerColor = myColors.onBackground / 0.5f var currentDialog by remember { mutableStateOf(null as Library?) } Column(modifier) { LazyColumn( state = state, contentPadding = contentPadding, ) { itemsIndexed(libs.libraries) { index, item -> val isFirstItem = index == 0 RenderLibraryItemInList( item, Modifier .ifThen(!isFirstItem) { drawBehind { drawLine( brush = Brush.horizontalGradient( listOf( Color.Transparent, dividerColor, Color.Transparent, ) ), start = Offset.Zero, end = Offset(size.width, 0f) ) } } .fillMaxWidth() .clickable { currentDialog = item } .padding(mySpacings.largeSpace) ) } } } LibraryDialog( library = currentDialog, onCloseRequest = { currentDialog = null } ) } @Composable private fun RenderLibraryItemInList( library: Library, modifier: Modifier, ) { Column(modifier) { Column { WithContentAlpha(1f) { Row(Modifier) { Text( library.name, fontSize = myTextSizes.base, overflow = TextOverflow.Ellipsis, maxLines = 1 ) Spacer(Modifier.width(2.dp)) library.artifactVersion?.let { version -> Text( text = version, fontSize = myTextSizes.base, overflow = TextOverflow.Ellipsis, maxLines = 1, ) } } } Spacer(Modifier.height(mySpacings.mediumSpace)) WithContentAlpha(0.75f) { Text( library.artifactId, maxLines = 1, overflow = TextOverflow.Ellipsis, fontSize = myTextSizes.sm, ) } } val by = library.by() if (by.isNotEmpty()) { Spacer(Modifier.height(mySpacings.mediumSpace)) Row { WithContentAlpha(0.7f) { ProvideTextStyle( TextStyle(fontSize = myTextSizes.sm) ) { for ((index, item) in by.withIndex()) { val (name, _) = item if (index != 0) { Spacer(Modifier.width(4.dp)) } Text( text = name, fontSize = myTextSizes.sm, maxLines = 1, overflow = TextOverflow.Ellipsis, ) } } } } } Spacer(Modifier.height(mySpacings.mediumSpace)) WithContentAlpha(0.75f) { Text( text = library.licenses.joinToString(", ") { it.name }, fontSize = myTextSizes.sm, maxLines = 1, overflow = TextOverflow.Ellipsis, ) } } } private fun Library.by(): List> { val d = developers.filter { it.name != null }.map { it.name!! to it.organisationUrl }.takeIf { it.isNotEmpty() } if (d != null) return d return organization?.let { listOf(it.name to it.url) } ?: emptyList() } @Composable private fun rememberLibs(): Libs { val resources = LocalResources.current return remember { val jsonContent = resources .openRawResource(com.abdownloadmanager.android.R.raw.aboutlibraries) .bufferedReader() .use { it.readText() } Libs.Builder().withJson(jsonContent).build() } } ================================================ FILE: android/app/src/main/kotlin/com/abdownloadmanager/android/pages/credits/thirdpartylibraries/LibraryDialog.kt ================================================ package com.abdownloadmanager.android.pages.credits.thirdpartylibraries import com.abdownloadmanager.shared.util.ui.myColors import com.abdownloadmanager.shared.util.ui.theme.myTextSizes import com.abdownloadmanager.shared.ui.widget.ActionButton import com.abdownloadmanager.shared.util.ui.WithContentAlpha import androidx.compose.foundation.background import androidx.compose.foundation.layout.* import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.verticalScroll import com.abdownloadmanager.shared.ui.widget.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.dp import com.abdownloadmanager.android.ui.SheetHeader import com.abdownloadmanager.android.ui.SheetTitle import com.abdownloadmanager.android.ui.SheetUI import com.abdownloadmanager.resources.Res import com.abdownloadmanager.shared.ui.widget.MaybeLinkText import com.abdownloadmanager.shared.util.OnFullyDismissed import com.abdownloadmanager.shared.util.ResponsiveDialog import com.abdownloadmanager.shared.util.div import com.abdownloadmanager.shared.util.rememberResponsiveDialogState import com.abdownloadmanager.shared.util.ui.theme.myShapes import com.abdownloadmanager.shared.util.ui.theme.mySpacings import com.mikepenz.aboutlibraries.entity.Developer import com.mikepenz.aboutlibraries.entity.Library import com.mikepenz.aboutlibraries.entity.License import com.mikepenz.aboutlibraries.entity.Organization import ir.amirab.util.compose.resources.myStringResource import ir.amirab.util.compose.StringSource import ir.amirab.util.compose.asStringSource import kotlinx.collections.immutable.ImmutableSet @Composable fun LibraryDialog( library: Library?, onCloseRequest: () -> Unit, ) { val state = rememberResponsiveDialogState(false) state.OnFullyDismissed(onCloseRequest) LaunchedEffect(library) { if (library == null) { state.hide() } else { state.show() } } val hideDialog: () -> Unit = state::hide ResponsiveDialog( state = state, onDismiss = hideDialog ) { Column { library?.let { library -> SheetUI( header = { SheetHeader( headerTitle = { SheetTitle(myStringResource(Res.string.info)) } ) } ) { Column( modifier = Modifier.padding(mySpacings.largeSpace) ) { Column( Modifier .weight(1f, false) .verticalScroll(rememberScrollState()) ) { LibraryNameAndVersion(library.name, library.artifactVersion, library.artifactId) Spacer(Modifier.height(16.dp)) library.description?.let { LibraryDescription(it) Spacer(Modifier.height(16.dp)) } library.developers.takeIf { it.isNotEmpty() }?.let { LibraryDevelopers(it) Spacer(Modifier.height(8.dp)) } library.organization?.let { LibraryOrganization(it) Spacer(Modifier.height(8.dp)) } val links = buildList { library.scm?.url?.let { add(Res.string.source_code.asStringSource() to it) } library.website?.let { add(Res.string.website.asStringSource() to it) } } links.takeIf { it.isNotEmpty() }?.let { LibraryLinks(links) Spacer(Modifier.height(8.dp)) } LibraryLicenseInfo(library.licenses) } Spacer(Modifier.height(8.dp)) Row( Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.End, ) { ActionButton( text = myStringResource(Res.string.close), onClick = { hideDialog() }, modifier = Modifier.weight(1f) ) } } } } } } } @Composable private fun LibraryLinks(links: List>) { KeyValue(myStringResource(Res.string.links)) { ListOfNamesWithLinks(links) } } @Composable private fun LibraryDescription(description: String) { Text( description, modifier = Modifier .fillMaxWidth() .clip(myShapes.defaultRounded) .background(myColors.onSurface / 0.1f) .padding(8.dp), color = myColors.onSurface, ) } @Composable private fun LibraryLicenseInfo(licenses: ImmutableSet) { KeyValue(myStringResource(Res.string.license)) { val l = licenses.map { it.name.asStringSource() to it.url } if (l.isEmpty()) { Text(myStringResource(Res.string.no_license_found)) } else { ListOfNamesWithLinks(l) } } } @Composable private fun LibraryDevelopers(devs: List) { KeyValue(myStringResource(Res.string.developers)) { ListOfNamesWithLinks( devs .filter { it.name != null } .map { it.name!!.asStringSource() to it.organisationUrl } ) } } @OptIn(ExperimentalLayoutApi::class) @Composable private fun ListOfNamesWithLinks(map: List>) { FlowRow { for ((i, v) in map.withIndex()) { val (name, link) = v MaybeLinkText(name.rememberString(), link) if (i < map.lastIndex) { Text(", ") } } } } @Composable fun LibraryOrganization(organization: Organization) { KeyValue(myStringResource(Res.string.organization)) { MaybeLinkText(organization.name, organization.url) } } @Composable private fun LibraryNameAndVersion( name: String, version: String?, artifactId: String, ) { val nameWithVersion = name + (version?.let { " $it" }.orEmpty()) Column { Row { Text( "$nameWithVersion", fontWeight = FontWeight.Bold, fontSize = myTextSizes.base, ) } Spacer(Modifier.height(4.dp)) WithContentAlpha(0.75f) { Row { Text( "($artifactId)", fontSize = myTextSizes.sm, ) } } } } @Composable private fun KeyValue( key: String, value: @Composable () -> Unit, ) { Row { WithContentAlpha(0.75f) { Text( "$key:", maxLines = 1, ) } Spacer(Modifier.width(8.dp)) value() } } ================================================ FILE: android/app/src/main/kotlin/com/abdownloadmanager/android/pages/credits/translators/TranslatorsPage.kt ================================================ package com.abdownloadmanager.android.pages.credits.translators import androidx.compose.animation.AnimatedContent import androidx.compose.animation.expandVertically import androidx.compose.animation.fadeIn import androidx.compose.animation.fadeOut import androidx.compose.animation.shrinkVertically import androidx.compose.animation.togetherWith import com.abdownloadmanager.android.di.Di import com.abdownloadmanager.resources.ABDMResources import com.abdownloadmanager.shared.ui.widget.MaybeLinkText import kotlinx.coroutines.runBlocking import androidx.compose.foundation.* import androidx.compose.foundation.layout.* import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.LazyListState import androidx.compose.foundation.lazy.itemsIndexed import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp import com.abdownloadmanager.android.ui.page.FooterFade import com.abdownloadmanager.android.ui.page.PageHeader import com.abdownloadmanager.android.ui.page.PageTitle import com.abdownloadmanager.android.ui.page.PageUi import com.abdownloadmanager.android.ui.page.rememberHeaderAlpha import com.abdownloadmanager.android.util.compose.useBack import com.abdownloadmanager.shared.util.ui.myColors import com.abdownloadmanager.shared.util.ui.theme.myTextSizes import com.abdownloadmanager.shared.ui.widget.Text import com.abdownloadmanager.shared.util.div import com.abdownloadmanager.resources.Res import com.abdownloadmanager.shared.pages.credits.translators.LanguageTranslationInfo import com.abdownloadmanager.shared.pages.credits.translators.TranslatorData import com.abdownloadmanager.shared.ui.widget.PrimaryMainActionButton import com.abdownloadmanager.shared.ui.widget.TransparentIconActionButton import com.abdownloadmanager.shared.util.SharedConstants import com.abdownloadmanager.shared.util.ui.LocalContentColor import com.abdownloadmanager.shared.util.ui.WithContentAlpha import com.abdownloadmanager.shared.util.ui.icon.MyIcons import com.abdownloadmanager.shared.util.ui.theme.mySpacings import ir.amirab.util.URLOpener import ir.amirab.util.compose.asStringSource import ir.amirab.util.compose.dpToPx import ir.amirab.util.compose.localizationmanager.LanguageNameProvider import ir.amirab.util.compose.localizationmanager.MyLocale import ir.amirab.util.compose.resources.myStringResource import ir.amirab.util.ifThen import kotlinx.serialization.json.Json import org.koin.core.component.get @Composable fun TranslatorsPage(onBack: () -> Unit) { Translators( Modifier .fillMaxSize() .background(myColors.background) ) } @Composable internal fun Translators(modifier: Modifier) { val listState = rememberLazyListState() var contentPadding by remember { mutableStateOf(PaddingValues.Zero) } val topPadding = contentPadding.calculateTopPadding() val bottomPadding = contentPadding.calculateBottomPadding() val density = LocalDensity.current val headerAlpha by rememberHeaderAlpha(listState, topPadding.dpToPx(density)) PageUi( modifier = modifier, header = { val onBack = useBack() PageHeader( leadingIcon = { TransparentIconActionButton( MyIcons.back, Res.string.back.asStringSource(), ) { onBack?.onBackPressed() } }, headerTitle = { PageTitle( myStringResource(Res.string.meet_the_translators) ) }, modifier = Modifier .background( myColors.background.copy( alpha = headerAlpha * 0.75f ) ) .statusBarsPadding() ) }, footer = { AnimatedContent( headerAlpha == 0f, transitionSpec = { fadeIn() + expandVertically() togetherWith fadeOut() + shrinkVertically() }, contentAlignment = Alignment.BottomCenter, ) { if (it) { ContributionNotice( modifier = Modifier, onUserWantsToContribute = { URLOpener.openUrl(SharedConstants.projectTranslations) } ) } else { Spacer( modifier = Modifier .fillMaxWidth() .navigationBarsPadding() ) } } // AnimatedVisibility( // headerAlpha == 0f, // enter = expandVertically() + fadeIn(), // exit = shrinkVertically() + fadeOut(), // ) { // // } }, ) { contentPadding = it.paddingValues Box { DearTranslators( Modifier .fillMaxWidth(), state = listState, contentPadding = it.paddingValues, ) FooterFade(bottomPadding) } } } @Composable private fun ContributionNotice( modifier: Modifier, onUserWantsToContribute: () -> Unit, ) { Column( modifier .fillMaxWidth() .background(myColors.surface), ) { Spacer( Modifier .fillMaxWidth() .height(1.dp) .background(myColors.onSurface / 0.15f) ) Column( Modifier .padding(mySpacings.largeSpace) .navigationBarsPadding() ) { Text( myStringResource(Res.string.translators_page_thanks), modifier = Modifier, fontSize = myTextSizes.lg, fontWeight = FontWeight.Bold, ) Spacer( Modifier .fillMaxWidth() .padding(vertical = 8.dp) .height(1.dp) .background(myColors.surface) ) Row( verticalAlignment = Alignment.CenterVertically, ) { Column( Modifier.weight(1f) ) { Text( myStringResource(Res.string.translators_contribute_title), fontSize = myTextSizes.lg, fontWeight = FontWeight.Bold, ) Spacer(Modifier.height(4.dp)) Text( myStringResource(Res.string.translators_contribute_description), fontSize = myTextSizes.base, color = LocalContentColor.current / 0.75f ) } } Spacer(Modifier.height(mySpacings.largeSpace)) PrimaryMainActionButton( text = myStringResource(Res.string.contribute), onClick = onUserWantsToContribute, modifier = Modifier.fillMaxWidth(), enabled = true, ) } } } @Composable private fun DearTranslators( modifier: Modifier, state: LazyListState, contentPadding: PaddingValues, ) { val itemHorizontalPadding = 16.dp val list = rememberLanguageTranslationInfo() LazyColumn( modifier, state = state, contentPadding = contentPadding, ) { itemsIndexed(list) { index, item -> TranslatedLanguageItem( item, Modifier .fillMaxWidth() .ifThen(index % 2 == 1) { background(myColors.surface) } .padding(16.dp, itemHorizontalPadding) ) } } } @Composable private fun TranslatedLanguageItem( translationInfo: LanguageTranslationInfo, modifier: Modifier, ) { Column(modifier) { Column { WithContentAlpha(1f) { Text( translationInfo.nativeName, fontSize = myTextSizes.base, overflow = TextOverflow.Ellipsis, maxLines = 1 ) } Spacer(Modifier.height(mySpacings.smallSpace)) WithContentAlpha(0.75f) { Row( verticalAlignment = Alignment.CenterVertically, ) { Text( translationInfo.englishName, maxLines = 1, overflow = TextOverflow.Ellipsis, fontSize = myTextSizes.base, ) Spacer(Modifier.width(4.dp)) Text( translationInfo.locale, maxLines = 1, overflow = TextOverflow.Ellipsis, fontSize = myTextSizes.base, color = myColors.primary, modifier = Modifier .background(myColors.primary / 10) .padding(vertical = 0.dp, horizontal = 4.dp) ) } } } Spacer(Modifier.height(mySpacings.mediumSpace)) Column( verticalArrangement = Arrangement.spacedBy(mySpacings.smallSpace) ) { translationInfo.translators.forEach { MaybeLinkText( it.name, it.link, ) } } } } private fun convertLanguageToMyLocale(language: String): MyLocale { return language.split("-").run { MyLocale( languageCode = get(0), countryCode = getOrNull(1) ) } } @Composable private fun rememberLanguageTranslationInfo(): List { return remember { val json = Di.get() val translatorData = runBlocking { ABDMResources.getTranslatorsContent() }.let { json.decodeFromString(it) } translatorData.map { val name = LanguageNameProvider.getName(convertLanguageToMyLocale(it.key)) LanguageTranslationInfo( locale = it.key, englishName = name.englishName, nativeName = name.nativeName, translators = it.value, ) } } } ================================================ FILE: android/app/src/main/kotlin/com/abdownloadmanager/android/pages/directorypicker/ComposeExtension.kt ================================================ package com.abdownloadmanager.android.pages.directorypicker import androidx.activity.compose.rememberLauncherForActivityResult import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue import androidx.compose.runtime.remember import androidx.compose.runtime.rememberUpdatedState import ir.amirab.util.compose.StringSource import okio.Path.Companion.toPath class DirectoryPickerLauncher( private val onLaunch: () -> Unit, ) { fun launch() { onLaunch() } } @Composable fun rememberAndroidDirectoryPickerLauncher( initialDirectory: String?, title: StringSource, onDirectorySelected: (String?) -> Unit, ): DirectoryPickerLauncher { val pickFolderLauncher = rememberLauncherForActivityResult( contract = DirectoryPickerActivity.Contract, ) { directory -> onDirectorySelected(directory?.toString()) } val initialDirectory by rememberUpdatedState(initialDirectory) val title by rememberUpdatedState(title) return remember { DirectoryPickerLauncher { pickFolderLauncher.launch( DirectoryPickerActivity.Inputs( title = title, initialDirectory = initialDirectory?.toPath(), ) ) } } } ================================================ FILE: android/app/src/main/kotlin/com/abdownloadmanager/android/pages/directorypicker/DirectoryPickerActivity.kt ================================================ package com.abdownloadmanager.android.pages.directorypicker import android.content.Context import android.content.Intent import android.os.Bundle import android.os.Environment import androidx.activity.result.contract.ActivityResultContract import com.abdownloadmanager.android.util.activity.ABDMActivity import ir.amirab.util.compose.StringSource import ir.amirab.util.compose.asStringSource import okio.Path import okio.Path.Companion.toPath class DirectoryPickerActivity : ABDMActivity() { override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) val title = DirectoryPickerActivity.getTitle(intent).orEmpty().asStringSource() val initialDirectory = ( DirectoryPickerActivity.getInitialDirectory(intent) // default if there is no directory provided to us ?: Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_DOWNLOADS).toString() ).toPath() setABDMContent { DirectoryPicker( title = title, isVisible = true, initialDirectory = initialDirectory, onDirectorySelected = { returnTheActivityResult(it) }, ) } } private fun returnTheActivityResult(path: Path?) { if (path == null) { setResult(RESULT_CANCELED) } else { setResult(RESULT_OK, Intent().putExtra(DIRECTORY_RESULT, path.toString())) } finish() } data class Inputs( val title: StringSource, val initialDirectory: Path?, ) companion object { const val TITLE_KEY = "title" const val INITIAL_DIR_KEY = "initialDirectory" const val DIRECTORY_RESULT = "directory" val Contract = object : ActivityResultContract() { override fun createIntent( context: Context, input: Inputs ): Intent { return Intent(context, DirectoryPickerActivity::class.java).apply { putExtra(TITLE_KEY, input.title.getString()) putExtra(INITIAL_DIR_KEY, input.initialDirectory.toString()) } } override fun parseResult(resultCode: Int, intent: Intent?): Path? { return if (resultCode == RESULT_OK) { intent?.getStringExtra(DIRECTORY_RESULT)?.toPath() } else null } } fun getTitle(intent: Intent): String? { return intent.getStringExtra(TITLE_KEY) } fun getInitialDirectory(intent: Intent): String? { return intent.getStringExtra(INITIAL_DIR_KEY) } } } ================================================ FILE: android/app/src/main/kotlin/com/abdownloadmanager/android/pages/directorypicker/DirectoryPickerPage.kt ================================================ package com.abdownloadmanager.android.pages.directorypicker import android.content.Context import android.os.Environment import androidx.compose.animation.AnimatedContent import androidx.compose.animation.AnimatedVisibility import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.heightIn import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.width import androidx.compose.foundation.layout.wrapContentSize import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.items import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.runtime.Composable import androidx.compose.runtime.Immutable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableIntStateOf import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.unit.dp import com.abdownloadmanager.android.pages.onboarding.permissions.ABDMPermissions import com.abdownloadmanager.android.pages.onboarding.permissions.rememberAppPermissionState import com.abdownloadmanager.android.ui.SheetHeader import com.abdownloadmanager.android.ui.SheetTitleWithDescription import com.abdownloadmanager.android.ui.SheetUI import com.abdownloadmanager.android.ui.configurable.RenderSpinnerInSheet import com.abdownloadmanager.android.ui.configurable.SheetInput import com.abdownloadmanager.resources.Res import com.abdownloadmanager.shared.ui.widget.ActionButton import com.abdownloadmanager.shared.ui.widget.IconActionButton import com.abdownloadmanager.shared.ui.widget.MyTextField import com.abdownloadmanager.shared.ui.widget.PrimaryMainActionButton import com.abdownloadmanager.shared.ui.widget.Text import com.abdownloadmanager.shared.ui.widget.TransparentIconActionButton import com.abdownloadmanager.shared.util.OnFullyDismissed import com.abdownloadmanager.shared.util.ResponsiveDialog import com.abdownloadmanager.shared.util.rememberResponsiveDialogState import com.abdownloadmanager.shared.util.ui.VerticalScrollableContent import com.abdownloadmanager.shared.util.ui.icon.MyIcons import com.abdownloadmanager.shared.util.ui.myColors import com.abdownloadmanager.shared.util.ui.theme.mySpacings import com.abdownloadmanager.shared.util.ui.widget.MyIcon import ir.amirab.util.PathValidator import ir.amirab.util.compose.StringSource import ir.amirab.util.compose.asStringSource import ir.amirab.util.compose.resources.myStringResource import ir.amirab.util.createDirectories import ir.amirab.util.exists import ir.amirab.util.isDirectory import ir.amirab.util.listFiles import ir.amirab.util.listFilesOrNull import ir.amirab.util.startsWith import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.delay import kotlinx.coroutines.launch import okio.Path import okio.Path.Companion.toOkioPath import okio.Path.Companion.toPath val alwaysAllowedPaths = listOf( Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_DOWNLOADS).toOkioPath(), ) @Composable fun DirectoryPicker( title: StringSource, isVisible: Boolean, initialDirectory: Path, onDirectorySelected: (Path?) -> Unit ) { var changingRoot by remember { mutableStateOf(false) } val state = rememberResponsiveDialogState(false) LaunchedEffect(isVisible) { if (isVisible) { state.show() } else { state.hide() } } state.OnFullyDismissed { onDirectorySelected(null) } val onDismiss = state::hide ResponsiveDialog( state, onDismiss ) { var currentDirectory by remember(initialDirectory) { mutableStateOf(initialDirectory) } var creatingNewFolder by remember { mutableStateOf(false) } // update this counter in order to refresh directory list! var updateDirectories by remember { mutableIntStateOf(0) } fun refreshDirectories() { updateDirectories++ } val storagePermissionState = rememberAppPermissionState(ABDMPermissions.StoragePermission) val directoryList = remember( currentDirectory, updateDirectories, storagePermissionState.isGranted, ) { val weHaveFullAccess = storagePermissionState.isGranted DirectoryList( currentDirectory = currentDirectory, directories = runCatching { currentDirectory .listFiles() .filter { it.isDirectory() } } .getOrNull() .orEmpty() .map { DirectoryItem(it.name, it) }, backDirectory = currentDirectory .parent // don't go somewhere that we can't return ?.takeIf { it.listFilesOrNull()?.isNotEmpty() ?: false }, currentDirectoryCanWrite = if (weHaveFullAccess) { true } else { alwaysAllowedPaths.any { allowedPath -> currentDirectory.startsWith(allowedPath) } } ) } val coroutineScope = rememberCoroutineScope() fun createNewFolderAndRefresh(newFolderName: String) { creatingNewFolder = false coroutineScope.launch(Dispatchers.IO) { runCatching { currentDirectory.resolve(newFolderName).createDirectories() } delay(50) refreshDirectories() // schedule refresh } } SheetUI( header = { SheetHeader( headerTitle = { SheetTitleWithDescription( title = title.rememberString(), description = currentDirectory.toString() ) }, headerActions = { TransparentIconActionButton( MyIcons.close, contentDescription = Res.string.close.asStringSource(), onClick = onDismiss ) } ) } ) { val horizontalPadding = mySpacings.largeSpace val itemPadding = PaddingValues( horizontal = mySpacings.largeSpace, vertical = mySpacings.mediumSpace ) Column { val lazyListState = rememberLazyListState() AnimatedContent( directoryList, modifier = Modifier .weight(1f, false) ) { directoryList -> VerticalScrollableContent( lazyListState = lazyListState, ) { Box( Modifier.heightIn(250.dp) ) { LazyColumn { if (directoryList.backDirectory != null) { item { val backDirectoryItem = remember(directoryList.currentDirectory) { DirectoryItem( name = "..", path = directoryList.backDirectory ) } RenderDirectoryItem( modifier = Modifier .animateItem() .fillMaxWidth(), item = backDirectoryItem, onDirectorySelected = { currentDirectory = backDirectoryItem.path }, itemPadding = itemPadding, ) } } items(directoryList.directories) { directoryItem -> RenderDirectoryItem( modifier = Modifier .animateItem() .fillMaxWidth(), item = directoryItem, onDirectorySelected = { currentDirectory = directoryItem.path }, itemPadding = itemPadding, ) } } if (directoryList.directories.isEmpty()) { Text( myStringResource(Res.string.list_is_empty), Modifier .matchParentSize() .wrapContentSize() ) } } } } Spacer(Modifier.height(mySpacings.mediumSpace)) Column( Modifier.padding(horizontal = horizontalPadding) ) { AnimatedVisibility(!directoryList.currentDirectoryCanWrite && !storagePermissionState.isGranted) { ActionButton( text = myStringResource(Res.string.give_storage_permission), onClick = { storagePermissionState.launchRequest() }, modifier = Modifier .fillMaxWidth() .padding(bottom = mySpacings.mediumSpace), borderColor = myColors.warningGradient, contentColor = myColors.warning, start = { MyIcon( icon = storagePermissionState.appPermission.icon, contentDescription = null, modifier = Modifier .size(24.dp) .padding(end = mySpacings.mediumSpace) ) } ) } Row( verticalAlignment = Alignment.CenterVertically, ) { IconActionButton( MyIcons.folder, contentDescription = Res.string.storage_roots.asStringSource(), onClick = { changingRoot = true } ) Spacer(Modifier.width(mySpacings.mediumSpace)) PrimaryMainActionButton( text = myStringResource(Res.string.ok), onClick = { onDirectorySelected(currentDirectory) }, modifier = Modifier.weight(1f), enabled = directoryList.currentDirectoryCanWrite, ) Spacer(Modifier.width(mySpacings.mediumSpace)) IconActionButton( MyIcons.add, contentDescription = Res.string.new_folder.asStringSource(), enabled = directoryList.currentDirectoryCanWrite, ) { creatingNewFolder = true } } } } SheetInput( isOpened = creatingNewFolder, title = Res.string.new_folder.asStringSource(), initialValue = { "" }, validate = { val newFolder = runCatching { currentDirectory.resolve(it) }.getOrNull() ?: return@SheetInput false PathValidator.isValidPath(newFolder.toString()) && !newFolder.exists() }, onConfirm = { newFolderName -> createNewFolderAndRefresh(newFolderName) }, onDismiss = { creatingNewFolder = false } ) { params -> MyTextField( text = params.editingValue, onTextChange = params.setEditingValue, modifier = params.modifier, placeholder = "New Folder", keyboardActions = params.keyboardActions, ) } } StorageRoots( onRequestChangeStorageRoot = { currentDirectory = it changingRoot = false }, currentDirectory = currentDirectory, isOpened = changingRoot, onDismiss = { changingRoot = false } ) } } @Composable private fun RenderDirectoryItem( modifier: Modifier, item: DirectoryItem, onDirectorySelected: () -> Unit, itemPadding: PaddingValues, ) { Row( modifier .clickable( onClick = onDirectorySelected ) .heightIn(mySpacings.thumbSize) .padding(itemPadding), verticalAlignment = Alignment.CenterVertically, ) { MyIcon(MyIcons.folder, null) Spacer(Modifier.width(mySpacings.mediumSpace)) Text(item.name) } } @Composable private fun StorageRoots( onRequestChangeStorageRoot: (Path) -> Unit, currentDirectory: Path, isOpened: Boolean, onDismiss: () -> Unit, ) { val context = LocalContext.current val roots = remember { runCatching { getRootPaths(context) } .onFailure { it.printStackTrace() } .getOrElse { emptyList() } } val currentRoot = remember(currentDirectory) { roots.firstOrNull { currentDirectory.startsWith(it) } } RenderSpinnerInSheet( title = Res.string.storage_roots.asStringSource(), isOpened = isOpened, onDismiss = onDismiss, possibleValues = roots, value = currentRoot, onSelect = { if (it == null) { onDismiss() } else { onRequestChangeStorageRoot(it) } }, ) { Row { Text(it.toString()) } } } private fun getRootPaths(context: Context): List { val externalFilesDirs = context.getExternalFilesDirs(null) if (externalFilesDirs.isNullOrEmpty()) { return emptyList() } return externalFilesDirs .map { it .absolutePath .substringBefore("/Android/") .toPath() } } @Immutable private data class DirectoryList( val currentDirectory: Path, val backDirectory: Path?, val directories: List, val currentDirectoryCanWrite: Boolean, ) @Immutable private data class DirectoryItem( val name: String, val path: Path, ) ================================================ FILE: android/app/src/main/kotlin/com/abdownloadmanager/android/pages/editdownload/AndroidEditDownloadComponent.kt ================================================ package com.abdownloadmanager.android.pages.editdownload import com.abdownloadmanager.shared.downloaderinui.DownloaderInUiRegistry import com.abdownloadmanager.shared.pages.editdownload.BaseEditDownloadComponent import com.abdownloadmanager.shared.util.mvi.ContainsEffects import com.abdownloadmanager.shared.util.mvi.supportEffects import com.abdownloadmanager.shared.util.DownloadSystem import com.abdownloadmanager.shared.util.FileIconProvider import com.arkivanov.decompose.ComponentContext import ir.amirab.downloader.downloaditem.DownloadJobExtraConfig import ir.amirab.downloader.downloaditem.IDownloadItem import kotlinx.coroutines.flow.* class AndroidEditDownloadComponent( ctx: ComponentContext, onRequestClose: () -> Unit, downloadId: Long, acceptEdit: StateFlow, onEdited: ((IDownloadItem) -> Unit, DownloadJobExtraConfig?) -> Unit, downloadSystem: DownloadSystem, downloaderInUiRegistry: DownloaderInUiRegistry, iconProvider: FileIconProvider, ) : BaseEditDownloadComponent( ctx = ctx, downloadSystem = downloadSystem, downloaderInUiRegistry = downloaderInUiRegistry, iconProvider = iconProvider, onEdited = onEdited, onRequestClose = onRequestClose, downloadId = downloadId, acceptEdit = acceptEdit, ), ContainsEffects by supportEffects() { sealed interface Effects { } } ================================================ FILE: android/app/src/main/kotlin/com/abdownloadmanager/android/pages/editdownload/EditDownload.kt ================================================ package com.abdownloadmanager.android.pages.editdownload import androidx.compose.runtime.Composable import com.abdownloadmanager.shared.util.ui.WithContentAlpha import ir.amirab.util.compose.IconSource import com.abdownloadmanager.shared.util.ui.widget.MyIcon import com.abdownloadmanager.shared.util.ui.icon.MyIcons import com.abdownloadmanager.shared.util.ui.myColors import com.abdownloadmanager.shared.util.ui.theme.myTextSizes import androidx.compose.animation.* import androidx.compose.foundation.* import androidx.compose.foundation.layout.* import androidx.compose.runtime.* import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip import androidx.compose.ui.draw.shadow import androidx.compose.ui.focus.FocusRequester import androidx.compose.ui.focus.focusRequester import androidx.compose.ui.graphics.* import androidx.compose.ui.input.pointer.PointerIcon import androidx.compose.ui.input.pointer.pointerHoverIcon import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.* import androidx.compose.ui.window.* import com.abdownloadmanager.android.pages.add.shared.ExtraConfig import com.abdownloadmanager.android.ui.SheetHeader import com.abdownloadmanager.android.ui.SheetTitle import com.abdownloadmanager.android.ui.SheetUI import com.abdownloadmanager.shared.ui.widget.* import ir.amirab.util.ifThen import com.abdownloadmanager.resources.Res import com.abdownloadmanager.shared.downloaderinui.edit.CanEditDownloadResult import com.abdownloadmanager.shared.downloaderinui.edit.CanEditWarnings import com.abdownloadmanager.shared.downloaderinui.edit.EditDownloadInputs import com.abdownloadmanager.shared.downloaderinui.edit.TAEditDownloadInputs import com.abdownloadmanager.shared.util.ClipboardUtil import com.abdownloadmanager.shared.util.FileIconProvider import com.abdownloadmanager.shared.util.OnFullyDismissed import com.abdownloadmanager.shared.util.ResponsiveDialog import com.abdownloadmanager.shared.util.ResponsiveDialogScope import com.abdownloadmanager.shared.util.ui.WithContentColor import com.abdownloadmanager.shared.util.div import com.abdownloadmanager.shared.util.rememberResponsiveDialogState import com.abdownloadmanager.shared.util.ui.theme.myShapes import com.abdownloadmanager.shared.util.ui.theme.mySpacings import ir.amirab.util.URLOpener import ir.amirab.util.compose.resources.myStringResource import ir.amirab.util.compose.asStringSource @Composable fun EditDownloadSheet( component: AndroidEditDownloadComponent?, onDismiss: () -> Unit, ) { val state = rememberResponsiveDialogState(false) state.OnFullyDismissed(onDismiss) LaunchedEffect(component) { if (component == null) { state.hide() } else { state.show() } } ResponsiveDialog(state, state::hide) { component?.let { EditDownloadPage(component, state::hide) } } } @Composable fun ResponsiveDialogScope.EditDownloadPage( component: AndroidEditDownloadComponent, onDismiss: () -> Unit, ) { SheetUI( header = { SheetHeader( headerTitle = { SheetTitle(myStringResource(Res.string.edit_download_title)) }, headerActions = { TransparentIconActionButton( MyIcons.close, contentDescription = Res.string.close.asStringSource(), onClick = onDismiss ) }, ) } ) { component.editDownloadUiChecker.collectAsState().value?.let { downloadInputs -> Column( Modifier .padding(mySpacings.mediumSpace) ) { val canAddResult by downloadInputs.canEditDownloadResult.collectAsState() val link by downloadInputs.link.collectAsState() fun setLink(link: String) { downloadInputs.setLink(link) } val linkFocus = remember { FocusRequester() } LaunchedEffect(Unit) { linkFocus.requestFocus() } UrlTextField( text = link, setText = { setLink(it) }, modifier = Modifier.focusRequester(linkFocus), errorText = when (canAddResult) { CanEditDownloadResult.InvalidURL -> Res.string.invalid_url else -> null }?.takeIf { link.isNotEmpty() }?.asStringSource()?.rememberString() // ATTENTION DO NOT use composable functions in when branches // it seems buggy (compose won't render ui properly) // stranger part is that in this case if we use ? before takeIf then it will work! (`}.takeIf {` is buggy but `}?.takeIf {` works!) // maybe there is a bug in compose compiler, or maybe I'm missed something. if you read this ,and you know why! please let me know! ) val name by downloadInputs.name.collectAsState() Spacer(Modifier.size(8.dp)) NameTextField( text = name, setText = { downloadInputs.setName(it) }, errorText = when (canAddResult) { CanEditDownloadResult.FileNameAlreadyExists -> Res.string.file_name_already_exists CanEditDownloadResult.InvalidFileName -> Res.string.invalid_file_name else -> null }?.takeIf { name.isNotEmpty() }?.asStringSource()?.rememberString() ) Spacer(Modifier.size(8.dp)) Row( modifier = Modifier, verticalAlignment = Alignment.CenterVertically, ) { RenderFileTypeAndSize(component.iconProvider, downloadInputs) RenderResumeSupport(downloadInputs, Modifier.weight(1f)) ConfigActionsButtons(downloadInputs) } Spacer(Modifier.size(8.dp)) MainActionButtons(component, downloadInputs) val showMoreSettings by downloadInputs.showMoreSettings.collectAsState() ExtraConfig( onDismiss = { downloadInputs.setShowMoreSettings(false) }, configurables = downloadInputs.configurableList, isOpened = showMoreSettings, ) } } } } @Composable fun BrowserImportButton( downloadUiState: EditDownloadInputs<*, *, *, *, *, *>, ) { val downloadPage = downloadUiState.currentDownloadItem.collectAsState().value.downloadPage IconActionButton( MyIcons.earth, Res.string.edit_download_update_from_download_page.asStringSource(), enabled = downloadPage != null, onClick = { downloadPage?.let { URLOpener.openUrl(it) } } ) } @Composable private fun RenderResumeSupport( editDownloadUiChecker: TAEditDownloadInputs, modifier: Modifier, ) { val fileInfo by editDownloadUiChecker.responseInfo.collectAsState() Row( verticalAlignment = Alignment.CenterVertically, modifier = modifier.height(16.dp) ) { val lineModifier = Modifier .weight(1f) .height(1.dp) .background(myColors.onBackground / 10) Box(lineModifier) val canEditDownload by editDownloadUiChecker.canEdit.collectAsState() AnimatedVisibility( visible = canEditDownload && fileInfo != null, ) { fileInfo?.let { fileInfo -> if (fileInfo.resumeSupport) { val iconModifier = Modifier .padding(horizontal = 2.dp) .size(10.dp) if (fileInfo.resumeSupport) { MyIcon( icon = MyIcons.check, contentDescription = null, modifier = iconModifier, tint = myColors.success ) } else { MyIcon( icon = MyIcons.clear, contentDescription = null, modifier = iconModifier, tint = myColors.error, ) } } } } Box(lineModifier) } } @Composable private fun MainConfigActionButton( text: String, modifier: Modifier, enabled: Boolean = true, onClick: () -> Unit, ) { ActionButton(text, modifier, enabled, onClick) } @Composable fun ConfigActionsButtons( downloadInputs: TAEditDownloadInputs, ) { val showMoreSettings by downloadInputs.showMoreSettings.collectAsState() val requiresAuth = downloadInputs.responseInfo.collectAsState().value?.requireBasicAuth ?: false Row { IconActionButton(MyIcons.refresh, Res.string.refresh.asStringSource()) { downloadInputs.refresh() } Spacer(Modifier.width(6.dp)) BrowserImportButton(downloadInputs) Spacer(Modifier.width(6.dp)) IconActionButton( MyIcons.settings, Res.string.settings.asStringSource(), indicateActive = showMoreSettings, requiresAttention = requiresAuth ) { downloadInputs.setShowMoreSettings(true) } } } @Composable private fun MainActionButtons( component: AndroidEditDownloadComponent, editDownloadUiChecker: TAEditDownloadInputs, ) { Row { val canEditResult by editDownloadUiChecker.canEditDownloadResult.collectAsState() val canEdit = run { val canBeEdited = editDownloadUiChecker.canEdit.collectAsState().value val componentAllowsEdit = component.acceptEdit.collectAsState().value canBeEdited && componentAllowsEdit } val warnings = (canEditResult as? CanEditDownloadResult.CanEdit)?.warnings.orEmpty() Spacer(Modifier.width(8.dp)) var showWarningPrompt by remember { mutableStateOf(false) } MainConfigActionButton( text = myStringResource(Res.string.cancel), modifier = Modifier.weight(1f), onClick = { component.onRequestClose() }, ) Spacer(Modifier.width(mySpacings.mediumSpace)) Box(Modifier.weight(1f)) { if (showWarningPrompt) { WarningPrompt( warnings = warnings, onClose = { showWarningPrompt = false }, onConfirm = { if (canEdit) { component.onRequestEdit() } } ) } PrimaryMainActionButton( text = myStringResource(Res.string.change), modifier = Modifier.fillMaxWidth(), enabled = canEdit, onClick = { if (warnings.isNotEmpty()) { showWarningPrompt = true } else { component.onRequestEdit() } }, ) } } } @Composable fun WarningPrompt( warnings: List, onClose: () -> Unit, onConfirm: () -> Unit, ) { Popup( popupPositionProvider = rememberMyComponentRectPositionProvider( anchor = Alignment.TopStart, alignment = Alignment.TopEnd, ), onDismissRequest = onClose ) { val shape = myShapes.defaultRounded Box( Modifier .padding(vertical = 4.dp) .widthIn(max = 240.dp) .shadow(24.dp) .clip(shape) .border(1.dp, myColors.surface, shape) .background(myColors.menuGradientBackground) .padding(8.dp) ) { WithContentColor(myColors.onSurface) { Column { Text( myStringResource(Res.string.warning), fontWeight = FontWeight.Bold, color = myColors.warning ) Spacer(Modifier.height(4.dp)) warnings.forEach { Text( it.asStringSource().rememberString(), fontSize = myTextSizes.base, ) } Text(myStringResource(Res.string.warning_you_may_have_to_restart_the_download_later)) Spacer(Modifier.height(8.dp)) ActionButton( modifier = Modifier.align(Alignment.CenterHorizontally), text = myStringResource(Res.string.change_anyway), onClick = onConfirm, borderColor = SolidColor(myColors.error), contentColor = myColors.error, ) } } } } } @Composable private fun RenderFileTypeAndSize( iconProvider: FileIconProvider, editDownloadUiChecker: TAEditDownloadInputs, ) { val isLinkLoading by editDownloadUiChecker.isLinkLoading.collectAsState() val fileInfo by editDownloadUiChecker.responseInfo.collectAsState() val iconModifier = Modifier.size(mySpacings.iconSize) Box(Modifier) { AnimatedContent( targetState = isLinkLoading, transitionSpec = { fadeIn() togetherWith fadeOut() } ) { loading -> if (loading) { LoadingIndicator(iconModifier) } else { val icon = iconProvider.rememberIcon(editDownloadUiChecker.name.collectAsState().value) AnimatedContent( fileInfo, ) { fileInfo -> Row( verticalAlignment = Alignment.CenterVertically, ) { WithContentAlpha(1f) { if (fileInfo != null) { if (fileInfo.requiresAuth) { MyIcon( MyIcons.lock, null, iconModifier, tint = myColors.error ) } MyIcon( icon, null, iconModifier ) val size by editDownloadUiChecker.lengthStringFlow.collectAsState() Spacer(Modifier.width(8.dp)) Text( size.rememberString(), fontSize = myTextSizes.sm, ) } else { MyIcon( icon = MyIcons.question, contentDescription = null, modifier = iconModifier, ) } } } } } } } } @Composable private fun MyTextFieldIcon( icon: IconSource, onClick: (() -> Unit)? = null, ) { MyIcon( icon, null, Modifier .fillMaxHeight() .ifThen(onClick != null) { pointerHoverIcon(PointerIcon.Default) .clickable { onClick?.invoke() } } .wrapContentHeight() .padding(horizontal = 8.dp) .size(16.dp)) } @Composable private fun UrlTextField( text: String, setText: (String) -> Unit, modifier: Modifier = Modifier, errorText: String? = null, ) { MyTextFieldWithIcons( text, setText, myStringResource(Res.string.download_link), modifier = modifier.fillMaxWidth(), start = { MyTextFieldIcon(MyIcons.link) }, end = { MyTextFieldIcon(MyIcons.paste) { setText( ClipboardUtil.read() .orEmpty() ) } }, errorText = errorText ) } @Composable private fun NameTextField( text: String, setText: (String) -> Unit, errorText: String? = null, ) { MyTextFieldWithIcons( text, setText, myStringResource(Res.string.name), modifier = Modifier.fillMaxWidth(), errorText = errorText, ) } ================================================ FILE: android/app/src/main/kotlin/com/abdownloadmanager/android/pages/enterurl/AndroidEnterNewURLComponent.kt ================================================ package com.abdownloadmanager.android.pages.enterurl import com.abdownloadmanager.shared.downloaderinui.DownloaderInUiRegistry import com.abdownloadmanager.shared.pages.enterurl.BaseEnterNewURLComponent import com.arkivanov.decompose.ComponentContext import ir.amirab.downloader.downloaditem.IDownloadCredentials class AndroidEnterNewURLComponent( ctx: ComponentContext, config: AndroidEnterNewURLComponent.Config, downloaderInUiRegistry: DownloaderInUiRegistry, onCloseRequest: () -> Unit, onRequestFinished: (IDownloadCredentials) -> Unit, ) : BaseEnterNewURLComponent( ctx = ctx, config = config, downloaderInUiRegistry = downloaderInUiRegistry, onCloseRequest = onCloseRequest, onRequestFinished = onRequestFinished, ) { object Config : BaseEnterNewURLComponent.Config override val shouldFillWithClipboard: Boolean = false } ================================================ FILE: android/app/src/main/kotlin/com/abdownloadmanager/android/pages/enterurl/EnterURLPage.kt ================================================ package com.abdownloadmanager.android.pages.enterurl import androidx.compose.foundation.background import androidx.compose.foundation.border import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.IntrinsicSize import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.width import androidx.compose.foundation.layout.widthIn import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip import androidx.compose.ui.focus.FocusRequester import androidx.compose.ui.focus.focusRequester import androidx.compose.ui.graphics.Brush import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.SolidColor import androidx.compose.ui.unit.dp import androidx.compose.ui.window.Popup import com.abdownloadmanager.android.ui.SheetHeader import com.abdownloadmanager.android.ui.SheetTitle import com.abdownloadmanager.android.ui.SheetUI import com.abdownloadmanager.shared.util.ClipboardUtil import com.abdownloadmanager.resources.Res import com.abdownloadmanager.shared.downloaderinui.TADownloaderInUI import com.abdownloadmanager.shared.pages.enterurl.DownloaderSelection import com.abdownloadmanager.shared.ui.widget.ActionButton import com.abdownloadmanager.shared.ui.widget.MyTextFieldIcon import com.abdownloadmanager.shared.ui.widget.MyTextFieldWithIcons import com.abdownloadmanager.shared.ui.widget.Text import com.abdownloadmanager.shared.ui.widget.TransparentIconActionButton import com.abdownloadmanager.shared.util.ResponsiveDialogScope import com.abdownloadmanager.shared.util.div import com.abdownloadmanager.shared.util.ui.WithContentColor import com.abdownloadmanager.shared.util.ui.icon.MyIcons import com.abdownloadmanager.shared.util.ui.myColors import com.abdownloadmanager.shared.util.ui.theme.myShapes import com.abdownloadmanager.shared.util.ui.theme.mySpacings import com.abdownloadmanager.shared.util.ui.widget.MyIcon import ir.amirab.util.compose.asStringSource import ir.amirab.util.compose.resources.myStringResource @Composable fun ResponsiveDialogScope.EnterNewURLPage( component: AndroidEnterNewURLComponent, onCloseRequest: () -> Unit, ) { val linkFocus = remember { FocusRequester() } LaunchedEffect(Unit) { linkFocus.requestFocus() component.onPageOpen() } val text by component.url.collectAsState() SheetUI( header = { SheetHeader( headerTitle = { SheetTitle(myStringResource(Res.string.new_download)) }, headerActions = { DownloaderSelectionSection(component) TransparentIconActionButton( MyIcons.close, contentDescription = Res.string.close.asStringSource(), onClick = onCloseRequest, ) } ) }, ) { Column( Modifier .padding(horizontal = mySpacings.mediumSpace) .padding(bottom = mySpacings.mediumSpace) ) { UrlTextField( text = text, setText = component::setURL, modifier = Modifier .focusRequester(linkFocus) .fillMaxWidth() ) Spacer(Modifier.height(mySpacings.largeSpace)) Actions(component, onCloseRequest) } } } @Composable private fun DownloaderSelectionSection( component: AndroidEnterNewURLComponent, ) { val downloaderSelection = component.downloaderSelection.collectAsState().value val bestDownloader = component.bestDownloader.collectAsState().value var isSelecting by remember { mutableStateOf(false) } val selectedName = rememberDownloaderSelectionItemString( downloaderSelection, bestDownloader ) ActionButton( text = selectedName, end = { Row( Modifier.align(Alignment.CenterVertically) ) { Spacer(Modifier.width(4.dp)) MyIcon(MyIcons.down, null, Modifier.size(12.dp)) } }, borderColor = SolidColor(Color.Transparent), onClick = { isSelecting = !isSelecting } ) if (isSelecting) { Popup( onDismissRequest = { isSelecting = false }, ) { val shape = myShapes.defaultRounded Column( Modifier .clip(shape) .border(2.dp, myColors.onBackground / 10, shape) .background( Brush.linearGradient( listOf( myColors.surface, myColors.background, ) ) ) ) { WithContentColor(myColors.onBackground) { Column( Modifier .widthIn(min = 100.dp, max = 300.dp) .width(IntrinsicSize.Max) ) { component.possibleValues.onEach { val text = rememberDownloaderSelectionItemString( it, bestDownloader, ) Text( text, Modifier .fillMaxWidth() .clickable { component.selectDownloader(it) isSelecting = false } .padding(vertical = 8.dp, horizontal = 16.dp) ) } } } } } } } @Composable private fun rememberDownloaderSelectionItemString( downloaderSelection: DownloaderSelection, bestDownloader: TADownloaderInUI?, ): String { return when (downloaderSelection) { DownloaderSelection.Auto -> { val autoText = myStringResource(Res.string.auto) val bestDownloaderName = bestDownloader?.name?.rememberString() buildString { append(autoText) if (bestDownloader != null) { append(" ($bestDownloaderName)") } } } is DownloaderSelection.Fixed -> { downloaderSelection.downloaderInUi.name.rememberString() } } } @Composable private fun Actions( component: AndroidEnterNewURLComponent, onCloseRequest: () -> Unit, ) { Row { ActionButton( myStringResource(Res.string.cancel), onClick = onCloseRequest, modifier = Modifier.weight(1f) ) Spacer(Modifier.width(8.dp)) ActionButton( myStringResource(Res.string.ok), enabled = component.canAdd.collectAsState().value, onClick = { component.newDownloadEntered() }, modifier = Modifier.weight(1f) ) } } @Composable private fun UrlTextField( text: String, setText: (String) -> Unit, modifier: Modifier = Modifier, errorText: String? = null, ) { MyTextFieldWithIcons( text, setText, myStringResource(Res.string.download_link), modifier = modifier.fillMaxWidth(), start = { MyIcon( MyIcons.link, null, Modifier .padding(horizontal = 8.dp) .size(16.dp), ) }, end = { MyTextFieldIcon( icon = MyIcons.paste, onClick = { setText( ClipboardUtil.read() .orEmpty() ) } ) }, errorText = errorText ) } ================================================ FILE: android/app/src/main/kotlin/com/abdownloadmanager/android/pages/home/AndroidDownloadActions.kt ================================================ package com.abdownloadmanager.android.pages.home import com.abdownloadmanager.resources.Res import com.abdownloadmanager.shared.pagemanager.DownloadDialogManager import com.abdownloadmanager.shared.pagemanager.EditDownloadDialogManager import com.abdownloadmanager.shared.pagemanager.FileChecksumDialogManager import com.abdownloadmanager.shared.pages.home.AbstractDownloadActions import com.abdownloadmanager.shared.util.DownloadSystem import com.abdownloadmanager.shared.util.category.CategoryManager import com.abdownloadmanager.shared.util.ui.icon.MyIcons import ir.amirab.downloader.downloaditem.DownloadJobStatus import ir.amirab.downloader.monitor.CompletedDownloadItemState import ir.amirab.downloader.monitor.IDownloadItemState import ir.amirab.downloader.monitor.statusOrFinished import ir.amirab.downloader.queue.QueueManager import ir.amirab.util.compose.action.buildMenu import ir.amirab.util.compose.action.simpleAction import ir.amirab.util.compose.asStringSource import ir.amirab.util.flow.mapStateFlow import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.map import kotlinx.coroutines.launch class AndroidDownloadActions( scope: CoroutineScope, downloadSystem: DownloadSystem, downloadDialogManager: DownloadDialogManager, editDownloadDialogManager: EditDownloadDialogManager, fileChecksumDialogManager: FileChecksumDialogManager, selections: StateFlow>, mainItem: StateFlow, queueManager: QueueManager, categoryManager: CategoryManager, openFile: (Long) -> Unit, requestDelete: (List) -> Unit, onRequestShareFiles: (ids: List) -> Unit, ) : AbstractDownloadActions( scope = scope, downloadSystem = downloadSystem, downloadDialogManager = downloadDialogManager, editDownloadDialogManager = editDownloadDialogManager, fileChecksumDialogManager = fileChecksumDialogManager, selections = selections, mainItem = mainItem, queueManager = queueManager, categoryManager = categoryManager, openFile = openFile, requestDelete = requestDelete, ) { val shareAction = simpleAction( title = Res.string.share.asStringSource(), icon = MyIcons.share, checkEnable = selections.mapStateFlow { list -> list.any { it.statusOrFinished() is DownloadJobStatus.Finished } }, onActionPerformed = { scope.launch { onRequestShareFiles(selections.value.filterIsInstance()) } } ) private val mainOptions = buildMenu { +resumeAction +pauseAction +deleteAction +openDownloadDialogAction } private val extraMenu = buildMenu { +openFileAction +shareAction separator() +reDownloadAction separator() +moveToQueueItems +moveToCategoryAction separator() subMenu(Res.string.copy.asStringSource(), MyIcons.copy) { +(copyDownloadLinkAction) +(copyDownloadCredentialsAsCurlAction) } +editDownloadAction +fileChecksumAction } val androidMenu = buildMenu { mainOptions.forEach { +it } subMenu( title = Res.string.more_options.asStringSource(), icon = MyIcons.menu, ) { extraMenu.forEach { +it } } } } ================================================ FILE: android/app/src/main/kotlin/com/abdownloadmanager/android/pages/home/BottomNavigation.kt ================================================ package com.abdownloadmanager.android.pages.home import androidx.activity.compose.BackHandler import androidx.compose.animation.AnimatedContent import androidx.compose.animation.AnimatedVisibility import androidx.compose.foundation.background import androidx.compose.foundation.border import androidx.compose.foundation.clickable import androidx.compose.foundation.gestures.detectHorizontalDragGestures import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.BoxScope import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.IntrinsicSize import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.RowScope import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxHeight import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.width import androidx.compose.foundation.layout.wrapContentHeight import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue import androidx.compose.runtime.remember import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.alpha import androidx.compose.ui.draw.clip import androidx.compose.ui.draw.shadow import androidx.compose.ui.focus.FocusRequester import androidx.compose.ui.focus.focusRequester import androidx.compose.ui.graphics.Brush import androidx.compose.ui.input.pointer.pointerInput import androidx.compose.ui.unit.dp import com.abdownloadmanager.android.pages.home.sections.sort.DownloadSortBy import com.abdownloadmanager.resources.Res import com.abdownloadmanager.shared.ui.widget.MyTextField import com.abdownloadmanager.shared.ui.widget.sort.Sort import com.abdownloadmanager.shared.util.div import com.abdownloadmanager.shared.util.ui.LocalContentColor import com.abdownloadmanager.shared.util.ui.icon.MyIcons import com.abdownloadmanager.shared.util.ui.myColors import com.abdownloadmanager.shared.util.ui.theme.myShapes import com.abdownloadmanager.shared.util.ui.widget.MyIcon import ir.amirab.downloader.db.QueueModel import ir.amirab.util.compose.IconSource import ir.amirab.util.compose.StringSource import ir.amirab.util.compose.asStringSource import ir.amirab.util.compose.resources.myStringResource object BottomNavigationConstants { const val DEFAULT_ICON_SIZE = 20 const val DEFAULT_ICON_PADDING = 16 } @Composable fun BottomNavigation( modifier: Modifier, component: HomeComponent, ) { val isShowingSearch by component.isShowingSearch.collectAsState() val shouldShowMainButton = !isShowingSearch val isShowingAddMenu by component.isAddMenuShowing.collectAsState() Row( modifier .padding(16.dp), verticalAlignment = Alignment.CenterVertically, ) { val shape = myShapes.defaultRounded Box( Modifier .weight(1f) .height(IntrinsicSize.Max) .shadow(4.dp, shape) .clip(shape) .border(1.dp, myColors.onSurface / 0.1f, shape) .background(myColors.surface) ) { AnimatedContent( isShowingSearch, ) { Row( Modifier .fillMaxWidth() .height(IntrinsicSize.Max), verticalAlignment = Alignment.CenterVertically, ) { if (it) { SearchBox( text = component.filterState.textToSearch, onValueChange = { component.filterState.textToSearch = it }, modifier = Modifier.fillMaxWidth(), onDismissRequest = { component.setIsShowingSearch(false) } ) } else { DefaultItems(component) } } } } AnimatedVisibility( shouldShowMainButton ) { Row { Spacer(Modifier.width(8.dp)) Column { RenderAddMenu(component) MainBottonNavigationItem( icon = MyIcons.add, contentDescription = Res.string.add.asStringSource(), onClick = { component.setIsAddMenuShowing(!isShowingAddMenu) }, modifier = Modifier, ) } } } } } @Composable private fun SearchBox( text: String, onValueChange: (search: String) -> Unit, modifier: Modifier, onDismissRequest: () -> Unit, ) { BackHandler { onDismissRequest() } val focusRequester = remember { FocusRequester() } LaunchedEffect(Unit) { focusRequester.requestFocus() } MyTextField( text = text, onTextChange = onValueChange, placeholder = myStringResource(Res.string.search), modifier = modifier .focusRequester(focusRequester), start = { MyIcon( MyIcons.search, null, Modifier .align(Alignment.CenterVertically) .padding(BottomNavigationConstants.DEFAULT_ICON_PADDING.dp) .size(BottomNavigationConstants.DEFAULT_ICON_SIZE.dp), tint = LocalContentColor.current / 0.5f ) }, end = { MyIcon( MyIcons.clear, null, Modifier .fillMaxHeight() .clickable { if (text.isEmpty()) { onDismissRequest() } else { onValueChange("") } } .padding(BottomNavigationConstants.DEFAULT_ICON_PADDING.dp) .size(BottomNavigationConstants.DEFAULT_ICON_SIZE.dp) .wrapContentHeight() .alpha(0.5f), tint = LocalContentColor.current / 0.5f ) } ) } @Composable fun RowScope.DefaultItems( component: HomeComponent, ) { val isMainMenuShowing by component.isMainMenuShowing.collectAsState() val isCategoryFilterMenuShowing by component.isCategoryFilterShowing.collectAsState() val isSortMenuShowing by component.isSortMenuShowing.collectAsState() val modifier = Modifier Column { RenderMainMenu(component) BottonNavigationItem( icon = MyIcons.menu, contentDescription = Res.string.menu.asStringSource(), onClick = { component.setIsMainMenuShowing(!isMainMenuShowing) }, modifier = modifier, isSelected = isMainMenuShowing, ) } BottonNavigationItem( icon = MyIcons.search, contentDescription = Res.string.search.asStringSource(), onClick = { component.setIsShowingSearch(true) }, modifier = modifier, isSelected = false, // search bar replaced with total bottomNavigation ) Spacer( Modifier .fillMaxHeight() .width(1.dp) .background(myColors.onSurface / 0.1f) ) val filterMode = component.filterMode.value when (filterMode) { is HomeComponent.FilterMode.Queue -> { QueueIndicator( Modifier .weight(1f) .fillMaxHeight(), filterMode, isSelected = isCategoryFilterMenuShowing ) { component.setIsCategoryFilterShowing(!isCategoryFilterMenuShowing) } } is HomeComponent.FilterMode.Status -> { FilterStatusIndicator( component, Modifier .weight(1f) .fillMaxHeight(), filterMode, isSelected = isCategoryFilterMenuShowing, onClick = { component.setIsCategoryFilterShowing(!isCategoryFilterMenuShowing) } ) } } Spacer( Modifier .fillMaxHeight() .width(1.dp) .background(myColors.onSurface / 0.1f) ) when (filterMode) { is HomeComponent.FilterMode.Status -> { SortIndicator( component.selectedSort.collectAsState().value, { component.setIsSortMenuShowing(!isSortMenuShowing) }, modifier = Modifier, isSelected = isSortMenuShowing, ) } is HomeComponent.FilterMode.Queue -> { ToggleQueueStatus(component, filterMode.queue) } } } @Composable fun ToggleQueueStatus( homeComponent: HomeComponent, queueModel: QueueModel ) { val queue = remember(queueModel.id) { homeComponent.queueManager.getQueue(queueModel.id) } val isQueueActive by queue.activeFlow.collectAsState() val icon: IconSource val contentDescription: StringSource val onClick: () -> Unit if (isQueueActive) { icon = MyIcons.queueStop contentDescription = Res.string.stop_queue.asStringSource() onClick = { homeComponent.stopQueue(queue.id) } } else { icon = MyIcons.queueStart contentDescription = Res.string.start_queue.asStringSource() onClick = { homeComponent.startQueue(queue.id) } } BottonNavigationItem( icon = icon, contentDescription = contentDescription, onClick = onClick, modifier = Modifier, isSelected = isQueueActive, ) } @Composable private fun MainBottonNavigationItem( icon: IconSource, contentDescription: StringSource, onClick: () -> Unit, modifier: Modifier, ) { Box(modifier) { val shape = myShapes.defaultRounded MyIcon( icon = icon, contentDescription = contentDescription.rememberString(), modifier = Modifier .shadow(4.dp, shape) .border( 1.dp, myColors.primaryGradient, shape, ) .clip(shape) .background(myColors.surface) .background( Brush.linearGradient( myColors.primaryGradientColors.map { it / 0.25f } ) ) .clickable(onClick = onClick) .padding(BottomNavigationConstants.DEFAULT_ICON_PADDING.dp) .size(BottomNavigationConstants.DEFAULT_ICON_SIZE.dp) ) } } @Composable private fun BottonNavigationItem( icon: IconSource, contentDescription: StringSource, onClick: () -> Unit, modifier: Modifier, isSelected: Boolean, ) { Box(modifier) { MyIcon( icon = icon, contentDescription = contentDescription.rememberString(), modifier = Modifier .clickable(onClick = onClick) .padding(BottomNavigationConstants.DEFAULT_ICON_PADDING.dp) .size(BottomNavigationConstants.DEFAULT_ICON_SIZE.dp) ) BottomNavigationSelectedIndicator(isSelected) } } @Composable fun BoxScope.BottomNavigationSelectedIndicator( isSelected: Boolean, ) { if (isSelected) { Box( Modifier .matchParentSize() .background( Brush.horizontalGradient( colors = myColors.primaryGradientColors.map { it / 0.15f } ) ) ) Box( Modifier .matchParentSize() .wrapContentHeight(Alignment.Bottom) .height(1.dp) .background( Brush.horizontalGradient( myColors.primaryGradientColors ) ) ) } } @Composable private fun SortIndicator( sort: Sort, onClick: () -> Unit, modifier: Modifier, isSelected: Boolean, ) { val totalIcon = BottomNavigationConstants.DEFAULT_ICON_SIZE val iconSize = (totalIcon * 0.7) val sortDirectionSize = totalIcon - iconSize Box( modifier .clickable(onClick = onClick) ) { Column( Modifier .padding( vertical = BottomNavigationConstants.DEFAULT_ICON_PADDING.dp, horizontal = (BottomNavigationConstants.DEFAULT_ICON_PADDING + (sortDirectionSize / 2)).dp ), horizontalAlignment = Alignment.CenterHorizontally, ) { val color = LocalContentColor.current val activeAlpha = color / 0.75f if (sort.isAscending()) { MyIcon( MyIcons.sortUp, null, Modifier .size(sortDirectionSize.dp), tint = activeAlpha ) } MyIcon( sort.cell.icon, null, Modifier.size(iconSize.dp), ) if (sort.isDescending()) { MyIcon( MyIcons.sortDown, null, Modifier .size(sortDirectionSize.dp), tint = activeAlpha ) } } BottomNavigationSelectedIndicator(isSelected) } } private fun Modifier.changeStatusOnSwipe( goToPrevious: () -> Unit, goToNext: () -> Unit, ): Modifier { return pointerInput(Unit) { val threshold = 100f var drag = 0f detectHorizontalDragGestures( onDragEnd = { if (drag > threshold) { goToPrevious() drag = 0f } else if (drag < -threshold) { goToNext() drag = 0f } } ) { _, dragAmount -> drag += dragAmount } } } ================================================ FILE: android/app/src/main/kotlin/com/abdownloadmanager/android/pages/home/DownloadList.kt ================================================ package com.abdownloadmanager.android.pages.home import androidx.activity.compose.BackHandler import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.padding import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.LazyListState import androidx.compose.foundation.lazy.itemsIndexed import androidx.compose.runtime.Composable import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.drawBehind import androidx.compose.ui.geometry.Offset import androidx.compose.ui.graphics.Brush import androidx.compose.ui.graphics.Color import com.abdownloadmanager.resources.Res import com.abdownloadmanager.shared.ui.widget.Text import com.abdownloadmanager.shared.util.FileIconProvider import com.abdownloadmanager.shared.util.div import com.abdownloadmanager.shared.util.ui.WithContentAlpha import com.abdownloadmanager.shared.util.ui.myColors import ir.amirab.downloader.monitor.IDownloadItemState import ir.amirab.util.compose.resources.myStringResource import ir.amirab.util.ifThen @Composable fun DownloadList( downloadList: List, selectionList: List, onItemSelectionChange: (Long, Boolean) -> Unit, onItemClicked: (IDownloadItemState) -> Unit, fileIconProvider: FileIconProvider, onNewSelection: (List) -> Unit, lazyListState: LazyListState, modifier: Modifier, contentPadding: PaddingValues, ) { fun newSelection(ids: List, isSelected: Boolean) { onNewSelection(ids.filter { isSelected }) } fun changeAllSelection(isSelected: Boolean) { newSelection(downloadList.map { it.id }, isSelected) } val isInSelectMode = selectionList.isNotEmpty() BackHandler( isInSelectMode ) { changeAllSelection(false) } val dividerColor = myColors.onBackground / 0.5f Box { LazyColumn( state = lazyListState, modifier = modifier, contentPadding = contentPadding ) { itemsIndexed( items = downloadList, key = { _, item -> item.id } ) { index, item -> val isFirstItem = index == 0 Column( modifier = Modifier.animateItem() ) { RenderDownloadItem( downloadItem = item, checked = if (isInSelectMode) { item.id in selectionList } else { null }, onClick = { if (isInSelectMode) { val wasInSelections = item.id in selectionList onItemSelectionChange(item.id, !wasInSelections) } else { onItemClicked(item) } }, onLongClick = { val wasInSelections = item.id in selectionList onItemSelectionChange(item.id, !wasInSelections) }, fileIconProvider = fileIconProvider, modifier = Modifier.ifThen(!isFirstItem) { drawBehind { drawLine( brush = Brush.horizontalGradient( listOf( Color.Transparent, dividerColor, Color.Transparent, ) ), start = Offset.Zero, end = Offset(size.width, 0f) ) } }, ) } } } if (downloadList.isEmpty()) { Box( Modifier .padding() .fillMaxSize() ) { WithContentAlpha(0.75f) { Text( myStringResource(Res.string.list_is_empty), Modifier.align(Alignment.Center), maxLines = 1, ) } } } } } ================================================ FILE: android/app/src/main/kotlin/com/abdownloadmanager/android/pages/home/FilterStatusIndicator.kt ================================================ package com.abdownloadmanager.android.pages.home import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxHeight import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.runtime.Composable import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.alpha import androidx.compose.ui.text.style.LineHeightStyle import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp import com.abdownloadmanager.shared.ui.widget.Text import com.abdownloadmanager.shared.util.ui.icon.MyIcons import com.abdownloadmanager.shared.util.ui.theme.myTextSizes import com.abdownloadmanager.shared.util.ui.widget.MyIcon import ir.amirab.util.compose.IconSource import ir.amirab.util.compose.modifiers.autoMirror @Composable fun FilterStatusIndicator( component: HomeComponent, modifier: Modifier, filterMode: HomeComponent.FilterMode.Status, isSelected: Boolean, onClick: () -> Unit, ) { val filter = component.filterState val categoryName = filter.typeCategoryFilter?.name Box( modifier = modifier .clickable(onClick = onClick) ) { StatusFilterSideButton( icon = MyIcons.back, modifier = Modifier .padding(start = 3.dp) .align(Alignment.CenterStart) .autoMirror() ) SimplePager( modifier = Modifier .align(Alignment.Center) .fillMaxHeight() .fillMaxWidth(), pageCount = component.allStatuseFilters.size, currentPage = component.currentStatusIndexInList, onPageChanged = { component.switchToNewStatus(it) } ) { val status = component.allStatuseFilters[it] val statusName = status.name.rememberString() Column( Modifier.fillMaxSize(), verticalArrangement = Arrangement.Center, ) { Text( statusName, fontSize = myTextSizes.sm, modifier = Modifier .fillMaxWidth(), textAlign = TextAlign.Center, maxLines = 1, ) categoryName?.let { categoryName -> Spacer(Modifier.height(4.dp)) Text( categoryName, fontSize = myTextSizes.xs, modifier = Modifier.fillMaxWidth(), textAlign = TextAlign.Center, ) } } } StatusFilterSideButton( icon = MyIcons.next, modifier = Modifier .align(Alignment.CenterEnd) .padding(end = 3.dp) .autoMirror() ) BottomNavigationSelectedIndicator(isSelected) } } @Composable fun QueueIndicator( modifier: Modifier, filterMode: HomeComponent.FilterMode.Queue, isSelected: Boolean, onClick: () -> Unit, ) { Box( modifier = modifier .clickable(onClick = onClick) .fillMaxSize() ) { Text( filterMode.queue.name, fontSize = myTextSizes.sm, modifier = Modifier .align(Alignment.Center) .fillMaxWidth(), textAlign = TextAlign.Center, maxLines = 1, overflow = TextOverflow.Ellipsis, ) BottomNavigationSelectedIndicator(isSelected) } } @Composable private fun StatusFilterSideButton( icon: IconSource, modifier: Modifier, ) { MyIcon( icon = icon, contentDescription = null, modifier = modifier .size(12.dp) .alpha(0.55f), ) } ================================================ FILE: android/app/src/main/kotlin/com/abdownloadmanager/android/pages/home/HomeComponent.kt ================================================ package com.abdownloadmanager.android.pages.home import androidx.compose.runtime.derivedStateOf import androidx.compose.runtime.getValue import androidx.compose.runtime.snapshotFlow import com.abdownloadmanager.android.action.createOpenBrowserAction import com.abdownloadmanager.android.pages.enterurl.AndroidEnterNewURLComponent import com.abdownloadmanager.android.pages.home.sections.sort.DownloadSortBy import com.abdownloadmanager.android.storage.HomePageStorage import com.abdownloadmanager.android.util.AppInfo import com.abdownloadmanager.android.util.pagemanager.IBrowserPageManager import com.abdownloadmanager.resources.Res import com.abdownloadmanager.shared.action.createCheckForUpdateAction import com.abdownloadmanager.shared.action.createDownloadFromClipboardAction import com.abdownloadmanager.shared.action.createDummyExceptionAction import com.abdownloadmanager.shared.action.createDummyMessageAction import com.abdownloadmanager.shared.action.createNewDownloadAction import com.abdownloadmanager.shared.action.createOpenAboutPage import com.abdownloadmanager.shared.action.createOpenBatchDownloadAction import com.abdownloadmanager.shared.action.createOpenOpenSourceThirdPartyLibrariesPage import com.abdownloadmanager.shared.action.createOpenSettingsAction import com.abdownloadmanager.shared.action.createOpenTranslatorsPageAction import com.abdownloadmanager.shared.action.createPerHostSettingsPage import com.abdownloadmanager.shared.action.createStartQueueGroupAction import com.abdownloadmanager.shared.action.createStopAllAction import com.abdownloadmanager.shared.action.createStopQueueGroupAction import com.abdownloadmanager.shared.action.donate import com.abdownloadmanager.shared.action.supportActionGroup import com.abdownloadmanager.shared.downloaderinui.DownloaderInUiRegistry import com.abdownloadmanager.shared.pagemanager.AboutPageManager import com.abdownloadmanager.shared.pagemanager.AddDownloadDialogManager import com.abdownloadmanager.shared.pagemanager.BatchDownloadPageManager import com.abdownloadmanager.shared.pagemanager.CategoryDialogManager import com.abdownloadmanager.shared.pagemanager.DownloadDialogManager import com.abdownloadmanager.shared.pagemanager.EditDownloadDialogManager import com.abdownloadmanager.shared.pagemanager.EnterNewURLDialogManager import com.abdownloadmanager.shared.pagemanager.FileChecksumDialogManager import com.abdownloadmanager.shared.pagemanager.NotificationSender import com.abdownloadmanager.shared.pagemanager.OpenSourceLibrariesPageManager import com.abdownloadmanager.shared.pagemanager.PerHostSettingsPageManager import com.abdownloadmanager.shared.pagemanager.QueuePageManager import com.abdownloadmanager.shared.pagemanager.SettingsPageManager import com.abdownloadmanager.shared.pagemanager.TranslatorsPageManager import com.abdownloadmanager.shared.pages.adddownload.AddDownloadCredentialsInUiProps import com.abdownloadmanager.shared.pages.home.BaseHomeComponent import com.abdownloadmanager.shared.pages.home.category.DefinedStatusCategories import com.abdownloadmanager.shared.pages.home.category.DownloadStatusCategoryFilter import com.abdownloadmanager.shared.pages.updater.UpdateComponent import com.abdownloadmanager.shared.ui.widget.sort.Sort import com.abdownloadmanager.shared.ui.widget.sort.sorted import com.abdownloadmanager.shared.util.DownloadItemOpener import com.abdownloadmanager.shared.util.DownloadSystem import com.abdownloadmanager.shared.util.FileIconProvider import com.abdownloadmanager.shared.util.category.Category import com.abdownloadmanager.shared.util.category.CategoryManager import com.abdownloadmanager.shared.util.category.DefaultCategories import com.abdownloadmanager.shared.util.subscribeAsStateFlow import com.abdownloadmanager.shared.util.ui.icon.MyIcons import com.arkivanov.decompose.ComponentContext import com.arkivanov.decompose.router.slot.SlotNavigation import com.arkivanov.decompose.router.slot.activate import com.arkivanov.decompose.router.slot.childSlot import com.arkivanov.decompose.router.slot.dismiss import ir.amirab.SelectionUtil import ir.amirab.downloader.db.QueueModel import ir.amirab.downloader.downloaditem.DownloadJobStatus import ir.amirab.downloader.monitor.CompletedDownloadItemState import ir.amirab.downloader.monitor.IDownloadItemState import ir.amirab.downloader.monitor.ProcessingDownloadItemState import ir.amirab.downloader.queue.DownloadQueue import ir.amirab.downloader.queue.QueueManager import ir.amirab.downloader.queue.activeQueuesFlow import ir.amirab.util.compose.action.buildMenu import ir.amirab.util.compose.asStringSource import ir.amirab.util.flow.mapStateFlow import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.launch import java.io.File import kotlin.collections.plus class HomeComponent( componentContext: ComponentContext, downloadItemOpener: DownloadItemOpener, downloadDialogManager: DownloadDialogManager, editDownloadDialogManager: EditDownloadDialogManager, addDownloadDialogManager: AddDownloadDialogManager, fileChecksumDialogManager: FileChecksumDialogManager, queuePageManager: QueuePageManager, categoryDialogManager: CategoryDialogManager, notificationSender: NotificationSender, downloadSystem: DownloadSystem, categoryManager: CategoryManager, queueManager: QueueManager, openSourceLibrariesPageManager: OpenSourceLibrariesPageManager, translatorsPageManager: TranslatorsPageManager, settingsPageManager: SettingsPageManager, perHostSettingsPageManager: PerHostSettingsPageManager, browserPageManager: IBrowserPageManager, aboutPageManager: AboutPageManager, batchDownloadPageManager: BatchDownloadPageManager, defaultCategories: DefaultCategories, fileIconProvider: FileIconProvider, downloaderInUiRegistry: DownloaderInUiRegistry, private val updateComponent: UpdateComponent, private val homePageStorage: HomePageStorage, ) : BaseHomeComponent( componentContext, downloadItemOpener, downloadDialogManager, editDownloadDialogManager, addDownloadDialogManager, fileChecksumDialogManager, queuePageManager, categoryDialogManager, notificationSender, downloadSystem, categoryManager, queueManager, defaultCategories, fileIconProvider, ), EnterNewURLDialogManager { private val enterNewLinkNavigation = SlotNavigation() val enterNewLinkSlot = childSlot( source = enterNewLinkNavigation, serializer = null, key = "enterNewLinkSlot", childFactory = { configuration, context -> AndroidEnterNewURLComponent( ctx = context, config = configuration, downloaderInUiRegistry = downloaderInUiRegistry, onCloseRequest = { closeEnterNewURLWindow() }, onRequestFinished = { addDownloadDialogManager.openAddDownloadDialog( links = listOf(AddDownloadCredentialsInUiProps(it)) ) } ) } ).subscribeAsStateFlow() override fun closeEnterNewURLWindow() { scope.launch { enterNewLinkNavigation.dismiss() } } override fun openEnterNewURLWindow() { scope.launch { enterNewLinkNavigation.activate(AndroidEnterNewURLComponent.Config) } } val downloadActions = AndroidDownloadActions( scope = scope, downloadSystem = downloadSystem, downloadDialogManager = downloadDialogManager, editDownloadDialogManager = editDownloadDialogManager, fileChecksumDialogManager = fileChecksumDialogManager, selections = selectionListItems, mainItem = selectionList.mapStateFlow { if (it.size == 1) it[0] else null }, queueManager = queueManager, categoryManager = categoryManager, openFile = ::openFile, requestDelete = ::requestDelete, onRequestShareFiles = ::shareFiles, ) private fun shareFiles(finishedDownloads: List) { finishedDownloads.mapNotNull { File(it.folder, it.name).takeIf { file -> file.exists() } }.takeIf { it.isNotEmpty() }?.let { sendEffect(Effects.ShareFiles(it)) } } fun onItemClicked(itemState: IDownloadItemState) { scope.launch { if (itemState is ProcessingDownloadItemState) { toggleDownload(itemState) return@launch } downloadItemOpener.openDownloadItem(itemState.id) } } suspend fun toggleDownload(dItem: ProcessingDownloadItemState) { when { dItem.canBeResumed() -> downloadSystem.userManualResume(dItem.id) dItem.canBePaused() -> downloadSystem.manualPause(dItem.id) } } private val _selectedSort = homePageStorage.sortBy val selectedSort = _selectedSort.asStateFlow() fun setSelectedSort( sort: Sort ) { if (sort.cell in possibleSorts) { _selectedSort.value = sort } } val filterMode = derivedStateOf { val queueFilter = filterState.queueFilter val statusFilter = filterState.statusFilter val categoryFilter = filterState.typeCategoryFilter if (queueFilter != null) { FilterMode.Queue(queueFilter) } else { FilterMode.Status(statusFilter, categoryFilter) } } val sortedDownloadList = combine( downloadList, selectedSort, snapshotFlow { filterMode.value }, ) { downloadList, sortBy, filterMode -> when (filterMode) { is FilterMode.Status -> { sortBy.sorted(downloadList) } is FilterMode.Queue -> { filterMode.queue.queueItems.mapNotNull { id -> downloadList.find { it.id == id } } } } }.stateIn(scope, SharingStarted.Eagerly, emptyList()) fun onRequestSelectInside() { SelectionUtil.toggleSelectInside( selectionList = selectionList.value, fullSortedList = sortedDownloadList.value, getId = { it.id } )?.let { newSelection(it) } } fun onRequestInvertSelection() { newSelection( SelectionUtil.invertSelection( selectionList = selectionList.value, all = sortedDownloadList.value, getId = { it.id } ) ) } val allStatuseFilters = DefinedStatusCategories.values() val currentStatusIndexInList by derivedStateOf { allStatuseFilters.indexOf(filterState.statusFilter) } fun switchToNewStatus(value: Int) { filterState.statusFilter = allStatuseFilters[ value.coerceIn(allStatuseFilters.indices) ] } private val _isShowingSearch: MutableStateFlow = MutableStateFlow(false) val isShowingSearch = _isShowingSearch.asStateFlow() fun setIsShowingSearch(shown: Boolean) { if (!shown) { filterState.textToSearch = "" } else { closePopups() } _isShowingSearch.value = shown } private val currentActivePopup = MutableStateFlow(null) fun onOverlayClicked() { closePopups() } fun closePopups() { currentActivePopup.value = null } val isMainMenuShowing = currentActivePopup.mapStateFlow { it == HomePopups.MainMenu } fun setIsMainMenuShowing(value: Boolean) { currentActivePopup.value = HomePopups.MainMenu.takeIf { value } } val isCategoryFilterShowing = currentActivePopup.mapStateFlow { it == HomePopups.FilterMenu } fun setIsCategoryFilterShowing(value: Boolean) { currentActivePopup.value = HomePopups.FilterMenu.takeIf { value } } val isSortMenuShowing = currentActivePopup.mapStateFlow { it == HomePopups.SortMenu } fun setIsSortMenuShowing(value: Boolean) { currentActivePopup.value = HomePopups.SortMenu.takeIf { value } } val isAddMenuShowing = currentActivePopup.mapStateFlow { it == HomePopups.AddMenu } fun setIsAddMenuShowing(value: Boolean) { currentActivePopup.value = HomePopups.AddMenu.takeIf { value } } val activeQueuesFlow = queueManager.activeQueuesFlow() .stateIn(scope, SharingStarted.Eagerly, emptyList()) val mainMenu = buildMenu { +createOpenBrowserAction(browserPageManager = browserPageManager) separator() +createStopAllAction(scope, downloadSystem, {}, activeQueuesFlow) separator() subMenu( title = Res.string.delete.asStringSource(), icon = MyIcons.remove ) { item(Res.string.all_missing_files.asStringSource()) { requestDelete(downloadSystem.getListOfDownloadThatMissingFileOrHaveNotProgress().map { it.id }) } item(Res.string.all_finished.asStringSource()) { requestDelete(downloadSystem.getFinishedDownloadIds()) } item(Res.string.all_unfinished.asStringSource()) { requestDelete(downloadSystem.getUnfinishedDownloadIds()) } item(Res.string.entire_list.asStringSource()) { requestDelete(downloadSystem.getAllDownloadIds()) } } separator() +createStartQueueGroupAction(scope, queueManager) +createStopQueueGroupAction(scope, activeQueuesFlow) if (AppInfo.isInDebugMode) { separator() +createDummyMessageAction(notificationSender) +createDummyExceptionAction() } separator() +createPerHostSettingsPage(perHostSettingsPageManager = perHostSettingsPageManager) +createOpenSettingsAction(settingsPageManager = settingsPageManager) separator() subMenu( Res.string.help.asStringSource(), MyIcons.question, ) { +supportActionGroup separator() +createOpenOpenSourceThirdPartyLibrariesPage(openSourceLibrariesPageManager = openSourceLibrariesPageManager) +createOpenTranslatorsPageAction(opeTranslatorsPageManager = translatorsPageManager) +donate separator() +createCheckForUpdateAction(updateComponent) +createOpenAboutPage(aboutPageManager) } } val addMenu = buildMenu { +createDownloadFromClipboardAction(addDownloadDialogManager = addDownloadDialogManager) +createNewDownloadAction(enterNewURLDialogManager = enterNewURLDialogManager) +createOpenBatchDownloadAction(batchDownloadPageManager = batchDownloadPageManager) } val isOverlayVisible = currentActivePopup.mapStateFlow { it != null } val possibleSorts = listOf( DownloadSortBy.DataAdded, DownloadSortBy.Name, DownloadSortBy.Size, DownloadSortBy.Status, ) fun startQueue(id: Long) { scope.launch { queueManager.getQueue(id).start() } } fun stopQueue(id: Long) { scope.launch { queueManager.getQueue(id).stop() } } private fun getCurrentDownloadQueue(): DownloadQueue? { val queueId = (filterMode.value as? FilterMode.Queue)?.queue?.id ?: return null return runCatching { queueManager.getQueue(queueId) }.getOrNull() } fun reorderQueueItemsUp() { val downloadQueue = getCurrentDownloadQueue() ?: return val itemsToMove = selectionList.value downloadQueue.moveUp(itemsToMove) val queueItems = downloadQueue.queueModel.value.queueItems val firstItemId = queueItems.firstOrNull { itemsToMove.contains(it) } firstItemId?.let { scope.launch { sendEffect(BaseHomeComponent.Effects.Common.ScrollToDownloadItem(it, true)) } } } fun reorderQueueItemsDown() { val downloadQueue = getCurrentDownloadQueue() ?: return val itemsToMove = selectionList.value downloadQueue.moveDown(itemsToMove) val queueItems = downloadQueue.queueModel.value.queueItems val lastItemId = queueItems.lastOrNull { itemsToMove.contains(it) } lastItemId?.let { sendEffect(BaseHomeComponent.Effects.Common.ScrollToDownloadItem(it, true)) } } fun reorderQueueItems(fromIndex: Int, toIndex: Int) { val downloadQueue = getCurrentDownloadQueue() ?: return val currentDraggingItem = runCatching { downloadQueue.getQueueItemFromOrder(fromIndex) }.getOrNull() val listOfIds = selectionList.value .let { if (currentDraggingItem != null && !it.contains(currentDraggingItem)) { it.plus(currentDraggingItem) } else { it } } val delta = toIndex - fromIndex downloadQueue.move( listOfIds, delta ) val queueItems = downloadQueue.queueModel.value.queueItems val itemToScroll = if (delta > 0) { queueItems.lastOrNull { listOfIds.contains(it) } } else { queueItems.firstOrNull { listOfIds.contains(it) } } itemToScroll?.let { sendEffect(BaseHomeComponent.Effects.Common.ScrollToDownloadItem(it)) } } fun removeQueueItems() { val downloadQueue = getCurrentDownloadQueue() ?: return val itemsToRemove = selectionList.value downloadQueue.removeFromQueue(itemsToRemove) } fun revealItem(downloadId: Long) { scope.launch { sendEffect(BaseHomeComponent.Effects.Common.ScrollToDownloadItem(downloadId)) } } override val enterNewURLDialogManager: EnterNewURLDialogManager get() = this sealed interface FilterMode { data class Status( val downloadStatus: DownloadStatusCategoryFilter, val category: Category?, ) : FilterMode data class Queue( val queue: QueueModel, ) : FilterMode } sealed interface Effects : BaseHomeComponent.Effects.PlatformEffects { data class ShareFiles( val files: List, ) : Effects } } sealed interface HomePopups { data object AddMenu : HomePopups data object MainMenu : HomePopups data object SortMenu : HomePopups data object FilterMenu : HomePopups } ================================================ FILE: android/app/src/main/kotlin/com/abdownloadmanager/android/pages/home/HomePage.kt ================================================ package com.abdownloadmanager.android.pages.home import androidx.compose.animation.AnimatedContent import androidx.compose.animation.AnimatedVisibility import androidx.compose.animation.core.tween import androidx.compose.animation.expandIn import androidx.compose.animation.fadeIn import androidx.compose.animation.fadeOut import androidx.compose.animation.shrinkOut import androidx.compose.animation.slideInVertically import androidx.compose.animation.slideOutVertically import androidx.compose.animation.togetherWith import androidx.compose.foundation.background import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.IntrinsicSize import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.navigationBarsPadding import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.statusBarsPadding import androidx.compose.foundation.layout.width import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Brush import androidx.compose.ui.graphics.Color import androidx.compose.ui.layout.boundsInWindow import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.platform.LocalLayoutDirection import androidx.compose.ui.unit.DpOffset import androidx.compose.ui.unit.IntSize import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.roundToIntRect import androidx.compose.ui.window.Popup import androidx.compose.ui.window.PopupProperties import com.abdownloadmanager.android.pages.enterurl.EnterNewURLPage import com.abdownloadmanager.android.pages.home.sections.sort.RenderSortMenu import com.abdownloadmanager.android.ui.menu.RenderMenuInSinglePage import com.abdownloadmanager.android.ui.page.PageFooter import com.abdownloadmanager.android.ui.page.PageUi import com.abdownloadmanager.android.ui.page.PageHeader import com.abdownloadmanager.android.ui.page.PageTitle import com.abdownloadmanager.android.ui.page.rememberHeaderAlpha import com.abdownloadmanager.android.util.AndroidIntentUtils import com.abdownloadmanager.resources.Res import com.abdownloadmanager.shared.pages.home.BaseHomeComponent import com.abdownloadmanager.shared.pages.home.CategoryDeletePromptState import com.abdownloadmanager.shared.pages.home.ConfirmPromptState import com.abdownloadmanager.shared.pages.home.DeletePromptState import com.abdownloadmanager.shared.ui.widget.rememberMyComponentCustomRectPositionProvider import com.abdownloadmanager.shared.util.OnFullyDismissed import com.abdownloadmanager.shared.util.ResponsiveDialog import com.abdownloadmanager.shared.util.mvi.HandleEffects import com.abdownloadmanager.shared.util.rememberChild import com.abdownloadmanager.shared.util.rememberResponsiveDialogState import com.abdownloadmanager.shared.util.ui.icon.MyIcons import com.abdownloadmanager.shared.util.ui.myColors import com.abdownloadmanager.shared.util.ui.theme.mySpacings import com.abdownloadmanager.shared.util.ui.widget.MyIcon import ir.amirab.util.compose.asStringSource import ir.amirab.util.compose.modifiers.silentClickable import ir.amirab.util.compose.resources.myStringResource import kotlinx.coroutines.launch @Composable fun HomePage(component: HomeComponent) { val selectionList by component.selectionList.collectAsState() val density = LocalDensity.current var contentPaddingValues by remember { mutableStateOf(PaddingValues.Zero) } val topPaddingInDp = contentPaddingValues.calculateTopPadding() val bottomPaddingInDp = contentPaddingValues.calculateBottomPadding() var showDeletePromptState by remember { mutableStateOf(null as DeletePromptState?) } var showDeleteCategoryPromptState by remember { mutableStateOf(null as CategoryDeletePromptState?) } var showConfirmPrompt by remember { mutableStateOf(null as ConfirmPromptState?) } val lazyListState = rememberLazyListState() val downloadList by component.sortedDownloadList.collectAsState() val coroutineScope = rememberCoroutineScope() val context = LocalContext.current val direction = LocalLayoutDirection.current HandleEffects(component) { effect -> when (effect) { is BaseHomeComponent.Effects.Common -> { when (effect) { is BaseHomeComponent.Effects.Common.DeleteItems -> { if (effect.list.isNotEmpty()) { showDeletePromptState = DeletePromptState( downloadList = effect.list, finishedCount = effect.finishedCount, unfinishedCount = effect.unfinishedCount, ) } } is BaseHomeComponent.Effects.Common.DeleteCategory -> { showDeleteCategoryPromptState = CategoryDeletePromptState(effect.category) } is BaseHomeComponent.Effects.Common.AutoCategorize -> { showConfirmPrompt = ConfirmPromptState( title = Res.string.confirm_auto_categorize_downloads_title.asStringSource(), description = Res.string.confirm_auto_categorize_downloads_description.asStringSource(), onConfirm = component::onConfirmAutoCategorize ) } is BaseHomeComponent.Effects.Common.ResetCategoriesToDefault -> { showConfirmPrompt = ConfirmPromptState( title = Res.string.confirm_reset_to_default_categories_title.asStringSource(), description = Res.string.confirm_reset_to_default_categories_description.asStringSource(), onConfirm = component::onConfirmResetCategories ) } is BaseHomeComponent.Effects.Common.ScrollToDownloadItem -> { val id = effect.downloadId val positionOrNull = downloadList .indexOfFirst { it.id == id } .takeIf { it != -1 } positionOrNull?.let { index -> if (effect.skipIfVisible) { val isVisible = lazyListState.layoutInfo.visibleItemsInfo.any { it.index == index } if (isVisible) { return@let } } coroutineScope.launch { lazyListState.scrollToItem(index) } } } } } is HomeComponent.Effects -> { when (effect) { is HomeComponent.Effects.ShareFiles -> { AndroidIntentUtils.shareFiles(context, effect.files) } } } else -> {} } } val isOverlayVisible by component.isOverlayVisible.collectAsState() Box( Modifier.fillMaxSize() ) { PageUi( header = { val headerAlpha = rememberHeaderAlpha( lazyListState, density.run { topPaddingInDp.toPx() }, ).value * 0.75f PageHeader( modifier = Modifier .background( myColors.background.copy( alpha = headerAlpha ) ) .statusBarsPadding() .padding(horizontal = mySpacings.largeSpace), leadingIcon = { MyIcon( MyIcons.appIcon, null, Modifier.size(mySpacings.iconSize), ) }, headerTitle = { PageTitle( myStringResource(Res.string.app_title) ) } ) }, footer = { PageFooter { Footer( Modifier, component, ) } }, ) { params -> contentPaddingValues = params.paddingValues Box { Column( Modifier .fillMaxSize() .background(myColors.background), horizontalAlignment = Alignment.CenterHorizontally, ) { val filterMode by component.filterMode DownloadList( downloadList = downloadList, selectionList = selectionList, onItemSelectionChange = { id, checked -> component.onItemSelectionChange(id, checked) }, onItemClicked = { component.onItemClicked(it) }, fileIconProvider = component.fileIconProvider, onNewSelection = { component.newSelection(ids = it) }, lazyListState = lazyListState, modifier = Modifier .weight(1f), contentPadding = params.paddingValues, ) } AnimatedVisibility( isOverlayVisible, enter = fadeIn(), exit = fadeOut(), ) { Box( Modifier .align(Alignment.Center) .fillMaxSize() .background( Color.Black.copy(alpha = 0.5f), ) .silentClickable { component.onOverlayClicked() } ) } Box( Modifier .align(Alignment.BottomCenter) .fillMaxWidth() .height(bottomPaddingInDp) .background( Brush.verticalGradient( listOf( Color.Transparent, myColors.background, ) ) ) ) } } RenderAboveBottonNavigation( component, Modifier .align(Alignment.BottomCenter) .padding( start = contentPaddingValues.calculateLeftPadding(direction), end = contentPaddingValues.calculateRightPadding(direction), ) .padding(horizontal = 16.dp) .statusBarsPadding() .padding(bottom = bottomPaddingInDp) ) } val enterNewURLComponent = component.enterNewLinkSlot.rememberChild() val state = rememberResponsiveDialogState(false) LaunchedEffect(enterNewURLComponent) { if (enterNewURLComponent == null) { state.hide() } else { state.show() } } state.OnFullyDismissed { component.closeEnterNewURLWindow() } val onDismissEnterNewURLComponent = { state.hide() } ResponsiveDialog( state = state, onDismiss = onDismissEnterNewURLComponent ) { enterNewURLComponent?.let { EnterNewURLPage(it, onDismissEnterNewURLComponent) } } RenderPrompts( component = component, showDeletePromptState = showDeletePromptState, showDeleteCategoryPrompt = showDeleteCategoryPromptState, showConfirmPrompt = showConfirmPrompt, closeConfirmPrompt = { showConfirmPrompt = null }, closeDeleteCategoryPrompt = { showDeleteCategoryPromptState = null }, closeDeletePrompt = { showDeletePromptState = null }, ) } @Composable fun Footer( modifier: Modifier, component: HomeComponent, ) { Column( modifier, horizontalAlignment = Alignment.CenterHorizontally, ) { val selectionList by component.selectionList.collectAsState() AnimatedContent( selectionList.isNotEmpty(), transitionSpec = { val enter = slideInVertically(tween()) { it } + fadeIn(tween()) val exit = slideOutVertically(tween()) { it } + fadeOut(tween()) enter togetherWith exit }, modifier = Modifier ) { hasSelection -> val commonModifier = Modifier .navigationBarsPadding() .padding(bottom = 8.dp) if (hasSelection) { RenderDownloadOptions( modifier = commonModifier, component = component, ) } else { BottomNavigation( commonModifier, component, ) } } } } @Composable fun RenderAboveBottonNavigation(component: HomeComponent, modifier: Modifier) { val enter = fadeIn() + expandIn { IntSize(it.width, 0) } val exit = fadeOut() + shrinkOut { IntSize(it.width, 0) } RenderSortMenu(component, modifier, enter, exit) RenderStatusFilterMenu(component, modifier, enter, exit) } @Composable private fun RenderDownloadOptions( modifier: Modifier, component: HomeComponent, ) { val selection by component.selectionList.collectAsState() val downloadList by component.sortedDownloadList.collectAsState() val filterMode by component.filterMode val selectedQueue = (filterMode as? HomeComponent.FilterMode.Queue)?.queue SelectionMenuBox( modifier = modifier, options = component.downloadActions.androidMenu, onRequestClose = component::clearSelection, renderSubMenu = { optionMenuProps, onRequestClose -> val state = rememberResponsiveDialogState(false) val onDismiss = { state.hide() } LaunchedEffect(optionMenuProps) { if (optionMenuProps == null) { state.hide() } else { state.show() } } state.OnFullyDismissed { onRequestClose() } optionMenuProps?.let { Popup( popupPositionProvider = rememberMyComponentCustomRectPositionProvider( providedAnchorBounds = it.layoutCoordinates.boundsInWindow().roundToIntRect(), anchor = Alignment.TopEnd, alignment = Alignment.TopStart, offset = DpOffset(0.dp, (-4).dp) ), properties = PopupProperties( focusable = true, dismissOnClickOutside = true, ), onDismissRequest = onDismiss, ) { RenderMenuInSinglePage( optionMenuProps.subMenu, onDismiss, Modifier.width(IntrinsicSize.Max) ) } } }, onRequestSelectAll = component::selectAll, onRequestSelectInside = component::onRequestSelectInside, onRequestInvertSelection = component::onRequestInvertSelection, selectionCount = selection.size, total = downloadList.size, queueItemsMenu = selectedQueue?.let { QueueSelectedItemsMenuProps( queueName = it.name, onRequestQueueItemsUp = component::reorderQueueItemsUp, onRequestQueueItemsDown = component::reorderQueueItemsDown, onRequestRemoveItemsFromQueue = component::removeQueueItems, ) } ) } ================================================ FILE: android/app/src/main/kotlin/com/abdownloadmanager/android/pages/home/HomePageStateToPersist.kt ================================================ package com.abdownloadmanager.android.pages.home import arrow.optics.optics import com.abdownloadmanager.android.pages.home.sections.sort.DownloadSortBy import com.abdownloadmanager.shared.ui.widget.sort.Sort import kotlinx.serialization.Serializable @optics @Serializable data class HomePageStateToPersist( val sortBy: Sort = Sort(DownloadSortBy.DataAdded, Sort.DEFAULT_IS_DESCENDING) ) { companion object {} } ================================================ FILE: android/app/src/main/kotlin/com/abdownloadmanager/android/pages/home/Prompts.kt ================================================ package com.abdownloadmanager.android.pages.home import androidx.compose.foundation.background import androidx.compose.foundation.border import androidx.compose.foundation.clickable import androidx.compose.foundation.interaction.MutableInteractionSource import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.IntrinsicSize import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.width import androidx.compose.foundation.layout.widthIn import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.remember import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip import androidx.compose.ui.focus.FocusRequester import androidx.compose.ui.focus.focusProperties import androidx.compose.ui.focus.focusRequester import androidx.compose.ui.graphics.Brush import androidx.compose.ui.graphics.SolidColor import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.dp import androidx.compose.ui.window.Dialog import com.abdownloadmanager.android.ui.SheetHeader import com.abdownloadmanager.android.ui.SheetTitle import com.abdownloadmanager.android.ui.SheetUI import com.abdownloadmanager.resources.Res import com.abdownloadmanager.shared.pages.home.CategoryDeletePromptState import com.abdownloadmanager.shared.pages.home.ConfirmPromptState import com.abdownloadmanager.shared.pages.home.DeletePromptState import com.abdownloadmanager.shared.ui.widget.ActionButton import com.abdownloadmanager.shared.ui.widget.CheckBox import com.abdownloadmanager.shared.ui.widget.Text import com.abdownloadmanager.shared.util.OnFullyDismissed import com.abdownloadmanager.shared.util.ResponsiveDialog import com.abdownloadmanager.shared.util.div import com.abdownloadmanager.shared.util.rememberResponsiveDialogState import com.abdownloadmanager.shared.util.ui.myColors import com.abdownloadmanager.shared.util.ui.theme.myShapes import com.abdownloadmanager.shared.util.ui.theme.mySpacings import com.abdownloadmanager.shared.util.ui.theme.myTextSizes import ir.amirab.util.compose.asStringSourceWithARgs import ir.amirab.util.compose.resources.myStringResource @Composable fun RenderPrompts( component: HomeComponent, showConfirmPrompt: ConfirmPromptState?, closeConfirmPrompt: () -> Unit, showDeleteCategoryPrompt: CategoryDeletePromptState?, closeDeleteCategoryPrompt: () -> Unit, showDeletePromptState: DeletePromptState?, closeDeletePrompt: () -> Unit, ) { showDeletePromptState?.let { ShowDeletePrompts( deletePromptState = it, onCancel = { closeDeletePrompt() }, onConfirm = { closeDeletePrompt() component.confirmDelete(it) }) } showDeleteCategoryPrompt?.let { ShowDeleteCategoryPrompt( deletePromptState = it, onCancel = { closeDeleteCategoryPrompt() }, onConfirm = { closeDeleteCategoryPrompt() component.onConfirmDeleteCategory(it) }) } showConfirmPrompt?.let { ShowConfirmPrompt( promptState = it, onCancel = { closeConfirmPrompt() }, onConfirm = { closeConfirmPrompt() showConfirmPrompt.onConfirm.invoke() } ) } } @Composable private fun ShowDeletePrompts( deletePromptState: DeletePromptState, onConfirm: () -> Unit, onCancel: () -> Unit, ) { val state = rememberResponsiveDialogState(false) LaunchedEffect(Unit) { state.show() } state.OnFullyDismissed(onCancel) // shadow the actual parameter ResponsiveDialog(state, state::hide) { deletePromptState?.let { deletePromptState -> SheetUI( header = { SheetHeader( headerTitle = { SheetTitle(myStringResource(Res.string.confirm_delete_download_items_title)) } ) } ) { Column( Modifier .padding(horizontal = mySpacings.largeSpace) .padding(bottom = mySpacings.largeSpace) ) { val finishedCount = deletePromptState.finishedCount val unfinishedCount = deletePromptState.unfinishedCount Text( when { deletePromptState.hasBothFinishedAndUnfinished() -> { Res.string.confirm_delete_download_finished_and_unfinished_items_description.asStringSourceWithARgs( Res.string.confirm_delete_download_finished_and_unfinished_items_description_createArgs( finishedCount = finishedCount.toString(), unfinishedCount = unfinishedCount.toString(), ) ) } deletePromptState.hasUnfinishedDownloads -> { Res.string.confirm_delete_download_unfinished_items_description.asStringSourceWithARgs( Res.string.confirm_delete_download_unfinished_items_description_createArgs( count = unfinishedCount.toString(), ) ) } else -> { Res.string.confirm_delete_download_items_description.asStringSourceWithARgs( Res.string.confirm_delete_download_items_description_createArgs( count = finishedCount.toString() ), ) } }.rememberString(), fontSize = myTextSizes.base, color = myColors.onBackground, ) if (deletePromptState.hasFinishedDownloads) { Spacer(Modifier.height(12.dp)) val alsoDeleteFileInteractionSource = remember { MutableInteractionSource() } Row( Modifier .clickable( interactionSource = alsoDeleteFileInteractionSource, indication = null ) { deletePromptState.alsoDeleteFile = !deletePromptState.alsoDeleteFile }, verticalAlignment = Alignment.CenterVertically, ) { CheckBox( value = deletePromptState.alsoDeleteFile, onValueChange = { deletePromptState.alsoDeleteFile = it }, modifier = Modifier // the Row itself is clickable (focusable) so we don't need to focus this checkbox // is there a better way? .focusProperties { canFocus = false }, interactionSource = alsoDeleteFileInteractionSource, ) Spacer(Modifier.width(8.dp)) Text( myStringResource(Res.string.also_delete_file_from_disk), fontSize = myTextSizes.base, color = myColors.onBackground, ) } } Spacer(Modifier.height(12.dp)) Row( modifier = Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically, ) { ActionButton( text = myStringResource(Res.string.cancel), onClick = onCancel, modifier = Modifier.weight(1f), ) Spacer(Modifier.width(8.dp)) ActionButton( text = myStringResource(Res.string.delete), onClick = onConfirm, borderColor = SolidColor(myColors.error), contentColor = myColors.error, modifier = Modifier.weight(1f) ) } } } } } } @Composable private fun ShowConfirmPrompt( promptState: ConfirmPromptState, onConfirm: () -> Unit, onCancel: () -> Unit, ) { val state = rememberResponsiveDialogState(false) LaunchedEffect(Unit) { state.show() } state.OnFullyDismissed(onCancel) ResponsiveDialog( state, state::hide, ) { SheetUI( header = { SheetHeader( headerTitle = { SheetTitle(promptState.title.rememberString()) } ) } ) { Column( Modifier .padding(horizontal = mySpacings.largeSpace) .padding(bottom = mySpacings.largeSpace) ) { Text( text = promptState.description.rememberString(), fontSize = myTextSizes.base, color = myColors.onBackground, ) Spacer(Modifier.height(12.dp)) Row( modifier = Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically, ) { ActionButton( text = myStringResource(Res.string.cancel), onClick = onCancel, modifier = Modifier.weight(1f), ) Spacer(Modifier.width(8.dp)) ActionButton( text = myStringResource(Res.string.ok), onClick = onConfirm, modifier = Modifier.weight(1f), ) } } } } } @Composable private fun ShowDeleteCategoryPrompt( deletePromptState: CategoryDeletePromptState, onConfirm: () -> Unit, onCancel: () -> Unit, ) { val state = rememberResponsiveDialogState(false) LaunchedEffect(Unit) { state.show() } state.OnFullyDismissed(onCancel) ResponsiveDialog(state, state::hide) { SheetUI( header = { SheetHeader( headerTitle = { SheetTitle( myStringResource( Res.string.confirm_delete_category_item_title, Res.string.confirm_delete_category_item_title_createArgs( name = deletePromptState.category.name ), ) ) } ) } ) { Column( Modifier .padding(horizontal = mySpacings.largeSpace) .padding(bottom = mySpacings.largeSpace) ) { Text( myStringResource( Res.string.confirm_delete_category_item_description, Res.string.confirm_delete_category_item_description_createArgs( value = deletePromptState.category.name ) ), fontSize = myTextSizes.base, color = myColors.onBackground, ) Spacer(Modifier.height(12.dp)) Text( myStringResource(Res.string.your_download_will_not_be_deleted), fontSize = myTextSizes.base, color = myColors.onBackground, ) Spacer(Modifier.height(12.dp)) Row( modifier = Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically, ) { ActionButton( text = myStringResource(Res.string.cancel), onClick = onCancel, modifier = Modifier.weight(1f) ) Spacer(Modifier.width(mySpacings.mediumSpace)) ActionButton( text = myStringResource(Res.string.delete), onClick = onConfirm, borderColor = SolidColor(myColors.error), modifier = Modifier.weight(1f), contentColor = myColors.error, ) } } } } } ================================================ FILE: android/app/src/main/kotlin/com/abdownloadmanager/android/pages/home/RenderAddMenu.kt ================================================ package com.abdownloadmanager.android.pages.home import androidx.compose.foundation.layout.IntrinsicSize import androidx.compose.foundation.layout.width import androidx.compose.runtime.Composable import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.unit.DpOffset import androidx.compose.ui.unit.dp import androidx.compose.ui.window.Popup import androidx.compose.ui.window.PopupProperties import com.abdownloadmanager.android.ui.menu.RenderMenuInSinglePage import com.abdownloadmanager.shared.ui.widget.rememberMyComponentRectPositionProvider @Composable fun RenderAddMenu(component: HomeComponent) { val mainMenuShowing by component.isAddMenuShowing.collectAsState() if (mainMenuShowing) { val onDismissRequest = { component.setIsAddMenuShowing(false) } Popup( popupPositionProvider = rememberMyComponentRectPositionProvider( anchor = Alignment.TopEnd, alignment = Alignment.TopStart, offset = DpOffset(x = 0.dp, y = (-8).dp) ), onDismissRequest = onDismissRequest, properties = PopupProperties( focusable = true, ) ) { RenderMenuInSinglePage( menu = component.addMenu, onDismissRequest = onDismissRequest, modifier = Modifier.width(IntrinsicSize.Max), ) } } } ================================================ FILE: android/app/src/main/kotlin/com/abdownloadmanager/android/pages/home/RenderDownloadItem.kt ================================================ package com.abdownloadmanager.android.pages.home import androidx.compose.animation.AnimatedVisibility import androidx.compose.animation.core.LinearEasing import androidx.compose.animation.core.RepeatMode import androidx.compose.animation.core.animateFloat import androidx.compose.animation.core.animateFloatAsState import androidx.compose.animation.core.infiniteRepeatable import androidx.compose.animation.core.keyframes import androidx.compose.animation.core.rememberInfiniteTransition import androidx.compose.animation.core.tween import androidx.compose.foundation.background import androidx.compose.foundation.border import androidx.compose.foundation.combinedClickable import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.aspectRatio import androidx.compose.foundation.layout.fillMaxHeight import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.width import androidx.compose.foundation.shape.CircleShape import androidx.compose.runtime.Composable import androidx.compose.runtime.CompositionLocalProvider import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.BlurredEdgeTreatment.Companion.Unbounded import androidx.compose.ui.draw.blur import androidx.compose.ui.draw.clip import androidx.compose.ui.graphics.Brush import androidx.compose.ui.graphics.Color import androidx.compose.ui.unit.dp import com.abdownloadmanager.resources.Res import com.abdownloadmanager.shared.singledownloadpage.createStatusString import com.abdownloadmanager.shared.ui.widget.CheckBox import com.abdownloadmanager.shared.ui.widget.Text import com.abdownloadmanager.shared.util.FileIconProvider import com.abdownloadmanager.shared.util.LocalSizeUnit import com.abdownloadmanager.shared.util.LocalSpeedUnit import com.abdownloadmanager.shared.util.LocalUseRelativeDateTime import com.abdownloadmanager.shared.util.MyDateAndTimeFormats import com.abdownloadmanager.shared.util.TimeNames import com.abdownloadmanager.shared.util.convertPositiveSizeToHumanReadable import com.abdownloadmanager.shared.util.convertPositiveSpeedToHumanReadable import com.abdownloadmanager.shared.util.convertTimeRemainingToHumanReadable import com.abdownloadmanager.shared.util.div import com.abdownloadmanager.shared.util.formatTime import com.abdownloadmanager.shared.util.prettifyRelativeTime import com.abdownloadmanager.shared.util.ui.LocalContentAlpha import com.abdownloadmanager.shared.util.ui.LocalContentColor import com.abdownloadmanager.shared.util.ui.LocalTextStyle import com.abdownloadmanager.shared.util.ui.WithContentColor import com.abdownloadmanager.shared.util.ui.myColors import com.abdownloadmanager.shared.util.ui.theme.myShapes import com.abdownloadmanager.shared.util.ui.theme.myTextSizes import com.abdownloadmanager.shared.util.ui.widget.MyIcon import ir.amirab.downloader.downloaditem.DownloadJobStatus import ir.amirab.downloader.monitor.CompletedDownloadItemState import ir.amirab.downloader.monitor.IDownloadItemState import ir.amirab.downloader.monitor.ProcessingDownloadItemState import ir.amirab.downloader.monitor.isFinished import ir.amirab.downloader.monitor.statusOrFinished import ir.amirab.downloader.utils.ExceptionUtils import ir.amirab.util.compose.StringSource import ir.amirab.util.compose.asStringSource import kotlinx.coroutines.delay import kotlinx.coroutines.isActive import kotlinx.datetime.TimeZone import kotlinx.datetime.format import kotlinx.datetime.periodUntil import kotlinx.datetime.toLocalDateTime import kotlin.time.Clock import kotlin.time.ExperimentalTime import kotlin.time.Instant private const val PROGRESS_HEIGHT = 6 @Composable fun RenderDownloadItem( checked: Boolean?, onClick: () -> Unit, onLongClick: () -> Unit, downloadItem: IDownloadItemState, fileIconProvider: FileIconProvider, modifier: Modifier ) { Row( modifier ) { WithContentColor( myColors.onSurface, ) { Column( Modifier .weight(1f) .let { if (checked == true) { val selectionColor = myColors.onBackground it.background(myColors.selectionGradient(0.15f, 0.03f, selectionColor)) } else { it.border(1.dp, Color.Transparent) } } .combinedClickable( onClick = onClick, onLongClick = onLongClick, ) .padding(16.dp) ) { Row( verticalAlignment = Alignment.CenterVertically, ) { AnimatedVisibility( checked != null ) { Row { val isChecked = checked ?: false CheckBox( value = isChecked, onValueChange = { onLongClick() }, size = 18.dp, ) Spacer(Modifier.width(8.dp)) } } RenderFileIcon( downloadItem = downloadItem, fileIconProvider = fileIconProvider, ) Spacer(Modifier.width(8.dp)) Column(Modifier.weight(1f)) { Text( downloadItem.name, maxLines = 1, ) Spacer(Modifier.height(8.dp)) Row( verticalAlignment = Alignment.CenterVertically ) { RenderProgressBar( downloadItem, Modifier .weight(1f) .height(PROGRESS_HEIGHT.dp) ) if (downloadItem is ProcessingDownloadItemState) { Spacer(Modifier.width(2.dp)) RenderProgressLight(downloadItem) } } } } Spacer(Modifier.height(8.dp)) RenderSubTexts(downloadItem) } } } } @Composable fun RenderProgressLight(itemState: IDownloadItemState) { val color = when (val status = itemState.statusOrFinished()) { is DownloadJobStatus.IsActive -> { myColors.primaryGradient } is DownloadJobStatus.CanBeResumed -> { if (status is DownloadJobStatus.Canceled && !ExceptionUtils.isNormalCancellation(status.e)) { myColors.errorGradient } else { myColors.warningGradient } } DownloadJobStatus.Finished -> { myColors.successGradient } } Box( modifier = Modifier .size((PROGRESS_HEIGHT).dp) .background(color, CircleShape), ) } @Composable fun RenderSubTexts(itemState: IDownloadItemState) { CompositionLocalProvider( LocalTextStyle provides LocalTextStyle.current.copy(fontSize = myTextSizes.xs), LocalContentAlpha provides 0.8f ) { Box( Modifier.fillMaxWidth() ) { RenderLeftSubText(itemState, Modifier.align(Alignment.CenterStart)) RenderCenterSubText(itemState, Modifier.align(Alignment.Center)) RenderRightSubText(itemState, Modifier.align(Alignment.CenterEnd)) } } } @Composable private fun RenderEta(itemState: ProcessingDownloadItemState, modifier: Modifier) { val eta = remember(itemState.remainingTime) { itemState.remainingTime?.let { convertTimeRemainingToHumanReadable( it, TimeNames.ShortNames ) }.orEmpty() } Text(eta, modifier) } @OptIn(ExperimentalTime::class) @Composable private fun RenderAddedTime(itemState: IDownloadItemState, modifier: Modifier) { var dateAddedString by remember { mutableStateOf("") } val useRelativeDateTime = LocalUseRelativeDateTime.current LaunchedEffect( itemState.dateAdded, useRelativeDateTime, ) { val instant = Instant.fromEpochMilliseconds(itemState.dateAdded) if (useRelativeDateTime) { while (isActive) { val now = Clock.System.now() val period = now.periodUntil(instant, TimeZone.UTC) val relativeTime = prettifyRelativeTime(period) dateAddedString = relativeTime delay(1000) } } else { val dateTime = instant.toLocalDateTime(TimeZone.currentSystemDefault()) dateAddedString = dateTime.format(MyDateAndTimeFormats.fullDateTime) } } Text(dateAddedString, modifier) } @Composable fun RenderRightSubText(itemState: IDownloadItemState, modifier: Modifier) { if (itemState is ProcessingDownloadItemState && itemState.status is DownloadJobStatus.IsActive) { RenderEta(itemState, modifier) } else { RenderAddedTime(itemState, modifier) } } @Composable fun RenderCenterSubText(itemState: IDownloadItemState, modifier: Modifier) { if (itemState is ProcessingDownloadItemState) { if (itemState.status is DownloadJobStatus.IsActive) { RenderSpeed(itemState.speed, modifier) } else { RenderTextStatus(itemState, modifier) } } } @Composable fun RenderTextStatus(itemState: IDownloadItemState, modifier: Modifier) { val status = createStatusString(itemState) Text( status.rememberString(), color = if (itemState.isFinished()) { myColors.success } else { LocalContentColor.current }, modifier = modifier, ) } @Composable fun RenderSpeed(speed: Long, modifier: Modifier) { val target = LocalSpeedUnit.current val speedString = remember(speed) { convertPositiveSpeedToHumanReadable(speed, target) } Text(speedString, modifier) } @Composable fun RenderLeftSubText(itemState: IDownloadItemState, modifier: Modifier) { val totalSize = itemState.contentLength val sizeUnit = LocalSizeUnit.current val totalSizeString = remember(totalSize, sizeUnit) { convertPositiveSizeToHumanReadable(totalSize, sizeUnit, true) } val progress = (itemState as? ProcessingDownloadItemState)?.progress val progressStringOrNull = remember(progress, sizeUnit) { progress?.let { convertPositiveSizeToHumanReadable(progress, sizeUnit, true) } } val text = when { else -> { buildString { progressStringOrNull?.let { append(it.rememberString()) append("/") } append(totalSizeString.rememberString()) } } } Text(text, modifier = modifier) } @Composable private fun RenderFileIcon( downloadItem: IDownloadItemState, fileIconProvider: FileIconProvider, ) { MyIcon( icon = fileIconProvider.rememberIcon(downloadItem.name), contentDescription = null, modifier = Modifier.size(24.dp), ) } @Composable private fun RenderProgressBar( itemState: IDownloadItemState, modifier: Modifier, ) { val progress = when (itemState) { is CompletedDownloadItemState -> 100 is ProcessingDownloadItemState -> when (val status = itemState.status) { is DownloadJobStatus.PreparingFile -> status.percent else -> itemState.percent } }?.let { it / 100f } val status = itemState.statusOrFinished() val background = when (status) { is DownloadJobStatus.Finished -> myColors.successGradient is DownloadJobStatus.Canceled -> if (ExceptionUtils.isNormalCancellation(status.e)) { myColors.warningGradient } else { myColors.errorGradient } DownloadJobStatus.IDLE -> myColors.warningGradient is DownloadJobStatus.Retrying -> myColors.errorGradient DownloadJobStatus.Finished -> myColors.successGradient is DownloadJobStatus.PreparingFile -> myColors.infoGradient DownloadJobStatus.Resuming, DownloadJobStatus.Downloading, -> myColors.primaryGradient } Box( modifier .fillMaxSize() .clip(myShapes.defaultRounded) .background(myColors.onBackground / 15) ) { progress?.let { progress -> Box( Modifier .clip(myShapes.defaultRounded) .background(background) .fillMaxHeight() .fillMaxWidth( animateFloatAsState( progress, tween(100, easing = LinearEasing) ).value ) ) { // if (status is DownloadJobStatus.Downloading) { // JetFade( // Modifier // .fillMaxSize() // .padding(end = 1.dp) // ) // } } } if (progress == null && status is DownloadJobStatus.IsActive) { val anim = rememberInfiniteTransition() val l = 2000 val endPos by anim.animateFloat( 0f, 1f, infiniteRepeatable(tween(l), RepeatMode.Restart) ) val width by anim.animateFloat( 6f, 16f, infiniteRepeatable( keyframes { durationMillis = l 0f atFraction 0f 0.75f atFraction 0.25f 0f atFraction 1f }, repeatMode = RepeatMode.Restart ) ) Box( Modifier .fillMaxHeight() .fillMaxWidth(endPos) ) { Box( Modifier .background(background) .fillMaxHeight() .align(Alignment.CenterEnd) .fillMaxWidth(width) ) } } } } @Composable private fun JetFade(modifier: Modifier) { val color = myColors.onContrast / 0.80f Box( modifier, contentAlignment = Alignment.CenterEnd, ) { Box( Modifier .blur(2.dp, edgeTreatment = Unbounded) .aspectRatio(1f) .clip(CircleShape) .background( Brush.radialGradient( listOf(color, Color.Transparent) ) ) ) Box( Modifier .blur(1.dp, edgeTreatment = Unbounded) .fillMaxHeight(0.6f) .fillMaxWidth(0.4f) .background( Brush.horizontalGradient( listOf( Color.Transparent, color, ) ) ) ) } } ================================================ FILE: android/app/src/main/kotlin/com/abdownloadmanager/android/pages/home/RenderMainMenu.kt ================================================ package com.abdownloadmanager.android.pages.home import androidx.compose.foundation.layout.IntrinsicSize import androidx.compose.foundation.layout.width import androidx.compose.runtime.Composable import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.unit.DpOffset import androidx.compose.ui.unit.dp import androidx.compose.ui.window.Popup import androidx.compose.ui.window.PopupProperties import com.abdownloadmanager.android.ui.menu.RenderMenuInSinglePage import com.abdownloadmanager.shared.ui.widget.rememberMyComponentRectPositionProvider @Composable fun RenderMainMenu(component: HomeComponent) { val mainMenuShowing by component.isMainMenuShowing.collectAsState() if (mainMenuShowing) { val onDismissRequest = { component.setIsMainMenuShowing(false) } Popup( popupPositionProvider = rememberMyComponentRectPositionProvider( anchor = Alignment.TopStart, alignment = Alignment.TopEnd, offset = DpOffset(x = 0.dp, y = (-8).dp) ), onDismissRequest = onDismissRequest, properties = PopupProperties( focusable = true, ) ) { RenderMenuInSinglePage( menu = component.mainMenu, onDismissRequest = onDismissRequest, modifier = Modifier.width(IntrinsicSize.Max), ) } } } ================================================ FILE: android/app/src/main/kotlin/com/abdownloadmanager/android/pages/home/RenderStatusFilterMenu.kt ================================================ package com.abdownloadmanager.android.pages.home import androidx.activity.compose.BackHandler import androidx.compose.animation.AnimatedVisibility import androidx.compose.animation.EnterTransition import androidx.compose.animation.ExitTransition import androidx.compose.foundation.background import androidx.compose.foundation.border import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.verticalScroll import androidx.compose.runtime.Composable import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip import androidx.compose.ui.unit.dp import com.abdownloadmanager.android.pages.home.sections.Categories import com.abdownloadmanager.android.pages.home.sections.queues.QueuesSection import com.abdownloadmanager.resources.Res import com.abdownloadmanager.shared.ui.widget.ActionButton import com.abdownloadmanager.shared.util.div import com.abdownloadmanager.shared.util.ui.myColors import com.abdownloadmanager.shared.util.ui.theme.myShapes import com.abdownloadmanager.shared.util.ui.theme.mySpacings import ir.amirab.util.compose.modifiers.hijackClick import ir.amirab.util.compose.resources.myStringResource @Composable fun RenderStatusFilterMenu( component: HomeComponent, modifier: Modifier, enter: EnterTransition, exit: ExitTransition, ) { val isShowingStatusFilterMenu by component.isCategoryFilterShowing.collectAsState() AnimatedVisibility( modifier = modifier, visible = isShowingStatusFilterMenu, enter = enter, exit = exit, ) { BackHandler { component.setIsCategoryFilterShowing(false) } val shape = myShapes.defaultRounded Column( Modifier .clip(shape) .hijackClick() .background(myColors.surface, shape) .border(1.dp, myColors.onSurface / 0.2f, shape) ) { Column( Modifier .weight(1f, false) .verticalScroll(rememberScrollState()) ) { Categories(component, Modifier) Spacer( Modifier .padding(4.dp) .height(1.dp) .fillMaxWidth() .background(myColors.onSurface / 0.1f) ) QueuesSection(component, Modifier) } ActionButton( text = myStringResource(Res.string.ok), onClick = { component.setIsSortMenuShowing(false) }, modifier = Modifier .fillMaxWidth() .padding( mySpacings.largeSpace ), ) } } } ================================================ FILE: android/app/src/main/kotlin/com/abdownloadmanager/android/pages/home/SelectedQueueItemsOption.kt ================================================ package com.abdownloadmanager.android.pages.home import androidx.compose.foundation.layout.Row import androidx.compose.runtime.Composable import androidx.compose.runtime.Immutable import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.RectangleShape import androidx.compose.ui.text.style.TextAlign import com.abdownloadmanager.resources.Res import com.abdownloadmanager.shared.ui.widget.IconActionButton import com.abdownloadmanager.shared.ui.widget.Text import com.abdownloadmanager.shared.ui.widget.TransparentIconActionButton import com.abdownloadmanager.shared.util.ui.icon.MyIcons import ir.amirab.util.compose.asStringSource @Immutable data class QueueSelectedItemsMenuProps( val queueName: String, val onRequestQueueItemsUp: () -> Unit, val onRequestQueueItemsDown: () -> Unit, val onRequestRemoveItemsFromQueue: () -> Unit, ) @Composable fun RenderSelectedQueueItemsOption( props: QueueSelectedItemsMenuProps, ) { Row( verticalAlignment = Alignment.CenterVertically, ) { TransparentIconActionButton( icon = MyIcons.up, contentDescription = Res.string.move_up.asStringSource(), onClick = props.onRequestQueueItemsUp, shape = RectangleShape, ) TransparentIconActionButton( icon = MyIcons.down, contentDescription = Res.string.move_down.asStringSource(), onClick = props.onRequestQueueItemsDown, shape = RectangleShape, ) Text( props.queueName, modifier = Modifier.weight(1f), textAlign = TextAlign.Center, ) TransparentIconActionButton( MyIcons.minus, Res.string.remove.asStringSource(), onClick = props.onRequestRemoveItemsFromQueue, shape = RectangleShape, ) } } ================================================ FILE: android/app/src/main/kotlin/com/abdownloadmanager/android/pages/home/Selection.kt ================================================ package com.abdownloadmanager.android.pages.home import androidx.compose.animation.AnimatedVisibility import androidx.compose.animation.expandVertically import androidx.compose.animation.shrinkVertically import androidx.compose.foundation.LocalIndication import androidx.compose.foundation.background import androidx.compose.foundation.border import androidx.compose.foundation.clickable import androidx.compose.foundation.interaction.MutableInteractionSource import androidx.compose.foundation.interaction.collectIsFocusedAsState import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.IntrinsicSize import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxHeight import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.width import androidx.compose.foundation.layout.wrapContentSize import androidx.compose.runtime.Composable import androidx.compose.runtime.Immutable import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier import androidx.compose.ui.draw.alpha import androidx.compose.ui.draw.clip import androidx.compose.ui.draw.shadow import androidx.compose.ui.graphics.RectangleShape import androidx.compose.ui.graphics.Shape import androidx.compose.ui.layout.LayoutCoordinates import androidx.compose.ui.layout.onGloballyPositioned import androidx.compose.ui.semantics.Role import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp import com.abdownloadmanager.android.ui.RenderControlSelections import com.abdownloadmanager.android.ui.SelectionControlButton import com.abdownloadmanager.resources.Res import com.abdownloadmanager.shared.ui.widget.alphaFlicker import com.abdownloadmanager.shared.util.div import com.abdownloadmanager.shared.util.ui.icon.MyIcons import com.abdownloadmanager.shared.util.ui.myColors import com.abdownloadmanager.shared.util.ui.theme.myShapes import com.abdownloadmanager.shared.util.ui.widget.MyIcon import ir.amirab.util.compose.IconSource import ir.amirab.util.compose.StringSource import ir.amirab.util.compose.action.MenuItem import ir.amirab.util.compose.asStringSource import ir.amirab.util.ifThen @Immutable data class OpenOptionMenuProps( val subMenu: MenuItem.SubMenu, val layoutCoordinates: LayoutCoordinates, ) @Composable fun SelectionMenuBox( modifier: Modifier, options: List, queueItemsMenu: QueueSelectedItemsMenuProps?, onRequestSelectAll: () -> Unit, onRequestSelectInside: () -> Unit, onRequestInvertSelection: () -> Unit, selectionCount: Int, total: Int, onRequestClose: () -> Unit, renderSubMenu: @Composable (menu: OpenOptionMenuProps?, close: () -> Unit) -> Unit ) { var submenuToOpen: OpenOptionMenuProps? by remember { mutableStateOf(null) } val dismissExtraMenu = { submenuToOpen = null } val shape = myShapes.defaultRounded Column( modifier .padding(16.dp) .shadow(4.dp, shape) .clip(shape) .border(1.dp, myColors.onSurface / 0.1f, shape) .background(myColors.surface), ) { AnimatedVisibility( submenuToOpen == null, enter = expandVertically(), exit = shrinkVertically(), ) { RenderDownloadControlSelections( onRequestSelectAll = onRequestSelectAll, onRequestSelectInside = onRequestSelectInside, onRequestInvertSelection = onRequestInvertSelection, selectionCount = selectionCount, total = total, onRequestClose = onRequestClose, ) } Spacer( Modifier .fillMaxWidth() .height(1.dp) .background(myColors.onBackground / 0.1f) ) RenderSelectionMenuActions(options, { submenuToOpen = it }) if (queueItemsMenu != null) { Spacer( Modifier .fillMaxWidth() .height(1.dp) .background(myColors.onBackground / 0.1f) ) RenderSelectedQueueItemsOption(queueItemsMenu) } } renderSubMenu(submenuToOpen, dismissExtraMenu) } @Composable fun RenderDownloadControlSelections( onRequestSelectAll: () -> Unit, onRequestSelectInside: () -> Unit, onRequestInvertSelection: () -> Unit, onRequestClose: () -> Unit, selectionCount: Int, total: Int, ) { RenderControlSelections( onRequestSelectAll = onRequestSelectAll, onRequestSelectInside = onRequestSelectInside, onRequestInvertSelection = onRequestInvertSelection, selectionCount = selectionCount, total = total, otherActions = { SelectionControlButton( icon = MyIcons.close, contentDescription = Res.string.close.asStringSource(), modifier = Modifier, enabled = true, toggledOff = false, onClick = { onRequestClose() }, ) } ) } @Composable fun RenderSelectionMenuActions( options: List, onRequestOpenSubmenu: (OpenOptionMenuProps) -> Unit, ) { Row( Modifier.height(IntrinsicSize.Max) ) { val reactableItemModifier = Modifier .weight(1f) for (action in options) { when (action) { MenuItem.Separator -> { Spacer( Modifier .fillMaxHeight() .width(1.dp) .background(myColors.onBackground / 0.2f) ) } is MenuItem.SingleItem -> { val icon = action.icon.collectAsState().value VerticalMenuOption( title = action.title.collectAsState().value, icon = requireNotNull(icon) { "use an action that has icon in the HorizontalMenu" }, enabled = action.isEnabled.collectAsState().value, onClick = { action() }, modifier = reactableItemModifier, ) } is MenuItem.SubMenu -> { val icon = action.icon.collectAsState().value VerticalMenuOption( title = action.title.collectAsState().value, icon = requireNotNull(icon) { "use an action that has icon in the HorizontalMenu" }, enabled = action.isEnabled.collectAsState().value, onClick = { onRequestOpenSubmenu(OpenOptionMenuProps(action, it)) }, modifier = reactableItemModifier, ) } } } } } @Composable private fun VerticalMenuOption( title: StringSource, icon: IconSource, enabled: Boolean, onClick: (LayoutCoordinates) -> Unit, modifier: Modifier, ) { SelectionActionButton( icon, contentDescription = title.rememberString(), enabled = enabled, onClick = onClick, modifier = modifier, size = 24.dp, padding = PaddingValues(12.dp), ) } @Composable private fun SelectionActionButton( icon: IconSource, contentDescription: String, modifier: Modifier = Modifier, indicateActive: Boolean = false, requiresAttention: Boolean = false, enabled: Boolean = true, interactionSource: MutableInteractionSource = remember { MutableInteractionSource() }, onClick: (LayoutCoordinates) -> Unit, padding: PaddingValues, size: Dp, shape: Shape = RectangleShape, ) { val isFocused by interactionSource.collectIsFocusedAsState() val isActiveOrFocused = indicateActive || isFocused var layoutCoordinates by remember { mutableStateOf(null as LayoutCoordinates?) } Box( modifier .ifThen(!enabled) { alpha(0.5f) } .ifThen(isActiveOrFocused || requiresAttention) { border( 1.dp, myColors.focusedBorderColor / if (isActiveOrFocused) 1f else alphaFlicker(), shape ) } .clip(shape) .onGloballyPositioned { layoutCoordinates = it } .clickable( enabled = enabled, indication = LocalIndication.current, interactionSource = interactionSource, role = Role.Button, onClick = { layoutCoordinates?.let(onClick) }, ) .padding(padding) .wrapContentSize() ) { MyIcon( icon, contentDescription, Modifier .size(size) ) } } ================================================ FILE: android/app/src/main/kotlin/com/abdownloadmanager/android/pages/home/SimplePager.kt ================================================ package com.abdownloadmanager.android.pages.home import android.util.Log import androidx.compose.foundation.horizontalScroll import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.fillMaxHeight import androidx.compose.foundation.layout.width import androidx.compose.foundation.rememberScrollState import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier import androidx.compose.ui.layout.onSizeChanged import androidx.compose.ui.platform.LocalDensity import kotlin.math.roundToInt @Composable fun SimplePager( pageCount: Int, currentPage: Int, onPageChanged: (Int) -> Unit, modifier: Modifier = Modifier, content: @Composable (Int) -> Unit, ) { val scrollState = rememberScrollState() var pageWidth by remember { mutableStateOf(0) } // Snap scroll when page changes externally LaunchedEffect(currentPage, pageWidth) { val target = currentPage * pageWidth if (scrollState.value != target) { scrollState.animateScrollTo(target) } } // When user finishes scrolling -> snap to nearest page LaunchedEffect(scrollState.isScrollInProgress) { if (!scrollState.isScrollInProgress && pageWidth > 0) { val pos = scrollState.value val newPage = (pos.toFloat() / pageWidth).roundToInt().coerceIn(0, pageCount - 1) val snapPos = newPage * pageWidth if (snapPos != pos) { scrollState.animateScrollTo(snapPos) } if (newPage != currentPage) { onPageChanged(newPage) } } } Box( modifier .onSizeChanged { pageWidth = it.width } .horizontalScroll(scrollState) ) { Row { repeat(pageCount) { index -> Box( Modifier .width(with(LocalDensity.current) { pageWidth.toDp() }) .fillMaxHeight() ) { content(index) } } } } } ================================================ FILE: android/app/src/main/kotlin/com/abdownloadmanager/android/pages/home/sections/Categories.kt ================================================ package com.abdownloadmanager.android.pages.home.sections import androidx.compose.animation.* import androidx.compose.foundation.LocalIndication import androidx.compose.foundation.background import androidx.compose.foundation.border import androidx.compose.foundation.clickable import com.abdownloadmanager.shared.util.ui.widget.MyIcon import com.abdownloadmanager.shared.util.ui.icon.MyIcons import com.abdownloadmanager.shared.util.ui.theme.myTextSizes import com.abdownloadmanager.shared.ui.widget.ExpandableItem import com.abdownloadmanager.shared.util.ui.WithContentAlpha import androidx.compose.foundation.interaction.MutableInteractionSource import androidx.compose.foundation.layout.* import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.RoundedCornerShape import com.abdownloadmanager.shared.ui.widget.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue import androidx.compose.runtime.key import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.alpha import androidx.compose.ui.draw.clip import androidx.compose.ui.draw.rotate import androidx.compose.ui.geometry.Offset import androidx.compose.ui.graphics.Color import androidx.compose.ui.layout.LayoutCoordinates import androidx.compose.ui.layout.onGloballyPositioned import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp import androidx.compose.ui.window.Popup import com.abdownloadmanager.android.pages.home.HomeComponent import com.abdownloadmanager.android.ui.menu.RenderMenuInSinglePage import com.abdownloadmanager.android.ui.myCombinedClickable import com.abdownloadmanager.shared.pages.home.category.DefinedStatusCategories import com.abdownloadmanager.shared.util.ui.myColors import com.abdownloadmanager.shared.util.div import com.abdownloadmanager.shared.pages.home.category.DownloadStatusCategoryFilter import com.abdownloadmanager.shared.util.category.Category import com.abdownloadmanager.shared.util.category.rememberIconPainter import com.abdownloadmanager.shared.util.ui.theme.myShapes import com.abdownloadmanager.resources.Res import com.abdownloadmanager.shared.pages.home.CategoryActions import com.abdownloadmanager.shared.ui.widget.rememberMyPopupPositionProviderAtPosition import com.abdownloadmanager.shared.util.ui.theme.mySpacings import ir.amirab.util.compose.action.MenuItem import ir.amirab.util.compose.asStringSource import sh.calvin.reorderable.ReorderableColumn import sh.calvin.reorderable.ReorderableListItemScope @Composable fun Categories( component: HomeComponent, modifier: Modifier, ) { val filterMode = component.filterMode.value val currentStatusFilter = (filterMode as? HomeComponent.FilterMode.Status)?.downloadStatus val currentTypeFilter = (filterMode as? HomeComponent.FilterMode.Status)?.category val categories by component.categoryManager.categoriesFlow.collectAsState() val clipShape = myShapes.defaultRounded val showCategoryOption by component.categoryActions.collectAsState() var popupOffset by remember { mutableStateOf(Offset.Zero) } fun showCategoryOption(item: Category?, offset: Offset) { popupOffset = offset component.showCategoryOptions(item) } fun closeCategoryOptions() { component.closeCategoryOptions() } Column( modifier .clip(clipShape) .border(1.dp, myColors.surface, clipShape) .padding(1.dp) ) { var expendedItem: DownloadStatusCategoryFilter? by remember { mutableStateOf(currentStatusFilter.takeIf { currentTypeFilter != null }) } for (statusCategoryFilter in DefinedStatusCategories.values()) { StatusFilterItem( isExpanded = expendedItem == statusCategoryFilter, currentTypeCategoryFilter = currentTypeFilter, currentStatusCategoryFilter = currentStatusFilter, statusFilter = statusCategoryFilter, categories = categories, onFilterChange = { component.onCategoryFilterChange(statusCategoryFilter, it) }, onRequestExpand = { expand -> expendedItem = statusCategoryFilter.takeIf { expand } }, onItemsDroppedInCategory = { category, ids -> component.moveItemsToCategory(category, ids) }, onRequestOpenOptionMenu = { category, offset -> showCategoryOption(category, offset) }, onCategoryReorderRequest = { index, delta -> component.reorderCategory(index, delta) } ) } } showCategoryOption?.let { CategoryOption( categoryOptionMenuState = it, onDismiss = { closeCategoryOptions() }, offset = popupOffset, ) } } @Composable fun CategoryOption( categoryOptionMenuState: CategoryActions, onDismiss: () -> Unit, offset: Offset, ) { ShowOptionsInPopupWithOffset( MenuItem.SubMenu( icon = categoryOptionMenuState.categoryItem?.rememberIconPainter(), title = categoryOptionMenuState.categoryItem?.name?.asStringSource() ?: Res.string.categories.asStringSource(), categoryOptionMenuState.menu, ), popupOffset = offset, onDismissRequest = onDismiss, ) } @Composable private fun ShowOptionsInPopupWithOffset( menu: MenuItem.SubMenu, popupOffset: Offset, onDismissRequest: () -> Unit ) { Popup( popupPositionProvider = rememberMyPopupPositionProviderAtPosition( popupOffset ), onDismissRequest = onDismissRequest ) { RenderMenuInSinglePage( menu, onDismissRequest, Modifier.width(IntrinsicSize.Max), ) } } @Composable private fun ReorderableListItemScope.CategoryFilterItem( modifier: Modifier, category: Category, isSelected: Boolean, onItemsDropped: (ids: List) -> Unit, onClick: () -> Unit, isDragging: Boolean, onRequestOpenOptionMenu: (Category?, Offset) -> Unit, ) { // var isDraggingOnMe by remember { mutableStateOf(false) } var layoutCoordinates by remember { mutableStateOf(null) } val shouldShowDrag = isSelected || isDragging Box( modifier // .dropDownloadItemsHere( // onDragIn = { isDraggingOnMe = true }, // onDragDone = { isDraggingOnMe = false }, // onItemsDropped = onItemsDropped, // ) .background( if (isSelected) { myColors.onBackground / 0.05f } else Color.Transparent ) // .ifThen(isDraggingOnMe) { // val infiniteTransition = rememberInfiniteTransition() // val color by infiniteTransition.animateColor( // initialValue = myColors.primary, // targetValue = myColors.secondary, // animationSpec = infiniteRepeatable( // animation = tween(1000, easing = LinearEasing), // repeatMode = RepeatMode.Reverse // ) // ) // border(1.dp, color) // } .heightIn(mySpacings.thumbSize) .onGloballyPositioned { layoutCoordinates = it } .myCombinedClickable( onLongClick = { onRequestOpenOptionMenu( category, layoutCoordinates?.localToWindow(it) ?: Offset.Zero ) }, onClick = { onClick() }, interactionSource = remember { MutableInteractionSource() }, indication = LocalIndication.current, ), contentAlignment = Alignment.CenterStart, ) { // if (isDraggingOnMe) { // DelayedTooltipPopup( // {}, // myStringResource(Res.string.move_to_this_category), // ) // } Row( modifier = Modifier .padding(start = 24.dp) .padding(horizontal = 4.dp, vertical = 6.dp), verticalAlignment = Alignment.CenterVertically, ) { WithContentAlpha(if (isSelected) 1f else 0.75f) { val iconPainter = category.rememberIconPainter() MyIcon( iconPainter ?: MyIcons.folder, null, Modifier.size(mySpacings.iconSize), ) Spacer(Modifier.width(8.dp)) Text( category.name, Modifier.weight(1f), maxLines = 1, overflow = TextOverflow.Ellipsis, fontWeight = if (isSelected) FontWeight.Bold else FontWeight.Normal, fontSize = myTextSizes.base ) AnimatedVisibility(shouldShowDrag) { MyIcon( MyIcons.grip, null, Modifier .draggableHandle() .size(mySpacings.iconSize) .alpha(if (isDragging) 1f else 0.5f), ) } } } AnimatedVisibility( isSelected, modifier = Modifier.align(Alignment.CenterStart), enter = scaleIn(), exit = scaleOut(), ) { Spacer( Modifier .height(16.dp) .width(3.dp) .clip( RoundedCornerShape( topStart = 0.dp, bottomStart = 0.dp, bottomEnd = 12.dp, topEnd = 12.dp, ) ) .background(myColors.primary) ) } } } @Composable fun StatusFilterItem( isExpanded: Boolean, onRequestExpand: (Boolean) -> Unit, currentTypeCategoryFilter: Category?, currentStatusCategoryFilter: DownloadStatusCategoryFilter?, statusFilter: DownloadStatusCategoryFilter, categories: List, onCategoryReorderRequest: (index: Int, delta: Int) -> Unit, onItemsDroppedInCategory: (category: Category, downloadIds: List) -> Unit, onFilterChange: ( typeFilter: Category?, ) -> Unit, onRequestOpenOptionMenu: (Category?, Offset) -> Unit, ) { val isStatusSelected = currentStatusCategoryFilter == statusFilter val isSelected = isStatusSelected && currentTypeCategoryFilter == null var layoutCoordinates by remember { mutableStateOf(null) } ExpandableItem( modifier = Modifier, isExpanded = isExpanded, header = { Box( Modifier .height(IntrinsicSize.Max) .heightIn(mySpacings.thumbSize) .background( if (isSelected) { myColors.onBackground / 0.05f } else Color.Transparent ) .onGloballyPositioned { layoutCoordinates = it } .myCombinedClickable( onClick = { onRequestExpand(!isExpanded) onFilterChange(null) }, onLongClick = { onRequestOpenOptionMenu( null, layoutCoordinates?.localToWindow(it) ?: Offset.Zero ) }, interactionSource = remember { MutableInteractionSource() }, indication = LocalIndication.current, ) ) { Row( Modifier .padding(vertical = 4.dp) .padding(start = 16.dp) .padding(end = 8.dp), verticalAlignment = Alignment.CenterVertically, ) { WithContentAlpha(if (isSelected) 1f else 0.75f) { MyIcon( statusFilter.icon, null, Modifier.size(mySpacings.iconSize) ) Spacer(Modifier.width(8.dp)) Text( statusFilter.name.rememberString(), Modifier.weight(1f), fontWeight = if (isSelected) FontWeight.Bold else FontWeight.Normal, fontSize = myTextSizes.lg, overflow = TextOverflow.Ellipsis, maxLines = 1, ) MyIcon( MyIcons.up, null, Modifier .fillMaxHeight() .wrapContentHeight() .clip(CircleShape) .clickable { onRequestExpand(!isExpanded) } .padding(6.dp) .size(16.dp) .rotate(if (isExpanded) 0f else 180f) ) } } AnimatedVisibility( isSelected, modifier = Modifier.align(Alignment.CenterStart), enter = scaleIn(), exit = scaleOut(), ) { Spacer( Modifier .height(16.dp) .width(3.dp) .clip( RoundedCornerShape( topStart = 0.dp, bottomStart = 0.dp, bottomEnd = 12.dp, topEnd = 12.dp, ) ) .background(myColors.primary) ) } } }, body = { ReorderableColumn( list = categories, onSettle = { from, to -> onCategoryReorderRequest(from, to - from) }, ) { index, category, isDragging -> key(category.id) { ReorderableItem { CategoryFilterItem( modifier = Modifier, category = category, isSelected = isStatusSelected && currentTypeCategoryFilter == category, onItemsDropped = { onItemsDroppedInCategory(category, it) }, onClick = { onFilterChange(category) }, onRequestOpenOptionMenu = onRequestOpenOptionMenu, isDragging = isDragging, ) } Spacer(Modifier.height(2.dp)) } } } ) } ================================================ FILE: android/app/src/main/kotlin/com/abdownloadmanager/android/pages/home/sections/queues/Queues.kt ================================================ package com.abdownloadmanager.android.pages.home.sections.queues import androidx.compose.animation.AnimatedVisibility import androidx.compose.animation.animateColorAsState import androidx.compose.animation.scaleIn import androidx.compose.animation.scaleOut import androidx.compose.foundation.LocalIndication import androidx.compose.foundation.background import androidx.compose.foundation.border import androidx.compose.foundation.clickable import androidx.compose.foundation.interaction.MutableInteractionSource import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.IntrinsicSize import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxHeight import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.heightIn import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.width import androidx.compose.foundation.layout.wrapContentHeight import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.runtime.Composable import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue import androidx.compose.runtime.key import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip import androidx.compose.ui.draw.rotate import androidx.compose.ui.geometry.Offset import androidx.compose.ui.graphics.Color import androidx.compose.ui.layout.LayoutCoordinates import androidx.compose.ui.layout.onGloballyPositioned import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp import androidx.compose.ui.window.Popup import com.abdownloadmanager.android.pages.home.HomeComponent import com.abdownloadmanager.android.ui.menu.RenderMenuInSinglePage import com.abdownloadmanager.android.ui.myCombinedClickable import com.abdownloadmanager.shared.pages.home.queue.QueueActions import com.abdownloadmanager.resources.Res import com.abdownloadmanager.shared.ui.widget.ExpandableItem import com.abdownloadmanager.shared.ui.widget.Text import com.abdownloadmanager.shared.ui.widget.rememberMyPopupPositionProviderAtPosition import com.abdownloadmanager.shared.util.div import com.abdownloadmanager.shared.util.ui.LocalContentAlpha import com.abdownloadmanager.shared.util.ui.LocalContentColor import com.abdownloadmanager.shared.util.ui.WithContentAlpha import com.abdownloadmanager.shared.util.ui.icon.MyIcons import com.abdownloadmanager.shared.util.ui.myColors import com.abdownloadmanager.shared.util.ui.theme.myShapes import com.abdownloadmanager.shared.util.ui.theme.mySpacings import com.abdownloadmanager.shared.util.ui.theme.myTextSizes import com.abdownloadmanager.shared.util.ui.widget.MyIcon import ir.amirab.downloader.db.QueueModel import ir.amirab.downloader.queue.DownloadQueue import ir.amirab.util.compose.action.MenuItem import ir.amirab.util.compose.asStringSource import ir.amirab.util.compose.resources.myStringResource @Composable internal fun QueuesSection( component: HomeComponent, modifier: Modifier, ) { val currentSelectedQueue = component.filterState.queueFilter val filterMode by component.filterMode val queues by component.queueManager.queues.collectAsState() val clipShape = myShapes.defaultRounded val showQueueOption by component.queueActions.collectAsState() var lastPointerPosition by remember { mutableStateOf(Offset.Zero) } fun showQueueOption(downloadQueue: DownloadQueue?, pointerPosition: Offset) { lastPointerPosition = pointerPosition component.showCategoryOptions(downloadQueue) } fun closeQueueOptions() { component.closeQueueOptions() } var isExpanded by remember { mutableStateOf( filterMode is HomeComponent.FilterMode.Queue ) } Column( modifier .border(1.dp, myColors.surface, clipShape) .clip(clipShape) .padding(1.dp), ) { var layoutCoordinates by remember { mutableStateOf(null as LayoutCoordinates?) } ExpandableItem( isExpanded = isExpanded, modifier = Modifier, header = { Box( Modifier .height(IntrinsicSize.Max) .heightIn(mySpacings.thumbSize) .onGloballyPositioned { layoutCoordinates = it } .myCombinedClickable( onClick = { isExpanded = !isExpanded }, onLongClick = { showQueueOption(null, layoutCoordinates?.localToWindow(it) ?: Offset.Zero) }, interactionSource = remember { MutableInteractionSource() }, indication = LocalIndication.current, ) ) { Row( Modifier .padding(vertical = 4.dp) .padding(start = 16.dp) .padding(end = 8.dp), verticalAlignment = Alignment.CenterVertically, ) { WithContentAlpha(0.75f) { MyIcon( MyIcons.queue, null, Modifier.size(mySpacings.iconSize) ) Spacer(Modifier.width(8.dp)) Text( myStringResource(Res.string.queues), Modifier.weight(1f), fontWeight = FontWeight.Normal, fontSize = myTextSizes.lg, overflow = TextOverflow.Ellipsis, maxLines = 1, ) MyIcon( MyIcons.up, null, Modifier .fillMaxHeight() .wrapContentHeight() .clip(CircleShape) .clickable { isExpanded = !isExpanded } .padding(6.dp) .size(16.dp) .rotate(if (isExpanded) 0f else 180f)) } } } }, body = { Column { queues.forEachIndexed { index, queue -> key(queue.id) { QueueFilterItem( modifier = Modifier, isSelected = currentSelectedQueue?.id == queue.id, onSelect = { component.onQueueFilterChange(queue.queueModel.value) }, // onItemsDroppedInQueue = { downloadIds -> // component.moveItemsToQueue(queue, downloadIds) // }, queueModel = queue.queueModel.collectAsState().value, isActive = queue.activeFlow.collectAsState().value, showQueueOption = { position -> showQueueOption(queue, position) } // parentShape = clipShape, // isLast = queues.lastIndex == index ) } } } }, ) } showQueueOption?.let { QueueOption( queueOptionMenuState = it, onDismiss = { closeQueueOptions() }, position = lastPointerPosition, ) } } @Composable private fun QueueFilterItem( isSelected: Boolean, onSelect: () -> Unit, // onItemsDroppedInQueue: (List) -> Unit, queueModel: QueueModel, isActive: Boolean, modifier: Modifier = Modifier, showQueueOption: (offset: Offset) -> Unit, // I add this to properly create border on drag when the item is in the last position // isLast: Boolean, // parentShape: RoundedCornerShape, ) { // var isDraggingOnMe by remember { mutableStateOf(false) } var layoutCoordinates by remember { mutableStateOf(null as LayoutCoordinates?) } Box( modifier // .dropDownloadItemsHere( // onDragIn = { isDraggingOnMe = true }, // onDragDone = { isDraggingOnMe = false }, // onItemsDropped = onItemsDroppedInQueue, // ) .background( if (isSelected) { myColors.onBackground / 0.05f } else Color.Transparent ) // .ifThen(isDraggingOnMe) { // val infiniteTransition = rememberInfiniteTransition() // val color by infiniteTransition.animateColor( // initialValue = myColors.primary, // targetValue = myColors.secondary, // animationSpec = infiniteRepeatable( // animation = tween(1000, easing = LinearEasing), // repeatMode = RepeatMode.Reverse // ) // ) // val shape = RoundedCornerShape(0.dp).let { // when { // isLast -> it.copy( // bottomStart = parentShape.bottomStart, // bottomEnd = parentShape.bottomEnd, // ) // // else -> it // } // } // border(1.dp, color, shape) // } .onGloballyPositioned { layoutCoordinates = it } .myCombinedClickable( onClick = { onSelect() }, onLongClick = { showQueueOption(layoutCoordinates?.localToWindow(it) ?: Offset.Zero) }, interactionSource = remember { MutableInteractionSource() }, indication = LocalIndication.current, ) ) { // if (isDraggingOnMe) { // DelayedTooltipPopup( // {}, // myStringResource(Res.string.move_to_this_queue), // ) // } Row( Modifier .heightIn(mySpacings.thumbSize) .padding(start = 24.dp) .padding(end = 8.dp) .padding(horizontal = 4.dp, vertical = 6.dp), verticalAlignment = Alignment.CenterVertically, ) { WithContentAlpha(if (isSelected) 1f else 0.75f) { MyIcon( MyIcons.folder, null, Modifier.size(mySpacings.iconSize) ) Spacer(Modifier.width(8.dp)) Text( queueModel.name, Modifier.weight(1f), fontWeight = if (isSelected) FontWeight.Bold else FontWeight.Normal, fontSize = myTextSizes.lg, overflow = TextOverflow.Ellipsis, maxLines = 1, ) val counterColor = animateColorAsState( if (isActive) { myColors.success } else { LocalContentColor.current / LocalContentAlpha.current } ).value Text( text = "${queueModel.queueItems.size}", modifier = Modifier.padding(horizontal = 6.dp), color = counterColor ) } } AnimatedVisibility( isSelected, modifier = Modifier.align(Alignment.CenterStart), enter = scaleIn(), exit = scaleOut(), ) { Spacer( Modifier .height(16.dp) .width(3.dp) .clip( RoundedCornerShape( topStart = 0.dp, bottomStart = 0.dp, bottomEnd = 12.dp, topEnd = 12.dp, ) ) .background(myColors.primary) ) } } } @Composable private fun QueueOption( queueOptionMenuState: QueueActions, onDismiss: () -> Unit, position: Offset, ) { ShowOptionsInPopup( MenuItem.SubMenu( icon = MyIcons.queue, title = queueOptionMenuState.mainQueueModel?.name?.asStringSource() ?: Res.string.queues.asStringSource(), items = queueOptionMenuState.menu, ), onDismiss, position, ) } @Composable private fun ShowOptionsInPopup( subMenu: MenuItem.SubMenu, onDismiss: () -> Unit, position: Offset, ) { Popup( popupPositionProvider = rememberMyPopupPositionProviderAtPosition(position), onDismissRequest = onDismiss, ) { RenderMenuInSinglePage( menu = subMenu, onDismissRequest = onDismiss, modifier = Modifier.width(IntrinsicSize.Max) ) } } ================================================ FILE: android/app/src/main/kotlin/com/abdownloadmanager/android/pages/home/sections/sort/DownloadSortBy.kt ================================================ package com.abdownloadmanager.android.pages.home.sections.sort import com.abdownloadmanager.resources.Res import com.abdownloadmanager.shared.ui.widget.sort.ComparatorProvider import com.abdownloadmanager.shared.util.ui.icon.MyIcons import ir.amirab.downloader.monitor.IDownloadItemState import ir.amirab.downloader.monitor.statusOrFinished import ir.amirab.util.compose.IconSource import ir.amirab.util.compose.StringSource import ir.amirab.util.compose.asStringSource import kotlinx.serialization.SerialName import kotlinx.serialization.Serializable @Serializable sealed class DownloadSortBy( val selector: (IDownloadItemState) -> Comparable<*>, val icon: IconSource, val name: StringSource, ) : ComparatorProvider { override fun comparator(): Comparator { return compareBy(selector) } @Serializable @SerialName("name") object Name : DownloadSortBy( selector = { it.name }, icon = MyIcons.alphabet, name = Res.string.name.asStringSource(), ) @Serializable @SerialName("dateAdded") object DataAdded : DownloadSortBy( selector = { it.dateAdded }, icon = MyIcons.clock, name = Res.string.date_added.asStringSource(), ) @Serializable @SerialName("status") data object Status : DownloadSortBy( selector = { it.statusOrFinished().order }, icon = MyIcons.info, name = Res.string.status.asStringSource(), ) @Serializable @SerialName("size") data object Size : DownloadSortBy( selector = { it.contentLength }, icon = MyIcons.data, name = Res.string.size.asStringSource(), ) } ================================================ FILE: android/app/src/main/kotlin/com/abdownloadmanager/android/pages/home/sections/sort/RenderSortMenu.kt ================================================ package com.abdownloadmanager.android.pages.home.sections.sort import androidx.activity.compose.BackHandler import androidx.compose.animation.AnimatedVisibility import androidx.compose.animation.EnterTransition import androidx.compose.animation.ExitTransition import androidx.compose.foundation.background import androidx.compose.foundation.border import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.heightIn import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.width import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.verticalScroll import androidx.compose.runtime.Composable import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue import androidx.compose.runtime.key import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.alpha import androidx.compose.ui.draw.clip import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.dp import com.abdownloadmanager.android.pages.home.HomeComponent import com.abdownloadmanager.resources.Res import com.abdownloadmanager.shared.ui.widget.ActionButton import com.abdownloadmanager.shared.ui.widget.Text import com.abdownloadmanager.shared.ui.widget.sort.Sort import com.abdownloadmanager.shared.ui.widget.sort.SortIndicatorMode import com.abdownloadmanager.shared.ui.widget.sort.isDescending import com.abdownloadmanager.shared.ui.widget.sort.toSortIndicatorMode import com.abdownloadmanager.shared.ui.widget.sort.next import com.abdownloadmanager.shared.util.div import com.abdownloadmanager.shared.util.ui.icon.MyIcons import com.abdownloadmanager.shared.util.ui.myColors import com.abdownloadmanager.shared.util.ui.theme.myShapes import com.abdownloadmanager.shared.util.ui.theme.mySpacings import com.abdownloadmanager.shared.util.ui.theme.myTextSizes import com.abdownloadmanager.shared.util.ui.widget.MyIcon import ir.amirab.util.compose.modifiers.hijackClick import ir.amirab.util.compose.resources.myStringResource import ir.amirab.util.ifThen @Composable fun RenderSortMenu( component: HomeComponent, modifier: Modifier, enter: EnterTransition, exit: ExitTransition, ) { val isShowingSortMenu by component.isSortMenuShowing.collectAsState() AnimatedVisibility( modifier = modifier, visible = isShowingSortMenu, enter = enter, exit = exit, ) { BackHandler { component.setIsSortMenuShowing(false) } val shape = myShapes.defaultRounded Column( Modifier .clip(shape) .hijackClick() .background(myColors.surface, shape) .border(1.dp, myColors.onSurface / 0.2f, shape) ) { val selectedSort by component.selectedSort.collectAsState() Text( text = myStringResource(Res.string.sort_by), fontWeight = FontWeight.Bold, fontSize = myTextSizes.xl, modifier = Modifier.padding( mySpacings.largeSpace ) ) Column( Modifier .weight(1f, false) .verticalScroll(rememberScrollState()), ) { for (downloadSortBy in component.possibleSorts) { val isSelected = downloadSortBy == selectedSort.cell key(downloadSortBy) { SortItem( downloadSortBy, sortIndicatorMode = if (isSelected) { selectedSort.toSortIndicatorMode() } else { SortIndicatorMode.None }, onSortChange = { component.setSelectedSort( Sort( cell = downloadSortBy, isDescending = it.isDescending() ) ) }, Modifier.fillMaxWidth(), ) } } } ActionButton( text = myStringResource(Res.string.ok), onClick = { component.setIsSortMenuShowing(false) }, modifier = Modifier .fillMaxWidth() .padding( mySpacings.largeSpace ), ) } } } @Composable private fun SortItem( sortBy: DownloadSortBy, sortIndicatorMode: SortIndicatorMode, onSortChange: (SortIndicatorMode) -> Unit, modifier: Modifier, ) { Row( verticalAlignment = Alignment.CenterVertically, modifier = modifier .clickable { onSortChange(sortIndicatorMode.next()) } .ifThen(sortIndicatorMode == SortIndicatorMode.None) { alpha(0.6f) } .heightIn(min = mySpacings.thumbSize) .padding(horizontal = mySpacings.largeSpace), ) { MyIcon(sortBy.icon, null, Modifier.size(24.dp)) Spacer(Modifier.width(8.dp)) Text( sortBy.name.rememberString(), Modifier.weight(1f) ) Spacer(Modifier.width(8.dp)) RenderSortIndicatorMode(sortIndicatorMode) } } @Composable fun RenderSortIndicatorMode(sortIndicatorMode: SortIndicatorMode) { val icon = when (sortIndicatorMode) { SortIndicatorMode.None -> null SortIndicatorMode.Ascending -> MyIcons.sortUp SortIndicatorMode.Descending -> MyIcons.sortDown } icon?.let { MyIcon( it, null, Modifier .size(16.dp) .alpha(0.75f) ) } } ================================================ FILE: android/app/src/main/kotlin/com/abdownloadmanager/android/pages/newqueue/NewQueue.kt ================================================ package com.abdownloadmanager.android.pages.newqueue import androidx.compose.runtime.Composable import com.abdownloadmanager.android.ui.configurable.SheetInput import com.abdownloadmanager.resources.Res import com.abdownloadmanager.shared.ui.widget.MyTextField import ir.amirab.util.compose.asStringSource import ir.amirab.util.compose.resources.myStringResource @Composable fun NewQueueSheet( onQueueCreate: (String) -> Unit, isOpened: Boolean, onCloseRequest: () -> Unit, ) { SheetInput( title = Res.string.add_new_queue.asStringSource(), validate = { it.isNotEmpty() }, isOpened = isOpened, initialValue = { "" }, onDismiss = onCloseRequest, onConfirm = onQueueCreate, inputContent = { MyTextField( modifier = it.modifier, text = it.editingValue, onTextChange = it.setEditingValue, placeholder = myStringResource(Res.string.queue_name), ) }, ) } ================================================ FILE: android/app/src/main/kotlin/com/abdownloadmanager/android/pages/onboarding/StartUpPageTemplate.kt ================================================ package com.abdownloadmanager.android.pages.onboarding import androidx.activity.compose.BackHandler import androidx.compose.foundation.Image import androidx.compose.foundation.background import androidx.compose.foundation.border import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.navigationBarsPadding import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.statusBarsPadding import androidx.compose.foundation.layout.wrapContentWidth import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip import androidx.compose.ui.draw.shadow import androidx.compose.ui.graphics.Brush import androidx.compose.ui.layout.onSizeChanged import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp import com.abdownloadmanager.resources.Res import com.abdownloadmanager.shared.ui.widget.Text import com.abdownloadmanager.shared.ui.widget.TransparentIconActionButton import com.abdownloadmanager.shared.util.ui.icon.MyIcons import com.abdownloadmanager.shared.util.ui.myColors import com.abdownloadmanager.shared.util.ui.theme.mySpacings import com.abdownloadmanager.shared.util.ui.theme.myTextSizes import ir.amirab.util.compose.StringSource import ir.amirab.util.compose.asStringSource import ir.amirab.util.compose.layout.RelativeAlignment @Composable fun StartUpPageTemplate( header: @Composable () -> Unit, actions: @Composable () -> Unit, content: @Composable () -> Unit, ) { Column( modifier = Modifier .background(myColors.background) .statusBarsPadding() .navigationBarsPadding(), ) { header() Box( modifier = Modifier.weight(1f), ) { content() } actions() } } @Composable fun StartUpPageHeader( title: StringSource, onBackPressed: (() -> Unit)? = null, ) { BackHandler(onBackPressed != null) { onBackPressed?.invoke() } Row( modifier = Modifier.padding( horizontal = mySpacings.largeSpace, vertical = mySpacings.largeSpace ), verticalAlignment = Alignment.CenterVertically, ) { var backButtonWidth by remember { mutableStateOf(0) } if (onBackPressed != null) { TransparentIconActionButton( MyIcons.back, contentDescription = Res.string.back.asStringSource(), onClick = { onBackPressed() }, modifier = Modifier.onSizeChanged { backButtonWidth = it.width } ) } else { backButtonWidth = 0 } Text( text = title.rememberString(), fontWeight = FontWeight.Bold, fontSize = myTextSizes.xl, modifier = Modifier .weight(1f) .wrapContentWidth( RelativeAlignment.Horizontal( mainAlignment = Alignment.CenterHorizontally, relative = -backButtonWidth / 2 ), ) ) } } @Composable fun StartUpPageActions( content: @Composable () -> Unit ) { Box( Modifier .padding(horizontal = mySpacings.largeSpace, vertical = mySpacings.largeSpace) ) { content() } } @Composable fun AppIcon( modifier: Modifier = Modifier, size: Dp = 52.dp, ) { val shape = RoundedCornerShape(24.dp) Image( MyIcons.appIcon.rememberPainter(), null, modifier .shadow(12.dp, shape, spotColor = myColors.primary) .clip(shape) .border( 1.dp, Brush.linearGradient( listOf(myColors.primary, myColors.secondary) ), shape ) .background(myColors.surface) .padding(16.dp) .size(size) ) } ================================================ FILE: android/app/src/main/kotlin/com/abdownloadmanager/android/pages/onboarding/initialsetup/InitialSetupComponent.kt ================================================ package com.abdownloadmanager.android.pages.onboarding.initialsetup import com.abdownloadmanager.shared.settings.CommonSettings import com.abdownloadmanager.shared.ui.configurable.ConfigurableGroup import com.abdownloadmanager.shared.ui.theme.ThemeManager import com.abdownloadmanager.shared.util.BaseComponent import com.arkivanov.decompose.ComponentContext import ir.amirab.util.compose.localizationmanager.LanguageManager class InitialSetupComponent( ctx: ComponentContext, private val languageManager: LanguageManager, private val themeManager: ThemeManager, private val onFinish: () -> Unit ) : BaseComponent(ctx) { val configurables = listOf( CommonSettings.languageConfig(languageManager, scope), CommonSettings.themeConfig(themeManager, scope), ) fun onUserPressFinish() { onFinish() } } ================================================ FILE: android/app/src/main/kotlin/com/abdownloadmanager/android/pages/onboarding/initialsetup/InitialSetupPage.kt ================================================ package com.abdownloadmanager.android.pages.onboarding.initialsetup import androidx.compose.foundation.background import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.wrapContentHeight import androidx.compose.foundation.layout.wrapContentWidth import androidx.compose.runtime.Composable import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip import androidx.compose.ui.layout.AlignmentLine import androidx.compose.ui.text.SpanStyle import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.dp import com.abdownloadmanager.android.pages.onboarding.AppIcon import com.abdownloadmanager.android.pages.onboarding.StartUpPageActions import com.abdownloadmanager.android.pages.onboarding.StartUpPageHeader import com.abdownloadmanager.android.pages.onboarding.StartUpPageTemplate import com.abdownloadmanager.resources.Res import com.abdownloadmanager.shared.ui.configurable.ConfigurableUiProps import com.abdownloadmanager.shared.ui.configurable.RenderConfigurable import com.abdownloadmanager.shared.ui.widget.PrimaryMainActionButton import com.abdownloadmanager.shared.ui.widget.Text import com.abdownloadmanager.shared.util.ui.LocalContentAlpha import com.abdownloadmanager.shared.util.ui.LocalContentColor import com.abdownloadmanager.shared.util.ui.myColors import com.abdownloadmanager.shared.util.ui.theme.myShapes import com.abdownloadmanager.shared.util.ui.theme.mySpacings import com.abdownloadmanager.shared.util.ui.theme.myTextSizes import ir.amirab.util.compose.asStringSource import ir.amirab.util.compose.resources.myStringResource @Composable fun InitialSetupPage( component: InitialSetupComponent, ) { StartUpPageTemplate( header = { StartUpPageHeader( title = Res.string.app_title.asStringSource(), onBackPressed = null ) }, actions = { StartUpPageActions { Column { Text( text = myStringResource(Res.string.initial_setup_notice), color = LocalContentColor.current.copy(alpha = 0.75f), modifier = Modifier .padding(mySpacings.smallSpace) ) Spacer(modifier = Modifier.height(mySpacings.mediumSpace)) Row { PrimaryMainActionButton( onClick = component::onUserPressFinish, text = myStringResource(Res.string.next), modifier = Modifier .fillMaxWidth(), ) } } } }, content = { Column { Column( Modifier .weight(1f) .wrapContentHeight() ) { AppIcon( Modifier .fillMaxWidth() .wrapContentWidth(), size = 72.dp, ) Spacer(Modifier.height(mySpacings.largeSpace)) Spacer(Modifier.height(mySpacings.largeSpace)) Text( text = myStringResource(Res.string.welcome), fontWeight = FontWeight.Bold, fontSize = myTextSizes.x2l, modifier = Modifier .fillMaxWidth() .wrapContentWidth(), ) Spacer(Modifier.height(mySpacings.mediumSpace)) Text( text = myStringResource(Res.string.initial_setup_description), fontSize = myTextSizes.lg, modifier = Modifier .fillMaxWidth() .wrapContentWidth(), ) } Column( Modifier .weight(1f) .wrapContentHeight(Alignment.Bottom) .padding(vertical = mySpacings.largeSpace), verticalArrangement = Arrangement.spacedBy(mySpacings.largeSpace), ) { for (configurable in component.configurables) { RenderConfigurable( cfg = configurable, configurableUiProps = ConfigurableUiProps( modifier = Modifier .fillMaxWidth() .padding(horizontal = mySpacings.largeSpace) .clip(myShapes.defaultRounded) .background(myColors.surface), itemPaddingValues = PaddingValues( horizontal = mySpacings.largeSpace, vertical = mySpacings.mediumSpace, ), ) ) } } } } ) } ================================================ FILE: android/app/src/main/kotlin/com/abdownloadmanager/android/pages/onboarding/permissions/ABDMPermissions.kt ================================================ package com.abdownloadmanager.android.pages.onboarding.permissions import android.Manifest import android.content.Context import android.os.Build import android.os.Environment import android.os.PowerManager import androidx.annotation.RequiresApi import com.abdownloadmanager.resources.Res import com.abdownloadmanager.shared.util.ui.icon.MyIcons import ir.amirab.util.compose.asStringSource object ABDMPermissions { private fun getReadRightStorage(): AppPermission { return AppPermission( title = Res.string.permission_read_write_external_storage_title.asStringSource(), description = Res.string.permission_read_write_external_storage_reason.asStringSource(), icon = MyIcons.data, isOptional = false, permissions = listOf( Manifest.permission.READ_EXTERNAL_STORAGE, Manifest.permission.WRITE_EXTERNAL_STORAGE, ), ) } @RequiresApi(Build.VERSION_CODES.R) private fun createManageStorage(): AppPermission { return AppPermission( title = Res.string.permissions_manage_storage_title.asStringSource(), description = Res.string.permissions_manage_storage_reason.asStringSource(), icon = MyIcons.data, isOptional = true, permissions = listOf( Manifest.permission.MANAGE_EXTERNAL_STORAGE, ), permissionRequestFactory = CustomPermissionActivityLauncher(::requestManageStoragePermission), permissionChecker = object : PermissionRequestChecker { override fun isGranted( context: Context, appPermission: AppPermission ) = Environment.isExternalStorageManager() } ) } val StoragePermission = run { val isManageStorageAvailable = Build.VERSION.SDK_INT >= 30 if (isManageStorageAvailable) { createManageStorage() } else { getReadRightStorage() } } @RequiresApi(Build.VERSION_CODES.TIRAMISU) fun createPostNotificationPermission(): AppPermission { return AppPermission( title = Res.string.permissions_post_notification_title.asStringSource(), description = Res.string.permissions_post_notification_reason.asStringSource(), icon = MyIcons.speaker, isOptional = false, permissions = listOf( Manifest.permission.POST_NOTIFICATIONS ) ) } val importantPermissions = buildList { add(StoragePermission) if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { add(createPostNotificationPermission()) } } // these are not introduced in the main screen. val BatteryOptimizationPermission = AppPermission( title = Res.string.permissions_ignore_battery_optimization_title.asStringSource(), description = Res.string.permissions_ignore_battery_optimization_reason.asStringSource(), icon = MyIcons.settings, isOptional = true, permissions = listOf(), permissionRequestFactory = CustomPermissionActivityLauncher(::requestIgnoreBatteryOptimizationPermission), permissionChecker = object : PermissionRequestChecker { override fun isGranted( context: Context, appPermission: AppPermission ): Boolean { return isBatteryOptimizationDisabled(context) } } ) } ================================================ FILE: android/app/src/main/kotlin/com/abdownloadmanager/android/pages/onboarding/permissions/AppPermission.kt ================================================ package com.abdownloadmanager.android.pages.onboarding.permissions import android.app.Activity import android.content.Context import android.content.pm.PackageManager import androidx.activity.compose.LocalActivity import androidx.activity.compose.rememberLauncherForActivityResult import androidx.activity.result.contract.ActivityResultContracts import androidx.compose.runtime.Composable import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.derivedStateOf import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.setValue import androidx.compose.ui.platform.LocalContext import androidx.core.app.ActivityCompat import androidx.core.content.ContextCompat import androidx.lifecycle.Lifecycle import androidx.lifecycle.LifecycleEventObserver import androidx.lifecycle.compose.LocalLifecycleOwner import ir.amirab.util.compose.IconSource import ir.amirab.util.compose.StringSource data class AppPermission( val title: StringSource, val description: StringSource, val icon: IconSource, val isOptional: Boolean, val permissions: List, // Manifest.Permissions val permissionRequestFactory: PermissionRequestLauncherFactory = DefaultPermissionRequesterFactory, val permissionChecker: PermissionRequestChecker = DefaultPermissionRequestChecker, ) @Composable fun rememberAppPermissionState( appPermission: AppPermission, onNewResult: (Boolean) -> Unit = {}, ): AppPermissionState { val activity = requireNotNull(LocalActivity.current) { "We should query permissions from activity" } val state = remember( appPermission, activity, ) { AppPermissionState( appPermission = appPermission, context = activity ) } val launcher = appPermission.permissionRequestFactory.rememberLauncher(appPermission) { state.refreshStatus() onNewResult(it) } ListenForPermissionChangesInLifecycle(appPermission) { state.refreshStatus() } LaunchedEffect(state, launcher) { state.setLauncher(launcher) } return state } interface PermissionRequestLauncherFactory { @Composable fun rememberLauncher( appPermission: AppPermission, onNewResult: (Boolean) -> Unit ): PermissionRequestLauncher } interface PermissionRequestLauncher { fun launchPermissionRequest() } interface PermissionRequestChecker { fun isGranted( context: Context, appPermission: AppPermission, ): Boolean // in case of deny access fun shouldShowRationale( activity: Activity, appPermission: AppPermission, ): Boolean { return false } } sealed interface PermissionStatus { data object Granted : PermissionStatus data class NotGranted( val shouldShowRationale: Boolean, ) : PermissionStatus } class AppPermissionState( val appPermission: AppPermission, private val context: Activity, ) { var permissionStatus by mutableStateOf(checkGranted()) private set fun refreshStatus() { permissionStatus = checkGranted() } fun checkGranted(): PermissionStatus { val permissionChecker = appPermission.permissionChecker return when { permissionChecker.isGranted(context, appPermission) -> PermissionStatus.Granted else -> PermissionStatus.NotGranted( permissionChecker.shouldShowRationale(context, appPermission), ) } } val isGranted by derivedStateOf { permissionStatus == PermissionStatus.Granted } val requiresRational by derivedStateOf { permissionStatus.let { it is PermissionStatus.NotGranted && it.shouldShowRationale } } private var launcher: PermissionRequestLauncher? = null fun setLauncher(requestLauncher: PermissionRequestLauncher) { launcher = requestLauncher } fun launchRequest() { launcher?.launchPermissionRequest() } } object DefaultPermissionRequesterFactory : PermissionRequestLauncherFactory { @Composable override fun rememberLauncher( appPermission: AppPermission, onNewResult: (Boolean) -> Unit ): PermissionRequestLauncher { val launcher = rememberLauncherForActivityResult( contract = ActivityResultContracts.RequestMultiplePermissions(), ) { onNewResult(it.all { entry -> entry.value }) } return remember(appPermission) { object : PermissionRequestLauncher { override fun launchPermissionRequest() { launcher.launch(appPermission.permissions.toTypedArray()) } } } } } @Composable private fun ListenForPermissionChangesInLifecycle( appPermission: AppPermission, onRequestRefreshStatus: () -> Unit, ) { val context = LocalContext.current val lifecycleOwner = LocalLifecycleOwner.current DisposableEffect(appPermission, context, lifecycleOwner) { val observer = LifecycleEventObserver { _, event -> if (event == Lifecycle.Event.ON_RESUME) { onRequestRefreshStatus() } } lifecycleOwner.lifecycle.addObserver(observer) onDispose { lifecycleOwner.lifecycle.removeObserver(observer) } } } object DefaultPermissionRequestChecker : PermissionRequestChecker { override fun isGranted( context: Context, appPermission: AppPermission ): Boolean { return appPermission.permissions.all { ContextCompat.checkSelfPermission(context, it) == PackageManager.PERMISSION_GRANTED } } override fun shouldShowRationale( activity: Activity, appPermission: AppPermission ): Boolean { return appPermission.permissions.any { ActivityCompat.shouldShowRequestPermissionRationale(activity, it) } } } ================================================ FILE: android/app/src/main/kotlin/com/abdownloadmanager/android/pages/onboarding/permissions/BatteryOptimizationUtil.kt ================================================ package com.abdownloadmanager.android.pages.onboarding.permissions import android.content.Context import android.content.Intent import android.os.PowerManager import android.provider.Settings import androidx.core.net.toUri import ir.amirab.util.ifThen fun requestIgnoreBatteryOptimizationPermission( context: Context, startNewTask: Boolean = false, ) { try { val intent = Intent(Settings.ACTION_REQUEST_IGNORE_BATTERY_OPTIMIZATIONS).apply { data = ("package:" + context.packageName).toUri() }.ifThen(startNewTask) { addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) } context.startActivity(intent) } catch (e: Exception) { // Fallback val intent = Intent(Settings.ACTION_REQUEST_IGNORE_BATTERY_OPTIMIZATIONS) .ifThen(startNewTask) { addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) } context.startActivity(intent) } } fun isBatteryOptimizationDisabled( context: Context ): Boolean { val powerManager = context.getSystemService(Context.POWER_SERVICE) as PowerManager return powerManager.isIgnoringBatteryOptimizations(context.packageName) } ================================================ FILE: android/app/src/main/kotlin/com/abdownloadmanager/android/pages/onboarding/permissions/CustomPermissions.kt ================================================ package com.abdownloadmanager.android.pages.onboarding.permissions import android.content.Context import androidx.compose.runtime.Composable import androidx.compose.runtime.remember import androidx.compose.ui.platform.LocalContext class CustomPermissionActivityLauncher( private val openActivity: (Context) -> Unit, ) : PermissionRequestLauncherFactory { @Composable override fun rememberLauncher( appPermission: AppPermission, onNewResult: (Boolean) -> Unit ): PermissionRequestLauncher { val context = LocalContext.current return remember(context) { object : PermissionRequestLauncher { override fun launchPermissionRequest() { openActivity(context) } } } } } ================================================ FILE: android/app/src/main/kotlin/com/abdownloadmanager/android/pages/onboarding/permissions/PermissionComponent.kt ================================================ package com.abdownloadmanager.android.pages.onboarding.permissions import com.abdownloadmanager.shared.util.BaseComponent import com.arkivanov.decompose.ComponentContext import kotlinx.coroutines.flow.MutableStateFlow class PermissionComponent( componentContext: ComponentContext, private val permissionManager: PermissionManager, private val onReady: () -> Unit, private val onDismiss: () -> Unit, ) : BaseComponent(componentContext) { val currentPermission: MutableStateFlow = MutableStateFlow(PermissionsPageSteps.Initial) val permissionsToAsk = permissionManager.permissions.sortedBy { !it.isOptional } fun goToNextPermissionPage() { when (val currentPermission = currentPermission.value) { is PermissionsPageSteps.AtPermission -> { val index = permissionsToAsk.indexOf(currentPermission.appPermission) val nextIndex = index + 1 if (nextIndex > permissionsToAsk.lastIndex) { this.currentPermission.value = PermissionsPageSteps.Done } else { if (permissionManager.isReady(currentPermission.appPermission)) { val appPermission = permissionsToAsk[nextIndex] this.currentPermission.value = PermissionsPageSteps.AtPermission(appPermission) } } } PermissionsPageSteps.Done -> { onReady() } PermissionsPageSteps.Initial -> { this.currentPermission.value = permissionsToAsk.firstOrNull()?.let { PermissionsPageSteps.AtPermission(it) } ?: PermissionsPageSteps.Done } } } fun goToPreviousPermissionPage() { when (val currentPermission = currentPermission.value) { is PermissionsPageSteps.AtPermission -> { val index = permissionsToAsk.indexOf(currentPermission.appPermission) val previousIndex = index - 1 this.currentPermission.value = if (previousIndex < 0) { PermissionsPageSteps.Initial } else { PermissionsPageSteps.AtPermission(permissionsToAsk[previousIndex]) } } PermissionsPageSteps.Done -> { // we don't reach this! // onReady() } PermissionsPageSteps.Initial -> { onDismiss() } } } } sealed interface PermissionsPageSteps { data object Initial : PermissionsPageSteps data class AtPermission( val appPermission: AppPermission, ) : PermissionsPageSteps data object Done : PermissionsPageSteps } ================================================ FILE: android/app/src/main/kotlin/com/abdownloadmanager/android/pages/onboarding/permissions/PermissionManager.kt ================================================ package com.abdownloadmanager.android.pages.onboarding.permissions import android.content.Context import android.content.pm.PackageManager import androidx.core.content.ContextCompat class PermissionManager( val permissions: List, private val context: Context, ) { fun isReady(): Boolean { return permissions.all { isReady(it) } } fun isGranted(appPermission: AppPermission): Boolean { return appPermission.permissionChecker.isGranted(context, appPermission) } fun isReady(appPermission: AppPermission): Boolean { return appPermission.isOptional || isGranted(appPermission) } } ================================================ FILE: android/app/src/main/kotlin/com/abdownloadmanager/android/pages/onboarding/permissions/PermissionsPage.kt ================================================ package com.abdownloadmanager.android.pages.onboarding.permissions import android.app.Activity import android.content.Intent import android.net.Uri import android.provider.Settings import androidx.activity.compose.LocalActivity import androidx.compose.animation.AnimatedContent import androidx.compose.animation.AnimatedVisibility import androidx.compose.foundation.background import androidx.compose.foundation.border import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxHeight import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.wrapContentHeight import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.runtime.Composable import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip import androidx.compose.ui.graphics.Color import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.dp import com.abdownloadmanager.android.pages.onboarding.StartUpPageActions import com.abdownloadmanager.android.pages.onboarding.StartUpPageHeader import com.abdownloadmanager.android.pages.onboarding.StartUpPageTemplate import com.abdownloadmanager.resources.Res import com.abdownloadmanager.shared.ui.widget.ActionButton import com.abdownloadmanager.shared.ui.widget.PrimaryMainActionButton import com.abdownloadmanager.shared.ui.widget.Text import com.abdownloadmanager.shared.util.div import com.abdownloadmanager.shared.util.ui.LocalContentColor import com.abdownloadmanager.shared.util.ui.icon.MyIcons import com.abdownloadmanager.shared.util.ui.myColors import com.abdownloadmanager.shared.util.ui.theme.myShapes import com.abdownloadmanager.shared.util.ui.theme.mySpacings import com.abdownloadmanager.shared.util.ui.theme.myTextSizes import com.abdownloadmanager.shared.util.ui.widget.MyIcon import ir.amirab.util.compose.IconSource import ir.amirab.util.compose.StringSource import ir.amirab.util.compose.asStringSource import ir.amirab.util.compose.resources.myStringResource @Composable fun PermissionsPage( component: PermissionComponent, ) { val currentPermission by component.currentPermission.collectAsState() StartUpPageTemplate( header = { StartUpPageHeader( title = Res.string.permissions.asStringSource(), onBackPressed = component::goToPreviousPermissionPage, ) }, content = { AnimatedContent( targetState = currentPermission, ) { val modifier = Modifier .fillMaxSize() .wrapContentHeight() .padding(horizontal = 24.dp) .padding(bottom = 24.dp) when (it) { is PermissionsPageSteps.AtPermission -> { RenderPermissionContent(it.appPermission, modifier) } PermissionsPageSteps.Done -> { RenderDonePermissionGranting(modifier) } PermissionsPageSteps.Initial -> { RenderInitialPermissionGranting(modifier) } } } }, actions = { StartUpPageActions { AnimatedContent( targetState = currentPermission, ) { val modifier = Modifier .fillMaxWidth() when (it) { is PermissionsPageSteps.AtPermission -> { RenderPermissionActions( component, it.appPermission, modifier, ) } PermissionsPageSteps.Done -> { PrimaryMainActionButton( text = myStringResource(Res.string.lets_go), onClick = component::goToNextPermissionPage, modifier = modifier, ) } PermissionsPageSteps.Initial -> { PrimaryMainActionButton( text = myStringResource(Res.string.next), onClick = component::goToNextPermissionPage, modifier = modifier, ) } } } } }, ) } @Composable fun RenderInitialPermissionGranting( modifier: Modifier, ) { BasePermissionPageContent( title = Res.string.permissions_initial_title.asStringSource(), description = Res.string.permissions_initial_description.asStringSource(), icon = MyIcons.permission, modifier = modifier, ) } @Composable fun RenderDonePermissionGranting( modifier: Modifier, ) { BasePermissionPageContent( title = Res.string.permissions_done_title.asStringSource(), description = Res.string.permissions_done_description.asStringSource(), icon = MyIcons.check, modifier = modifier, iconColor = myColors.success, ) } @Composable fun RenderPermissionContent( appPermission: AppPermission, modifier: Modifier, ) { BasePermissionPageContent( title = appPermission.title, description = appPermission.description, modifier = modifier, icon = appPermission.icon, ) } @Composable fun RenderPermissionActions( permissionComponent: PermissionComponent, appPermission: AppPermission, modifier: Modifier, ) { var rejectedOnce by remember(appPermission) { mutableStateOf(false) } val permissionState = rememberAppPermissionState(appPermission) { if (!it) { rejectedOnce = true } } val userProbablyPressedOnDontAskAgain = !permissionState.requiresRational && rejectedOnce Column(modifier) { val permissionStatus = permissionState.permissionStatus val isGranted = permissionStatus is PermissionStatus.Granted AnimatedVisibility(isGranted) { Text( myStringResource( Res.string.permission_granted, ), color = myColors.success, fontWeight = FontWeight.Bold, modifier = Modifier .padding( start = mySpacings.mediumSpace, bottom = mySpacings.mediumSpace, ) ) } AnimatedVisibility( userProbablyPressedOnDontAskAgain && !isGranted && !appPermission.isOptional ) { Text( myStringResource( Res.string.permission_not_granted, ), color = myColors.error, fontWeight = FontWeight.Bold, modifier = Modifier .padding( start = mySpacings.mediumSpace, bottom = mySpacings.mediumSpace, ) ) } val activity = requireNotNull(LocalActivity.current) { "Activity is required to open app details" } if (permissionStatus is PermissionStatus.NotGranted) { PrimaryMainActionButton( text = myStringResource( if (userProbablyPressedOnDontAskAgain) { Res.string.open_settings } else { Res.string.give_permission }, ), modifier = Modifier.fillMaxWidth(), onClick = { if (userProbablyPressedOnDontAskAgain) { openApplicationDetailsInSettings(activity) } else { permissionState.launchRequest() } }, ) } else { PrimaryMainActionButton( text = myStringResource(Res.string.next), modifier = Modifier.fillMaxWidth(), onClick = { permissionComponent.goToNextPermissionPage() }, ) } if (appPermission.isOptional && permissionStatus !is PermissionStatus.Granted) { Spacer(Modifier.height(mySpacings.mediumSpace)) ActionButton( text = myStringResource(Res.string.skip), modifier = Modifier.fillMaxWidth(), onClick = { permissionComponent.goToNextPermissionPage() }, ) } } } fun openApplicationDetailsInSettings(activity: Activity) { activity.startActivity( Intent(Settings.ACTION_APPLICATION_DETAILS_SETTINGS).apply { data = Uri.fromParts("package", activity.packageName, null) } ) } @Composable private fun BasePermissionPageContent( title: StringSource, description: StringSource, icon: IconSource, iconColor: Color = LocalContentColor.current, modifier: Modifier = Modifier, ) { Column(modifier) { val shape = RoundedCornerShape(24.dp) MyIcon( icon = icon, null, Modifier .weight(1f) .wrapContentHeight() .align(Alignment.CenterHorizontally) .clip(shape) .background(color = myColors.menuGradientBackground) .border(1.dp, myColors.menuBorderColor / 0.1f, shape) .padding(16.dp) .size(72.dp), tint = iconColor ) Spacer(Modifier.height(mySpacings.largeSpace)) Text( text = title.rememberString(), fontWeight = FontWeight.Bold, fontSize = myTextSizes.x2l, ) Spacer(Modifier.height(mySpacings.largeSpace)) Text( text = description.rememberString(), fontWeight = FontWeight.Normal, fontSize = myTextSizes.base, ) } } ================================================ FILE: android/app/src/main/kotlin/com/abdownloadmanager/android/pages/onboarding/permissions/StoragePermissionUtil.kt ================================================ package com.abdownloadmanager.android.pages.onboarding.permissions import android.content.Context import android.content.Intent import android.os.Build import android.provider.Settings import androidx.core.net.toUri fun requestManageStoragePermission(context: Context) { if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) { try { val intent = Intent(Settings.ACTION_MANAGE_APP_ALL_FILES_ACCESS_PERMISSION).apply { data = ("package:" + context.packageName).toUri() } context.startActivity(intent) } catch (e: Exception) { // Fallback val intent = Intent(Settings.ACTION_MANAGE_ALL_FILES_ACCESS_PERMISSION) context.startActivity(intent) } } } ================================================ FILE: android/app/src/main/kotlin/com/abdownloadmanager/android/pages/perhostsettings/AndroidPerHostSettingsComponent.kt ================================================ package com.abdownloadmanager.android.pages.perhostsettings import com.abdownloadmanager.shared.pages.perhostsettings.BasePerHostSettingsComponent import com.abdownloadmanager.shared.repository.BaseAppRepository import com.abdownloadmanager.shared.util.perhostsettings.PerHostSettingsManager import com.arkivanov.decompose.ComponentContext import kotlinx.coroutines.CoroutineScope import kotlinx.serialization.Serializable class AndroidPerHostSettingsComponent( ctx: ComponentContext, perHostSettingsManager: PerHostSettingsManager, appRepository: BaseAppRepository, appScope: CoroutineScope, closeRequested: () -> Unit, ) : BasePerHostSettingsComponent( ctx = ctx, perHostSettingsManager = perHostSettingsManager, appRepository = appRepository, appScope = appScope, closeRequested = closeRequested, ) { @Serializable data class Config( override val openedHost: String? ) : BasePerHostSettingsComponent.Config sealed interface Effects : BasePerHostSettingsComponent.Effects.Platform { } fun reset() { editedPerHostSettings.value = savedPerHostSettings.value onIdSelected(null) } fun saveAndReturn() { save() onIdSelected(null) } } ================================================ FILE: android/app/src/main/kotlin/com/abdownloadmanager/android/pages/perhostsettings/PerHostSettingsPage.kt ================================================ package com.abdownloadmanager.android.pages.perhostsettings import androidx.activity.compose.BackHandler import androidx.activity.compose.LocalOnBackPressedDispatcherOwner import androidx.compose.animation.AnimatedContent import androidx.compose.animation.AnimatedVisibility import androidx.compose.animation.scaleIn import androidx.compose.animation.scaleOut import com.abdownloadmanager.shared.ui.configurable.ConfigurableGroup import com.abdownloadmanager.shared.ui.configurable.RenderConfigurableGroup import com.abdownloadmanager.shared.util.ui.icon.MyIcons import com.abdownloadmanager.shared.util.ui.myColors import androidx.compose.foundation.background import androidx.compose.foundation.border import androidx.compose.foundation.layout.* import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.items import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.selection.selectable import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.verticalScroll import androidx.compose.runtime.* import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip import androidx.compose.ui.graphics.Brush import androidx.compose.ui.graphics.Color import androidx.compose.ui.platform.LocalFocusManager import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp import com.abdownloadmanager.android.ui.page.PageHeader import com.abdownloadmanager.android.ui.page.PageTitle import com.abdownloadmanager.android.ui.page.PageUi import com.abdownloadmanager.shared.ui.widget.* import com.abdownloadmanager.resources.Res import com.abdownloadmanager.shared.pages.perhostsettings.PerHostSettingsItemWithId import com.abdownloadmanager.shared.util.div import com.abdownloadmanager.shared.util.ui.WithContentAlpha import com.abdownloadmanager.shared.util.ui.theme.myShapes import com.abdownloadmanager.shared.util.ui.theme.mySpacings import com.abdownloadmanager.shared.util.ui.theme.myTextSizes import ir.amirab.util.compose.asStringSource import ir.amirab.util.compose.resources.myStringResource import ir.amirab.util.ifThen import kotlinx.coroutines.* @Composable fun PerHostSettingsPage(component: AndroidPerHostSettingsComponent) { val perHostSettings by component.editedPerHostSettings.collectAsState() val selectedItemId by component.selectedId.collectAsState() // WindowTitle(myStringResource(Res.string.settings_per_host_settings)) val configurableList = component.selectedItemConfigurableList.collectAsState().value val canSave by component.canSave.collectAsState() val scope = rememberCoroutineScope() val backDispatcher = LocalOnBackPressedDispatcherOwner.current BackHandler( configurableList != null ) { component.reset() } PageUi( header = { PageHeader( leadingIcon = { TransparentIconActionButton( icon = MyIcons.back, contentDescription = Res.string.back.asStringSource(), onClick = { backDispatcher?.onBackPressedDispatcher?.onBackPressed() } ) }, headerTitle = { PageTitle(myStringResource(Res.string.settings_per_host_settings)) }, headerActions = { if (configurableList == null) { TransparentIconActionButton( icon = MyIcons.add, contentDescription = Res.string.add.asStringSource(), onClick = { component.onRequestAddNewHostSettingsItem() } ) } else { TransparentIconActionButton( icon = MyIcons.remove, contentDescription = Res.string.remove.asStringSource(), onClick = { component.onRequestDeleteConfig(configurableList.id) } ) TransparentIconActionButton( icon = MyIcons.check, enabled = canSave, onClick = { scope.launch { component.saveAndReturn() } }, contentDescription = Res.string.update.asStringSource() ) } } ) }, footer = { }, modifier = Modifier .systemBarsPadding() .navigationBarsPadding() ) { // TODO improvement make it tablet friendly AnimatedContent( configurableList, modifier = Modifier.padding(it.paddingValues) ) { configurableList -> if (configurableList != null) { RenderPerHostSettingsItem( modifier = Modifier .padding(8.dp), itemId = configurableList.id, configurableList = configurableList.configurableGroups, ) } else { Column { HostList( modifier = Modifier .padding(8.dp) .fillMaxWidth() .weight(1f), hosts = perHostSettings, selectedId = selectedItemId, setSelected = { id -> component.onIdSelected(id) }, component = component ) } } } } } @Composable private fun RenderPerHostSettingsItem( modifier: Modifier, itemId: String, configurableList: List, ) { val fm = LocalFocusManager.current //remove focus to prevent accidentally change config in different queue LaunchedEffect(itemId) { fm.clearFocus() } Column(modifier) { val pageModifier = Modifier .fillMaxSize() RenderPerHostSettingsConfigurableGroup(pageModifier, configurableList) } } @Composable private fun RenderPerHostSettingsConfigurableGroup( modifier: Modifier, configurableGroups: List, ) { Column( modifier .verticalScroll(rememberScrollState()) ) { for ((index, cfgGroup) in configurableGroups.withIndex()) { RenderConfigurableGroup( group = cfgGroup, modifier = Modifier, itemPadding = PaddingValues( vertical = 8.dp, horizontal = 16.dp ) ) if (index != configurableGroups.lastIndex) { Spacer(Modifier.height(8.dp)) } } } } @Composable private fun HostList( modifier: Modifier, hosts: List, selectedId: String?, setSelected: (String) -> Unit, component: AndroidPerHostSettingsComponent, ) { val shape = myShapes.defaultRounded val borderColor = myColors.surface / 0.5f var search by remember { mutableStateOf("") } val defaultEmptyName = myStringResource(Res.string.settings_per_host_settings_new_host) val filteredHosts = remember(hosts, search) { hosts.ifThen(search.isNotEmpty()) { filter { it.perHostSettingsItem.host.contains(search, true) } } } Column( modifier .border(1.dp, borderColor, shape) .clip(shape) ) { Box( Modifier .weight(1f) .fillMaxWidth() ) { LazyColumn { items(filteredHosts, key = { it.id }) { s -> val isSelected = selectedId == s.id SideBarItem( isSelected = isSelected, onClick = { setSelected(s.id) }, name = s.perHostSettingsItem.host.takeIf { it.isNotBlank() } ?: defaultEmptyName, modifier = Modifier.animateItem(), ) } } if (filteredHosts.isEmpty()) { WithContentAlpha(0.75f) { Text( myStringResource(Res.string.list_is_empty), modifier = Modifier.align(Alignment.Center) ) } } } // Row( // modifier = Modifier // .padding(vertical = 4.dp) // .padding(horizontal = 8.dp) // .height(IntrinsicSize.Max) // .fillMaxWidth(), // verticalAlignment = Alignment.CenterVertically, // horizontalArrangement = Arrangement.End // ) { // SearchBox( // search, // onTextChange = { // search = it // }, // placeholder = myStringResource(Res.string.search), // modifier = Modifier.weight(1f).fillMaxHeight(), // ) // } } } @Composable private fun SideBarItem( name: String, isSelected: Boolean, onClick: () -> Unit, modifier: Modifier = Modifier, ) { Box( modifier .height(IntrinsicSize.Max) .heightIn(mySpacings.thumbSize) .ifThen(isSelected) { background(myColors.onBackground / 0.05f) } .selectable( selected = isSelected, onClick = onClick ), contentAlignment = Alignment.Center, ) { Row( Modifier .padding(vertical = 8.dp) .padding(start = 16.dp) .padding(end = 2.dp), verticalAlignment = Alignment.CenterVertically, ) { WithContentAlpha(if (isSelected) 1f else 0.75f) { Text( name, Modifier.weight(1f), fontWeight = if (isSelected) FontWeight.Bold else FontWeight.Normal, fontSize = myTextSizes.lg, overflow = TextOverflow.Ellipsis, maxLines = 1, ) } } AnimatedVisibility( isSelected, modifier = Modifier .align(Alignment.CenterStart), enter = scaleIn(), exit = scaleOut(), ) { Spacer( Modifier .height(16.dp) .width(3.dp) .clip( RoundedCornerShape( topStart = 0.dp, bottomStart = 0.dp, bottomEnd = 12.dp, topEnd = 12.dp, ) ) .background(myColors.primary) ) } if (isSelected) { listOf( Alignment.TopCenter, Alignment.BottomCenter, ).forEach { Spacer( Modifier .align(it) .fillMaxWidth() .height(1.dp) .background( Brush.horizontalGradient( listOf( Color.Transparent, myColors.onBackground / 0.1f, myColors.onBackground / 0.1f, Color.Transparent, ) ) ) ) } } } } ================================================ FILE: android/app/src/main/kotlin/com/abdownloadmanager/android/pages/queue/QueueConfigurationComponent.kt ================================================ package com.abdownloadmanager.android.pages.queue import com.abdownloadmanager.resources.Res import com.abdownloadmanager.shared.ui.configurable.ConfigurableGroup import com.abdownloadmanager.shared.ui.configurable.item.BooleanConfigurable import com.abdownloadmanager.shared.ui.configurable.item.DayOfWeekConfigurable import com.abdownloadmanager.shared.ui.configurable.item.IntConfigurable import com.abdownloadmanager.shared.ui.configurable.item.StringConfigurable import com.abdownloadmanager.shared.ui.configurable.item.TimeConfigurable import com.abdownloadmanager.shared.util.BaseComponent import com.abdownloadmanager.shared.util.newScopeBasedOn import com.arkivanov.decompose.ComponentContext import ir.amirab.downloader.queue.DownloadQueue import ir.amirab.downloader.queue.QueueManager import ir.amirab.util.compose.asStringSource import ir.amirab.util.compose.asStringSourceWithARgs import ir.amirab.util.flow.combineStateFlows import ir.amirab.util.flow.createMutableStateFlowFromStateFlow import ir.amirab.util.flow.mapStateFlow import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.datetime.LocalTime class QueueConfigurationComponent( ctx: ComponentContext, id: Long, queueManager: QueueManager, ) : BaseComponent(ctx) { val downloadQueue = queueManager.queues.value.find { it.id == id }!! val configurations: List = createConfigurableList(downloadQueue, scope) private fun createConfigurableList( downloadQueue: DownloadQueue, parentScope: CoroutineScope, ): List { val scope = newScopeBasedOn(parentScope) val enabledStartTimeFlow = downloadQueue.queueModel.mapStateFlow() { it.scheduledTimes.enabledStartTime } val enabledEndTimeFlow = downloadQueue.queueModel.mapStateFlow() { it.scheduledTimes.enabledEndTime } val enabledSchedulerFlow = combineStateFlows(enabledStartTimeFlow, enabledEndTimeFlow) { start, end -> start || end } return listOf( ConfigurableGroup( groupTitle = MutableStateFlow(Res.string.general.asStringSource()), nestedConfigurable = listOf( StringConfigurable( Res.string.name.asStringSource(), Res.string.queue_name_help.asStringSource(), backedBy = createMutableStateFlowFromStateFlow( scope = scope, flow = downloadQueue.queueModel.mapStateFlow() { it.name }, updater = { newValue -> downloadQueue.setName(newValue) }, ), validate = { it.length in 1..32 }, describe = { Res.string.queue_name_describe .asStringSourceWithARgs( Res.string.queue_name_describe_createArgs( value = it ) ) }, ), IntConfigurable( Res.string.queue_max_concurrent_download.asStringSource(), Res.string.queue_max_concurrent_download_description.asStringSource(), backedBy = createMutableStateFlowFromStateFlow( scope = scope, flow = downloadQueue.queueModel.mapStateFlow() { it.maxConcurrent }, updater = { newValue -> downloadQueue.setMaxConcurrent(newValue) }, ), describe = { "$it".asStringSource() }, range = 1..32, renderMode = IntConfigurable.RenderMode.TextField, ), ), ), ConfigurableGroup( groupTitle = MutableStateFlow(Res.string.on_completion.asStringSource()), nestedConfigurable = listOf( BooleanConfigurable( Res.string.queue_automatic_stop.asStringSource(), Res.string.queue_automatic_stop_description.asStringSource(), backedBy = createMutableStateFlowFromStateFlow( scope = scope, flow = downloadQueue.queueModel.mapStateFlow() { it.stopQueueOnEmpty }, updater = { newValue -> downloadQueue.setStopQueueOnEmpty(newValue) }, ), describe = { if (it) Res.string.enabled.asStringSource() else Res.string.disabled.asStringSource() }, ), ) ), ConfigurableGroup( groupTitle = MutableStateFlow(Res.string.queue_scheduler.asStringSource()), nestedVisible = enabledSchedulerFlow, mainConfigurable = BooleanConfigurable( Res.string.queue_enable_scheduler.asStringSource(), description = "".asStringSource(), describe = { "".asStringSource() }, backedBy = createMutableStateFlowFromStateFlow( flow = enabledSchedulerFlow, scope = scope, updater = { newValue -> downloadQueue.setScheduledTimes { copy( enabledStartTime = newValue, enabledEndTime = newValue, ) } } ), ), nestedConfigurable = listOf( DayOfWeekConfigurable( Res.string.queue_active_days.asStringSource(), Res.string.queue_active_days_description.asStringSource(), backedBy = createMutableStateFlowFromStateFlow( scope = scope, flow = downloadQueue.queueModel.mapStateFlow() { it.scheduledTimes.daysOfWeek }, updater = { newValue -> downloadQueue.setScheduledTimes { copy(daysOfWeek = newValue) } }, ), validate = { it.isNotEmpty() }, describe = { "".asStringSource() }, ), BooleanConfigurable( Res.string.queue_scheduler_enable_auto_start_time.asStringSource(), description = "".asStringSource(), describe = { "".asStringSource() }, backedBy = createMutableStateFlowFromStateFlow( scope = scope, flow = enabledStartTimeFlow, updater = { newValue -> downloadQueue.setScheduledTimes { copy(enabledStartTime = newValue) } }, ), ), TimeConfigurable( Res.string.queue_scheduler_auto_start_time.asStringSource(), "".asStringSource(), backedBy = createMutableStateFlowFromStateFlow( scope = scope, flow = downloadQueue.queueModel.mapStateFlow() { it.scheduledTimes.startTime }, updater = { downloadQueue.setScheduledTimes { copy(startTime = it) } }, ), describe = { hourAndMinutesToString(it).asStringSource() }, visible = enabledStartTimeFlow, ), BooleanConfigurable( Res.string.queue_scheduler_enable_auto_stop_time.asStringSource(), description = "".asStringSource(), describe = { "".asStringSource() }, backedBy = createMutableStateFlowFromStateFlow( scope = scope, flow = enabledEndTimeFlow, updater = { newValue -> downloadQueue.setScheduledTimes { copy(enabledEndTime = newValue) } }, ), ), TimeConfigurable( Res.string.queue_scheduler_auto_stop_time.asStringSource(), "".asStringSource(), backedBy = createMutableStateFlowFromStateFlow( scope = scope, flow = downloadQueue.queueModel.mapStateFlow() { it.scheduledTimes.endTime }, updater = { newValue -> downloadQueue.setScheduledTimes { copy(endTime = newValue) } }, ), describe = { hourAndMinutesToString(it).asStringSource() }, visible = enabledEndTimeFlow, ), ) ), ) } } private fun hourAndMinutesToString(it: LocalTime): String { val hour = it.hour.toString().padStart(2, '0') val min = it.minute.toString().padStart(2, '0') return "$hour:$min" } ================================================ FILE: android/app/src/main/kotlin/com/abdownloadmanager/android/pages/queue/QueuesSheet.kt ================================================ package com.abdownloadmanager.android.pages.queue import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.verticalScroll import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.collectAsState import androidx.compose.ui.Modifier import androidx.compose.ui.unit.dp import com.abdownloadmanager.android.ui.SheetHeader import com.abdownloadmanager.android.ui.SheetTitle import com.abdownloadmanager.android.ui.SheetUI import com.abdownloadmanager.resources.Res import com.abdownloadmanager.shared.ui.configurable.ConfigurableGroup import com.abdownloadmanager.shared.ui.configurable.RenderConfigurableGroup import com.abdownloadmanager.shared.util.OnFullyDismissed import com.abdownloadmanager.shared.util.ResponsiveDialog import com.abdownloadmanager.shared.util.ResponsiveDialogScope import com.abdownloadmanager.shared.util.rememberResponsiveDialogState import ir.amirab.util.compose.resources.myStringResource @Composable fun QueueConfigSheet( queuesConfigurationComponent: QueueConfigurationComponent?, onDismiss: () -> Unit, ) { val state = rememberResponsiveDialogState(false) LaunchedEffect( queuesConfigurationComponent ) { if (queuesConfigurationComponent != null) { state.show() } else { state.hide() } } state.OnFullyDismissed(onDismiss) ResponsiveDialog(state, onDismiss = state::hide) { queuesConfigurationComponent?.let { QueueConfig( name = it.downloadQueue.queueModel.collectAsState().value.name, groups = it.configurations, onDismissRequest = state::hide, ) } } } @Composable private fun ResponsiveDialogScope.QueueConfig( name: String, groups: List, onDismissRequest: () -> Unit, ) { SheetUI( header = { SheetHeader( headerTitle = { val queues = myStringResource(Res.string.queues) SheetTitle("${queues}: $name") } ) } ) { Column( Modifier .verticalScroll(rememberScrollState()) ) { for (group in groups) { RenderConfigurableGroup( modifier = Modifier, group = group, itemPadding = PaddingValues(8.dp) ) } } } } ================================================ FILE: android/app/src/main/kotlin/com/abdownloadmanager/android/pages/settings/AndroidSettings.kt ================================================ package com.abdownloadmanager.android.pages.settings import com.abdownloadmanager.android.pages.onboarding.permissions.ABDMPermissions import com.abdownloadmanager.android.storage.AppSettingsStorage import com.abdownloadmanager.android.ui.configurable.android.item.PermissionConfigurable import com.abdownloadmanager.android.util.pagemanager.PermissionsPageManager import com.abdownloadmanager.resources.Res import com.abdownloadmanager.shared.ui.configurable.item.BooleanConfigurable import com.abdownloadmanager.shared.ui.configurable.item.NavigatableConfigurable import ir.amirab.util.compose.asStringSource import kotlinx.coroutines.flow.MutableStateFlow object AndroidSettings { fun permissionSettings( permissionsPageManager: PermissionsPageManager ): NavigatableConfigurable { return NavigatableConfigurable( title = Res.string.permissions.asStringSource(), description = "".asStringSource(), onRequestNavigate = { permissionsPageManager.openPermissionsPage(false) }, ) } fun ignoreBatteryOptimizations(): PermissionConfigurable { val permission = ABDMPermissions.BatteryOptimizationPermission return PermissionConfigurable( title = permission.title, description = permission.description, backedBy = MutableStateFlow(permission), ) } fun browserIconInLauncher( appSettingsStorage: AppSettingsStorage ): BooleanConfigurable { return BooleanConfigurable( title = Res.string.settings_browser_in_launcher.asStringSource(), description = Res.string.settings_browser_in_launcher_description.asStringSource(), backedBy = appSettingsStorage.browserIconInLauncher, describe = { if (it) { Res.string.enabled } else { Res.string.disabled }.asStringSource() } ) } } ================================================ FILE: android/app/src/main/kotlin/com/abdownloadmanager/android/pages/settings/AndroidSettingsComponent.kt ================================================ package com.abdownloadmanager.android.pages.settings import com.abdownloadmanager.android.storage.AppSettingsStorage import com.abdownloadmanager.android.util.pagemanager.PermissionsPageManager import com.abdownloadmanager.shared.pagemanager.PerHostSettingsPageManager import com.abdownloadmanager.shared.repository.BaseAppRepository import com.abdownloadmanager.shared.settings.BaseSettingsComponent import com.abdownloadmanager.shared.settings.CommonSettings import com.abdownloadmanager.shared.ui.configurable.ConfigurableGroup import com.abdownloadmanager.shared.ui.theme.ThemeManager import com.abdownloadmanager.shared.util.proxy.ProxyManager import com.arkivanov.decompose.ComponentContext import ir.amirab.util.compose.localizationmanager.LanguageManager import ir.amirab.util.flow.mapStateFlow import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import org.koin.core.component.KoinComponent import org.koin.core.component.inject import kotlin.getValue class AndroidSettingsComponent( ctx: ComponentContext, perHostSettingsPageManager: PerHostSettingsPageManager, permissionsPageManager: PermissionsPageManager, ) : BaseSettingsComponent( ctx ), KoinComponent { private val appSettings by inject() // private val pageStorage by inject() private val appRepository by inject() private val proxyManager by inject() private val themeManager by inject() private val languageManager by inject() override val configurables: StateFlow> = MutableStateFlow( listOf( ConfigurableGroup( mainConfigurable = CommonSettings.themeConfig(themeManager, scope), nestedVisible = themeManager.currentThemeInfo.mapStateFlow { it.id == ThemeManager.systemThemeInfo.id }, nestedConfigurable = listOfNotNull( CommonSettings.defaultDarkThemeConfig(themeManager, scope), CommonSettings.defaultLightThemeConfig(themeManager, scope), ) ), ConfigurableGroup( nestedConfigurable = listOf( CommonSettings.languageConfig(languageManager, scope), // DesktopSettings.fontConfig(fontManager, scope), CommonSettings.uiScaleConfig(appSettings), ) ), ConfigurableGroup( nestedConfigurable = listOfNotNull( // DesktopSettings.useNativeMenuBarConfig(appSettings), // DesktopSettings.mergeTopBarWithTitleBarConfig(appSettings), // CommonSettings.showIconLabels(appSettings), CommonSettings.useRelativeDateTime(appSettings), CommonSettings.playSoundNotification(appSettings), ) ), ConfigurableGroup( nestedConfigurable = listOf( CommonSettings.autoStartConfig(appSettings), // DesktopSettings.useSystemTray(appSettings), ) ), ConfigurableGroup( nestedConfigurable = listOf( CommonSettings.sizeUnit(appRepository, scope), CommonSettings.speedUnit(appRepository, scope), CommonSettings.useAverageSpeedConfig(appRepository), ) ), ConfigurableGroup( nestedConfigurable = listOf( CommonSettings.autoShowDownloadProgressWindow(appSettings), CommonSettings.showDownloadFinishWindow(appSettings), ) ), // download engine ConfigurableGroup( nestedConfigurable = listOf( CommonSettings.defaultDownloadFolderConfig(appSettings), CommonSettings.useCategoryByDefault(appSettings), ) ), ConfigurableGroup( nestedConfigurable = listOf( CommonSettings.speedLimitConfig(appRepository), CommonSettings.threadCountConfig(appRepository), CommonSettings.maxConcurrentDownloads(appRepository), CommonSettings.maxDownloadRetryCount(appRepository), CommonSettings.dynamicPartDownloadConfig(appRepository), ) ), ConfigurableGroup( nestedConfigurable = listOf( CommonSettings.perHostSettings(perHostSettingsPageManager), ) ), ConfigurableGroup( nestedConfigurable = listOf( CommonSettings.proxyConfig(proxyManager), CommonSettings.userAgent(appSettings), CommonSettings.ignoreSSLCertificates(appSettings), CommonSettings.useServerLastModified(appRepository), ) ), ConfigurableGroup( nestedConfigurable = listOf( CommonSettings.trackDeletedFilesOnDisk(appRepository), CommonSettings.appendExtensionToIncompleteDownloads(appRepository), CommonSettings.deletePartialFileOnDownloadCancellation(appSettings), CommonSettings.useSparseFileAllocation(appRepository), ) ), ConfigurableGroup( nestedConfigurable = listOf( AndroidSettings.browserIconInLauncher(appSettings), ) ), ConfigurableGroup( nestedConfigurable = listOf( AndroidSettings.permissionSettings(permissionsPageManager), AndroidSettings.ignoreBatteryOptimizations(), ) ), // browser integration // disabled for now // ConfigurableGroup( // nestedConfigurable = listOf( // CommonSettings.browserIntegrationEnabled(appRepository), // CommonSettings.browserIntegrationPort(appRepository) // ) // ) ) ) } ================================================ FILE: android/app/src/main/kotlin/com/abdownloadmanager/android/pages/settings/SettingsPage.kt ================================================ package com.abdownloadmanager.android.pages.settings import androidx.activity.compose.LocalOnBackPressedDispatcherOwner import androidx.compose.foundation.background import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.navigationBarsPadding import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.statusBarsPadding import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.verticalScroll import androidx.compose.runtime.Composable import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.unit.dp import com.abdownloadmanager.android.ui.page.FooterFade import com.abdownloadmanager.android.ui.page.PageUi import com.abdownloadmanager.android.ui.page.PageHeader import com.abdownloadmanager.android.ui.page.PageTitle import com.abdownloadmanager.android.ui.page.createAlphaForHeader import com.abdownloadmanager.resources.Res import com.abdownloadmanager.shared.ui.configurable.RenderConfigurableGroup import com.abdownloadmanager.shared.ui.widget.TransparentIconActionButton import com.abdownloadmanager.shared.util.ui.VerticalScrollableContent import com.abdownloadmanager.shared.util.ui.icon.MyIcons import com.abdownloadmanager.shared.util.ui.myColors import ir.amirab.util.compose.asStringSource import ir.amirab.util.compose.resources.myStringResource @Composable fun SettingsPage( settingsComponent: AndroidSettingsComponent, ) { // WindowIcon(MyIcons.settings) // WindowIcon(MyIcons.appIcon) val scrollState = rememberScrollState() var pageContentPaddingValues by remember { mutableStateOf(PaddingValues()) } val topPadding = pageContentPaddingValues.calculateTopPadding() val bottomPadding = pageContentPaddingValues.calculateBottomPadding() val density = LocalDensity.current PageUi( header = { val backDispatcher = LocalOnBackPressedDispatcherOwner.current PageHeader( modifier = Modifier .fillMaxWidth() .background( myColors.background.copy( createAlphaForHeader( scrollState.value.toFloat(), density.run { topPadding.toPx() }, ) * 0.75f ) ) .statusBarsPadding(), leadingIcon = { TransparentIconActionButton( icon = MyIcons.back, contentDescription = Res.string.back.asStringSource(), onClick = { backDispatcher?.onBackPressedDispatcher?.onBackPressed() } ) }, headerTitle = { PageTitle(myStringResource(Res.string.settings)) }, ) }, footer = { Spacer(Modifier.navigationBarsPadding()) } ) { params -> pageContentPaddingValues = params.paddingValues Box { VerticalScrollableContent( scrollState, Modifier.fillMaxSize() ) { Column( Modifier .fillMaxSize() .verticalScroll(scrollState) .navigationBarsPadding() .padding(bottom = 8.dp) .padding( horizontal = 8.dp, ), verticalArrangement = Arrangement.spacedBy(16.dp) ) { Spacer(Modifier.height(topPadding)) val configurableGroups by settingsComponent.configurables.collectAsState() for (cfgGroup in configurableGroups) { RenderConfigurableGroup( cfgGroup, Modifier, itemPadding = PaddingValues( vertical = 8.dp, horizontal = 16.dp ) ) } } } FooterFade(bottomPadding) } } } ================================================ FILE: android/app/src/main/kotlin/com/abdownloadmanager/android/pages/singledownload/CompletedDownloadPage.kt ================================================ package com.abdownloadmanager.android.pages.singledownload import androidx.compose.foundation.background import androidx.compose.foundation.basicMarquee import androidx.compose.foundation.layout.* import androidx.compose.runtime.Composable import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.dp import com.abdownloadmanager.shared.util.ui.icon.MyIcons import com.abdownloadmanager.shared.util.ui.myColors import com.abdownloadmanager.shared.util.ui.theme.myTextSizes import com.abdownloadmanager.shared.ui.widget.ActionButton import com.abdownloadmanager.shared.ui.widget.Text import com.abdownloadmanager.shared.util.LocalSizeUnit import com.abdownloadmanager.shared.util.convertPositiveSizeToHumanReadable import com.abdownloadmanager.shared.util.div import com.abdownloadmanager.resources.Res import com.abdownloadmanager.shared.util.ui.WithContentColor import com.abdownloadmanager.shared.util.ui.widget.MyIcon import ir.amirab.downloader.monitor.CompletedDownloadItemState import ir.amirab.util.compose.resources.myStringResource @Composable fun CompletedDownloadPage( component: AndroidSingleDownloadComponent, completedDownloadItemState: CompletedDownloadItemState, ) { Column { Row( Modifier .padding( horizontal = 16.dp, vertical = 8.dp ) ) { RenderFileIconAndSize( modifier = Modifier.align(Alignment.CenterVertically), component = component, itemState = completedDownloadItemState, ) Spacer(Modifier.width(16.dp)) RenderName( Modifier.weight(1f), completedDownloadItemState.name, ) } Actions(Modifier, component) } } @Composable private fun Actions( modifier: Modifier, component: AndroidSingleDownloadComponent, ) { val iDownloadItemState by component.itemStateFlow.collectAsState() Column(modifier) { Spacer( Modifier .fillMaxWidth() .height(1.dp) .background(myColors.onBackground / 0.15f) ) Row( Modifier .fillMaxWidth() .background(myColors.surface / 0.5f) .padding(horizontal = 16.dp) .padding(vertical = 8.dp), verticalAlignment = Alignment.CenterVertically, ) { ActionButton( myStringResource(Res.string.open), modifier = Modifier.weight(1f), onClick = { component.openFile() }, ) } } } @Composable private fun RenderName( modifier: Modifier, name: String, ) { Column( modifier = modifier ) { WithContentColor( myColors.success ) { Row( verticalAlignment = Alignment.CenterVertically, ) { MyIcon( MyIcons.check, null, Modifier.size(24.dp) ) Spacer(Modifier.width(4.dp)) Text( myStringResource(Res.string.download_page_download_completed), fontWeight = FontWeight.Bold, fontSize = myTextSizes.lg, ) } } Spacer(Modifier.height(8.dp)) Text( text = name, maxLines = 1, modifier = Modifier.basicMarquee( iterations = Int.MAX_VALUE ) ) } } @Composable private fun RenderFileIconAndSize( modifier: Modifier, component: AndroidSingleDownloadComponent, itemState: CompletedDownloadItemState, ) { Column( modifier = modifier, horizontalAlignment = Alignment.CenterHorizontally, ) { MyIcon( icon = component.fileIconProvider.rememberIcon(itemState.name), contentDescription = null, modifier = Modifier.size(24.dp), ) Spacer(Modifier.height(4.dp)) Text( text = convertPositiveSizeToHumanReadable( itemState.contentLength, LocalSizeUnit.current, ).rememberString(), ) } } ================================================ FILE: android/app/src/main/kotlin/com/abdownloadmanager/android/pages/singledownload/DesktopSingleDownloadPageComponent.kt ================================================ package com.abdownloadmanager.android.pages.singledownload import com.abdownloadmanager.android.storage.AndroidExtraDownloadItemSettings import com.abdownloadmanager.resources.Res import com.abdownloadmanager.shared.repository.BaseAppRepository import com.abdownloadmanager.shared.singledownloadpage.BaseSingleDownloadComponent import com.abdownloadmanager.shared.storage.BaseAppSettingsStorage import com.abdownloadmanager.shared.storage.ExtraDownloadSettingsStorage import com.abdownloadmanager.shared.ui.configurable.item.BooleanConfigurable import com.abdownloadmanager.shared.util.* import com.arkivanov.decompose.ComponentContext import ir.amirab.util.compose.asStringSource import ir.amirab.util.flow.mapTwoWayStateFlow import kotlinx.coroutines.CoroutineScope import kotlin.getValue class AndroidSingleDownloadComponent( ctx: ComponentContext, downloadItemOpener: DownloadItemOpener, onDismiss: () -> Unit, downloadId: Long, extraDownloadSettingsStorage: ExtraDownloadSettingsStorage, downloadSystem: DownloadSystem, appSettings: BaseAppSettingsStorage, appRepository: BaseAppRepository, applicationScope: CoroutineScope, fileIconProvider: FileIconProvider, val comesFromExternalApplication: Boolean, ) : BaseSingleDownloadComponent( ctx = ctx, downloadItemOpener = downloadItemOpener, onDismiss = onDismiss, downloadId = downloadId, extraDownloadSettingsStorage = extraDownloadSettingsStorage, downloadSystem = downloadSystem, appSettings = appSettings, appRepository = appRepository, applicationScope = applicationScope, fileIconProvider = fileIconProvider, ) { override val defaultShowPartInfo: Boolean = false // private val singleDownloadPageStateToPersist by lazy { // get().singleDownloadPageState // } // override fun setShowPartInfo(value: Boolean) { // super.setShowPartInfo(value) // singleDownloadPageStateToPersist.update { // it.copy { // SingleDownloadPageStateToPersist.showPartInfo.set(value) // } // } // } sealed interface Effects : BaseSingleDownloadComponent.Effects.Platform { } val onCompletion by lazy { listOf( BooleanConfigurable( title = Res.string.download_item_settings_show_download_completion_dialog.asStringSource(), description = Res.string.download_item_settings_show_download_completion_dialog_description.asStringSource(), backedBy = itemShouldShowCompletionDialog.mapTwoWayStateFlow( map = { it ?: globalShowCompletionDialog.value }, unMap = { it } ), describe = { when (it) { true -> Res.string.enabled false -> Res.string.disabled }.asStringSource() }, ), ) } data class Config( override val id: Long ) : BaseSingleDownloadComponent.Config } ================================================ FILE: android/app/src/main/kotlin/com/abdownloadmanager/android/pages/singledownload/ProgressDownloadPage.kt ================================================ package com.abdownloadmanager.android.pages.singledownload import androidx.compose.animation.AnimatedVisibility import androidx.compose.animation.animateContentSize import com.abdownloadmanager.shared.ui.configurable.RenderConfigurable import com.abdownloadmanager.shared.util.ui.LocalContentColor import com.abdownloadmanager.shared.util.ui.WithContentAlpha import ir.amirab.util.compose.IconSource import com.abdownloadmanager.shared.util.ui.widget.MyIcon import com.abdownloadmanager.shared.util.ui.icon.MyIcons import com.abdownloadmanager.shared.util.ui.myColors import com.abdownloadmanager.shared.util.ui.theme.myTextSizes import androidx.compose.animation.core.* import androidx.compose.foundation.* import androidx.compose.foundation.layout.* import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.items import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.foundation.shape.CircleShape import androidx.compose.runtime.* import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip import androidx.compose.ui.graphics.Brush import androidx.compose.ui.graphics.Color import androidx.compose.ui.text.SpanStyle import androidx.compose.ui.text.buildAnnotatedString import androidx.compose.ui.text.withStyle import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.DpOffset import androidx.compose.ui.unit.dp import androidx.compose.ui.window.Popup import com.abdownloadmanager.shared.ui.widget.rememberMyComponentRectPositionProvider import com.abdownloadmanager.shared.ui.widget.* import com.abdownloadmanager.resources.Res import com.abdownloadmanager.shared.singledownloadpage.SingleDownloadPagePropertyItem import com.abdownloadmanager.shared.ui.configurable.ConfigurableUiProps import com.abdownloadmanager.shared.util.LocalSizeUnit import com.abdownloadmanager.shared.util.convertPositiveSizeToHumanReadable import com.abdownloadmanager.shared.util.ui.useIsInDebugMode import com.abdownloadmanager.shared.util.div import com.abdownloadmanager.shared.util.ui.VerticalScrollableContent import com.abdownloadmanager.shared.util.ui.theme.myShapes import com.abdownloadmanager.shared.util.ui.theme.mySpacings import ir.amirab.downloader.downloaditem.DownloadJobStatus import ir.amirab.downloader.monitor.* import ir.amirab.downloader.part.PartDownloadStatus import ir.amirab.downloader.utils.ExceptionUtils import ir.amirab.util.compose.StringSource import ir.amirab.util.compose.asStringSource import ir.amirab.util.compose.resources.myStringResource enum class SingleDownloadPageSections( val title: StringSource, val icon: IconSource, ) { Info( Res.string.info.asStringSource(), MyIcons.info ), Settings( Res.string.speed.asStringSource(), MyIcons.fast, ), OnCompletion( Res.string.on_completion.asStringSource(), MyIcons.flag ), } private val tabs = SingleDownloadPageSections.entries.toList() @Composable fun ProgressDownloadPage( singleDownloadComponent: AndroidSingleDownloadComponent, itemState: ProcessingDownloadItemState ) { var selectedTab by remember { mutableStateOf(SingleDownloadPageSections.Info) } val showPartInfo by singleDownloadComponent.showPartInfo.collectAsState() val setShowPartInfo = singleDownloadComponent::setShowPartInfo val horizontalPadding = 16.dp Column { Column( Modifier .clip(myShapes.defaultRounded) .padding(1.dp), ) { val scrollState = rememberScrollState() //info / settings ... val tabContentModifier = Modifier VerticalScrollableContent( scrollState, ) { Box( Modifier .animateContentSize() // .height(150.dp) .verticalScroll(scrollState) ) { when (selectedTab) { SingleDownloadPageSections.Info -> RenderInfo( tabContentModifier, horizontalPadding, singleDownloadComponent, ) SingleDownloadPageSections.Settings -> RenderSettings( modifier = tabContentModifier, horizontalPadding = horizontalPadding, singleDownloadComponent = singleDownloadComponent, ) SingleDownloadPageSections.OnCompletion -> RenderOnCompletion( modifier = tabContentModifier, horizontalPadding = horizontalPadding, singleDownloadComponent = singleDownloadComponent, ) } } } } //tabs MyTabRow { for (tab in tabs) { MyTab( selected = tab == selectedTab, onClick = { selectedTab = tab }, icon = tab.icon, title = tab.title, selectionBackground = Color.Transparent ) } } Spacer( Modifier .fillMaxWidth() .height(1.dp) .background(myColors.onBackground / 0.15f) ) Column( Modifier .background(myColors.surface / 0.5f) ) { Column( Modifier .padding(horizontal = horizontalPadding) ) { Spacer(Modifier.size(8.dp)) RenderProgressBar(itemState) Spacer(Modifier.size(8.dp)) RenderParts( itemState.parts, Modifier .height(4.dp) .clip(myShapes.defaultRounded) .background(myColors.onBackground / 15) ) Spacer(Modifier.size(mySpacings.largeSpace)) RenderActions(itemState, singleDownloadComponent, showPartInfo, setShowPartInfo) Spacer(Modifier.size(mySpacings.largeSpace)) } AnimatedVisibility(showPartInfo) { RenderPartInfo( modifier = Modifier.height(240.dp), itemState = itemState, horizontalPadding = horizontalPadding, ) } } } } @Composable private fun RenderSettings( modifier: Modifier, horizontalPadding: Dp, singleDownloadComponent: AndroidSingleDownloadComponent, ) { Column(modifier) { for (configurable in singleDownloadComponent.settings) { RenderConfigurable( configurable, ConfigurableUiProps( modifier = Modifier, itemPaddingValues = PaddingValues( horizontal = horizontalPadding, ) ) ) } } } @Composable private fun RenderOnCompletion( modifier: Modifier, horizontalPadding: Dp, singleDownloadComponent: AndroidSingleDownloadComponent, ) { Column(modifier) { for (configurable in singleDownloadComponent.onCompletion) { RenderConfigurable( configurable, ConfigurableUiProps( modifier = Modifier, itemPaddingValues = PaddingValues( horizontal = horizontalPadding, ) ) ) } } } @Composable private fun RenderProgressBar(itemState: IDownloadItemState) { val progress = when (itemState) { is CompletedDownloadItemState -> 100 is ProcessingDownloadItemState -> when (val status = itemState.status) { is DownloadJobStatus.PreparingFile -> status.percent else -> itemState.percent } }?.let { it / 100f } val status = itemState.statusOrFinished() val background = when (status) { is DownloadJobStatus.Finished -> myColors.successGradient is DownloadJobStatus.Canceled -> if (ExceptionUtils.isNormalCancellation(status.e)) { myColors.warningGradient } else { myColors.errorGradient } DownloadJobStatus.IDLE -> myColors.warningGradient is DownloadJobStatus.Retrying -> myColors.errorGradient DownloadJobStatus.Finished -> myColors.successGradient is DownloadJobStatus.PreparingFile -> myColors.infoGradient DownloadJobStatus.Resuming, DownloadJobStatus.Downloading, -> myColors.primaryGradient } Box( Modifier .fillMaxWidth() .clip(myShapes.defaultRounded) .height(14.dp) .background(myColors.onBackground / 15) ) { progress?.let { progress -> Box( Modifier .background(background) .fillMaxHeight() .fillMaxWidth( animateFloatAsState( progress, tween(100, easing = LinearEasing) ).value ) ) { if (progress == 1f) { MyIcon( MyIcons.check, null, Modifier .padding(1.dp) .clip(CircleShape) .background(myColors.onBackground) .padding(1.dp) .fillMaxHeight() .align(Alignment.CenterEnd), tint = myColors.background, ) } } } if (progress == null && status is DownloadJobStatus.IsActive) { val anim = rememberInfiniteTransition() val l = 2000 val endPos by anim.animateFloat( 0f, 1f, infiniteRepeatable(tween(l), RepeatMode.Restart) ) val width by anim.animateFloat( 6f, 16f, infiniteRepeatable( keyframes { durationMillis = l 0f atFraction 0f 0.75f atFraction 0.25f 0f atFraction 1f }, repeatMode = RepeatMode.Restart ) ) Box( Modifier .fillMaxHeight() .fillMaxWidth(endPos) ) { Box( Modifier .background(background) .fillMaxHeight() .align(Alignment.CenterEnd) .fillMaxWidth(width) ) } } } } @Composable private fun RenderPartInfo( modifier: Modifier, itemState: ProcessingDownloadItemState, horizontalPadding: Dp, ) { Column(modifier) { Column( Modifier.weight(1f) ) { Box( Modifier.weight(1f) ) { val (onlyActiveParts, setOnlyActiveParts) = rememberSaveable { mutableStateOf(true) } val listToShow = remember(itemState, onlyActiveParts) { itemState.parts .let { parts -> if (onlyActiveParts) { parts.filter { when (it.status) { is PartDownloadStatus.Canceled -> true PartDownloadStatus.Completed -> false PartDownloadStatus.IDLE -> false PartDownloadStatus.ReceivingData -> true PartDownloadStatus.Connecting -> true } } } else { parts } } .withIndex() .toList() } LazyColumn( Modifier.fillMaxSize(), state = rememberLazyListState() ) { items(listToShow, key = { it.value.id }) { item -> Box( Modifier .fillMaxWidth() .padding(horizontal = horizontalPadding) .padding(vertical = 4.dp) ) { RenderSinglePart( index = item.index + 1, part = item.value, size = listToShow.size ) } } } if (useIsInDebugMode()) { Row( modifier = Modifier .align(Alignment.BottomEnd) .padding(bottom = 8.dp, end = 8.dp) .clickable { setOnlyActiveParts(!onlyActiveParts) }, verticalAlignment = Alignment.CenterVertically ) { Text("Only Actives") Spacer(Modifier.width(4.dp)) CheckBox(onlyActiveParts, { setOnlyActiveParts(it) }) } } } } } } @Composable private fun RenderSinglePart( index: Int, part: UiPart, size: Int, ) { val sizeStringLength = size.toString().length Row { Text( index.toString().padStart(sizeStringLength, '0'), color = LocalContentColor.current / 0.5f, modifier = Modifier, ) Spacer(Modifier.width(mySpacings.mediumSpace)) Text( prettifyStatus(part.status).rememberString(), color = LocalContentColor.current / 0.75f, modifier = Modifier.weight(1f), ) val progress = convertPositiveSizeToHumanReadable( part.howMuchProceed, LocalSizeUnit.current ).rememberString() val total = part.length?.let { length -> convertPositiveSizeToHumanReadable(length, LocalSizeUnit.current).rememberString() } ?: myStringResource(Res.string.unknown) Text( "$progress / $total", color = LocalContentColor.current / 0.75f, modifier = Modifier ) } } private fun prettifyStatus(status: PartDownloadStatus): StringSource { return when (status) { is PartDownloadStatus.Canceled -> Res.string.disconnected PartDownloadStatus.IDLE -> Res.string.idle PartDownloadStatus.Completed -> Res.string.finished PartDownloadStatus.ReceivingData -> Res.string.receiving_data PartDownloadStatus.Connecting -> Res.string.connecting }.asStringSource() } @Composable private fun RenderPropertyItem(propertyItem: SingleDownloadPagePropertyItem) { val title = propertyItem.name val value = propertyItem.value Row( verticalAlignment = Alignment.CenterVertically, modifier = Modifier.fillMaxWidth() ) { WithContentAlpha(0.75f) { Text( text = "${title.rememberString()}:", modifier = Modifier.weight(0.3f), maxLines = 1, fontSize = myTextSizes.base ) } WithContentAlpha(1f) { Text( text = value.rememberString(), modifier = Modifier .basicMarquee( iterations = Int.MAX_VALUE ) .weight(0.7f), maxLines = 1, fontSize = myTextSizes.base, color = when (propertyItem.valueState) { SingleDownloadPagePropertyItem.ValueType.Normal -> LocalContentColor.current SingleDownloadPagePropertyItem.ValueType.Error -> myColors.error SingleDownloadPagePropertyItem.ValueType.Success -> myColors.success } ) } } } @Composable private fun RenderInfo( modifier: Modifier, horizontalPadding: Dp, singleDownloadComponent: AndroidSingleDownloadComponent, ) { Column( modifier .padding(horizontal = horizontalPadding) .padding(top = 8.dp) ) { for (propertyItem in singleDownloadComponent.extraDownloadProgressInfo.collectAsState().value) { Spacer(Modifier.height(2.dp)) RenderPropertyItem(propertyItem) } } } @Composable private fun RenderActions( itemState: ProcessingDownloadItemState, singleDownloadComponent: AndroidSingleDownloadComponent, showingPartInfo: Boolean, onRequestShowPartInfo: (show: Boolean) -> Unit, ) { Row { PartInfoButton(showingPartInfo, onRequestShowPartInfo) Spacer(Modifier.width(8.dp)) ToggleButton( itemState = itemState, toggle = singleDownloadComponent::toggle, pause = singleDownloadComponent::pause, modifier = Modifier.weight(1f), ) Spacer(Modifier.width(8.dp)) CancelButton( cancel = singleDownloadComponent::cancel, icon = if (singleDownloadComponent.deletePartialFileOnDownloadCancellation.collectAsState().value) { MyIcons.stop } else { null }, modifier = Modifier, ) } } @Composable private fun PartInfoButton( showing: Boolean, onClick: (Boolean) -> Unit, ) { val partsInfoTitle = Res.string.parts_info.asStringSource() Tooltip(partsInfoTitle) { IconActionButton( onClick = { onClick(!showing) }, contentDescription = partsInfoTitle, icon = if (showing) { MyIcons.up } else { MyIcons.down } ) } } @Composable private fun SingleDownloadPageButton( onClick: () -> Unit, text: String, color: Color = LocalContentColor.current, icon: IconSource? = null, modifier: Modifier, ) { ActionButton( modifier = modifier, text = text, start = { icon?.let { Row { MyIcon(it, null, Modifier.size(16.dp)) Spacer(Modifier.width(4.dp)) } } }, contentPadding = PaddingValues(vertical = 6.dp, horizontal = 12.dp), contentColor = color, onClick = onClick, ) } @Composable private fun CancelButton( cancel: () -> Unit, icon: IconSource?, modifier: Modifier, ) { SingleDownloadPageButton( { cancel() }, icon = icon, text = myStringResource(Res.string.cancel), modifier = modifier, ) } @Composable private fun ToggleButton( itemState: ProcessingDownloadItemState, toggle: () -> Unit, pause: () -> Unit, modifier: Modifier, ) { var showPromptOnNonePresumablePause by remember(itemState.status is DownloadJobStatus.IsActive) { mutableStateOf(false) } val isResumeSupported = itemState.supportResume == true val (icon, text) = when { itemState.canBeResumed() -> { MyIcons.resume to Res.string.resume } itemState.canBePaused() -> { MyIcons.pause to Res.string.pause } else -> return } Box(modifier) { SingleDownloadPageButton( { if (isResumeSupported) { toggle() } else { if (itemState.status is DownloadJobStatus.IsActive) { showPromptOnNonePresumablePause = true } else { toggle() } } }, icon = icon, text = myStringResource(text), color = if (isResumeSupported) { LocalContentColor.current } else { if (itemState.status is DownloadJobStatus.IsActive) { myColors.error } else { LocalContentColor.current } }, modifier = Modifier.fillMaxWidth(), ) if (showPromptOnNonePresumablePause) { val shape = myShapes.defaultRounded val closePopup = { showPromptOnNonePresumablePause = false } Popup( popupPositionProvider = rememberMyComponentRectPositionProvider( offset = DpOffset.Zero, anchor = Alignment.TopEnd, alignment = Alignment.TopStart, ), onDismissRequest = closePopup ) { Column( Modifier .clip(shape) .border(2.dp, myColors.onBackground / 10, shape) .background( Brush.linearGradient( listOf( myColors.surface, myColors.background, ) ) ) .padding(16.dp) .widthIn(max = 140.dp) ) { Text(buildAnnotatedString { withStyle(SpanStyle(color = myColors.warning)) { append("${myStringResource(Res.string.warning)}:\n") } append(myStringResource(Res.string.unsupported_resume_warning)) }) Spacer(Modifier.height(8.dp)) ActionButton( myStringResource(Res.string.stop_anyway), onClick = { closePopup() pause() }, contentColor = myColors.error ) } } } } } @Composable private fun RenderParts(parts: List, modifier: Modifier) { Row( modifier .fillMaxWidth() ) { if (parts.isNotEmpty()) { val sortedParts = remember(parts) { parts.sortedBy { it.id } } for (p in sortedParts) { val partSpace = p.partSpace if (partSpace <= 0f) continue key(p.id) { RenderPart( p, Modifier .fillMaxHeight() .weight(partSpace) ) } } } } } @Composable private fun RenderPart(part: UiPart, modifier: Modifier) { val partProgress = part.percent?.let { it / 100f } ?: 0f val foregroundColor = when (part.status) { is PartDownloadStatus.Canceled -> myColors.error PartDownloadStatus.Completed -> myColors.info PartDownloadStatus.IDLE -> myColors.info / 25 PartDownloadStatus.ReceivingData -> myColors.success PartDownloadStatus.Connecting -> myColors.warning } Row(modifier) { Box( Modifier .fillMaxSize() ) { Box( Modifier .align(Alignment.CenterStart) .fillMaxWidth(partProgress) .fillMaxHeight() .background(foregroundColor) ) } } } ================================================ FILE: android/app/src/main/kotlin/com/abdownloadmanager/android/pages/singledownload/ShowDownloadDialogs.kt ================================================ package com.abdownloadmanager.android.pages.singledownload import androidx.compose.animation.AnimatedContent import androidx.compose.runtime.* import com.abdownloadmanager.android.ui.SheetHeader import com.abdownloadmanager.android.ui.SheetTitle import com.abdownloadmanager.android.ui.SheetUI import com.abdownloadmanager.resources.Res import com.abdownloadmanager.shared.singledownloadpage.createStatusString import com.abdownloadmanager.shared.ui.widget.TransparentIconActionButton import com.abdownloadmanager.shared.util.OnFullyDismissed import com.abdownloadmanager.shared.util.ResponsiveDialog import com.abdownloadmanager.shared.util.rememberResponsiveDialogState import com.abdownloadmanager.shared.util.ui.icon.MyIcons import ir.amirab.downloader.monitor.CompletedDownloadItemState import ir.amirab.downloader.monitor.IDownloadItemState import ir.amirab.downloader.monitor.ProcessingDownloadItemState import ir.amirab.util.compose.asStringSource import kotlinx.coroutines.delay @Composable private fun getDownloadTitle(itemState: IDownloadItemState): String { return buildString { if (itemState is ProcessingDownloadItemState && itemState.percent != null) { append("${itemState.percent}%") append(" ") } append(createStatusString(itemState).rememberString()) } } @Composable fun ShowDownloadDialog( singleDownloadComponent: AndroidSingleDownloadComponent, onRequestShowInDownloads: () -> Unit, ) { val itemState by singleDownloadComponent.itemStateFlow.collectAsState() val dialogState = rememberResponsiveDialogState(false) dialogState.OnFullyDismissed { singleDownloadComponent.close() } LaunchedEffect(Unit) { // animate open after activity becomes fully open // is there a better way? delay(10) dialogState.show() } val closeDialog = dialogState::hide ResponsiveDialog( dialogState, closeDialog ) { itemState?.let { downloadItemState -> SheetUI(header = { SheetHeader( headerTitle = { SheetTitle(getDownloadTitle(downloadItemState)) }, headerActions = { if (singleDownloadComponent.comesFromExternalApplication) { TransparentIconActionButton( MyIcons.externalLink, contentDescription = Res.string.show_downloads.asStringSource(), onClick = onRequestShowInDownloads, ) } TransparentIconActionButton( MyIcons.close, contentDescription = Res.string.close.asStringSource(), onClick = closeDialog ) } ) }) { AnimatedContent( targetState = downloadItemState, contentKey = { when (it) { is CompletedDownloadItemState -> 0 is ProcessingDownloadItemState -> 1 } } ) { downloadItemState -> when (downloadItemState) { is CompletedDownloadItemState -> { CompletedDownloadPage( singleDownloadComponent, downloadItemState, ) } is ProcessingDownloadItemState -> { ProgressDownloadPage( singleDownloadComponent, downloadItemState, ) } } } } } } } ================================================ FILE: android/app/src/main/kotlin/com/abdownloadmanager/android/pages/singledownload/SingleDownloadPageActivity.kt ================================================ package com.abdownloadmanager.android.pages.singledownload import android.content.Context import android.content.Intent import android.os.Bundle import com.abdownloadmanager.android.storage.AndroidExtraDownloadItemSettings import com.abdownloadmanager.android.ui.MainActivity import com.abdownloadmanager.android.util.AndroidDownloadItemOpener import com.abdownloadmanager.android.util.activity.ABDMActivity import com.abdownloadmanager.android.util.activity.HandleActivityEffects import com.abdownloadmanager.shared.storage.ExtraDownloadSettingsStorage import com.abdownloadmanager.shared.util.DownloadSystem import com.abdownloadmanager.shared.util.FileIconProvider import org.koin.core.component.inject class SingleDownloadPageActivity : ABDMActivity() { private val downloadSystem: DownloadSystem by inject() private val downloadItemOpener: AndroidDownloadItemOpener by inject() private val iconProvider: FileIconProvider by inject() private val extraDownloadSettingsStorage: ExtraDownloadSettingsStorage by inject() override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) val downloadId = getDownloadId(intent) val isComingFromOutside = isComingFromExternalApplication(intent) val myRetainedComponent = myRetainedComponent { val closeAddDownloadDialog = { this@myRetainedComponent.finishActivityAction() } AndroidSingleDownloadComponent( ctx = it, onDismiss = closeAddDownloadDialog, downloadId = downloadId, extraDownloadSettingsStorage = extraDownloadSettingsStorage, downloadSystem = downloadSystem, downloadItemOpener = downloadItemOpener, appSettings = appSettingsStorage, appRepository = appRepository, fileIconProvider = iconProvider, applicationScope = applicationScope, comesFromExternalApplication = isComingFromOutside, ) } val singleDownloadComponent = myRetainedComponent.component setABDMContent { myRetainedComponent.HandleActivityEffects() ShowDownloadDialog( singleDownloadComponent = singleDownloadComponent, onRequestShowInDownloads = { startActivity( MainActivity.createRevelDownloadIntent( context = this, singleDownloadComponent.downloadId, ) ) finish() }, ) } } private fun getDownloadId(intent: Intent): Long { return intent.getLongExtra(DOWNLOAD_ID, -1) } private fun isComingFromExternalApplication(intent: Intent): Boolean { return intent.getBooleanExtra(COMING_FROM_OUTSIDE, true) } companion object { const val DOWNLOAD_ID = "downloadId" /** * if we are inside app then there is no need to add app icon shortcut */ const val COMING_FROM_OUTSIDE = "comeFromOutside" fun createIntent( context: Context, downloadId: Long, comingFromOutside: Boolean, ): Intent { val intent = Intent( context, SingleDownloadPageActivity::class.java, ) intent.putExtra(DOWNLOAD_ID, downloadId) intent.putExtra(COMING_FROM_OUTSIDE, comingFromOutside) return intent } } } ================================================ FILE: android/app/src/main/kotlin/com/abdownloadmanager/android/pages/updater/NewUpdatePage.kt ================================================ package com.abdownloadmanager.android.pages.updater import androidx.compose.animation.animateColor import androidx.compose.animation.core.* import androidx.compose.foundation.* import com.abdownloadmanager.shared.util.ui.icon.MyIcons import com.abdownloadmanager.shared.util.ui.myColors import com.abdownloadmanager.shared.util.ui.theme.myTextSizes import com.abdownloadmanager.shared.ui.widget.ActionButton import com.abdownloadmanager.shared.util.ui.WithContentAlpha import com.abdownloadmanager.shared.util.div import androidx.compose.foundation.layout.* import androidx.compose.foundation.shape.CircleShape import com.abdownloadmanager.shared.ui.widget.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.remember import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.BlurredEdgeTreatment import androidx.compose.ui.draw.blur import androidx.compose.ui.draw.clip import androidx.compose.ui.graphics.Brush import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp import com.abdownloadmanager.android.ui.SheetHeader import com.abdownloadmanager.android.ui.SheetTitle import com.abdownloadmanager.android.ui.SheetUI import com.abdownloadmanager.shared.ui.theme.myMarkdownColors import com.abdownloadmanager.shared.ui.theme.myMarkdownTypography import com.abdownloadmanager.resources.Res import com.abdownloadmanager.shared.util.ResponsiveDialogScope import com.abdownloadmanager.shared.util.ui.VerticalScrollableContent import io.github.z4kn4fein.semver.Version import com.abdownloadmanager.updatechecker.UpdateInfo import com.mikepenz.markdown.compose.Markdown import ir.amirab.util.compose.resources.myStringResource @Composable fun ResponsiveDialogScope.NewUpdatePage( newVersionInfo: UpdateInfo, currentVersion: Version, update: () -> Unit, cancel: () -> Unit, ) { SheetUI( header = { SheetHeader( headerTitle = { SheetTitle( myStringResource(Res.string.update_updater), MyIcons.refresh, ) } ) } ) { val contentHorizontalPadding = 16.dp Box { BackgroundEffects() Column( Modifier ) { Column( Modifier .padding( top = 8.dp ) .weight(1f, false) ) { Column( Modifier .padding(horizontal = contentHorizontalPadding) ) { Row(verticalAlignment = Alignment.CenterVertically) { Text( text = myStringResource(Res.string.update_available), fontSize = myTextSizes.xl, fontWeight = FontWeight.Bold ) Spacer(Modifier.width(8.dp)) Text( text = myStringResource( Res.string.version_n, Res.string.version_n_createArgs( newVersionInfo.version.toString() ) ), fontSize = myTextSizes.xl, fontWeight = FontWeight.Bold, color = myColors.success, ) } Spacer(Modifier.height(8.dp)) Text( text = myStringResource(Res.string.update_available_suggest_to_to_update), fontSize = myTextSizes.base, ) Spacer(Modifier.height(8.dp)) } RenderChangeLog( Modifier .fillMaxWidth() .weight(1f, false), newVersionInfo.changeLog, horizontalPadding = contentHorizontalPadding, ) } Actions( Modifier.fillMaxWidth(), update, cancel ) } } } } @Composable private fun BoxScope.BackgroundEffects() { Box( Modifier .align(Alignment.TopCenter) .offset(y = (-148).dp) .fillMaxWidth(0.5f) .height(200.dp) .blur( 56.dp, edgeTreatment = BlurredEdgeTreatment.Unbounded ) .clip(CircleShape) .background( myColors.primary / 0.15f ) ) Box( Modifier .align(Alignment.BottomEnd) .size(180.dp) .offset(x = 32.dp, y = (-32).dp) .blur( 56.dp, edgeTreatment = BlurredEdgeTreatment.Unbounded ) .clip(CircleShape) .background( myColors.secondary / 0.15f ) ) } @Composable fun Actions(modifier: Modifier, update: () -> Unit, cancel: () -> Unit) { Column(modifier) { Spacer( Modifier .fillMaxWidth() .height(1.dp) .background(myColors.onBackground / 0.15f) ) Row( Modifier .fillMaxWidth() .padding(horizontal = 16.dp) .padding(vertical = 16.dp), horizontalArrangement = Arrangement.End ) { UpdateButton(Modifier.weight(1f), update) Spacer(Modifier.width(8.dp)) CancelButton(Modifier.weight(1f), cancel) } } } @Composable fun UpdateButton( modifier: Modifier, update: () -> Unit, ) { val backgroundColor = Brush.horizontalGradient( myColors.primaryGradientColors.map { it / 30 } ) val borderColor = Brush.horizontalGradient( myColors.primaryGradientColors ) val disabledBorderColor = Brush.horizontalGradient( myColors.primaryGradientColors.map { it / 50 } ) ActionButton( text = myStringResource(Res.string.update), modifier = modifier, onClick = update, backgroundColor = backgroundColor, disabledBackgroundColor = backgroundColor, borderColor = borderColor, disabledBorderColor = disabledBorderColor, ) } @Composable fun CancelButton( modifier: Modifier, cancel: () -> Unit, ) { ActionButton( text = myStringResource(Res.string.cancel), modifier = modifier, onClick = cancel, ) } @Composable private fun RenderChangeLog( modifier: Modifier, changeLog: String, horizontalPadding: Dp, ) { val trimmedChangelog = remember(changeLog) { changeLog .lines() .filterNot { it.isBlank() } .joinToString("\n") } Column(modifier) { Text( text = myStringResource(Res.string.update_release_notes), modifier = Modifier.padding(horizontal = horizontalPadding), fontWeight = FontWeight.Bold, fontSize = myTextSizes.lg, ) Spacer(Modifier.height(8.dp)) Column( Modifier.background(myColors.surface / 75) ) { val transition = rememberInfiniteTransition() val topBorderColors = listOf( myColors.primary to myColors.secondaryVariant, myColors.secondary to myColors.primaryVariant, myColors.primaryVariant to myColors.secondary, myColors.secondaryVariant to myColors.primary, ) val animatedTopBorderColors = topBorderColors.map { transition.animateColor( it.first, it.second, infiniteRepeatable( animation = tween(durationMillis = 3000, easing = LinearEasing), repeatMode = RepeatMode.Reverse ) ) } Spacer( Modifier .fillMaxWidth() .height(2.dp) .background( Brush.horizontalGradient( animatedTopBorderColors.map { it.value } ) ) ) val scrollState = rememberScrollState() VerticalScrollableContent( scrollState, modifier, ) { Markdown( modifier = Modifier .weight(1f) .verticalScroll(scrollState) .padding( horizontal = horizontalPadding, vertical = 8.dp ), content = trimmedChangelog, colors = myMarkdownColors(), typography = myMarkdownTypography() ) } } } } @Composable private fun RenderKeyValue( key: String, value: String, ) { Row(verticalAlignment = Alignment.CenterVertically) { WithContentAlpha(0.50f) { Text( key, fontSize = myTextSizes.base, maxLines = 1, ) } Spacer(Modifier.width(8.dp)) Text( value, fontSize = myTextSizes.base, maxLines = 1, ) } } ================================================ FILE: android/app/src/main/kotlin/com/abdownloadmanager/android/pages/updater/UpdaterDialog.kt ================================================ package com.abdownloadmanager.android.pages.updater import androidx.compose.runtime.* import com.abdownloadmanager.shared.pages.updater.RenderUpdateNotifications import com.abdownloadmanager.shared.pages.updater.UpdateComponent import com.abdownloadmanager.shared.util.OnFullyDismissed import com.abdownloadmanager.shared.util.ResponsiveDialog import com.abdownloadmanager.shared.util.rememberResponsiveDialogState @Composable fun UpdaterSheet( updaterComponent: UpdateComponent, ) { ShowUpdaterDialog(updaterComponent) } @Composable private fun ShowUpdaterDialog(updaterComponent: UpdateComponent) { val showUpdate = updaterComponent.showNewUpdate.collectAsState().value val newVersion = updaterComponent.newVersionData.collectAsState().value val closeUpdatePage = { updaterComponent.requestClose() } RenderUpdateNotifications(updaterComponent) val isOpened = showUpdate && newVersion != null val state = rememberResponsiveDialogState(false) LaunchedEffect(isOpened) { if (isOpened) { state.show() } else { state.hide() } } state.OnFullyDismissed(closeUpdatePage) ResponsiveDialog( state, state::hide ) { newVersion?.let { NewUpdatePage( newVersionInfo = newVersion, currentVersion = updaterComponent.currentVersion, cancel = closeUpdatePage, update = { updaterComponent.performUpdate() closeUpdatePage() } ) } } } ================================================ FILE: android/app/src/main/kotlin/com/abdownloadmanager/android/receiver/StartOnBootBroadcastReceiver.kt ================================================ package com.abdownloadmanager.android.receiver import android.content.BroadcastReceiver import android.content.Context import android.content.Intent import com.abdownloadmanager.android.pages.onboarding.permissions.PermissionManager import com.abdownloadmanager.android.util.ABDMAppManager import com.abdownloadmanager.shared.storage.BaseAppSettingsStorage import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.launch import org.koin.core.component.KoinComponent import org.koin.core.component.inject class StartOnBootBroadcastReceiver : BroadcastReceiver(), KoinComponent { private val appManager: ABDMAppManager by inject() private val appSettingStorage: BaseAppSettingsStorage by inject() override fun onReceive(context: Context, intent: Intent) { if (intent.action == Intent.ACTION_BOOT_COMPLETED) { if (appSettingStorage.autoStartOnBoot.value) { appManager.bootDownloadSystemAndService() } } } } ================================================ FILE: android/app/src/main/kotlin/com/abdownloadmanager/android/repository/AppRepository.kt ================================================ package com.abdownloadmanager.android.repository import com.abdownloadmanager.android.pages.browser.BrowserActivity import com.abdownloadmanager.android.storage.AppSettingsStorage import com.abdownloadmanager.shared.repository.BaseAppRepository import com.abdownloadmanager.shared.util.DownloadSystem import com.abdownloadmanager.shared.util.autoremove.RemovedDownloadsFromDiskTracker import com.abdownloadmanager.shared.util.category.CategoryManager import com.abdownloadmanager.shared.util.proxy.ProxyManager import ir.amirab.downloader.DownloadSettings import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.flow.debounce import kotlinx.coroutines.flow.distinctUntilChanged import kotlinx.coroutines.flow.launchIn import kotlinx.coroutines.flow.onEach class AppRepository( scope: CoroutineScope, appSettings: AppSettingsStorage, proxyManager: ProxyManager, downloadSystem: DownloadSystem, downloadSettings: DownloadSettings, removedDownloadsFromDiskTracker: RemovedDownloadsFromDiskTracker, categoryManager: CategoryManager, ) : BaseAppRepository( scope = scope, appSettings = appSettings, proxyManager = proxyManager, downloadSystem = downloadSystem, downloadSettings = downloadSettings, removedDownloadsFromDiskTracker = removedDownloadsFromDiskTracker, categoryManager = categoryManager, ) { init { appSettings.browserIconInLauncher .debounce(500) .distinctUntilChanged() .onEach { enabled -> BrowserActivity.Companion.Launcher.setEnabled(enabled) }.launchIn(scope) } } ================================================ FILE: android/app/src/main/kotlin/com/abdownloadmanager/android/service/DownloadSystemService.kt ================================================ package com.abdownloadmanager.android.service import android.app.Service import android.content.Intent import android.util.Log import androidx.core.app.ServiceCompat import com.abdownloadmanager.android.util.ABDMServiceNotificationManager import com.abdownloadmanager.android.util.AndroidConstants import com.abdownloadmanager.android.util.AndroidUi import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.first import org.koin.core.component.KoinComponent import org.koin.core.component.inject class DownloadSystemService : Service(), KoinComponent { val abdmServiceNotificationManager: ABDMServiceNotificationManager by inject() override fun onCreate() { _isServiceRunningFlow.value = true AndroidUi.boot() abdmServiceNotificationManager.initNotificationChannel() } override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int { Log.i("DownloadSystemService", "onStartCommand: at the beginning") startForeground( AndroidConstants.SERVICE_NOTIFICATION_ID, abdmServiceNotificationManager.createMainNotification() ) abdmServiceNotificationManager.startUpdatingNotifications() Log.i("DownloadSystemService", "onStartCommand: service goes to foreground") return START_STICKY } override fun onDestroy() { super.onDestroy() abdmServiceNotificationManager.stopUpdatingNotifications() ServiceCompat.stopForeground(this, ServiceCompat.STOP_FOREGROUND_REMOVE) _isServiceRunningFlow.value = false } override fun onBind(intent: Intent?) = null companion object { private val _isServiceRunningFlow = MutableStateFlow(false) val isServiceRunningFlow = _isServiceRunningFlow.asStateFlow() fun isServiceRunning(): Boolean { return isServiceRunningFlow.value } suspend fun awaitStart() { if (isServiceRunning()) { return } isServiceRunningFlow.first { it } } } } ================================================ FILE: android/app/src/main/kotlin/com/abdownloadmanager/android/service/KeepAliveServiceReason.kt ================================================ package com.abdownloadmanager.android.service import androidx.compose.runtime.Composable import androidx.compose.runtime.remember import com.abdownloadmanager.resources.Res import com.abdownloadmanager.shared.util.MyDateAndTimeFormats import ir.amirab.downloader.db.QueueModel import ir.amirab.util.compose.asStringSource import kotlinx.datetime.TimeZone import kotlinx.datetime.format import kotlinx.datetime.toLocalDateTime import kotlin.time.ExperimentalTime import kotlin.time.Instant sealed interface KeepAliveServiceReason { fun getKeyChanges(): Any fun getReasonString(): String data class ActiveDownloads(val count: Int) : KeepAliveServiceReason { override fun getKeyChanges() = count override fun getReasonString(): String { return Res.string.downloading.asStringSource().getString() + ": $count" } } data class ActiveQueue(val queueModels: List) : KeepAliveServiceReason { override fun getKeyChanges() = queueModels.size override fun getReasonString(): String { val qNames = queueModels.joinToString(", ") { it.name } return "Q: $qNames ⏳" } } data class ScheduledQueues(val queueModels: List) : KeepAliveServiceReason { override fun getKeyChanges() = queueModels.map { it.scheduledTimes } @OptIn(ExperimentalTime::class) override fun getReasonString(): String { val q = queueModels.map { it to it.scheduledTimes.getNearestTimeToStart() }.minByOrNull() { it.second } ?: return "" val startTime = q.second val instant = Instant.fromEpochMilliseconds(startTime) val dateTime = instant.toLocalDateTime(TimeZone.currentSystemDefault()) val fDateTime = dateTime.format(MyDateAndTimeFormats.fullDateTimeWithoutYearAndSeconds) return "Q: ${q.first.name} - $fDateTime" } } data object AppIsInForeground : KeepAliveServiceReason { override fun getKeyChanges() = Unit override fun getReasonString(): String { return Res.string.idle.asStringSource().getString() } } @Composable fun rememberReasonString(): String { return remember(getKeyChanges()) { getReasonString() } } } ================================================ FILE: android/app/src/main/kotlin/com/abdownloadmanager/android/storage/AndroidExtraDownloadItemSettings.kt ================================================ package com.abdownloadmanager.android.storage import com.abdownloadmanager.shared.storage.IExtraDownloadItemSettings import kotlinx.serialization.KSerializer import kotlinx.serialization.Serializable @Serializable data class AndroidExtraDownloadItemSettings( override val id: Long, // turnOffWifi: Boolean ) : IExtraDownloadItemSettings { companion object : IExtraDownloadItemSettings.DataClassDefinitions { override fun createDefault(id: Long) = AndroidExtraDownloadItemSettings(id = id) override val serializer: KSerializer = AndroidExtraDownloadItemSettings.serializer() } } ================================================ FILE: android/app/src/main/kotlin/com/abdownloadmanager/android/storage/AndroidExtraQueueSettings.kt ================================================ package com.abdownloadmanager.android.storage import com.abdownloadmanager.shared.storage.IExtraQueueSettings import kotlinx.serialization.KSerializer import kotlinx.serialization.Serializable @Serializable data class AndroidExtraQueueSettings( override val id: Long, ) : IExtraQueueSettings { companion object : IExtraQueueSettings.DataClassDefinitions { override fun createDefault(id: Long) = AndroidExtraQueueSettings(id) override val serializer: KSerializer = AndroidExtraQueueSettings.serializer() } } ================================================ FILE: android/app/src/main/kotlin/com/abdownloadmanager/android/storage/AndroidOnBoardingStorage.kt ================================================ package com.abdownloadmanager.android.storage import androidx.datastore.core.DataStore import arrow.optics.optics import com.abdownloadmanager.shared.util.ConfigBaseSettingsByJson import kotlinx.serialization.Serializable @optics @Serializable data class OnBoardingData( val initialSetupPassed: Boolean = false, val permissionsPassedAtLeastOnce: Boolean = false, ) { companion object } class AndroidOnBoardingStorage( dataStore: DataStore, ) : ConfigBaseSettingsByJson(dataStore) { val onBoardingFlow = data val initialSetupPassed = from(OnBoardingData.initialSetupPassed) val permissionsPassedAtLeastOnce = from(OnBoardingData.permissionsPassedAtLeastOnce) } ================================================ FILE: android/app/src/main/kotlin/com/abdownloadmanager/android/storage/AppSettingsStorage.kt ================================================ package com.abdownloadmanager.android.storage import androidx.datastore.core.DataStore import arrow.optics.Lens import arrow.optics.optics import com.abdownloadmanager.shared.storage.BaseAppSettingsStorage import com.abdownloadmanager.shared.storage.IAppSettingsModel import com.abdownloadmanager.shared.storage.SupportedSizeUnits import com.abdownloadmanager.shared.util.downloadlocation.PlatformDownloadLocationProvider import com.abdownloadmanager.shared.util.ConfigBaseSettingsByMapConfig import com.abdownloadmanager.shared.util.ui.theme.DEFAULT_UI_SCALE import ir.amirab.util.config.* import ir.amirab.util.enumValueOrNull import kotlinx.serialization.Serializable import org.koin.core.component.KoinComponent @optics([arrow.optics.OpticsTarget.LENS]) @Serializable data class AppSettingsModel( override val theme: String = "dark", override val defaultDarkTheme: String = "dark", override val defaultLightTheme: String = "light", override val language: String? = null, override val font: String? = null, override val uiScale: Float? = null, override val showIconLabels: Boolean = true, override val useRelativeDateTime: Boolean = true, override val threadCount: Int = 8, override val maxConcurrentDownloads: Int = 0, override val maxDownloadRetryCount: Int = 3, override val dynamicPartCreation: Boolean = true, override val useServerLastModifiedTime: Boolean = false, override val appendExtensionToIncompleteDownloads: Boolean = false, override val useSparseFileAllocation: Boolean = true, override val useAverageSpeed: Boolean = true, override val showDownloadProgressDialog: Boolean = true, override val showDownloadCompletionDialog: Boolean = true, override val speedLimit: Long = 0, override val autoStartOnBoot: Boolean = true, override val notificationSound: Boolean = true, override val defaultDownloadFolder: String = PlatformDownloadLocationProvider .instance.getDownloadLocation() .resolve("ABDM") .canonicalFile.absolutePath, override val browserIntegrationEnabled: Boolean = true, override val browserIntegrationPort: Int = 15151, override val trackDeletedFilesOnDisk: Boolean = false, override val deletePartialFileOnDownloadCancellation: Boolean = false, override val sizeUnit: SupportedSizeUnits = SupportedSizeUnits.BinaryBytes, override val speedUnit: SupportedSizeUnits = SupportedSizeUnits.BinaryBytes, override val ignoreSSLCertificates: Boolean = false, override val useCategoryByDefault: Boolean = true, override val userAgent: String = "", val browserIconInLauncher: Boolean = false, ) : IAppSettingsModel { companion object { val default: AppSettingsModel get() = AppSettingsModel() } object ConfigLens : Lens, KoinComponent { object Keys { val theme = stringKeyOf("theme") val defaultDarkTheme = stringKeyOf("defaultDarkTheme") val defaultLightTheme = stringKeyOf("defaultLightTheme") val language = stringKeyOf("language") val font = stringKeyOf("font") val uiScale = floatKeyOf("uiScale") val mergeTopBarWithTitleBar = booleanKeyOf("mergeTopBarWithTitleBar") val useNativeMenuBar = booleanKeyOf("useNativeMenuBar") val showIconLabels = booleanKeyOf("showIconLabels") val useRelativeDateTime = booleanKeyOf("useRelativeDateTime") val useSystemTray = booleanKeyOf("useSystemTray") val threadCount = intKeyOf("threadCount") val maxConcurrentDownloads = intKeyOf("maxConcurrentDownloads") val maxDownloadRetryCount = intKeyOf("maxDownloadRetryCount") val dynamicPartCreation = booleanKeyOf("dynamicPartCreation") val useServerLastModifiedTime = booleanKeyOf("useServerLastModifiedTime") val appendExtensionToIncompleteDownloads = booleanKeyOf("appendExtensionToIncompleteDownloads") val useSparseFileAllocation = booleanKeyOf("useSparseFileAllocation") val useAverageSpeed = booleanKeyOf("useAverageSpeed") val showDownloadProgressDialog = booleanKeyOf("showDownloadProgressDialog") val showDownloadCompletionDialog = booleanKeyOf("showDownloadCompletionDialog") val speedLimit = longKeyOf("speedLimit") val autoStartOnBoot = booleanKeyOf("autoStartOnBoot") val notificationSound = booleanKeyOf("notificationSound") val defaultDownloadFolder = stringKeyOf("defaultDownloadFolder") val browserIntegrationEnabled = booleanKeyOf("browserIntegrationEnabled") val browserIntegrationPort = intKeyOf("browserIntegrationPort") val trackDeletedFilesOnDisk = booleanKeyOf("trackDeletedFilesOnDisk") val deletePartialFileOnDownloadCancellation = booleanKeyOf("deletePartialFileOnDownloadCancellation") val sizeUnit = stringKeyOf("sizeUnit") val speedUnit = stringKeyOf("speedUnit") val ignoreSSLCertificates = booleanKeyOf("ignoreSSLCertificates") val useCategoryByDefault = booleanKeyOf("useCategoryByDefault") val userAgent = stringKeyOf("userAgent") val browserIconInLauncher = booleanKeyOf("browserIconInLauncher") } override fun get(source: MapConfig): AppSettingsModel { val default by lazy { AppSettingsModel.default } // for nullable types we don't get default value return AppSettingsModel( theme = source.get(Keys.theme) ?: default.theme, defaultDarkTheme = source.get(Keys.defaultDarkTheme) ?: default.defaultDarkTheme, defaultLightTheme = source.get(Keys.defaultLightTheme) ?: default.defaultLightTheme, language = source.get(Keys.language), font = source.get(Keys.font), uiScale = source.get(Keys.uiScale), showIconLabels = source.get(Keys.showIconLabels) ?: default.showIconLabels, useRelativeDateTime = source.get(Keys.useRelativeDateTime) ?: default.useRelativeDateTime, threadCount = source.get(Keys.threadCount) ?: default.threadCount, maxConcurrentDownloads = source.get(Keys.maxConcurrentDownloads) ?: default.maxConcurrentDownloads, maxDownloadRetryCount = source.get(Keys.maxDownloadRetryCount) ?: default.maxDownloadRetryCount, dynamicPartCreation = source.get(Keys.dynamicPartCreation) ?: default.dynamicPartCreation, useServerLastModifiedTime = source.get(Keys.useServerLastModifiedTime) ?: default.useServerLastModifiedTime, appendExtensionToIncompleteDownloads = source.get(Keys.appendExtensionToIncompleteDownloads) ?: default.appendExtensionToIncompleteDownloads, useSparseFileAllocation = source.get(Keys.useSparseFileAllocation) ?: default.useSparseFileAllocation, useAverageSpeed = source.get(Keys.useAverageSpeed) ?: default.useAverageSpeed, showDownloadProgressDialog = source.get(Keys.showDownloadProgressDialog) ?: default.showDownloadProgressDialog, showDownloadCompletionDialog = source.get(Keys.showDownloadCompletionDialog) ?: default.showDownloadCompletionDialog, speedLimit = source.get(Keys.speedLimit) ?: default.speedLimit, autoStartOnBoot = source.get(Keys.autoStartOnBoot) ?: default.autoStartOnBoot, notificationSound = source.get(Keys.notificationSound) ?: default.notificationSound, defaultDownloadFolder = source.get(Keys.defaultDownloadFolder) ?: default.defaultDownloadFolder, browserIntegrationEnabled = source.get(Keys.browserIntegrationEnabled) ?: default.browserIntegrationEnabled, browserIntegrationPort = source.get(Keys.browserIntegrationPort) ?: default.browserIntegrationPort, trackDeletedFilesOnDisk = source.get(Keys.trackDeletedFilesOnDisk) ?: default.trackDeletedFilesOnDisk, deletePartialFileOnDownloadCancellation = source.get(Keys.deletePartialFileOnDownloadCancellation) ?: default.deletePartialFileOnDownloadCancellation, sizeUnit = source.get(Keys.sizeUnit)?.enumValueOrNull() ?: default.sizeUnit, speedUnit = source.get(Keys.speedUnit)?.enumValueOrNull() ?: default.speedUnit, ignoreSSLCertificates = source.get(Keys.ignoreSSLCertificates) ?: default.ignoreSSLCertificates, useCategoryByDefault = source.get(Keys.useCategoryByDefault) ?: default.useCategoryByDefault, userAgent = source.get(Keys.userAgent) ?: default.userAgent, browserIconInLauncher = source.get(Keys.browserIconInLauncher) ?: default.browserIconInLauncher, ) } override fun set(source: MapConfig, focus: AppSettingsModel): MapConfig { return source.apply { put(Keys.theme, focus.theme) put(Keys.defaultDarkTheme, focus.defaultDarkTheme) put(Keys.defaultLightTheme, focus.defaultLightTheme) putNullable(Keys.language, focus.language) putNullable(Keys.font, focus.font) putNullable(Keys.uiScale, focus.uiScale) put(Keys.showIconLabels, focus.showIconLabels) put(Keys.useRelativeDateTime, focus.useRelativeDateTime) put(Keys.threadCount, focus.threadCount) put(Keys.maxConcurrentDownloads, focus.maxConcurrentDownloads) put(Keys.maxDownloadRetryCount, focus.maxDownloadRetryCount) put(Keys.dynamicPartCreation, focus.dynamicPartCreation) put(Keys.useServerLastModifiedTime, focus.useServerLastModifiedTime) put(Keys.appendExtensionToIncompleteDownloads, focus.appendExtensionToIncompleteDownloads) put(Keys.useSparseFileAllocation, focus.useSparseFileAllocation) put(Keys.useAverageSpeed, focus.useAverageSpeed) put(Keys.showDownloadProgressDialog, focus.showDownloadProgressDialog) put(Keys.showDownloadCompletionDialog, focus.showDownloadCompletionDialog) put(Keys.speedLimit, focus.speedLimit) put(Keys.autoStartOnBoot, focus.autoStartOnBoot) put(Keys.notificationSound, focus.notificationSound) put(Keys.defaultDownloadFolder, focus.defaultDownloadFolder) put(Keys.browserIntegrationEnabled, focus.browserIntegrationEnabled) put(Keys.browserIntegrationPort, focus.browserIntegrationPort) put(Keys.trackDeletedFilesOnDisk, focus.trackDeletedFilesOnDisk) put(Keys.deletePartialFileOnDownloadCancellation, focus.deletePartialFileOnDownloadCancellation) put(Keys.sizeUnit, focus.sizeUnit.name) put(Keys.speedUnit, focus.speedUnit.name) put(Keys.ignoreSSLCertificates, focus.ignoreSSLCertificates) put(Keys.useCategoryByDefault, focus.useCategoryByDefault) put(Keys.userAgent, focus.userAgent) put(Keys.browserIconInLauncher, focus.browserIconInLauncher) } } } } private val fontLens: Lens get() = Lens( get = { it.font }, set = { s, f -> s.copy(font = f) } ) // use null for default scale! private val uiScaleLens: Lens get() = Lens( get = { it.uiScale ?: DEFAULT_UI_SCALE }, set = { s, f -> s.copy(uiScale = f.takeIf { it != DEFAULT_UI_SCALE }) } ) private val languageLens: Lens get() = Lens( get = { it.language }, set = { s, f -> s.copy(language = f) } ) class AppSettingsStorage( settings: DataStore, ) : BaseAppSettingsStorage, ConfigBaseSettingsByMapConfig(settings, AppSettingsModel.ConfigLens) { override val theme = from(AppSettingsModel.theme) override val defaultDarkTheme = from(AppSettingsModel.defaultDarkTheme) override val defaultLightTheme = from(AppSettingsModel.defaultLightTheme) override val selectedLanguage = from(languageLens) override val font = from(fontLens) override val uiScale = from(uiScaleLens) override val showIconLabels = from(AppSettingsModel.showIconLabels) override val useRelativeDateTime = from(AppSettingsModel.useRelativeDateTime) override val threadCount = from(AppSettingsModel.threadCount) override val maxConcurrentDownloads = from(AppSettingsModel.maxConcurrentDownloads) override val dynamicPartCreation = from(AppSettingsModel.dynamicPartCreation) override val useServerLastModifiedTime = from(AppSettingsModel.useServerLastModifiedTime) override val appendExtensionToIncompleteDownloads = from(AppSettingsModel.appendExtensionToIncompleteDownloads) override val useSparseFileAllocation = from(AppSettingsModel.useSparseFileAllocation) override val useAverageSpeed = from(AppSettingsModel.useAverageSpeed) override val maxDownloadRetryCount = from(AppSettingsModel.maxDownloadRetryCount) override val showDownloadProgressDialog = from(AppSettingsModel.showDownloadProgressDialog) override val showDownloadCompletionDialog = from(AppSettingsModel.showDownloadCompletionDialog) override val speedLimit = from(AppSettingsModel.speedLimit) override val autoStartOnBoot = from(AppSettingsModel.autoStartOnBoot) override val notificationSound = from(AppSettingsModel.notificationSound) override val defaultDownloadFolder = from(AppSettingsModel.defaultDownloadFolder) override val browserIntegrationEnabled = from(AppSettingsModel.browserIntegrationEnabled) override val browserIntegrationPort = from(AppSettingsModel.browserIntegrationPort) override val trackDeletedFilesOnDisk = from(AppSettingsModel.trackDeletedFilesOnDisk) override val deletePartialFileOnDownloadCancellation = from(AppSettingsModel.deletePartialFileOnDownloadCancellation) override val sizeUnit = from(AppSettingsModel.sizeUnit) override val speedUnit = from(AppSettingsModel.speedUnit) override val ignoreSSLCertificates = from(AppSettingsModel.ignoreSSLCertificates) override val useCategoryByDefault = from(AppSettingsModel.useCategoryByDefault) override val userAgent = from(AppSettingsModel.userAgent) val browserIconInLauncher = from(AppSettingsModel.browserIconInLauncher) } ================================================ FILE: android/app/src/main/kotlin/com/abdownloadmanager/android/storage/BrowserBookmarksStorage.kt ================================================ package com.abdownloadmanager.android.storage import androidx.compose.runtime.Immutable import androidx.datastore.core.DataStore import com.abdownloadmanager.shared.util.ConfigBaseSettingsByJson import kotlinx.serialization.Serializable @Serializable @Immutable data class BrowserBookmark( val url: String, val title: String, ) class BrowserBookmarksStorage( dataStore: DataStore>, ) : ConfigBaseSettingsByJson>(dataStore) { val bookmarksFlow = data } ================================================ FILE: android/app/src/main/kotlin/com/abdownloadmanager/android/storage/HomePageStorage.kt ================================================ package com.abdownloadmanager.android.storage import androidx.datastore.core.DataStore import com.abdownloadmanager.android.pages.home.HomePageStateToPersist import com.abdownloadmanager.android.pages.home.sortBy import com.abdownloadmanager.shared.util.ConfigBaseSettingsByJson class HomePageStorage( dataStore: DataStore, ) : ConfigBaseSettingsByJson( dataStore = dataStore, ) { val sortBy = from(HomePageStateToPersist.sortBy) } ================================================ FILE: android/app/src/main/kotlin/com/abdownloadmanager/android/ui/ABDownloadManagerApplicationContent.kt ================================================ package com.abdownloadmanager.android.ui import androidx.compose.runtime.Composable import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue import androidx.compose.runtime.remember import com.abdownloadmanager.android.ui.configurable.comon.CommonConfigurableRenderersForAndroid import com.abdownloadmanager.android.ui.configurable.comon.ConfigurableRenderersForAndroid import com.abdownloadmanager.android.util.AppInfo import com.abdownloadmanager.shared.repository.BaseAppRepository import com.abdownloadmanager.shared.storage.BaseAppSettingsStorage import com.abdownloadmanager.shared.ui.ProvideCommonSettings import com.abdownloadmanager.shared.ui.ProvideSizeUnits import com.abdownloadmanager.shared.ui.configurable.ConfigurableRendererRegistry import com.abdownloadmanager.shared.ui.theme.ABDownloaderTheme import com.abdownloadmanager.shared.ui.theme.ThemeManager import com.abdownloadmanager.shared.ui.widget.NotificationManager import com.abdownloadmanager.shared.ui.widget.ProvideLanguageManager import com.abdownloadmanager.shared.ui.widget.ProvideNotificationManager import com.abdownloadmanager.shared.util.PopUpContainer import com.abdownloadmanager.shared.util.ResponsiveBox import com.abdownloadmanager.shared.util.ui.ProvideDebugInfo import ir.amirab.util.compose.IIconResolver import ir.amirab.util.compose.localizationmanager.LanguageManager import kotlin.collections.component1 import kotlin.collections.component2 @Composable fun ABDownloadManagerApplicationContent( languageManager: LanguageManager, themeManager: ThemeManager, appSettingsStorage: BaseAppSettingsStorage, iconResolver: IIconResolver, appRepository: BaseAppRepository, notificationManager: NotificationManager, content: @Composable () -> Unit, ) { val configurableRendererRegistry = remember { ConfigurableRendererRegistry { listOf( CommonConfigurableRenderersForAndroid, ConfigurableRenderersForAndroid ).forEach { it.getAllRenderers().forEach { (key, renderer) -> this.register(key, renderer) } } } } ProvideDebugInfo(AppInfo.isInDebugMode) { ProvideLanguageManager(languageManager) { ProvideCommonSettings( appSettings = appSettingsStorage, iconProvider = iconResolver, configurableRendererRegistry = configurableRendererRegistry, ) { ProvideNotificationManager(notificationManager) { val myColors by themeManager.currentThemeColor.collectAsState() val uiScale by appSettingsStorage.uiScale.collectAsState() ABDownloaderTheme( myColors = myColors, fontFamily = null, uiScale = uiScale, ) { ResponsiveBox { ProvideSizeUnits( appRepository ) { PopUpContainer { content() } } } } } } } } } ================================================ FILE: android/app/src/main/kotlin/com/abdownloadmanager/android/ui/MainActivity.kt ================================================ package com.abdownloadmanager.android.ui import android.content.Context import android.content.Intent import android.os.Bundle import com.abdownloadmanager.UpdateManager import com.abdownloadmanager.android.pages.onboarding.permissions.PermissionManager import com.abdownloadmanager.android.util.activity.ABDMActivity import com.abdownloadmanager.shared.downloaderinui.DownloaderInUiRegistry import com.abdownloadmanager.shared.util.DownloadItemOpener import com.abdownloadmanager.shared.util.DownloadSystem import com.abdownloadmanager.shared.util.FileIconProvider import com.abdownloadmanager.shared.util.category.CategoryManager import com.abdownloadmanager.shared.util.category.DefaultCategories import com.arkivanov.decompose.retainedComponent import ir.amirab.downloader.queue.QueueManager import kotlinx.serialization.json.Json import org.koin.core.component.inject class MainActivity : ABDMActivity() { private val downloadItemOpener: DownloadItemOpener by inject() private val downloadSystem: DownloadSystem by inject() private val categoryManager: CategoryManager by inject() private val queueManager: QueueManager by inject() private val defaultCategories: DefaultCategories by inject() private val fileIconProvider: FileIconProvider by inject() private val downloaderInUiRegistry: DownloaderInUiRegistry by inject() private val json: Json by inject() private val updateManager: UpdateManager by inject() private val permissionManager: PermissionManager by inject() val mainComponent by lazy { retainedComponent { // make sure to not pass any activity to retained component MainComponent( ctx = it, context = applicationContext, downloadItemOpener = downloadItemOpener, downloadSystem = downloadSystem, categoryManager = categoryManager, queueManager = queueManager, defaultCategories = defaultCategories, fileIconProvider = fileIconProvider, json = json, downloaderInUiRegistry = downloaderInUiRegistry, perHostSettingsManager = perHostSettingsManager, applicationScope = applicationScope, appRepository = appRepository, updateManager = updateManager, permissionManager = permissionManager, languageManager = languageManager, themeManager = themeManager, abdmAppManager = abdmAppManager, onBoardingStorage = onBoardingStorage, homePageStorage = homePageStorage, ) } } override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) setABDMContent { MainContent( mainComponent = mainComponent, ) } } override fun handleIntent(intent: Intent) { if (intent.action == ACTION_REVEAL_DOWNLOAD_IN_LIST) { val downloadId = intent.getLongExtra(DOWNLOAD_ID_KEY, -1) .takeIf { it >= 0 } ?: return mainComponent.revealDownload(downloadId) } } companion object { private const val DOWNLOAD_ID_KEY = "downloadId" private const val ACTION_REVEAL_DOWNLOAD_IN_LIST = "revealDownloadList" fun createRevelDownloadIntent( context: Context, downloadId: Long, ): Intent { return Intent(context, MainActivity::class.java).apply { action = ACTION_REVEAL_DOWNLOAD_IN_LIST putExtra(DOWNLOAD_ID_KEY, downloadId) } } } } ================================================ FILE: android/app/src/main/kotlin/com/abdownloadmanager/android/ui/MainComponent.kt ================================================ package com.abdownloadmanager.android.ui import android.content.Context import android.content.Intent import com.abdownloadmanager.UpdateManager import com.abdownloadmanager.android.pages.add.multiple.AddMultiDownloadActivity import com.abdownloadmanager.android.pages.add.single.AddSingleDownloadActivity import com.abdownloadmanager.android.pages.batchdownload.AndroidBatchDownloadComponent import com.abdownloadmanager.android.pages.browser.BrowserActivity import com.abdownloadmanager.android.pages.checksum.AndroidFileChecksumComponent import com.abdownloadmanager.android.pages.editdownload.AndroidEditDownloadComponent import com.abdownloadmanager.android.pages.home.HomeComponent import com.abdownloadmanager.android.pages.onboarding.initialsetup.InitialSetupComponent import com.abdownloadmanager.android.pages.onboarding.permissions.PermissionComponent import com.abdownloadmanager.android.pages.onboarding.permissions.PermissionManager import com.abdownloadmanager.android.pages.perhostsettings.AndroidPerHostSettingsComponent import com.abdownloadmanager.android.pages.queue.QueueConfigurationComponent import com.abdownloadmanager.android.pages.settings.AndroidSettingsComponent import com.abdownloadmanager.android.pages.singledownload.SingleDownloadPageActivity import com.abdownloadmanager.android.storage.AndroidOnBoardingStorage import com.abdownloadmanager.android.storage.HomePageStorage import com.abdownloadmanager.android.ui.Screen.* import com.abdownloadmanager.android.util.ABDMAppManager import com.abdownloadmanager.android.util.pagemanager.IBrowserPageManager import com.abdownloadmanager.android.util.pagemanager.PermissionsPageManager import com.abdownloadmanager.shared.downloaderinui.DownloaderInUiRegistry import com.abdownloadmanager.shared.pagemanager.AboutPageManager import com.abdownloadmanager.shared.pagemanager.AddDownloadDialogManager import com.abdownloadmanager.shared.pagemanager.BatchDownloadPageManager import com.abdownloadmanager.shared.pagemanager.CategoryDialogManager import com.abdownloadmanager.shared.pagemanager.DownloadDialogManager import com.abdownloadmanager.shared.pagemanager.EditDownloadDialogManager import com.abdownloadmanager.shared.pagemanager.FileChecksumDialogManager import com.abdownloadmanager.shared.pagemanager.NotificationSender import com.abdownloadmanager.shared.pagemanager.OpenSourceLibrariesPageManager import com.abdownloadmanager.shared.pagemanager.PerHostSettingsPageManager import com.abdownloadmanager.shared.pagemanager.QueuePageManager import com.abdownloadmanager.shared.pagemanager.SettingsPageManager import com.abdownloadmanager.shared.pagemanager.TranslatorsPageManager import com.abdownloadmanager.shared.pages.adddownload.AddDownloadConfig import com.abdownloadmanager.shared.pages.adddownload.AddDownloadCredentialsInUiProps import com.abdownloadmanager.shared.pages.adddownload.ImportOptions import com.abdownloadmanager.shared.pages.category.CategoryComponent import com.abdownloadmanager.shared.pages.updater.UpdateComponent import com.abdownloadmanager.shared.repository.BaseAppRepository import com.abdownloadmanager.shared.ui.theme.ThemeManager import com.abdownloadmanager.shared.ui.widget.MessageDialogType import com.abdownloadmanager.shared.ui.widget.NotificationModel import com.abdownloadmanager.shared.ui.widget.NotificationType import com.abdownloadmanager.shared.util.BaseComponent import com.abdownloadmanager.shared.util.DownloadItemOpener import com.abdownloadmanager.shared.util.DownloadSystem import com.abdownloadmanager.shared.util.FileIconProvider import com.abdownloadmanager.shared.util.category.CategoryManager import com.abdownloadmanager.shared.util.category.DefaultCategories import com.abdownloadmanager.shared.util.mvi.ContainsEffects import com.abdownloadmanager.shared.util.mvi.supportEffects import com.abdownloadmanager.shared.util.perhostsettings.PerHostSettingsManager import com.abdownloadmanager.shared.util.subscribeAsStateFlow import com.arkivanov.decompose.ComponentContext import com.arkivanov.decompose.childContext import com.arkivanov.decompose.router.slot.SlotNavigation import com.arkivanov.decompose.router.slot.activate import com.arkivanov.decompose.router.slot.childSlot import com.arkivanov.decompose.router.slot.dismiss import com.arkivanov.decompose.router.stack.StackNavigation import com.arkivanov.decompose.router.stack.childStack import com.arkivanov.decompose.router.stack.navigate import com.arkivanov.decompose.router.stack.pushToFront import ir.amirab.downloader.monitor.isDownloadActiveFlow import ir.amirab.downloader.queue.DefaultQueueInfo import ir.amirab.downloader.queue.QueueManager import ir.amirab.util.compose.StringSource import ir.amirab.util.compose.localizationmanager.LanguageManager import ir.amirab.util.flow.mapStateFlow import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.launch import kotlinx.serialization.Serializable import kotlinx.serialization.builtins.serializer import kotlinx.serialization.json.Json sealed interface Screen { data class Home( val component: HomeComponent, ) : Screen data class Settings( val component: AndroidSettingsComponent, ) : Screen data object About : Screen data object OpenSourceThirdPartyLibraries : Screen data object Translators : Screen data class PerHostSettings( val component: AndroidPerHostSettingsComponent, ) : Screen data class FileChecksum( val component: AndroidFileChecksumComponent, ) : Screen data class InitialSetup( val component: InitialSetupComponent, ) : Screen data class Permissions( val component: PermissionComponent, ) : Screen } @Serializable sealed interface ScreenConfig { @Serializable data object Home : ScreenConfig @Serializable data object Settings : ScreenConfig @Serializable data object About : ScreenConfig @Serializable data object OpenSourceThirdPartyLibraries : ScreenConfig @Serializable data object Translators : ScreenConfig @Serializable data class PerHostSettings( val config: AndroidPerHostSettingsComponent.Config ) : ScreenConfig @Serializable data class FileChecksum( val config: AndroidFileChecksumComponent.Config ) : ScreenConfig @Serializable data object InitialSetup : ScreenConfig @Serializable data class Permissions( val openHomeAfterFinish: Boolean, ) : ScreenConfig } class MainComponent( ctx: ComponentContext, private val context: Context, private val downloadItemOpener: DownloadItemOpener, private val downloadSystem: DownloadSystem, private val categoryManager: CategoryManager, private val queueManager: QueueManager, private val defaultCategories: DefaultCategories, private val fileIconProvider: FileIconProvider, private val downloaderInUiRegistry: DownloaderInUiRegistry, private val perHostSettingsManager: PerHostSettingsManager, private val applicationScope: CoroutineScope, private val appRepository: BaseAppRepository, private val updateManager: UpdateManager, private val permissionManager: PermissionManager, private val languageManager: LanguageManager, private val themeManager: ThemeManager, val abdmAppManager: ABDMAppManager, val onBoardingStorage: AndroidOnBoardingStorage, val homePageStorage: HomePageStorage, private val json: Json, ) : BaseComponent(ctx), DownloadDialogManager, EditDownloadDialogManager, AddDownloadDialogManager, FileChecksumDialogManager, QueuePageManager, CategoryDialogManager, NotificationSender, SettingsPageManager, TranslatorsPageManager, OpenSourceLibrariesPageManager, AboutPageManager, BatchDownloadPageManager, PerHostSettingsPageManager, PermissionsPageManager, IBrowserPageManager, ContainsEffects by supportEffects() { val categoryComponentNavigation = SlotNavigation() val categorySlot = childSlot( source = categoryComponentNavigation, key = "categoryEdit", childFactory = { config, ctx -> CategoryComponent( ctx = ctx, id = config, close = ::closeCategoryDialog, submit = { submittedCategory -> if (submittedCategory.id < 0) { categoryManager.addCustomCategory(submittedCategory) } else { categoryManager.updateCategory( submittedCategory.id ) { submittedCategory.copy( items = it.items ) } } closeCategoryDialog() }, ) }, serializer = Long.serializer(), ).subscribeAsStateFlow() val queueConfigComponentNavigation = SlotNavigation() val queueConfigSlot = childSlot( source = queueConfigComponentNavigation, key = "queueConfigs", childFactory = { config, ctx -> QueueConfigurationComponent( ctx = ctx, id = config, queueManager = queueManager, ) }, serializer = Long.serializer(), ).subscribeAsStateFlow() val batchDownloadNavigation = SlotNavigation() val batchDownloadSlot = childSlot( source = batchDownloadNavigation, key = "batchDownload", childFactory = { config, ctx -> AndroidBatchDownloadComponent( ctx = ctx, onClose = ::closeBatchDownload, importLinks = { links -> openAddDownloadDialog( links.mapNotNull { link -> downloaderInUiRegistry .bestMatchForThisLink(link) ?.createMinimumCredentials(link) ?.let { credentials -> AddDownloadCredentialsInUiProps( credentials = credentials, ) } } ) } ) }, serializer = null, ).subscribeAsStateFlow() val editDownloadNavigation = SlotNavigation() val editDownloadSlot = childSlot( source = editDownloadNavigation, key = "editDownload", childFactory = { editDownloadConfig: Long, componentContext: ComponentContext -> AndroidEditDownloadComponent( ctx = componentContext, onRequestClose = { closeEditDownloadDialog() }, onEdited = { updater, downloadJobExtraConfig -> scope.launch { downloadSystem.editDownload( id = editDownloadConfig, applyUpdate = updater, downloadJobExtraConfig = downloadJobExtraConfig ) closeEditDownloadDialog() } }, downloadId = editDownloadConfig, acceptEdit = downloadSystem.downloadMonitor .isDownloadActiveFlow(editDownloadConfig) .mapStateFlow { !it }, downloadSystem = downloadSystem, downloaderInUiRegistry = downloaderInUiRegistry, iconProvider = fileIconProvider, ) }, serializer = null, ).subscribeAsStateFlow() val updaterComponent = UpdateComponent( childContext("updater"), this, updateManager, ) val stackNavigation = StackNavigation() val stack = childStack( stackNavigation, key = "mainStack", serializer = ScreenConfig.serializer(), initialStack = { val initialConfigPassed = onBoardingStorage.initialSetupPassed.value val firstPage = if (!initialConfigPassed) { ScreenConfig.InitialSetup } else { if (shouldGoToPermissionsPage()) { ScreenConfig.Permissions(openHomeAfterFinish = true) } else { ScreenConfig.Home } } listOf(firstPage) }, handleBackButton = true, childFactory = { cfg, ctx -> when (cfg) { ScreenConfig.Home -> { Home( HomeComponent( componentContext = ctx, downloadItemOpener = downloadItemOpener, downloadDialogManager = this, editDownloadDialogManager = this, addDownloadDialogManager = this, fileChecksumDialogManager = this, queuePageManager = this, categoryDialogManager = this, notificationSender = this, downloadSystem = downloadSystem, categoryManager = categoryManager, queueManager = queueManager, defaultCategories = defaultCategories, fileIconProvider = fileIconProvider, openSourceLibrariesPageManager = this, translatorsPageManager = this, aboutPageManager = this, batchDownloadPageManager = this, settingsPageManager = this, perHostSettingsPageManager = this, downloaderInUiRegistry = downloaderInUiRegistry, updateComponent = updaterComponent, homePageStorage = homePageStorage, browserPageManager = this, ) ) } ScreenConfig.Settings -> { Settings( AndroidSettingsComponent( ctx = ctx, perHostSettingsPageManager = this, permissionsPageManager = this, ) ) } ScreenConfig.About -> { About } ScreenConfig.OpenSourceThirdPartyLibraries -> { OpenSourceThirdPartyLibraries } ScreenConfig.Translators -> { Translators } is ScreenConfig.PerHostSettings -> { PerHostSettings( AndroidPerHostSettingsComponent( ctx = ctx, perHostSettingsManager = perHostSettingsManager, appRepository = appRepository, appScope = applicationScope, closeRequested = ::closePerHostSettings ) ) } is ScreenConfig.FileChecksum -> { FileChecksum( AndroidFileChecksumComponent( ctx = ctx, id = cfg.config.id, itemIds = cfg.config.itemIds, closeComponent = { closeFileChecksumPage(cfg.config.id) }, downloadSystem = downloadSystem, iconProvider = fileIconProvider, ) ) } ScreenConfig.InitialSetup -> { Screen.InitialSetup( InitialSetupComponent( ctx = ctx, languageManager = languageManager, themeManager = themeManager, onFinish = { afterInitialFinish() } ) ) } is ScreenConfig.Permissions -> { Screen.Permissions( PermissionComponent( componentContext = ctx, permissionManager = permissionManager, onReady = { onPermissionsReady(cfg.openHomeAfterFinish) }, onDismiss = { closePermissionsPage() } ) ) } } }, ).subscribeAsStateFlow() private fun onPermissionsReady(openHomeAfterFinish: Boolean) { if (openHomeAfterFinish) { onBoardingStorage.permissionsPassedAtLeastOnce.value = true scope.launch { abdmAppManager.startDownloadSystem() abdmAppManager.startOurService() initiallyGoToHome() } } else { closePermissionsPage() } } private fun shouldGoToPermissionsPage(): Boolean { val permissionsPassedAtLeastOnce = onBoardingStorage.permissionsPassedAtLeastOnce.value if (!permissionsPassedAtLeastOnce) { return true } return !permissionManager.isReady() } private fun afterInitialFinish() { onBoardingStorage.initialSetupPassed.value = true if (shouldGoToPermissionsPage()) { openPermissionsPage(true) } else { initiallyGoToHome() } } private fun initiallyGoToHome() { scope.launch { stackNavigation.navigate { listOf(ScreenConfig.Home) } } } override fun openDownloadDialog(id: Long) { sendEffect( MainAppEffects.StartActivity( SingleDownloadPageActivity.createIntent( context = context, downloadId = id, comingFromOutside = false ) ) ) } override fun closeDownloadDialog() { TODO("Not yet implemented") } override fun openEditDownloadDialog(id: Long) { scope.launch { editDownloadNavigation.activate(id) } } override fun closeEditDownloadDialog() { scope.launch { editDownloadNavigation.dismiss() } } override fun closeAddDownloadDialog() { TODO("Not yet implemented") } override fun openAddDownloadDialog( links: List, importOptions: ImportOptions ) { scope.launch { when (links.size) { 0 -> return@launch 1 -> { val intent = AddSingleDownloadActivity.createIntent( context = context, singleAddConfig = AddDownloadConfig.SingleAddConfig( newDownload = links.first(), importOptions = importOptions, ), json = json, ) sendEffect(MainAppEffects.StartActivity(intent)) } else -> { val intent = AddMultiDownloadActivity.createIntent( context = context, multipleAddConfig = AddDownloadConfig.MultipleAddConfig( newDownloads = links, importOptions = importOptions, ), json = json, ) sendEffect(MainAppEffects.StartActivity(intent)) } } } } override fun openFileChecksumPage(ids: List) { scope.launch { stackNavigation.pushToFront( ScreenConfig.FileChecksum( AndroidFileChecksumComponent.Config( itemIds = ids, ) ) ) } } override fun closeFileChecksumPage(dialogId: String) { scope.launch { stackNavigation.navigate { it.filterNot { config -> config is ScreenConfig.FileChecksum } } } } override fun openQueues(openQueueId: Long?) { scope.launch { queueConfigComponentNavigation.activate(openQueueId ?: DefaultQueueInfo.ID) } } override fun closeQueues() { scope.launch { queueConfigComponentNavigation.dismiss() } } override fun openCategoryDialog(categoryId: Long) { scope.launch { categoryComponentNavigation.activate(categoryId) } } override fun closeCategoryDialog() { scope.launch { categoryComponentNavigation.dismiss() } } override fun sendDialogNotification( title: StringSource, description: StringSource, type: MessageDialogType ) { sendNotification( tag = title, title = title, description = description, type = when (type) { MessageDialogType.Error -> NotificationType.Error MessageDialogType.Info -> NotificationType.Info MessageDialogType.Success -> NotificationType.Success MessageDialogType.Warning -> NotificationType.Warning }, ) } override fun openSettings() { scope.launch { stackNavigation.pushToFront(ScreenConfig.Settings) } } override fun closeSettings() { scope.launch { stackNavigation.navigate { it.filterNot { config -> config is ScreenConfig.Settings } } } } override fun sendNotification( tag: Any, title: StringSource, description: StringSource, type: NotificationType ) { sendEffect( MainAppEffects.SimpleNotificationNotification( NotificationModel( tag = tag, initialTitle = title, initialDescription = description, initialNotificationType = type, ) ) ) } override fun openTranslatorsPage() { scope.launch { stackNavigation.pushToFront(ScreenConfig.Translators) } } override fun closeTranslatorsPage() { scope.launch { stackNavigation.navigate { it.filterNot { config -> config is ScreenConfig.Translators } } } } override fun openOpenSourceLibrariesPage() { scope.launch { stackNavigation.pushToFront(ScreenConfig.OpenSourceThirdPartyLibraries) } } override fun openAboutPage() { scope.launch { stackNavigation.pushToFront(ScreenConfig.About) } } override fun openBatchDownloadPage() { scope.launch { batchDownloadNavigation.activate(Unit) } } override fun closeBatchDownload() { scope.launch { batchDownloadNavigation.dismiss() } } override fun openPerHostSettings(openedHost: String?) { scope.launch { stackNavigation.pushToFront( ScreenConfig.PerHostSettings( AndroidPerHostSettingsComponent.Config(openedHost) ) ) } } override fun closePerHostSettings() { scope.launch { stackNavigation.navigate { it.filterNot { config -> config is ScreenConfig.PerHostSettings } } } } override fun openPermissionsPage( openHomeAfterFinish: Boolean ) { scope.launch { stackNavigation.pushToFront( ScreenConfig.Permissions(openHomeAfterFinish) ) } } override fun closePermissionsPage() { scope.launch { stackNavigation.navigate { val newList = it.filterNot { config -> config is ScreenConfig.Permissions } newList.ifEmpty { listOf(ScreenConfig.InitialSetup) } } } } private val _showAddQueue = MutableStateFlow(false) val showAddQueue = _showAddQueue.asStateFlow() fun setShowAddQueue(value: Boolean) { _showAddQueue.value = value } fun createQueueWithName(name: String) { scope.launch { queueManager.addQueue(name) } setShowAddQueue(false) } override fun closeNewQueueDialog() { setShowAddQueue(false) } override fun openNewQueueDialog() { setShowAddQueue(true) } fun revealDownload(downloadId: Long) { stackNavigation.pushToFront( ScreenConfig.Home, ) { if (downloadId < 0) { return@pushToFront } stack.value.items .lastOrNull() ?.let { (it.instance as? Screen.Home)?.component?.revealItem(downloadId) } } } override fun openBrowser(url: String?) { val intent = BrowserActivity.createIntent( context = context, url = url, ) sendEffect(MainAppEffects.StartActivity(intent)) } sealed interface MainAppEffects { data class StartActivity(val intent: Intent) : MainAppEffects data class SimpleNotificationNotification(val notificationModel: NotificationModel) : MainAppEffects } } ================================================ FILE: android/app/src/main/kotlin/com/abdownloadmanager/android/ui/MainContent.kt ================================================ package com.abdownloadmanager.android.ui import androidx.activity.compose.LocalActivity import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.imePadding import androidx.compose.foundation.layout.navigationBarsPadding import androidx.compose.foundation.layout.padding import androidx.compose.runtime.Composable import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.collectAsState import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.unit.dp import com.abdownloadmanager.android.pages.about.AboutPage import com.abdownloadmanager.android.pages.batchdownload.BatchDownloadSheet import com.abdownloadmanager.android.pages.category.CategorySheet import com.abdownloadmanager.android.pages.checksum.FileChecksumPage import com.abdownloadmanager.android.pages.home.HomePage import com.abdownloadmanager.android.pages.settings.SettingsPage import com.abdownloadmanager.android.pages.credits.thirdpartylibraries.ThirdPartyLibrariesPage import com.abdownloadmanager.android.pages.credits.translators.TranslatorsPage import com.abdownloadmanager.android.pages.editdownload.EditDownloadSheet import com.abdownloadmanager.android.pages.newqueue.NewQueueSheet import com.abdownloadmanager.android.pages.onboarding.initialsetup.InitialSetupPage import com.abdownloadmanager.android.pages.onboarding.permissions.PermissionsPage import com.abdownloadmanager.android.pages.perhostsettings.PerHostSettingsPage import com.abdownloadmanager.android.pages.queue.QueueConfigSheet import com.abdownloadmanager.android.pages.updater.UpdaterSheet import com.abdownloadmanager.android.util.compose.rememberIsUiVisible import com.abdownloadmanager.shared.ui.widget.NotificationArea import com.abdownloadmanager.shared.ui.widget.useNotification import com.abdownloadmanager.shared.util.mvi.HandleEffects import com.abdownloadmanager.shared.util.rememberChild import com.abdownloadmanager.shared.util.ui.myColors import com.abdownloadmanager.shared.util.ui.widget.ScreenSurface import com.arkivanov.decompose.extensions.compose.stack.Children import com.arkivanov.decompose.extensions.compose.stack.animation.fade import com.arkivanov.decompose.extensions.compose.stack.animation.plus import com.arkivanov.decompose.extensions.compose.stack.animation.scale import com.arkivanov.decompose.extensions.compose.stack.animation.stackAnimation import kotlinx.coroutines.launch import kotlinx.coroutines.withTimeout @Composable fun MainContent( mainComponent: MainComponent, ) { val activity = LocalActivity.current val notificationManager = useNotification() val scope = rememberCoroutineScope() ScreenSurface( modifier = Modifier.fillMaxSize(), background = myColors.background, contentColor = myColors.onBackground ) { HandleEffects(mainComponent) { effect -> when (effect) { is MainComponent.MainAppEffects.StartActivity -> { activity?.startActivity(effect.intent) } is MainComponent.MainAppEffects.SimpleNotificationNotification -> { scope.launch { withTimeout(5000) { notificationManager.showNotification(effect.notificationModel) } } } } } Children( mainComponent.stack.collectAsState().value, modifier = Modifier.imePadding(), animation = stackAnimation { scale() + fade() }, ) { when (val screen = it.instance) { is Screen.Home -> { HomePage(screen.component) } is Screen.Settings -> { SettingsPage(screen.component) } Screen.About -> { AboutPage( onRequestShowOpenSourceLibraries = { mainComponent.openOpenSourceLibrariesPage() }, onRequestShowTranslators = { mainComponent.openTranslatorsPage() } ) } Screen.OpenSourceThirdPartyLibraries -> { ThirdPartyLibrariesPage() } Screen.Translators -> { TranslatorsPage( onBack = { mainComponent.closeTranslatorsPage() } ) } is Screen.PerHostSettings -> { PerHostSettingsPage(component = screen.component) } is Screen.FileChecksum -> { FileChecksumPage(component = screen.component) } is Screen.InitialSetup -> { InitialSetupPage(component = screen.component) } is Screen.Permissions -> { PermissionsPage(component = screen.component) } } } CategorySheet( mainComponent.categorySlot.rememberChild(), mainComponent::closeCategoryDialog ) QueueConfigSheet( mainComponent.queueConfigSlot.rememberChild(), mainComponent::closeQueues ) NewQueueSheet( onQueueCreate = mainComponent::createQueueWithName, isOpened = mainComponent.showAddQueue.collectAsState().value, onCloseRequest = { mainComponent.setShowAddQueue(false) }, ) BatchDownloadSheet( component = mainComponent.batchDownloadSlot.rememberChild(), onDismiss = mainComponent::closeBatchDownload ) EditDownloadSheet( component = mainComponent.editDownloadSlot.rememberChild(), onDismiss = mainComponent::closeEditDownloadDialog, ) UpdaterSheet( updaterComponent = mainComponent.updaterComponent, ) val isUiVisible = rememberIsUiVisible() LaunchedEffect(isUiVisible) { mainComponent.abdmAppManager.setNotificationsHandledInUi(isUiVisible) } // is this really necessary? DisposableEffect(Unit) { onDispose { mainComponent.abdmAppManager.setNotificationsHandledInUi(false) } } if (isUiVisible) { NotificationArea( Modifier .fillMaxWidth() .align(Alignment.BottomEnd) .padding(bottom = 96.dp) .padding(horizontal = 24.dp) .navigationBarsPadding() ) } } } ================================================ FILE: android/app/src/main/kotlin/com/abdownloadmanager/android/ui/SelectionControls.kt ================================================ package com.abdownloadmanager.android.ui import androidx.compose.foundation.LocalIndication import androidx.compose.foundation.border import androidx.compose.foundation.clickable import androidx.compose.foundation.interaction.MutableInteractionSource import androidx.compose.foundation.interaction.collectIsFocusedAsState import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue import androidx.compose.runtime.remember import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.alpha import androidx.compose.ui.draw.clip import androidx.compose.ui.graphics.RectangleShape import androidx.compose.ui.graphics.Shape import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp import com.abdownloadmanager.resources.Res import com.abdownloadmanager.shared.ui.widget.Text import com.abdownloadmanager.shared.util.ui.icon.MyIcons import com.abdownloadmanager.shared.util.ui.myColors import com.abdownloadmanager.shared.util.ui.widget.MyIcon import ir.amirab.util.compose.IconSource import ir.amirab.util.compose.StringSource import ir.amirab.util.compose.asStringSource import ir.amirab.util.ifThen object SelectionControlsScope @Composable fun RenderControlSelections( onRequestSelectAll: () -> Unit, onRequestSelectInside: () -> Unit, onRequestInvertSelection: () -> Unit, selectionCount: Int, total: Int, otherActions: @Composable SelectionControlsScope.() -> Unit ) { with(SelectionControlsScope) { Row( verticalAlignment = Alignment.CenterVertically, ) { RenderSelectAll( onClick = onRequestSelectAll, Modifier, ) RenderSelectInside( onClick = onRequestSelectInside, Modifier, ) RenderInvertSelection( onClick = onRequestInvertSelection, Modifier, ) Text( "$selectionCount / $total", Modifier.weight(1f), textAlign = TextAlign.Center, fontWeight = FontWeight.Bold, ) otherActions() } } } @Composable context(_: SelectionControlsScope) private fun RenderSelectAll( onClick: () -> Unit, modifier: Modifier, ) { SelectionControlButton( icon = MyIcons.selectAll, contentDescription = Res.string.select_all.asStringSource(), modifier = modifier, enabled = true, toggledOff = false, onClick = { onClick() }, padding = PaddingValues(12.dp), ) } @Composable context(_: SelectionControlsScope) private fun RenderSelectInside( onClick: () -> Unit, modifier: Modifier, ) { SelectionControlButton( icon = MyIcons.selectInside, contentDescription = Res.string.select_inside.asStringSource(), modifier = modifier, enabled = true, toggledOff = false, onClick = { onClick() }, padding = PaddingValues(12.dp), ) } @Composable context(_: SelectionControlsScope) private fun RenderInvertSelection( onClick: () -> Unit, modifier: Modifier, ) { SelectionControlButton( icon = MyIcons.selectInvert, contentDescription = Res.string.select_invert.asStringSource(), modifier = modifier, enabled = true, toggledOff = false, onClick = { onClick() }, ) } @Composable context(_: SelectionControlsScope) fun SelectionControlButton( icon: IconSource, contentDescription: StringSource, modifier: Modifier = Modifier, onClick: () -> Unit, interactionSource: MutableInteractionSource = remember { MutableInteractionSource() }, padding: PaddingValues = PaddingValues(12.dp), toggledOff: Boolean = false, enabled: Boolean = true, shape: Shape = RectangleShape, ) { val size: Dp = 24.dp val isFocused by interactionSource.collectIsFocusedAsState() Box( modifier .ifThen(!enabled || toggledOff) { alpha(0.5f) } .ifThen(isFocused) { border( 1.dp, myColors.focusedBorderColor, shape ) } .clip(shape) .clickable( enabled = enabled, indication = LocalIndication.current, interactionSource = interactionSource, onClick = onClick, ) .padding(padding) ) { MyIcon( icon, contentDescription.rememberString(), Modifier .size(size) ) } } ================================================ FILE: android/app/src/main/kotlin/com/abdownloadmanager/android/ui/SheetUI.kt ================================================ package com.abdownloadmanager.android.ui import androidx.compose.foundation.background import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.RowScope import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.WindowInsets import androidx.compose.foundation.layout.WindowInsetsSides import androidx.compose.foundation.layout.areNavigationBarsVisible import androidx.compose.foundation.layout.asPaddingValues import androidx.compose.foundation.layout.consumeWindowInsets import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.imePadding import androidx.compose.foundation.layout.navigationBars import androidx.compose.foundation.layout.navigationBarsPadding import androidx.compose.foundation.layout.only import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.statusBarsPadding import androidx.compose.foundation.layout.systemBarsPadding import androidx.compose.foundation.layout.width import androidx.compose.runtime.Composable import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextOverflow import com.abdownloadmanager.shared.ui.widget.Text import com.abdownloadmanager.shared.util.ResponsiveDialogScope import com.abdownloadmanager.shared.util.div import com.abdownloadmanager.shared.util.ui.LocalContentColor import com.abdownloadmanager.shared.util.ui.WithContentColor import com.abdownloadmanager.shared.util.ui.myColors import com.abdownloadmanager.shared.util.ui.theme.myShapes import com.abdownloadmanager.shared.util.ui.theme.mySpacings import com.abdownloadmanager.shared.util.ui.theme.myTextSizes import com.abdownloadmanager.shared.util.ui.widget.MyIcon import ir.amirab.util.compose.IconSource @Composable fun ResponsiveDialogScope.SheetUI( header: @Composable () -> Unit, content: @Composable () -> Unit, ) { WithContentColor(myColors.onSurface) { Column( Modifier .padding( WindowInsets.navigationBars .only(WindowInsetsSides.Horizontal) .asPaddingValues() ) .fillMaxWidth() .statusBarsPadding() .clip( myShapes.createSheetWithCustomEdges( topStart = isTopStartFree, bottomStart = isBottomStartFree, topEnd = isTopEndFree, bottomEnd = isBottomEndFree, ) ) .background(myColors.surface) .let { modifier -> val verticalNavigationBarPaddingValues = WindowInsets.navigationBars .only(WindowInsetsSides.Vertical) .asPaddingValues() modifier .padding(verticalNavigationBarPaddingValues) .consumeWindowInsets(verticalNavigationBarPaddingValues) } .imePadding() .padding(mySpacings.smallSpace) ) { header() Spacer(Modifier.height(mySpacings.mediumSpace)) Box( Modifier .fillMaxWidth() ) { content() } } } } @Composable fun SheetHeader( headerTitle: @Composable () -> Unit = {}, headerActions: @Composable RowScope.() -> Unit = {}, ) { Row( verticalAlignment = Alignment.CenterVertically, modifier = Modifier .fillMaxWidth() .padding(vertical = mySpacings.mediumSpace) .padding(horizontal = mySpacings.mediumSpace), ) { Box(Modifier.weight(1f)) { headerTitle() } Spacer(Modifier.width(mySpacings.smallSpace)) Row( verticalAlignment = Alignment.CenterVertically, ) { headerActions() } } } @Composable fun SheetTitle( title: String, icon: IconSource? = null ) { Row( Modifier.padding(start = mySpacings.mediumSpace, top = mySpacings.mediumSpace) ) { icon?.let { MyIcon(icon, null) Spacer(Modifier.width(mySpacings.mediumSpace)) } Text( text = title, fontWeight = FontWeight.Bold, fontSize = myTextSizes.xl, modifier = Modifier, maxLines = 1, overflow = TextOverflow.Ellipsis, ) } } @Composable fun SheetTitleWithDescription( title: String, description: String, ) { Column { Text( text = title, fontWeight = FontWeight.Bold, fontSize = myTextSizes.xl, modifier = Modifier.padding(start = mySpacings.mediumSpace, top = mySpacings.mediumSpace) ) Text( text = description, modifier = Modifier .padding(start = mySpacings.mediumSpace, top = mySpacings.mediumSpace), color = LocalContentColor.current / 0.75f ) } } ================================================ FILE: android/app/src/main/kotlin/com/abdownloadmanager/android/ui/configurable/AndroidConfigurableUtils.kt ================================================ package com.abdownloadmanager.android.ui.configurable import androidx.compose.animation.AnimatedContent import androidx.compose.animation.fadeIn import androidx.compose.animation.fadeOut import androidx.compose.animation.scaleIn import androidx.compose.animation.scaleOut import androidx.compose.animation.togetherWith import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.ColumnScope import androidx.compose.foundation.layout.IntrinsicSize import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxHeight import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.runtime.Composable import androidx.compose.runtime.collectAsState import androidx.compose.runtime.remember import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.alpha import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.dp import com.abdownloadmanager.shared.ui.configurable.Configurable import com.abdownloadmanager.shared.ui.configurable.Help import com.abdownloadmanager.shared.ui.configurable.isConfigEnabled import com.abdownloadmanager.shared.ui.widget.Text import com.abdownloadmanager.shared.util.ui.WithContentAlpha import com.abdownloadmanager.shared.util.ui.icon.MyIcons import com.abdownloadmanager.shared.util.ui.theme.myTextSizes import com.abdownloadmanager.shared.util.ui.widget.MyIcon import ir.amirab.util.compose.modifiers.autoMirror import ir.amirab.util.ifThen @Composable fun ConfigTemplate( modifier: Modifier, title: @Composable ColumnScope.() -> Unit, value: @Composable ColumnScope.() -> Unit, nestedContent: @Composable ColumnScope.() -> Unit = {}, ) { Column( modifier ) { Row( Modifier .height(IntrinsicSize.Max), horizontalArrangement = Arrangement.Center, ) { Column( Modifier.weight(1f, true), verticalArrangement = Arrangement.Center, horizontalAlignment = Alignment.Start, ) { title() } Column( Modifier.fillMaxHeight(), verticalArrangement = Arrangement.Center, horizontalAlignment = Alignment.End, ) { value() } } Column( Modifier.fillMaxWidth() ) { nestedContent() } } } @Composable fun TitleAndDescription( cfg: Configurable, describe: Boolean = true, modifier: Modifier = Modifier, contentPadding: PaddingValues = PaddingValues(8.dp), ) { val value = cfg.backedBy.collectAsState().value val describedStringSource = remember(value) { cfg.describe(value) } val describeContent = describedStringSource.rememberString() TitleAndDescription( cfg = cfg, describe = describe, modifier = modifier, describeContent = describeContent, contentPadding = contentPadding, ) } @Composable fun TitleAndDescription( cfg: Configurable, describe: Boolean = true, describeContent: String, describeWrapper: @Composable (@Composable () -> Unit) -> Unit = { it() }, modifier: Modifier = Modifier, contentPadding: PaddingValues = PaddingValues(8.dp), ) { val enabled = isConfigEnabled() Column( modifier .padding(contentPadding) .ifThen(!enabled) { alpha(0.5f) } ) { Row(verticalAlignment = Alignment.CenterVertically) { Text( cfg.title.rememberString(), fontSize = myTextSizes.base, fontWeight = FontWeight.Bold, modifier = Modifier.weight(1f, false) ) if (cfg.description.rememberString().isNotBlank()) { Spacer(Modifier.size(4.dp)) Help( Modifier.align(Alignment.Top), cfg ) } } if (describe) { if (describeContent.isNotBlank()) { Spacer(Modifier.size(4.dp)) describeWrapper { WithContentAlpha(0.75f) { AnimatedContent( targetState = describeContent, transitionSpec = { scaleIn() + fadeIn() togetherWith scaleOut() + fadeOut() } ) { content -> Text( content, fontSize = myTextSizes.base, ) } } } } } } } @Composable fun NextIcon() { MyIcon( MyIcons.next, null, Modifier .size(16.dp) .autoMirror() ) } ================================================ FILE: android/app/src/main/kotlin/com/abdownloadmanager/android/ui/configurable/ConfigurableSheet.kt ================================================ package com.abdownloadmanager.android.ui.configurable import androidx.compose.foundation.layout.RowScope import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import com.abdownloadmanager.android.ui.SheetHeader import com.abdownloadmanager.android.ui.SheetTitle import com.abdownloadmanager.android.ui.SheetUI import com.abdownloadmanager.shared.ui.widget.Text import com.abdownloadmanager.shared.util.OnFullyDismissed import com.abdownloadmanager.shared.util.ResponsiveDialog import com.abdownloadmanager.shared.util.rememberResponsiveDialogState import ir.amirab.util.compose.StringSource @Composable fun ConfigurableSheet( title: StringSource, isOpened: Boolean, onDismiss: () -> Unit, headerActions: @Composable RowScope.() -> Unit = {}, content: @Composable () -> Unit, ) { val dialogState = rememberResponsiveDialogState(isOpened) LaunchedEffect(isOpened) { when (isOpened) { true -> dialogState.show() false -> dialogState.hide() } } dialogState.OnFullyDismissed { onDismiss() } ResponsiveDialog( state = dialogState, onDismiss = dialogState::hide, ) { SheetUI( header = { SheetHeader( headerTitle = { SheetTitle( title.rememberString() ) }, headerActions = headerActions, ) } ) { content() } } } ================================================ FILE: android/app/src/main/kotlin/com/abdownloadmanager/android/ui/configurable/SheetInput.kt ================================================ package com.abdownloadmanager.android.ui.configurable import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.width import androidx.compose.foundation.text.KeyboardActions import androidx.compose.runtime.Composable import androidx.compose.runtime.Immutable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier import androidx.compose.ui.focus.FocusRequester import androidx.compose.ui.focus.focusRequester import com.abdownloadmanager.resources.Res import com.abdownloadmanager.shared.ui.configurable.Configurable import com.abdownloadmanager.shared.ui.widget.ActionButton import com.abdownloadmanager.shared.ui.widget.TransparentIconActionButton import com.abdownloadmanager.shared.util.ui.icon.MyIcons import com.abdownloadmanager.shared.util.ui.theme.mySpacings import ir.amirab.util.compose.StringSource import ir.amirab.util.compose.asStringSource import ir.amirab.util.compose.resources.myStringResource @Immutable data class InputParams( val editingValue: T, val setEditingValue: (T) -> Unit, val modifier: Modifier, val keyboardActions: KeyboardActions, ) @Composable fun SheetInput( configurable: Configurable, isOpened: Boolean, onDismiss: () -> Unit, onConfirm: (T) -> Unit, inputContent: @Composable (InputParams) -> Unit, ) { SheetInput( title = configurable.title, validate = configurable.validate, isOpened = isOpened, onDismiss = onDismiss, onConfirm = onConfirm, inputContent = inputContent, initialValue = { configurable.stateFlow.value }, ) } @Composable fun SheetInput( title: StringSource, validate: (T) -> Boolean, isOpened: Boolean, initialValue: () -> T, onDismiss: () -> Unit, onConfirm: (T) -> Unit, inputContent: @Composable (InputParams) -> Unit, ) { ConfigurableSheet( title = title, onDismiss = onDismiss, isOpened = isOpened, headerActions = { TransparentIconActionButton( MyIcons.close, contentDescription = Res.string.close.asStringSource(), onClick = onDismiss ) } ) { Column( Modifier.padding(horizontal = mySpacings.mediumSpace) ) { var editingValue by remember(initialValue) { mutableStateOf(initialValue()) } val isInputValid = remember(validate, editingValue) { validate(editingValue) } val fr = remember { FocusRequester() } LaunchedEffect(Unit) { fr.requestFocus() } inputContent( InputParams( editingValue = editingValue, setEditingValue = { editingValue = it }, modifier = Modifier .focusRequester(fr), keyboardActions = KeyboardActions( onDone = { if (isInputValid) { onConfirm(editingValue) } }, ) ) ) Spacer(Modifier.height(mySpacings.mediumSpace)) Row { ActionButton( text = myStringResource(Res.string.cancel), onClick = onDismiss, modifier = Modifier.weight(1f), ) Spacer(Modifier.width(mySpacings.mediumSpace)) ActionButton( text = myStringResource(Res.string.ok), onClick = { onConfirm(editingValue) }, modifier = Modifier.weight(1f), enabled = isInputValid, ) } Spacer(Modifier.height(mySpacings.mediumSpace)) } } } ================================================ FILE: android/app/src/main/kotlin/com/abdownloadmanager/android/ui/configurable/SheetSpinner.kt ================================================ package com.abdownloadmanager.android.ui.configurable import androidx.compose.foundation.background import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.heightIn import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.width import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.verticalScroll import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue import androidx.compose.runtime.key import androidx.compose.runtime.mutableStateMapOf import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip import androidx.compose.ui.focus.FocusRequester import androidx.compose.ui.focus.focusRequester import androidx.compose.ui.graphics.Brush import androidx.compose.ui.layout.onGloballyPositioned import androidx.compose.ui.layout.positionInParent import androidx.compose.ui.unit.dp import com.abdownloadmanager.resources.Res import com.abdownloadmanager.shared.ui.configurable.defaultValueToString import com.abdownloadmanager.shared.ui.widget.TransparentIconActionButton import com.abdownloadmanager.shared.util.div import com.abdownloadmanager.shared.util.ui.VerticalScrollableContent import com.abdownloadmanager.shared.util.ui.WithContentAlpha import com.abdownloadmanager.shared.util.ui.WithContentColor import com.abdownloadmanager.shared.util.ui.icon.MyIcons import com.abdownloadmanager.shared.util.ui.myColors import com.abdownloadmanager.shared.util.ui.theme.myShapes import com.abdownloadmanager.shared.util.ui.theme.mySpacings import com.abdownloadmanager.shared.util.ui.widget.MyIcon import ir.amirab.util.compose.StringSource import ir.amirab.util.compose.asStringSource import ir.amirab.util.ifThen import kotlin.collections.set @Composable fun RenderSpinnerInSheet( title: StringSource, isOpened: Boolean, onDismiss: () -> Unit, possibleValues: List, value: T, onSelect: (T) -> Unit, valueToString: (T) -> List = ::defaultValueToString, // minWidth:Dp, render: @Composable (T) -> Unit, ) { val verticalPadding = 4.dp val horizontalPadding = 16.dp val shape = myShapes.defaultRounded val borderWidth = 1.dp ConfigurableSheet( title = title, isOpened = isOpened, onDismiss = onDismiss, headerActions = { TransparentIconActionButton( MyIcons.close, contentDescription = Res.string.close.asStringSource(), onClick = onDismiss ) } ) { val focusRequester = remember { FocusRequester() } LaunchedEffect(Unit) { focusRequester.requestFocus() } val possibleValuePositions = remember(possibleValues) { mutableStateMapOf() } var itemToBeIndicated: Int by remember { mutableStateOf(-1) } val scrollState = rememberScrollState() VerticalScrollableContent(scrollState) { Column( Modifier .focusRequester(focusRequester) .clip(shape) .verticalScroll(scrollState) ) { WithContentColor(myColors.onSurface) { for ((index, p) in possibleValues.withIndex()) { key(p) { val isIndicating = itemToBeIndicated == index Row( modifier = Modifier .onGloballyPositioned { possibleValuePositions[index] = it.positionInParent().y } .ifThen(isIndicating) { background( myColors.onBackground / 0.05f ) } .clickable(onClick = { onSelect(p) }) .heightIn(mySpacings.thumbSize) .padding(horizontal = horizontalPadding), verticalAlignment = Alignment.CenterVertically, ) { val selected = p == value WithContentAlpha(if (selected) 1f else 0.75f) { Box( Modifier .weight(1f) .padding(vertical = verticalPadding) ) { render(p) } } Spacer( Modifier.width(borderWidth) ) if (selected) { MyIcon( MyIcons.check, null, Modifier .padding(4.dp) .size(16.dp) ) } } Spacer( Modifier .fillMaxWidth() .height(1.dp) .background( Brush.horizontalGradient( listOf( myColors.onSurface / 0.05f, myColors.onSurface / 0.1f, myColors.onSurface / 0.05f, ) ) ) ) } } } } } } } ================================================ FILE: android/app/src/main/kotlin/com/abdownloadmanager/android/ui/configurable/android/AndroidConfigurableRenderers.kt ================================================ package com.abdownloadmanager.android.ui.configurable.android import com.abdownloadmanager.android.ui.configurable.android.item.PermissionConfigurable import com.abdownloadmanager.shared.ui.configurable.Configurable import com.abdownloadmanager.shared.ui.configurable.ConfigurableRenderer import com.abdownloadmanager.shared.ui.configurable.ContainsConfigurableRenderers data class AndroidConfigurableRenderers( val permissionConfigurableRenderers: ConfigurableRenderer, ) : ContainsConfigurableRenderers { override fun getAllRenderers(): Map> { return mapOf( PermissionConfigurable.Key to permissionConfigurableRenderers, ) } } ================================================ FILE: android/app/src/main/kotlin/com/abdownloadmanager/android/ui/configurable/android/item/PermissionConfigurable.kt ================================================ package com.abdownloadmanager.android.ui.configurable.android.item import com.abdownloadmanager.android.pages.onboarding.permissions.AppPermission import com.abdownloadmanager.shared.ui.configurable.Configurable import ir.amirab.util.compose.StringSource import ir.amirab.util.compose.asStringSource import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow class PermissionConfigurable( title: StringSource, description: StringSource, backedBy: MutableStateFlow, describe: () -> StringSource = { "".asStringSource() }, enabled: StateFlow = DefaultEnabledValue, visible: StateFlow = DefaultVisibleValue, ) : Configurable( title = title, description = description, backedBy = backedBy, describe = { describe() }, enabled = enabled, visible = visible, ) { object Key : Configurable.Key override fun getKey() = Key } ================================================ FILE: android/app/src/main/kotlin/com/abdownloadmanager/android/ui/configurable/android/renderer/PermissionConfigurableRenderer.kt ================================================ package com.abdownloadmanager.android.ui.configurable.android.renderer import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.padding import androidx.compose.runtime.Composable import androidx.compose.runtime.CompositionLocalProvider import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue import com.abdownloadmanager.android.pages.onboarding.permissions.AppPermissionState import com.abdownloadmanager.android.pages.onboarding.permissions.rememberAppPermissionState import com.abdownloadmanager.android.ui.configurable.ConfigTemplate import com.abdownloadmanager.android.ui.configurable.NextIcon import com.abdownloadmanager.android.ui.configurable.TitleAndDescription import com.abdownloadmanager.android.ui.configurable.android.item.PermissionConfigurable import com.abdownloadmanager.resources.Res import com.abdownloadmanager.shared.ui.configurable.ConfigurableRenderer import com.abdownloadmanager.shared.ui.configurable.ConfigurableUiProps import com.abdownloadmanager.shared.util.ui.LocalContentColor import com.abdownloadmanager.shared.util.ui.myColors import ir.amirab.util.compose.asStringSource object PermissionConfigurableRenderer : ConfigurableRenderer { @Composable override fun RenderConfigurable( configurable: PermissionConfigurable, configurableUiProps: ConfigurableUiProps ) { val permission by configurable.stateFlow.collectAsState() val permissionState = rememberAppPermissionState(permission) { result -> } RenderPermissionConfigurable( cfg = configurable, configurableUiProps = configurableUiProps, permissionState = permissionState ) } @Composable fun RenderPermissionConfigurable( cfg: PermissionConfigurable, configurableUiProps: ConfigurableUiProps, permissionState: AppPermissionState, ) { ConfigTemplate( modifier = configurableUiProps.modifier .clickable { permissionState.launchRequest() } .padding(configurableUiProps.itemPaddingValues), title = { TitleAndDescription( cfg = cfg, describe = true, describeContent = if (permissionState.isGranted) { Res.string.permission_granted } else { Res.string.permission_not_granted }.asStringSource().rememberString(), describeWrapper = { content -> val contentColor = if (permissionState.isGranted) myColors.success else myColors.warning CompositionLocalProvider( LocalContentColor provides contentColor ) { content() } } ) }, value = { NextIcon() } ) } } ================================================ FILE: android/app/src/main/kotlin/com/abdownloadmanager/android/ui/configurable/comon/CommonConfigurableRenderersForAndroid.kt ================================================ package com.abdownloadmanager.android.ui.configurable.comon import com.abdownloadmanager.android.ui.configurable.comon.renderer.BooleanConfigurableRenderer import com.abdownloadmanager.android.ui.configurable.comon.renderer.DayOfWeekConfigurableRenderer import com.abdownloadmanager.android.ui.configurable.comon.renderer.EnumConfigurableRenderer import com.abdownloadmanager.android.ui.configurable.comon.renderer.FileChecksumConfigurableRenderer import com.abdownloadmanager.android.ui.configurable.comon.renderer.FloatConfigurableRenderer import com.abdownloadmanager.android.ui.configurable.comon.renderer.FolderConfigurableRenderer import com.abdownloadmanager.android.ui.configurable.comon.renderer.IntConfigurableRenderer import com.abdownloadmanager.android.ui.configurable.comon.renderer.LongConfigurableRenderer import com.abdownloadmanager.android.ui.configurable.comon.renderer.NavigatableConfigurableRenderer import com.abdownloadmanager.android.ui.configurable.comon.renderer.ProxyConfigurableRenderer import com.abdownloadmanager.android.ui.configurable.comon.renderer.SpeedLimitConfigurableRenderer import com.abdownloadmanager.android.ui.configurable.comon.renderer.StringConfigurableRenderer import com.abdownloadmanager.android.ui.configurable.comon.renderer.ThemeConfigurableRenderer import com.abdownloadmanager.android.ui.configurable.comon.renderer.TimeConfigurableRenderer import com.abdownloadmanager.shared.ui.configurable.CommonConfigurableRenderers val CommonConfigurableRenderersForAndroid = CommonConfigurableRenderers( booleanConfigurableRenderer = BooleanConfigurableRenderer, dayOfWeekConfigurableRenderer = DayOfWeekConfigurableRenderer, fileChecksumConfigurableRenderer = FileChecksumConfigurableRenderer, floatConfigurableRenderer = FloatConfigurableRenderer, folderConfigurableRenderer = FolderConfigurableRenderer, intConfigurableRenderer = IntConfigurableRenderer, longConfigurableRenderer = LongConfigurableRenderer, perHostSettingsConfigurableRenderer = NavigatableConfigurableRenderer, enumConfigurableRenderer = EnumConfigurableRenderer, speedConfigurableRenderer = SpeedLimitConfigurableRenderer, stringConfigurableRenderer = StringConfigurableRenderer, themeConfigurableRenderer = ThemeConfigurableRenderer, timeConfigurableRenderer = TimeConfigurableRenderer, proxyConfigurableRenderer = ProxyConfigurableRenderer, ) ================================================ FILE: android/app/src/main/kotlin/com/abdownloadmanager/android/ui/configurable/comon/ConfigurableRenderersForAndroid.kt ================================================ package com.abdownloadmanager.android.ui.configurable.comon import com.abdownloadmanager.android.ui.configurable.android.AndroidConfigurableRenderers import com.abdownloadmanager.android.ui.configurable.android.renderer.PermissionConfigurableRenderer val ConfigurableRenderersForAndroid = AndroidConfigurableRenderers( permissionConfigurableRenderers = PermissionConfigurableRenderer ) ================================================ FILE: android/app/src/main/kotlin/com/abdownloadmanager/android/ui/configurable/comon/renderer/BooleanConfigurableRenderer.kt ================================================ package com.abdownloadmanager.android.ui.configurable.comon.renderer import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.padding import androidx.compose.runtime.Composable import androidx.compose.runtime.collectAsState import androidx.compose.ui.Modifier import com.abdownloadmanager.android.ui.configurable.ConfigTemplate import com.abdownloadmanager.android.ui.configurable.TitleAndDescription import com.abdownloadmanager.shared.ui.configurable.ConfigurableRenderer import com.abdownloadmanager.shared.ui.configurable.ConfigurableUiProps import com.abdownloadmanager.shared.ui.configurable.isConfigEnabled import com.abdownloadmanager.shared.ui.configurable.item.BooleanConfigurable import com.abdownloadmanager.shared.ui.widget.CheckBox import com.abdownloadmanager.shared.ui.widget.Switch object BooleanConfigurableRenderer : ConfigurableRenderer { @Composable override fun RenderConfigurable( configurable: BooleanConfigurable, configurableUiProps: ConfigurableUiProps ) { RenderBooleanConfig(configurable, configurableUiProps) } @Composable private fun RenderBooleanConfig( cfg: BooleanConfigurable, configurableUiProps: ConfigurableUiProps, ) { val checked = cfg.stateFlow.collectAsState().value val setValue = cfg::set val enabled = isConfigEnabled() ConfigTemplate( modifier = configurableUiProps.modifier .clickable { setValue(!checked) } .padding(configurableUiProps.itemPaddingValues), title = { TitleAndDescription(cfg, true) }, value = { when (cfg.renderMode) { BooleanConfigurable.RenderMode.Checkbox -> { CheckBox( value = checked, enabled = enabled, onValueChange = { setValue(it) } ) } BooleanConfigurable.RenderMode.Switch -> { Switch( checked = checked, enabled = enabled, onCheckedChange = { setValue(it) } ) } } }) } } ================================================ FILE: android/app/src/main/kotlin/com/abdownloadmanager/android/ui/configurable/comon/renderer/DayOfWeekConfigurableRenderer.kt ================================================ package com.abdownloadmanager.android.ui.configurable.comon.renderer import androidx.compose.foundation.background import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.FlowRow import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.heightIn import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.width import androidx.compose.foundation.shape.CircleShape import androidx.compose.runtime.Composable import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.alpha import androidx.compose.ui.draw.clip import androidx.compose.ui.unit.dp import com.abdownloadmanager.android.ui.configurable.ConfigTemplate import com.abdownloadmanager.android.ui.configurable.TitleAndDescription import com.abdownloadmanager.resources.Res import com.abdownloadmanager.shared.ui.configurable.ConfigurableRenderer import com.abdownloadmanager.shared.ui.configurable.ConfigurableUiProps import com.abdownloadmanager.shared.ui.configurable.isConfigEnabled import com.abdownloadmanager.shared.ui.configurable.item.DayOfWeekConfigurable import com.abdownloadmanager.shared.ui.widget.Text import com.abdownloadmanager.shared.util.div import com.abdownloadmanager.shared.util.ui.icon.MyIcons import com.abdownloadmanager.shared.util.ui.myColors import com.abdownloadmanager.shared.util.ui.theme.myShapes import com.abdownloadmanager.shared.util.ui.theme.mySpacings import com.abdownloadmanager.shared.util.ui.theme.myTextSizes import com.abdownloadmanager.shared.util.ui.widget.MyIcon import ir.amirab.util.compose.asStringSource import ir.amirab.util.ifThen import kotlinx.datetime.DayOfWeek object DayOfWeekConfigurableRenderer : ConfigurableRenderer { @Composable override fun RenderConfigurable(configurable: DayOfWeekConfigurable, configurableUiProps: ConfigurableUiProps) { RenderDayOfWeekConfigurable(configurable, configurableUiProps) } @Composable private fun RenderDayOfWeekConfigurable(cfg: DayOfWeekConfigurable, configurableUiProps: ConfigurableUiProps) { val value by cfg.stateFlow.collectAsState() val setValue = cfg::set val allDays = DayOfWeek.entries.toSet() val enabled = isConfigEnabled() fun isSelected(dayOfWeek: DayOfWeek): Boolean { return dayOfWeek in value } fun selectDay(dayOfWeek: DayOfWeek, select: Boolean) { if (!enabled) return if (select) { setValue( value.plus(dayOfWeek).sorted().toSet() ) } else { setValue( value.minus(dayOfWeek).sorted().toSet() ) } } ConfigTemplate( modifier = configurableUiProps.modifier .padding(configurableUiProps.itemPaddingValues), title = { TitleAndDescription(cfg, true) }, value = {}, nestedContent = { Row( verticalAlignment = Alignment.CenterVertically ) { Row( Modifier.ifThen(!enabled) { alpha(0.5f) } ) { FlowRow(Modifier.fillMaxWidth()) { allDays.forEach { dayOfWeek -> RenderDayOfWeek( modifier = Modifier, enabled = enabled, dayOfWeek = dayOfWeek, selected = isSelected(dayOfWeek), onSelect = { s, isSelected -> selectDay(dayOfWeek, isSelected) } ) } } } } } ) } @Composable fun RenderDayOfWeek( modifier: Modifier, dayOfWeek: DayOfWeek, selected: Boolean, onSelect: (DayOfWeek, Boolean) -> Unit, enabled: Boolean = true, ) { Row( verticalAlignment = Alignment.CenterVertically, modifier = modifier .heightIn(mySpacings.thumbSize) .padding(2.dp) .clip(myShapes.defaultRounded) .ifThen(selected) { background(myColors.onBackground / 10) } .clickable(enabled = enabled) { onSelect(dayOfWeek, !selected) } .padding(vertical = 4.dp) .padding(horizontal = 8.dp) ) { MyIcon( MyIcons.check, null, Modifier .size(16.dp) .alpha(if (selected) 1f else 0f), ) Spacer(Modifier.width(4.dp)) Text( text = dayOfWeek.asStringSource().rememberString(), modifier = Modifier.alpha( if (selected) 1f else 0.5f ), softWrap = false, fontSize = myTextSizes.base, ) } } private fun DayOfWeek.asStringSource() = when (this) { DayOfWeek.MONDAY -> Res.string.monday DayOfWeek.TUESDAY -> Res.string.tuesday DayOfWeek.WEDNESDAY -> Res.string.wednesday DayOfWeek.THURSDAY -> Res.string.thursday DayOfWeek.FRIDAY -> Res.string.friday DayOfWeek.SATURDAY -> Res.string.saturday DayOfWeek.SUNDAY -> Res.string.sunday }.asStringSource() } ================================================ FILE: android/app/src/main/kotlin/com/abdownloadmanager/android/ui/configurable/comon/renderer/EnumConfigurableRenderer.kt ================================================ package com.abdownloadmanager.android.ui.configurable.comon.renderer import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.runtime.Composable import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier import androidx.compose.ui.unit.dp import com.abdownloadmanager.android.ui.configurable.ConfigTemplate import com.abdownloadmanager.android.ui.configurable.NextIcon import com.abdownloadmanager.android.ui.configurable.RenderSpinnerInSheet import com.abdownloadmanager.android.ui.configurable.TitleAndDescription import com.abdownloadmanager.shared.ui.configurable.ConfigurableRenderer import com.abdownloadmanager.shared.ui.configurable.ConfigurableUiProps import com.abdownloadmanager.shared.ui.configurable.isConfigEnabled import com.abdownloadmanager.shared.ui.configurable.item.EnumConfigurable import com.abdownloadmanager.shared.ui.widget.Text import com.abdownloadmanager.shared.util.ui.icon.MyIcons import com.abdownloadmanager.shared.util.ui.widget.MyIcon object EnumConfigurableRenderer : ConfigurableRenderer> { @Composable override fun RenderConfigurable(configurable: EnumConfigurable, configurableUiProps: ConfigurableUiProps) { RenderEnumConfig(configurable, configurableUiProps) } @Composable private fun RenderEnumConfig(cfg: EnumConfigurable, configurableUiProps: ConfigurableUiProps) { val value by cfg.stateFlow.collectAsState() val setValue = cfg::set val index = remember(cfg.possibleValues, value) { cfg.possibleValues.indexOf(value) } val enabled = isConfigEnabled() var isOpened by remember { mutableStateOf(false) } val onDismiss = { isOpened = false } ConfigTemplate( modifier = configurableUiProps.modifier .clickable { isOpened = true } .padding(configurableUiProps.itemPaddingValues), title = { Column { TitleAndDescription(cfg, true) } }, value = { NextIcon() } ) RenderSpinnerInSheet( title = cfg.title, onDismiss = onDismiss, isOpened = isOpened, possibleValues = cfg.possibleValues, value = value, onSelect = { setValue(it) onDismiss() }, valueToString = cfg.valueToString, render = { Text(cfg.describe(it).rememberString()) }) } } ================================================ FILE: android/app/src/main/kotlin/com/abdownloadmanager/android/ui/configurable/comon/renderer/FileChecksumConfigurableRenderer.kt ================================================ package com.abdownloadmanager.android.ui.configurable.comon.renderer import androidx.compose.animation.AnimatedVisibility import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.padding import androidx.compose.runtime.Composable import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.RectangleShape import androidx.compose.ui.unit.dp import com.abdownloadmanager.android.ui.configurable.ConfigTemplate import com.abdownloadmanager.android.ui.configurable.TitleAndDescription import com.abdownloadmanager.resources.Res import com.abdownloadmanager.shared.ui.configurable.ConfigurableRenderer import com.abdownloadmanager.shared.ui.configurable.ConfigurableUiProps import com.abdownloadmanager.shared.ui.configurable.RenderSpinner import com.abdownloadmanager.shared.ui.configurable.isConfigEnabled import com.abdownloadmanager.shared.ui.configurable.item.FileChecksumConfigurable import com.abdownloadmanager.shared.ui.widget.CheckBox import com.abdownloadmanager.shared.ui.widget.MyTextField import com.abdownloadmanager.shared.ui.widget.Text import com.abdownloadmanager.shared.util.FileChecksum import com.abdownloadmanager.shared.util.FileChecksumAlgorithm import ir.amirab.util.compose.resources.myStringResource object FileChecksumConfigurableRenderer : ConfigurableRenderer { @Composable override fun RenderConfigurable(configurable: FileChecksumConfigurable, configurableUiProps: ConfigurableUiProps) { RenderFileChecksumConfig(configurable, configurableUiProps) } @Composable private fun RenderFileChecksumConfig(cfg: FileChecksumConfigurable, configurableUiProps: ConfigurableUiProps) { val value by cfg.stateFlow.collectAsState() val setValue = cfg::set val enabled = isConfigEnabled() val hasFileChecksum = value != null ConfigTemplate( configurableUiProps.modifier.padding(configurableUiProps.itemPaddingValues), title = { Row(verticalAlignment = Alignment.CenterVertically) { TitleAndDescription(cfg, true) } }, nestedContent = { Column(Modifier.align(Alignment.End)) { AnimatedVisibility( hasFileChecksum, ) { value?.let { value -> Row( Modifier.padding(vertical = 8.dp), verticalAlignment = Alignment.CenterVertically, ) { RenderSpinner( possibleValues = FileChecksumAlgorithm .all() .map { it.algorithm }, value = value.algorithm, modifier = Modifier.Companion, enabled = enabled, onSelect = { setValue(value.copy(algorithm = it)) } ) { Text(it) } Text(":", Modifier.padding(horizontal = 4.dp)) MyTextField( text = value.value, onTextChange = { setValue(value.copy(value = it)) }, shape = RectangleShape, textPadding = PaddingValues(4.dp), enabled = enabled, modifier = Modifier.weight(1f), placeholder = myStringResource(Res.string.file_checksum), ) } } } } }, value = { CheckBox( value = hasFileChecksum, enabled = enabled, onValueChange = { if (it) { setValue( FileChecksum( FileChecksumAlgorithm.default().algorithm, "", ) ) } else { setValue(null) } }) } ) } } ================================================ FILE: android/app/src/main/kotlin/com/abdownloadmanager/android/ui/configurable/comon/renderer/FloatConfigurableRenderer.kt ================================================ package com.abdownloadmanager.android.ui.configurable.comon.renderer import androidx.compose.foundation.clickable import androidx.compose.foundation.interaction.MutableInteractionSource import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.text.KeyboardOptions import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier import androidx.compose.ui.text.input.ImeAction import androidx.compose.ui.text.input.KeyboardType import androidx.compose.ui.unit.dp import com.abdownloadmanager.android.ui.configurable.ConfigTemplate import com.abdownloadmanager.android.ui.configurable.NextIcon import com.abdownloadmanager.android.ui.configurable.SheetInput import com.abdownloadmanager.android.ui.configurable.TitleAndDescription import com.abdownloadmanager.shared.ui.configurable.ConfigurableRenderer import com.abdownloadmanager.shared.ui.configurable.ConfigurableUiProps import com.abdownloadmanager.shared.ui.configurable.item.FloatConfigurable import com.abdownloadmanager.shared.ui.widget.FloatTextField import com.abdownloadmanager.shared.util.ui.icon.MyIcons import com.abdownloadmanager.shared.util.ui.widget.MyIcon object FloatConfigurableRenderer : ConfigurableRenderer { @Composable override fun RenderConfigurable(configurable: FloatConfigurable, configurableUiProps: ConfigurableUiProps) { RenderFloatConfig(configurable, configurableUiProps) } @Composable private fun RenderFloatConfig(cfg: FloatConfigurable, configurableUiProps: ConfigurableUiProps) { // val value by cfg.stateFlow.collectAsState() // val setValue = cfg::set // val enabled = isConfigEnabled() var isOpened by remember { mutableStateOf(false) } val onDismiss = { isOpened = false } ConfigTemplate( modifier = configurableUiProps.modifier .clickable { isOpened = true } .padding(configurableUiProps.itemPaddingValues), title = { TitleAndDescription(cfg, true) }, value = { when (cfg.renderMode) { FloatConfigurable.RenderMode.TextField -> { NextIcon() RenderTextFieldFloatInput(cfg = cfg, isOpened = isOpened, onDismiss = onDismiss) } } } ) } @Composable fun RenderTextFieldFloatInput( cfg: FloatConfigurable, isOpened: Boolean, onDismiss: () -> Unit, ) { val interactionSource: MutableInteractionSource = remember { MutableInteractionSource() } SheetInput( configurable = cfg, isOpened = isOpened, onDismiss = onDismiss, inputContent = { params -> FloatTextField( value = params.editingValue, onValueChange = { v -> params.setEditingValue(v) }, interactionSource = interactionSource, range = cfg.range, modifier = params.modifier.fillMaxWidth(), keyboardOptions = KeyboardOptions( keyboardType = KeyboardType.Decimal, imeAction = ImeAction.Done, ), textPadding = PaddingValues(8.dp), keyboardActions = params.keyboardActions, placeholder = "", ) }, onConfirm = { cfg.set(it) onDismiss() }, ) } } ================================================ FILE: android/app/src/main/kotlin/com/abdownloadmanager/android/ui/configurable/comon/renderer/FolderConfigurableRenderer.kt ================================================ package com.abdownloadmanager.android.ui.configurable.comon.renderer import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.runtime.Composable import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue import androidx.compose.runtime.remember import androidx.compose.ui.Modifier import androidx.compose.ui.unit.dp import com.abdownloadmanager.android.pages.directorypicker.rememberAndroidDirectoryPickerLauncher import com.abdownloadmanager.android.ui.configurable.ConfigTemplate import com.abdownloadmanager.android.ui.configurable.NextIcon import com.abdownloadmanager.android.ui.configurable.TitleAndDescription import com.abdownloadmanager.shared.ui.configurable.ConfigurableRenderer import com.abdownloadmanager.shared.ui.configurable.ConfigurableUiProps import com.abdownloadmanager.shared.ui.configurable.item.FolderConfigurable import com.abdownloadmanager.shared.util.ui.icon.MyIcons import com.abdownloadmanager.shared.util.ui.widget.MyIcon import java.io.File object FolderConfigurableRenderer : ConfigurableRenderer { @Composable override fun RenderConfigurable(configurable: FolderConfigurable, configurableUiProps: ConfigurableUiProps) { RenderFolderConfig(configurable, configurableUiProps) } @Composable private fun RenderFolderConfig(cfg: FolderConfigurable, configurableUiProps: ConfigurableUiProps) { val value by cfg.stateFlow.collectAsState() val setValue = cfg::set val pickFolderLauncher = rememberAndroidDirectoryPickerLauncher( title = cfg.title, initialDirectory = remember(value) { runCatching { File(value).canonicalPath }.getOrNull() }, ) { directory -> directory?.let(setValue) } ConfigTemplate( modifier = configurableUiProps.modifier .clickable { pickFolderLauncher.launch() } .padding(configurableUiProps.itemPaddingValues), title = { TitleAndDescription(cfg, true) }, value = { NextIcon() } ) } } ================================================ FILE: android/app/src/main/kotlin/com/abdownloadmanager/android/ui/configurable/comon/renderer/IntConfigurableRenderer.kt ================================================ package com.abdownloadmanager.android.ui.configurable.comon.renderer import androidx.compose.foundation.clickable import androidx.compose.foundation.interaction.MutableInteractionSource import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.width import androidx.compose.foundation.text.KeyboardOptions import androidx.compose.runtime.Composable import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier import androidx.compose.ui.text.input.KeyboardType import androidx.compose.ui.unit.dp import com.abdownloadmanager.android.ui.configurable.ConfigTemplate import com.abdownloadmanager.android.ui.configurable.NextIcon import com.abdownloadmanager.android.ui.configurable.SheetInput import com.abdownloadmanager.android.ui.configurable.TitleAndDescription import com.abdownloadmanager.shared.ui.configurable.ConfigurableRenderer import com.abdownloadmanager.shared.ui.configurable.ConfigurableUiProps import com.abdownloadmanager.shared.ui.configurable.isConfigEnabled import com.abdownloadmanager.shared.ui.configurable.item.FloatConfigurable import com.abdownloadmanager.shared.ui.configurable.item.IntConfigurable import com.abdownloadmanager.shared.ui.widget.FloatTextField import com.abdownloadmanager.shared.ui.widget.IntTextField import com.abdownloadmanager.shared.util.ui.icon.MyIcons import com.abdownloadmanager.shared.util.ui.widget.MyIcon object IntConfigurableRenderer : ConfigurableRenderer { @Composable override fun RenderConfigurable(configurable: IntConfigurable, configurableUiProps: ConfigurableUiProps) { RenderIntegerConfig(configurable, configurableUiProps) } private operator fun IntRange.get(index: Int): Int { return (start + index).also { if (it > last) { throw IndexOutOfBoundsException("$it bigger that $last") } } } @Composable private fun RenderIntegerConfig(cfg: IntConfigurable, configurableUiProps: ConfigurableUiProps) { // val value by cfg.stateFlow.collectAsState() // val setValue = cfg::set // val enabled = isConfigEnabled() var isOpened by remember { mutableStateOf(false) } val onDismiss = { isOpened = false } ConfigTemplate( modifier = configurableUiProps.modifier .clickable { isOpened = true } .padding(configurableUiProps.itemPaddingValues), title = { TitleAndDescription(cfg, true) }, value = { when (cfg.renderMode) { IntConfigurable.RenderMode.TextField -> { NextIcon() RenderTextFieldIntInput(cfg = cfg, isOpened = isOpened, onDismiss = onDismiss) } } }) } @Composable fun RenderTextFieldIntInput( cfg: IntConfigurable, isOpened: Boolean, onDismiss: () -> Unit, ) { val interactionSource: MutableInteractionSource = remember { MutableInteractionSource() } SheetInput( configurable = cfg, isOpened = isOpened, onDismiss = onDismiss, inputContent = { params -> IntTextField( value = params.editingValue, onValueChange = { v -> params.setEditingValue(v) }, interactionSource = interactionSource, range = cfg.range, modifier = params.modifier.fillMaxWidth(), keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Decimal), keyboardActions = params.keyboardActions, textPadding = PaddingValues(8.dp), placeholder = "", ) }, onConfirm = { cfg.set(it) onDismiss() }, ) } } ================================================ FILE: android/app/src/main/kotlin/com/abdownloadmanager/android/ui/configurable/comon/renderer/LongConfigurableRenderer.kt ================================================ package com.abdownloadmanager.android.ui.configurable.comon.renderer import androidx.compose.foundation.clickable import androidx.compose.foundation.interaction.MutableInteractionSource import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.width import androidx.compose.foundation.text.KeyboardOptions import androidx.compose.runtime.Composable import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier import androidx.compose.ui.text.input.KeyboardType import androidx.compose.ui.unit.dp import com.abdownloadmanager.android.ui.configurable.ConfigTemplate import com.abdownloadmanager.android.ui.configurable.NextIcon import com.abdownloadmanager.android.ui.configurable.SheetInput import com.abdownloadmanager.android.ui.configurable.TitleAndDescription import com.abdownloadmanager.shared.ui.configurable.ConfigurableRenderer import com.abdownloadmanager.shared.ui.configurable.ConfigurableUiProps import com.abdownloadmanager.shared.ui.configurable.isConfigEnabled import com.abdownloadmanager.shared.ui.configurable.item.IntConfigurable import com.abdownloadmanager.shared.ui.configurable.item.LongConfigurable import com.abdownloadmanager.shared.ui.widget.IntTextField import com.abdownloadmanager.shared.ui.widget.LongTextField import com.abdownloadmanager.shared.util.ui.icon.MyIcons import com.abdownloadmanager.shared.util.ui.widget.MyIcon object LongConfigurableRenderer : ConfigurableRenderer { @Composable override fun RenderConfigurable(configurable: LongConfigurable, configurableUiProps: ConfigurableUiProps) { RenderLongConfig(configurable, configurableUiProps) } private operator fun LongRange.get(index: Int): Long { return (start + index).also { if (it > last) { throw IndexOutOfBoundsException("$it bigger that $last") } } } @Composable private fun RenderLongConfig(cfg: LongConfigurable, configurableUiProps: ConfigurableUiProps) { // val value by cfg.stateFlow.collectAsState() // val setValue = cfg::set // val enabled = isConfigEnabled() var isOpened by remember { mutableStateOf(false) } val onDismiss = { isOpened = false } ConfigTemplate( modifier = configurableUiProps.modifier .clickable { isOpened = true } .padding(configurableUiProps.itemPaddingValues), title = { TitleAndDescription(cfg, true) }, value = { when (cfg.renderMode) { LongConfigurable.RenderMode.TextField -> { NextIcon() RenderTextFieldIntInput( cfg = cfg, isOpened = isOpened, onDismiss = onDismiss ) } } }) } @Composable fun RenderTextFieldIntInput( cfg: LongConfigurable, isOpened: Boolean, onDismiss: () -> Unit, ) { val interactionSource: MutableInteractionSource = remember { MutableInteractionSource() } SheetInput( configurable = cfg, isOpened = isOpened, onDismiss = onDismiss, inputContent = { params -> LongTextField( value = params.editingValue, onValueChange = { v -> params.setEditingValue(v) }, interactionSource = interactionSource, range = cfg.range, modifier = params.modifier.fillMaxWidth(), keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Decimal), keyboardActions = params.keyboardActions, textPadding = PaddingValues(8.dp), placeholder = "", ) }, onConfirm = { cfg.set(it) onDismiss() }, ) } } ================================================ FILE: android/app/src/main/kotlin/com/abdownloadmanager/android/ui/configurable/comon/renderer/NavigatableConfigurableRenderer.kt ================================================ package com.abdownloadmanager.android.ui.configurable.comon.renderer import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier import androidx.compose.ui.unit.dp import com.abdownloadmanager.android.ui.configurable.ConfigTemplate import com.abdownloadmanager.android.ui.configurable.NextIcon import com.abdownloadmanager.android.ui.configurable.TitleAndDescription import com.abdownloadmanager.shared.ui.configurable.ConfigurableRenderer import com.abdownloadmanager.shared.ui.configurable.ConfigurableUiProps import com.abdownloadmanager.shared.ui.configurable.item.NavigatableConfigurable import com.abdownloadmanager.shared.util.ui.icon.MyIcons import com.abdownloadmanager.shared.util.ui.widget.MyIcon object NavigatableConfigurableRenderer : ConfigurableRenderer { @Composable override fun RenderConfigurable( configurable: NavigatableConfigurable, configurableUiProps: ConfigurableUiProps ) { RenderPerHostSettingsConfigurable( cfg = configurable, configurableUiProps = configurableUiProps, onRequestOpenConfigWindow = configurable.onRequestNavigate, ) } @Composable private fun RenderPerHostSettingsConfigurable( cfg: NavigatableConfigurable, configurableUiProps: ConfigurableUiProps, onRequestOpenConfigWindow: () -> Unit ) { // val value by cfg.stateFlow.collectAsState() // val setValue = cfg::set // val enabled = isConfigEnabled() ConfigTemplate( modifier = configurableUiProps.modifier .clickable { onRequestOpenConfigWindow() } .padding(configurableUiProps.itemPaddingValues), title = { TitleAndDescription(cfg, true) }, value = { NextIcon() }, ) } } ================================================ FILE: android/app/src/main/kotlin/com/abdownloadmanager/android/ui/configurable/comon/renderer/ProxyConfigurableRenderer.kt ================================================ package com.abdownloadmanager.android.ui.configurable.comon.renderer import androidx.compose.animation.core.animateDpAsState import androidx.compose.foundation.background import androidx.compose.foundation.border import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.ColumnScope import androidx.compose.foundation.layout.IntrinsicSize import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.RowScope import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.heightIn import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.width import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.text.KeyboardOptions import androidx.compose.foundation.verticalScroll import androidx.compose.runtime.Composable import androidx.compose.runtime.Stable import androidx.compose.runtime.collectAsState import androidx.compose.runtime.derivedStateOf import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip import androidx.compose.ui.graphics.Brush import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.dp import com.abdownloadmanager.android.ui.configurable.ConfigTemplate import com.abdownloadmanager.android.ui.configurable.ConfigurableSheet import com.abdownloadmanager.android.ui.configurable.NextIcon import com.abdownloadmanager.android.ui.configurable.TitleAndDescription import com.abdownloadmanager.resources.Res import com.abdownloadmanager.shared.ui.configurable.ConfigurableRenderer import com.abdownloadmanager.shared.ui.configurable.ConfigurableUiProps import com.abdownloadmanager.shared.ui.configurable.isConfigEnabled import com.abdownloadmanager.shared.ui.configurable.item.ProxyConfigurable import com.abdownloadmanager.shared.ui.widget.ActionButton import com.abdownloadmanager.shared.ui.widget.CheckBox import com.abdownloadmanager.shared.ui.widget.ExpandableItem import com.abdownloadmanager.shared.ui.widget.Help import com.abdownloadmanager.shared.ui.widget.IntTextField import com.abdownloadmanager.shared.ui.widget.Multiselect import com.abdownloadmanager.shared.ui.widget.MyTextField import com.abdownloadmanager.shared.ui.widget.RadioButton import com.abdownloadmanager.shared.ui.widget.Text import com.abdownloadmanager.shared.util.div import com.abdownloadmanager.shared.util.proxy.ProxyData import com.abdownloadmanager.shared.util.proxy.ProxyMode import com.abdownloadmanager.shared.util.proxy.ProxyRules import com.abdownloadmanager.shared.util.ui.LocalContentColor import com.abdownloadmanager.shared.util.ui.icon.MyIcons import com.abdownloadmanager.shared.util.ui.myColors import com.abdownloadmanager.shared.util.ui.theme.myShapes import com.abdownloadmanager.shared.util.ui.theme.mySpacings import com.abdownloadmanager.shared.util.ui.theme.myTextSizes import com.abdownloadmanager.shared.util.ui.widget.MyIcon import ir.amirab.downloader.connection.proxy.Proxy import ir.amirab.downloader.connection.proxy.ProxyType import ir.amirab.util.compose.StringSource import ir.amirab.util.compose.asStringSource import ir.amirab.util.compose.resources.myStringResource import ir.amirab.util.ifThen object ProxyConfigurableRenderer : ConfigurableRenderer { @Composable override fun RenderConfigurable(configurable: ProxyConfigurable, configurableUiProps: ConfigurableUiProps) { RenderProxyConfig(configurable, configurableUiProps) } @Composable fun RenderProxyConfig(cfg: ProxyConfigurable, configurableUiProps: ConfigurableUiProps) { val value by cfg.stateFlow.collectAsState() val setValue = cfg::set val enabled = isConfigEnabled() var proxyConfigState by remember { mutableStateOf(null as ProxyEditState?) } val dismiss = { proxyConfigState = null } ConfigTemplate( modifier = configurableUiProps.modifier .clickable( onClick = { proxyConfigState = ProxyEditState( proxyData = value, setProxyData = { setValue(it) dismiss() } ) } ) .padding(configurableUiProps.itemPaddingValues), title = { TitleAndDescription(cfg, true) }, value = { NextIcon() } ) proxyConfigState?.let { ProxyEditDialog(it, onDismiss = dismiss) } } @Stable private class ProxyEditState( private val proxyData: ProxyData, private val setProxyData: (ProxyData) -> Unit, ) { var proxyMode = mutableStateOf(proxyData.proxyMode) //pac var pacURL = mutableStateOf(proxyData.pac.uri) //manual var proxyType = mutableStateOf(proxyData.proxyWithRules.proxy.type) var proxyHost = mutableStateOf(proxyData.proxyWithRules.proxy.host) var proxyPort = mutableStateOf(proxyData.proxyWithRules.proxy.port) var useAuth = mutableStateOf(proxyData.proxyWithRules.proxy.username != null) var proxyUsername = mutableStateOf(proxyData.proxyWithRules.proxy.username.orEmpty()) var proxyPassword = mutableStateOf(proxyData.proxyWithRules.proxy.password.orEmpty()) var excludeURLPatterns = mutableStateOf(proxyData.proxyWithRules.rules.excludeURLPatterns.joinToString(" ")) val canSave: Boolean by derivedStateOf { when (proxyMode.value) { ProxyMode.Direct -> true ProxyMode.Manual -> { val hostValid = proxyHost.value.isNotBlank() hostValid } // at the moment these two not supported on android ProxyMode.UseSystem -> false ProxyMode.Pac -> false // ProxyMode.Pac -> { // HttpUrlUtils.isValidUrl(pacURL.value) // } } } fun save() { val useAuth = useAuth.value if (!canSave) { return } setProxyData( proxyData.copy( proxyMode = proxyMode.value, pac = proxyData.pac.copy(pacURL.value), proxyWithRules = proxyData.proxyWithRules.copy( proxy = Proxy( type = proxyType.value, host = proxyHost.value.trim(), port = proxyPort.value, username = proxyUsername.value.takeIf { it.isNotEmpty() && useAuth }, password = proxyPassword.value.takeIf { it.isNotEmpty() && useAuth }, ), rules = ProxyRules( excludeURLPatterns = excludeURLPatterns.value .split(" ") .map { it.trim() } .filterNot { it.isEmpty() }, ) ) ) ) } } @Composable fun RenderChangeProxyConfig() { NextIcon() } @Composable private fun ProxyEditDialog( state: ProxyEditState?, onDismiss: () -> Unit, ) { val headerTitle = Res.string.proxy_change_title.asStringSource() ConfigurableSheet( title = headerTitle, onDismiss = onDismiss, isOpened = state != null, content = { state?.let { state -> val (mode, setMode) = state.proxyMode val shape = myShapes.defaultRounded Column( Modifier .verticalScroll(rememberScrollState()) ) { Accordion( wrapItem = { item, content -> val selected = item == mode Box( Modifier.ifThen(selected) { Modifier .clip(shape) .border(1.dp, myColors.onBackground / 0.15f, shape) .background(myColors.background / 25) } ) { content() } }, possibleValues = ProxyMode.usableValues(), selectedItem = mode, renderHeader = { val selected = it == mode Row( Modifier .fillMaxWidth() .clip(shape) .clickable { setMode(it) } .padding(8.dp) .padding( animateDpAsState( if (selected) 8.dp else 4.dp ).value ) ) { RadioButton( value = selected, onValueChange = {}, ) Spacer(Modifier.width(8.dp)) Text( text = it.asStringSource().rememberString(), fontSize = if (selected) { myTextSizes.lg } else { myTextSizes.base }, fontWeight = if (selected) { FontWeight.Bold } else { null } ) } }, renderContent = { val cm = Modifier .fillMaxWidth() .padding( vertical = 12.dp, horizontal = 16.dp ) when (it) { ProxyMode.Direct -> { } ProxyMode.UseSystem -> { } ProxyMode.Manual -> { Column(cm) { RenderManualConfig(state) } } ProxyMode.Pac -> { // Column(cm) { // RenderPACConfig(state) // } } } } ) ProxyConfigSpacer() Row { val btnModifier = Modifier.weight(1f) ActionButton( myStringResource(Res.string.change), enabled = state.canSave, modifier = btnModifier, onClick = { state.save() }) Spacer(Modifier.width(mySpacings.mediumSpace)) ActionButton( myStringResource(Res.string.cancel), modifier = btnModifier, onClick = { onDismiss() }) } } } } ) } @Composable private fun RenderPACConfig( state: ProxyEditState, ) { Column { val (url, setPacUrl) = state.pacURL DialogConfigItem( modifier = Modifier.Companion, title = { Text(myStringResource(Res.string.proxy_pac_url)) }, value = { Row( verticalAlignment = Alignment.CenterVertically, ) { MyTextField( text = url, onTextChange = setPacUrl, placeholder = "http://path/to/file.pac", modifier = Modifier.weight(1f), ) } } ) } } @Composable private fun RenderManualConfig( state: ProxyEditState, ) { val (type, setType) = state.proxyType val (host, setHost) = state.proxyHost val (port, setPort) = state.proxyPort val (useAuth, setUseAuth) = state.useAuth val (username, setUsername) = state.proxyUsername val (password, setPassword) = state.proxyPassword val (excludeURLPatterns, setExcludeURLPatterns) = state.excludeURLPatterns DialogConfigItem( modifier = Modifier.Companion, title = { Text(myStringResource(Res.string.proxy_type)) }, value = { Multiselect( selections = ProxyType.entries.toList(), selectedItem = type, onSelectionChange = setType, modifier = Modifier.Companion, render = { Text( it.name, modifier = Modifier.padding(vertical = 4.dp, horizontal = 8.dp), ) }, selectedColor = LocalContentColor.current / 15, unselectedAlpha = 0.8f, ) } ) ProxyConfigSpacer() DialogConfigItem( modifier = Modifier.Companion, title = { Text(myStringResource(Res.string.address_and_port)) }, value = { Row( verticalAlignment = Alignment.CenterVertically, ) { MyTextField( text = host, onTextChange = setHost, placeholder = "127.0.0.1", modifier = Modifier.weight(1f), ) Text(":", Modifier.padding(horizontal = 8.dp)) IntTextField( value = port, onValueChange = setPort, placeholder = myStringResource(Res.string.port), range = 1..65535, modifier = Modifier.width(120.dp), keyboardOptions = KeyboardOptions(), textPadding = PaddingValues(8.dp), shape = RoundedCornerShape(12.dp), ) } } ) ProxyConfigSpacer() DialogConfigItem( modifier = Modifier.Companion, title = { Row( modifier = Modifier.clickable { setUseAuth(!useAuth) } ) { CheckBox( value = useAuth, onValueChange = setUseAuth, size = 16.dp ) Spacer(Modifier.width(8.dp)) Text(myStringResource(Res.string.use_authentication)) } }, value = { Row( verticalAlignment = Alignment.CenterVertically, ) { MyTextField( text = username, onTextChange = setUsername, placeholder = myStringResource(Res.string.username), modifier = Modifier.weight(1f), enabled = useAuth, ) Spacer(Modifier.width(8.dp)) MyTextField( text = password, onTextChange = setPassword, placeholder = myStringResource(Res.string.password), modifier = Modifier.weight(1f), enabled = useAuth, ) } } ) ProxyConfigSpacer() DialogConfigItem( modifier = Modifier.Companion, title = { Row { Text(myStringResource(Res.string.proxy_do_not_use_proxy_for)) Spacer(Modifier.width(8.dp)) Help( myStringResource(Res.string.proxy_do_not_use_proxy_for_description) ) } }, value = { Row( verticalAlignment = Alignment.CenterVertically, ) { MyTextField( text = excludeURLPatterns, onTextChange = setExcludeURLPatterns, placeholder = "example.com 192.168.1.*", modifier = Modifier.Companion, ) } } ) } @Composable private fun SettingsDialog( headerTitle: String, onDismiss: () -> Unit, content: @Composable () -> Unit, actions: (@Composable RowScope.() -> Unit)? = null, ) { val shape = myShapes.defaultRounded Column( modifier = Modifier .clip(shape) .border(2.dp, myColors.onBackground / 10, shape) .background( Brush.linearGradient( listOf( myColors.surface, myColors.background, ) ) ) .padding(16.dp) .width(450.dp), ) { Row( verticalAlignment = Alignment.CenterVertically, modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween, ) { Text( headerTitle, fontSize = myTextSizes.lg, fontWeight = FontWeight.Bold, ) MyIcon( MyIcons.windowClose, myStringResource(Res.string.close), Modifier .clip(CircleShape) .clickable { onDismiss() } .padding(12.dp) .size(12.dp), ) } Spacer(Modifier.height(8.dp)) Box(Modifier.weight(1f, false)) { content() } actions?.let { Spacer(Modifier.height(8.dp)) Row( Modifier.align(Alignment.End), verticalAlignment = Alignment.CenterVertically, ) { actions() } } } } @Composable private fun ProxyConfigSpacer() { Spacer(Modifier.height(8.dp)) } @Composable private fun DialogConfigItem( modifier: Modifier, title: @Composable ColumnScope.() -> Unit, value: @Composable ColumnScope.() -> Unit, ) { Column( modifier, ) { Column( Modifier .height(IntrinsicSize.Max), ) { Column( verticalArrangement = Arrangement.Center, horizontalAlignment = Alignment.Start, ) { title() } Spacer(Modifier.height(8.dp)) Column( verticalArrangement = Arrangement.Center, horizontalAlignment = Alignment.End, ) { value() } } } } private fun ProxyMode.asStringSource(): StringSource { return when (this) { ProxyMode.Direct -> Res.string.proxy_no ProxyMode.UseSystem -> Res.string.proxy_system ProxyMode.Manual -> Res.string.proxy_manual ProxyMode.Pac -> Res.string.proxy_pac }.asStringSource() } @Composable private fun Accordion( possibleValues: List, selectedItem: T, wrapItem: @Composable (T, @Composable () -> Unit) -> Unit = { _, content -> content() }, renderHeader: @Composable (T) -> Unit, renderContent: @Composable (T) -> Unit, ) { Column { possibleValues.forEach { wrapItem(it) { ExpandableItem( isExpanded = selectedItem == it, header = { renderHeader(it) }, body = { renderContent(it) }, ) } } } } } ================================================ FILE: android/app/src/main/kotlin/com/abdownloadmanager/android/ui/configurable/comon/renderer/SpeedLimitConfigurableRenderer.kt ================================================ package com.abdownloadmanager.android.ui.configurable.comon.renderer import androidx.compose.animation.AnimatedVisibility import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.heightIn import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.width import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.unit.dp import com.abdownloadmanager.android.ui.configurable.ConfigTemplate import com.abdownloadmanager.android.ui.configurable.TitleAndDescription import com.abdownloadmanager.shared.ui.configurable.ConfigurableRenderer import com.abdownloadmanager.shared.ui.configurable.ConfigurableUiProps import com.abdownloadmanager.shared.ui.configurable.RenderSpinner import com.abdownloadmanager.shared.ui.configurable.isConfigEnabled import com.abdownloadmanager.shared.ui.configurable.item.SpeedLimitConfigurable import com.abdownloadmanager.shared.ui.widget.CheckBox import com.abdownloadmanager.shared.ui.widget.DoubleTextField import com.abdownloadmanager.shared.ui.widget.Text import com.abdownloadmanager.shared.util.LocalSpeedUnit import com.abdownloadmanager.shared.util.ui.theme.mySpacings import ir.amirab.util.datasize.SizeConverter import ir.amirab.util.datasize.SizeFactors import ir.amirab.util.datasize.SizeUnit import ir.amirab.util.datasize.SizeWithUnit import ir.amirab.util.datasize.asConverterConfig object SpeedLimitConfigurableRenderer : ConfigurableRenderer { @Composable override fun RenderConfigurable(configurable: SpeedLimitConfigurable, configurableUiProps: ConfigurableUiProps) { RenderSpeedConfig(configurable, configurableUiProps) } @Composable private fun RenderSpeedConfig(cfg: SpeedLimitConfigurable, configurableUiProps: ConfigurableUiProps) { val value by cfg.stateFlow.collectAsState() val setValue = cfg::set val speedUnit = LocalSpeedUnit.current val allowedFactors = listOf( SizeFactors.FactorValue.Kilo, SizeFactors.FactorValue.Mega, ) val units = allowedFactors.map { SizeUnit( factorValue = it, baseSize = speedUnit.baseSize, factors = speedUnit.factors ) } val enabled = isConfigEnabled() val hasLimitSpeed = value > 0L var currentUnit by remember(hasLimitSpeed) { mutableStateOf( SizeConverter.bytesToSize( value, speedUnit.copy(acceptedFactors = allowedFactors) ).unit ) } var currentValue by remember(value) { val v = SizeConverter.bytesToSize( value, currentUnit.asConverterConfig() ).formatedValue().toDouble() mutableStateOf(v) } LaunchedEffect(currentValue, currentUnit) { setValue( SizeConverter.sizeToBytes( SizeWithUnit(currentValue, currentUnit), ) ) } ConfigTemplate( configurableUiProps.modifier.padding(configurableUiProps.itemPaddingValues), title = { Row(verticalAlignment = Alignment.CenterVertically) { TitleAndDescription(cfg, true) } }, nestedContent = { Column(Modifier.align(Alignment.End)) { AnimatedVisibility(hasLimitSpeed) { Row( Modifier .padding(vertical = 8.dp) .width(250.dp) ) { DoubleTextField( value = currentValue, onValueChange = { currentValue = it }, enabled = enabled && hasLimitSpeed, range = 0.0..1_000.0, unit = 1.0, modifier = Modifier.weight(1f), ) Spacer(Modifier.width(8.dp)) RenderSpinner( possibleValues = units, value = currentUnit, modifier = Modifier.Companion, enabled = enabled && hasLimitSpeed, onSelect = { currentUnit = it } ) { val prettified = remember(it) { "$it/s" } Text(prettified, Modifier.padding(horizontal = 4.dp)) } } } } }, value = { CheckBox( value = hasLimitSpeed, enabled = enabled, onValueChange = { if (it) { setValue( SizeConverter.sizeToBytes( SizeWithUnit( 256.0, currentUnit ) ) ) } else { setValue(0) } }) } ) } } ================================================ FILE: android/app/src/main/kotlin/com/abdownloadmanager/android/ui/configurable/comon/renderer/StringConfigurableRenderer.kt ================================================ package com.abdownloadmanager.android.ui.configurable.comon.renderer import androidx.compose.foundation.clickable import androidx.compose.foundation.interaction.MutableInteractionSource import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.text.KeyboardOptions import androidx.compose.runtime.Composable import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier import androidx.compose.ui.unit.dp import com.abdownloadmanager.android.ui.configurable.ConfigTemplate import com.abdownloadmanager.android.ui.configurable.NextIcon import com.abdownloadmanager.android.ui.configurable.TitleAndDescription import com.abdownloadmanager.android.ui.configurable.SheetInput import com.abdownloadmanager.shared.ui.configurable.ConfigurableRenderer import com.abdownloadmanager.shared.ui.configurable.ConfigurableUiProps import com.abdownloadmanager.shared.ui.configurable.item.StringConfigurable import com.abdownloadmanager.shared.ui.widget.MyTextField import com.abdownloadmanager.shared.util.ui.icon.MyIcons import com.abdownloadmanager.shared.util.ui.theme.myShapes import com.abdownloadmanager.shared.util.ui.widget.MyIcon object StringConfigurableRenderer : ConfigurableRenderer { @Composable override fun RenderConfigurable(configurable: StringConfigurable, configurableUiProps: ConfigurableUiProps) { RenderStringConfig(configurable, configurableUiProps) } @Composable fun RenderStringConfig(cfg: StringConfigurable, configurableUiProps: ConfigurableUiProps) { val value by cfg.stateFlow.collectAsState() val setValue = cfg::set var isOpened by remember { mutableStateOf(false) } val onDismiss = { isOpened = false } ConfigTemplate( modifier = configurableUiProps.modifier .clickable { isOpened = true } .padding(configurableUiProps.itemPaddingValues), title = { TitleAndDescription(cfg, true) }, value = { NextIcon() } ) val interactionSource: MutableInteractionSource = remember { MutableInteractionSource() } SheetInput( configurable = cfg, isOpened = isOpened, onDismiss = onDismiss, inputContent = { params -> MyTextField( modifier = params.modifier.fillMaxWidth(), text = params.editingValue, onTextChange = { params.setEditingValue(it) }, shape = myShapes.defaultRounded, textPadding = PaddingValues(8.dp), placeholder = "", interactionSource = interactionSource, keyboardActions = params.keyboardActions, ) }, onConfirm = { cfg.set(it) onDismiss() }, ) } } ================================================ FILE: android/app/src/main/kotlin/com/abdownloadmanager/android/ui/configurable/comon/renderer/ThemeConfigurableRenderer.kt ================================================ package com.abdownloadmanager.android.ui.configurable.comon.renderer import androidx.compose.foundation.background import androidx.compose.foundation.border import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.width import androidx.compose.foundation.shape.CircleShape import androidx.compose.runtime.Composable import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.alpha import androidx.compose.ui.draw.clip import androidx.compose.ui.graphics.Brush import androidx.compose.ui.unit.dp import com.abdownloadmanager.android.ui.configurable.ConfigTemplate import com.abdownloadmanager.android.ui.configurable.NextIcon import com.abdownloadmanager.android.ui.configurable.RenderSpinnerInSheet import com.abdownloadmanager.android.ui.configurable.TitleAndDescription import com.abdownloadmanager.shared.ui.configurable.ConfigurableRenderer import com.abdownloadmanager.shared.ui.configurable.ConfigurableUiProps import com.abdownloadmanager.shared.ui.configurable.isConfigEnabled import com.abdownloadmanager.shared.ui.configurable.item.ThemeConfigurable import com.abdownloadmanager.shared.ui.widget.Text import com.abdownloadmanager.shared.util.ui.icon.MyIcons import com.abdownloadmanager.shared.util.ui.myColors import com.abdownloadmanager.shared.util.ui.widget.MyIcon import ir.amirab.util.ifThen object ThemeConfigurableRenderer : ConfigurableRenderer { @Composable override fun RenderConfigurable(configurable: ThemeConfigurable, configurableUiProps: ConfigurableUiProps) { RenderThemeConfig(configurable, configurableUiProps) } @Composable private fun RenderThemeConfig(cfg: ThemeConfigurable, configurableUiProps: ConfigurableUiProps) { val value by cfg.stateFlow.collectAsState() val setValue = cfg::set val enabled = isConfigEnabled() var isOpened by remember { mutableStateOf(false) } val onDismiss = { isOpened = false } ConfigTemplate( modifier = configurableUiProps.modifier .clickable { isOpened = true } .padding(configurableUiProps.itemPaddingValues), title = { TitleAndDescription(cfg, true) }, value = { Row( verticalAlignment = Alignment.CenterVertically, modifier = Modifier.ifThen(!enabled) { alpha(0.5f) } ) { Spacer( Modifier .clip(CircleShape) .border( 1.dp, Brush.verticalGradient(myColors.primaryGradientColors), CircleShape ) .padding(1.dp) .background( value.color, ) .size(16.dp) ) Spacer(Modifier.width(16.dp)) NextIcon() } } ) RenderSpinnerInSheet( title = cfg.title, onDismiss = onDismiss, isOpened = isOpened, possibleValues = cfg.possibleValues, value = value, onSelect = { setValue(it) }, valueToString = cfg.valueToString, render = { Row( verticalAlignment = Alignment.CenterVertically, modifier = Modifier.ifThen(!enabled) { alpha(0.5f) } ) { Spacer( Modifier .clip(CircleShape) .border( 1.dp, Brush.verticalGradient(myColors.primaryGradientColors), CircleShape ) .padding(1.dp) .background( it.color, ) .size(16.dp) ) Spacer(Modifier.width(16.dp)) Text(cfg.describe(it).rememberString()) } }) } } ================================================ FILE: android/app/src/main/kotlin/com/abdownloadmanager/android/ui/configurable/comon/renderer/TimeConfigurableRenderer.kt ================================================ package com.abdownloadmanager.android.ui.configurable.comon.renderer import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.width import androidx.compose.foundation.text.KeyboardActions import androidx.compose.foundation.text.KeyboardOptions import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.text.input.ImeAction import androidx.compose.ui.text.input.KeyboardType import androidx.compose.ui.unit.dp import com.abdownloadmanager.android.ui.configurable.ConfigTemplate import com.abdownloadmanager.android.ui.configurable.SheetInput import com.abdownloadmanager.android.ui.configurable.TitleAndDescription import com.abdownloadmanager.shared.ui.configurable.ConfigurableRenderer import com.abdownloadmanager.shared.ui.configurable.ConfigurableUiProps import com.abdownloadmanager.shared.ui.configurable.isConfigEnabled import com.abdownloadmanager.shared.ui.configurable.item.TimeConfigurable import com.abdownloadmanager.shared.ui.widget.IntTextField import com.abdownloadmanager.shared.ui.widget.Text import com.abdownloadmanager.shared.util.ui.icon.MyIcons import com.abdownloadmanager.shared.util.ui.widget.MyIcon import kotlinx.datetime.LocalTime object TimeConfigurableRenderer : ConfigurableRenderer { @Composable override fun RenderConfigurable(configurable: TimeConfigurable, configurableUiProps: ConfigurableUiProps) { RenderTimeConfig(configurable, configurableUiProps) } @Composable fun RenderTimeConfig(cfg: TimeConfigurable, configurableUiProps: ConfigurableUiProps) { val value by cfg.stateFlow.collectAsState() val setValue = cfg::set val enabled = isConfigEnabled() var isOpened by remember { mutableStateOf(false) } ConfigTemplate( modifier = configurableUiProps.modifier .clickable { isOpened = true } .padding(configurableUiProps.itemPaddingValues), title = { TitleAndDescription(cfg, true) }, value = { MyIcon(MyIcons.next, null, Modifier.size(16.dp)) SheetInput( configurable = cfg, isOpened = isOpened, onDismiss = { isOpened = false }, onConfirm = { setValue(it) isOpened = false }, ) { inputParams -> var hour by remember(value) { mutableStateOf(value.hour) } var minute by remember(value) { mutableStateOf(value.minute) } LaunchedEffect(hour, minute) { inputParams.setEditingValue( LocalTime( hour = hour, minute = minute, ) ) } Row( modifier = inputParams.modifier, verticalAlignment = Alignment.CenterVertically ) { val textFieldModifier = Modifier .weight(1f) IntTextField( value = hour, onValueChange = { hour = it }, range = 0..23, modifier = textFieldModifier, enabled = enabled, keyboardOptions = KeyboardOptions( keyboardType = KeyboardType.Companion.Decimal, imeAction = ImeAction.Next ), keyboardActions = KeyboardActions.Default, placeholder = "hour", prettify = { it.toString().padStart(2, '0') }, ) Text(":", Modifier.padding(horizontal = 4.dp)) IntTextField( value = minute, onValueChange = { minute = it }, range = 0..59, modifier = textFieldModifier, enabled = enabled, keyboardOptions = KeyboardOptions( keyboardType = KeyboardType.Decimal, imeAction = ImeAction.Done ), keyboardActions = inputParams.keyboardActions, placeholder = "minute", prettify = { it.toString().padStart(2, '0') }, ) } } }, ) } } ================================================ FILE: android/app/src/main/kotlin/com/abdownloadmanager/android/ui/menu/Menu.kt ================================================ package com.abdownloadmanager.android.ui.menu import androidx.compose.foundation.background import androidx.compose.foundation.clickable import androidx.compose.foundation.hoverable import androidx.compose.foundation.interaction.MutableInteractionSource import androidx.compose.foundation.interaction.collectIsHoveredAsState import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.heightIn import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.width import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.runtime.Composable import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.alpha import androidx.compose.ui.draw.clip import androidx.compose.ui.graphics.Color import androidx.compose.ui.text.TextStyle import androidx.compose.ui.unit.dp import com.abdownloadmanager.shared.ui.widget.Text import com.abdownloadmanager.shared.ui.widget.menu.custom.LocalMenuDisabledItemBehavior import com.abdownloadmanager.shared.ui.widget.menu.custom.MenuDisabledItemBehavior import com.abdownloadmanager.shared.util.LocalShortCutManager import com.abdownloadmanager.shared.util.PlatformKeyStroke import com.abdownloadmanager.shared.util.div import com.abdownloadmanager.shared.util.ui.ProvideTextStyle import com.abdownloadmanager.shared.util.ui.WithContentColor import com.abdownloadmanager.shared.util.ui.icon.MyIcons import com.abdownloadmanager.shared.util.ui.myColors import com.abdownloadmanager.shared.util.ui.theme.mySpacings import com.abdownloadmanager.shared.util.ui.theme.myTextSizes import com.abdownloadmanager.shared.util.ui.widget.MyIcon import ir.amirab.util.compose.action.MenuItem import ir.amirab.util.compose.modifiers.autoMirror import ir.amirab.util.ifThen /** * render a menu */ @Composable fun Menu( menu: MenuItem.SubMenu, onRequestClose: () -> Unit, onNewMenuSelected: (MenuItem.SubMenu) -> Unit, modifier: Modifier, ) { var openedItem: MenuItem.SubMenu? by remember { mutableStateOf(null) } WithContentColor(myColors.onMenuColor) { Column( modifier ) { val items by menu.items.collectAsState() for (menuItem in items) { val interactionSource = remember { MutableInteractionSource() } RenderMenuItem( menuItem = menuItem, // openedItem = openedItem, onRequestCLose = onRequestClose, isSelected = openedItem == menuItem, onRequestOpenItem = { onNewMenuSelected(it) }, modifier = Modifier.hoverable(interactionSource) ) } } } } @Composable private fun ReactableItem( item: MenuItem.ReadableItem, onClick: () -> Unit, isSelected: Boolean, modifier: Modifier = Modifier, interactionSource: MutableInteractionSource = remember { MutableInteractionSource() }, extraContent: @Composable () -> Unit = {}, ) { val iconModifier = Modifier.size(menuIconSize) val title by item.title.collectAsState() val icon by item.icon.collectAsState() val itemPadding = Modifier.padding(horizontal = 12.dp, vertical = 8.dp) val isHovered by interactionSource.collectIsHoveredAsState() val isEnabled = (item as? MenuItem.HasEnable) ?.isEnabled ?.collectAsState() ?.value ?: true Row( modifier .ifThen(!isEnabled) { alpha(0.5f) } .heightIn(mySpacings.thumbSize) .hoverable(interactionSource) .background( when { (isHovered && isEnabled) || isSelected -> { myColors.surface } else -> { Color.Transparent } } ) .clickable(enabled = isEnabled) { onClick() } .then(itemPadding) .fillMaxWidth(), verticalAlignment = Alignment.CenterVertically ) { icon.let { icon -> if (icon != null) { Spacer(Modifier.width(4.dp)) MyIcon(icon, null, iconModifier) Spacer(Modifier.width(16.dp)) } else { Spacer(iconModifier) } } Text( title.rememberString(), Modifier.weight(1f), fontSize = myTextSizes.base, softWrap = false, maxLines = 1, ) Spacer(Modifier.width(16.dp)) extraContent() } } @Composable private fun RenderMenuItem( menuItem: MenuItem, onRequestCLose: () -> Unit, isSelected: Boolean, modifier: Modifier = Modifier, onRequestOpenItem: (MenuItem.SubMenu) -> Unit, ) { // val isEnabled by menuItem.isEnabled.collectAsState() Row( modifier .fillMaxWidth() ) { when (menuItem) { MenuItem.Separator -> { RenderSeparator() } is MenuItem.SingleItem -> { RenderSingleItem( item = menuItem, isSelected = isSelected, onRequestClose = onRequestCLose, ) } is MenuItem.SubMenu -> { RenderSubMenuItem( menuItem = menuItem, isSelected = isSelected, // onRequestCLose = onRequestCLose, // openedItem = openedItem, onRequestOpenItem = onRequestOpenItem, ) } } } } @Composable fun RenderSeparator() { Spacer( Modifier .fillMaxWidth() .height(1.dp) .background(myColors.onSurface / 5) ) } @Composable private fun RenderSubMenuItem( menuItem: MenuItem.SubMenu, isSelected: Boolean, // openedItem: MenuItem.SubMenu?, onRequestOpenItem: (MenuItem.SubMenu) -> Unit, // onRequestCLose: () -> Unit, ) { ReactableItem( item = menuItem, onClick = { onRequestOpenItem(menuItem) }, isSelected = isSelected, extraContent = { MyIcon( MyIcons.next, null, Modifier .size(16.dp) .autoMirror(), ) }) // if (openedItem == menuItem) { // SiblingDropDown( // onDismissRequest = { // onRequestOpenItem(null) // } // ) { // SubMenu(menuItem, onRequestCLose) // } // } } @Composable private fun RenderSingleItem( onRequestClose: () -> Unit, isSelected: Boolean, item: MenuItem.SingleItem, ) { val isEnabled by item.isEnabled.collectAsState() if (!isEnabled && LocalMenuDisabledItemBehavior.current == MenuDisabledItemBehavior.Filter) { return } val shortcutManager = LocalShortCutManager.current val shortcutStroke = remember(shortcutManager, item) { shortcutManager?.getShortCutOf(item) } val onClick = { if (item.shouldDismissOnClick) { onRequestClose() } item.onClick() } ReactableItem( item = item, onClick = onClick, isSelected = isSelected, extraContent = { if (shortcutStroke != null) { RenderShortcutStroke(shortcutStroke) } } ) } @Composable private fun RenderShortcutStroke(shortcutStroke: PlatformKeyStroke) { val modifiers = remember(shortcutStroke) { buildList { addAll(shortcutStroke.getModifiers()) add(shortcutStroke.getKeyText()) } } ProvideTextStyle( TextStyle( fontSize = myTextSizes.xs, ) ) { Row( verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(1.dp) ) { val shape = RoundedCornerShape(10) WithContentColor(myColors.onBackground) { Text( modifiers.joinToString("+"), Modifier .clip(shape) .background(myColors.onBackground / 5) .padding(2.dp) ) } } } } val menuIconSize = 20.dp ================================================ FILE: android/app/src/main/kotlin/com/abdownloadmanager/android/ui/menu/RenderMenuInSheet.kt ================================================ package com.abdownloadmanager.android.ui.menu import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.collectAsState import androidx.compose.ui.Modifier import com.abdownloadmanager.android.ui.SheetHeader import com.abdownloadmanager.android.ui.SheetTitle import com.abdownloadmanager.android.ui.SheetUI import com.abdownloadmanager.resources.Res import com.abdownloadmanager.shared.ui.widget.TransparentIconActionButton import com.abdownloadmanager.shared.util.OnFullyDismissed import com.abdownloadmanager.shared.util.ResponsiveDialog import com.abdownloadmanager.shared.util.ResponsiveDialogScope import com.abdownloadmanager.shared.util.rememberResponsiveDialogState import com.abdownloadmanager.shared.util.ui.icon.MyIcons import ir.amirab.util.compose.action.MenuItem import ir.amirab.util.compose.asStringSource @Composable private fun ResponsiveDialogScope.RenderMenuInSheetUi( menuStack: StackMenuState, onDismissRequest: () -> Unit, ) { val currentMenu = menuStack.currentMenu SheetUI( header = { SheetHeader( headerTitle = { SheetTitle( title = currentMenu.title.collectAsState().value.rememberString(), icon = currentMenu.icon.collectAsState().value, ) }, headerActions = { if (menuStack.canGoBack) { TransparentIconActionButton( icon = MyIcons.back, contentDescription = Res.string.back.asStringSource(), ) { menuStack.pop() } } TransparentIconActionButton( MyIcons.close, Res.string.close.asStringSource() ) { onDismissRequest() } } ) } ) { BaseStackedMenu( menuStack = menuStack, onDismissRequest = onDismissRequest, modifier = Modifier.fillMaxWidth(), ) } } @Composable fun RenderMenuInSheet( menu: MenuItem.SubMenu?, onDismissRequest: () -> Unit, ) { val responsiveDialogState = rememberResponsiveDialogState(false) LaunchedEffect(menu) { if (menu != null) { responsiveDialogState.show() } else { responsiveDialogState.hide() } } responsiveDialogState.OnFullyDismissed { onDismissRequest() } val hideDialog = responsiveDialogState::hide menu?.let { ResponsiveDialog( responsiveDialogState, hideDialog, ) { val menuStackState = rememberMenuStack(it) RenderMenuInSheetUi(menuStackState, hideDialog) } } } ================================================ FILE: android/app/src/main/kotlin/com/abdownloadmanager/android/ui/menu/RenderMenuInSinglePage.kt ================================================ package com.abdownloadmanager.android.ui.menu import androidx.activity.compose.LocalOnBackPressedDispatcherOwner import androidx.compose.animation.core.Animatable import androidx.compose.foundation.background import androidx.compose.foundation.border import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.heightIn import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.width import androidx.compose.foundation.layout.widthIn import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.collectAsState import androidx.compose.runtime.mutableStateListOf import androidx.compose.runtime.remember import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip import androidx.compose.ui.draw.shadow import androidx.compose.ui.unit.dp import com.abdownloadmanager.shared.ui.widget.Text import com.abdownloadmanager.shared.ui.widget.menu.custom.LocalMenuBoxClip import com.abdownloadmanager.shared.util.div import com.abdownloadmanager.shared.util.ui.LocalContentColor import com.abdownloadmanager.shared.util.ui.icon.MyIcons import com.abdownloadmanager.shared.util.ui.myColors import com.abdownloadmanager.shared.util.ui.theme.mySpacings import com.abdownloadmanager.shared.util.ui.widget.MyIcon import ir.amirab.util.compose.action.MenuItem import ir.amirab.util.compose.asStringSource import ir.amirab.util.compose.localizationmanager.WithLanguageDirection import ir.amirab.util.compose.modifiers.autoMirror @Composable private fun RenderMenuInSinglePage( menuStack: StackMenuState, onDismissRequest: () -> Unit, modifier: Modifier, ) { val shape = LocalMenuBoxClip.current val alpha = remember { Animatable(0f) } LaunchedEffect(Unit) { alpha.animateTo(1f) } WithLanguageDirection { Column( modifier .shadow(4.dp, shape) .clip(shape) .widthIn(200.dp) .border(1.dp, myColors.onSurface / 0.1f, shape) .background(myColors.surface) .padding(horizontal = 0.dp, vertical = 0.dp) ) { BaseStackedMenu( menuStack, onDismissRequest, ) { currentMenu, render -> val onBackPressedDispatcher = LocalOnBackPressedDispatcherOwner.current?.onBackPressedDispatcher render() val currentTitle = currentMenu.title.collectAsState().value.rememberString() if (currentTitle.isNotEmpty()) { RenderSeparator() Row( verticalAlignment = Alignment.CenterVertically, modifier = Modifier .clickable( enabled = menuStack.size > 1 ) { onBackPressedDispatcher?.onBackPressed() } .fillMaxWidth() .heightIn(mySpacings.thumbSize) .padding(horizontal = 16.dp) ) { val iconModifier = Modifier .size(menuIconSize) if (menuStack.size > 1) { MyIcon( MyIcons.back, null, iconModifier.autoMirror(), ) Spacer(Modifier.width(16.dp)) } Text( currentTitle, Modifier.weight(1f), color = LocalContentColor.current / 0.75f, ) } } } } } } @Composable fun RenderMenuInSinglePage( menu: List, onDismissRequest: () -> Unit, modifier: Modifier, ) { RenderMenuInSinglePage( menuStack = rememberMenuStack(menu), modifier = modifier, onDismissRequest = onDismissRequest, ) } @Composable fun RenderMenuInSinglePage( menu: MenuItem.SubMenu, onDismissRequest: () -> Unit, modifier: Modifier, ) { RenderMenuInSinglePage( menuStack = rememberMenuStack(menu), modifier = modifier, onDismissRequest = onDismissRequest, ) } ================================================ FILE: android/app/src/main/kotlin/com/abdownloadmanager/android/ui/menu/StackedMenu.kt ================================================ package com.abdownloadmanager.android.ui.menu import androidx.activity.compose.BackHandler import androidx.compose.animation.AnimatedContent import androidx.compose.foundation.layout.Column import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.verticalScroll import androidx.compose.runtime.Composable import androidx.compose.runtime.Stable import androidx.compose.runtime.derivedStateOf import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateListOf import androidx.compose.runtime.remember import androidx.compose.runtime.snapshots.SnapshotStateList import androidx.compose.ui.Modifier import ir.amirab.util.compose.action.MenuItem import ir.amirab.util.compose.asStringSource @Composable fun rememberMenuStack( menu: MenuItem.SubMenu, ): StackMenuState { return remember(menu) { StackMenuState(mutableStateListOf(menu)) } } @Composable fun rememberMenuStack( menu: List, ): StackMenuState { return remember(menu) { StackMenuState( mutableStateListOf( MenuItem.SubMenu( title = "".asStringSource(), items = menu, ) ) ) } } @Stable class StackMenuState(val menu: SnapshotStateList) { val menuStack = menu val currentMenu by derivedStateOf { menuStack.last() } val size by derivedStateOf { menu.size } fun push(newMenu: MenuItem.SubMenu) { menu.add(newMenu) } val canGoBack by derivedStateOf { menuStack.size > 1 } fun pop(): Boolean { if (menuStack.size == 1) { return false } else { menuStack.removeAt(menuStack.lastIndex) return true } } } @Composable fun BaseStackedMenu( menuStack: StackMenuState, onDismissRequest: () -> Unit, modifier: Modifier = Modifier, menuWrapper: (@Composable ( subMenu: MenuItem.SubMenu, renderMenu: @Composable () -> Unit ) -> Unit) = @Composable { _, render -> render() } ) { BackHandler { val menuStack = menuStack if (!menuStack.pop()) { onDismissRequest() } } val currentMenu = menuStack.currentMenu AnimatedContent( currentMenu ) { currentMenu -> Column { menuWrapper(currentMenu) { Menu( menu = currentMenu, onNewMenuSelected = { newMenu -> menuStack.push(newMenu) }, onRequestClose = { onDismissRequest() }, modifier = modifier .verticalScroll(rememberScrollState()), ) } } } } ================================================ FILE: android/app/src/main/kotlin/com/abdownloadmanager/android/ui/myCombinedClickable.kt ================================================ package com.abdownloadmanager.android.ui import androidx.compose.foundation.Indication import androidx.compose.foundation.LocalIndication import androidx.compose.foundation.clickable import androidx.compose.foundation.gestures.detectTapGestures import androidx.compose.foundation.hoverable import androidx.compose.foundation.indication import androidx.compose.foundation.interaction.MutableInteractionSource import androidx.compose.foundation.interaction.PressInteraction import androidx.compose.ui.Modifier import androidx.compose.ui.geometry.Offset import androidx.compose.ui.input.pointer.pointerInput fun Modifier.myCombinedClickable( onClick: ((offset: Offset) -> Unit)? = null, onLongClick: ((offset: Offset) -> Unit)? = null, onDoubleClick: ((offset: Offset) -> Unit)? = null, interactionSource: MutableInteractionSource?, indication: Indication?, ): Modifier { return pointerInput( interactionSource, onClick, onLongClick, onDoubleClick, ) { detectTapGestures( onPress = { offset -> interactionSource?.let { mutableInteractionSource -> val press = PressInteraction.Press(offset) mutableInteractionSource.emit(press) awaitRelease() mutableInteractionSource.emit(PressInteraction.Release(press)) } }, onTap = onClick, onLongPress = onLongClick, onDoubleTap = onDoubleClick ) }.let { if (interactionSource != null && indication != null) { it.indication(interactionSource, indication) } else it } } ================================================ FILE: android/app/src/main/kotlin/com/abdownloadmanager/android/ui/page/PageUI.kt ================================================ package com.abdownloadmanager.android.ui.page import androidx.compose.foundation.basicMarquee import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.RowScope import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.WindowInsets import androidx.compose.foundation.layout.WindowInsetsSides import androidx.compose.foundation.layout.consumeWindowInsets import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.only import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.safeDrawing import androidx.compose.foundation.layout.width import androidx.compose.runtime.Composable import androidx.compose.runtime.Immutable import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableIntStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.composed import androidx.compose.ui.layout.onSizeChanged import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.platform.LocalLayoutDirection import androidx.compose.ui.text.font.FontWeight import com.abdownloadmanager.shared.ui.widget.Text import com.abdownloadmanager.shared.util.div import com.abdownloadmanager.shared.util.ui.LocalContentColor import com.abdownloadmanager.shared.util.ui.theme.mySpacings import com.abdownloadmanager.shared.util.ui.theme.myTextSizes import ir.amirab.util.compose.pxToDp @Immutable data class PageContentParams( val paddingValues: PaddingValues, ) @Composable fun PageUi( header: @Composable () -> Unit, footer: @Composable () -> Unit, modifier: Modifier = Modifier, content: @Composable (params: PageContentParams) -> Unit, ) { var headerHeight by remember { mutableIntStateOf(0) } var footerHeight by remember { mutableIntStateOf(0) } val density = LocalDensity.current val direction = LocalLayoutDirection.current val horizontalInsets = WindowInsets.safeDrawing.only(WindowInsetsSides.Horizontal) val contentPadding = PaddingValues( top = density.run { headerHeight.toDp() }, bottom = density.run { footerHeight.toDp() }, start = horizontalInsets.getLeft(density, direction).pxToDp(density), end = horizontalInsets.getRight(density, direction).pxToDp(density), ) Box( modifier .consumeWindowInsets(horizontalInsets) ) { content( PageContentParams(contentPadding) ) Box( Modifier .onSizeChanged { headerHeight = it.height } .align(Alignment.TopCenter) ) { header() } Box( Modifier .onSizeChanged { footerHeight = it.height } .align(Alignment.BottomCenter) ) { footer() } } } @Composable fun PageHeader( modifier: Modifier = Modifier, headerTitle: @Composable () -> Unit = {}, leadingIcon: (@Composable () -> Unit)? = null, headerActions: @Composable RowScope.() -> Unit = {}, ) { Row( verticalAlignment = Alignment.CenterVertically, modifier = modifier .fillMaxWidth() // .padding(vertical = mySpacings.mediumSpace) .padding(horizontal = mySpacings.mediumSpace) .systemHorizontalPaddings(), ) { leadingIcon?.let { it() Spacer(Modifier.width(mySpacings.smallSpace)) } Box( Modifier.weight(1f), contentAlignment = Alignment.CenterStart, ) { headerTitle() } Spacer(Modifier.width(mySpacings.smallSpace)) Row( verticalAlignment = Alignment.CenterVertically, ) { headerActions() } } } @Composable fun PageTitle( title: String, ) { Text( text = title, fontWeight = FontWeight.Bold, fontSize = myTextSizes.xl, maxLines = 1, modifier = Modifier .padding(start = mySpacings.largeSpace) .padding(vertical = mySpacings.largeSpace) .basicMarquee() ) } @Composable fun PageTitleWithDescription( title: String, description: String, ) { Column( Modifier .padding(start = mySpacings.largeSpace) .padding(vertical = mySpacings.largeSpace) ) { Text( text = title, fontWeight = FontWeight.Bold, fontSize = myTextSizes.xl, modifier = Modifier ) Spacer(Modifier.width(mySpacings.mediumSpace)) Text( text = description, modifier = Modifier, color = LocalContentColor.current / 0.75f ) } } @Composable fun PageFooter( content: @Composable () -> Unit, ) { Box( Modifier.systemHorizontalPaddings() ) { content() } } @Composable fun Modifier.systemHorizontalPaddings(): Modifier { return composed { val horizontalInsets = WindowInsets.safeDrawing.only(WindowInsetsSides.Horizontal) val direction = LocalLayoutDirection.current val density = LocalDensity.current val horizontalPaddings = PaddingValues( start = horizontalInsets.getLeft(density, direction).pxToDp(density), end = horizontalInsets.getRight(density, direction).pxToDp(density), ) this .consumeWindowInsets(horizontalInsets) .padding(horizontalPaddings) } } ================================================ FILE: android/app/src/main/kotlin/com/abdownloadmanager/android/ui/page/PageUtils.kt ================================================ package com.abdownloadmanager.android.ui.page import androidx.compose.foundation.background import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.BoxScope import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.lazy.LazyListState import androidx.compose.runtime.Composable import androidx.compose.runtime.State import androidx.compose.runtime.derivedStateOf import androidx.compose.runtime.getValue import androidx.compose.runtime.remember import androidx.compose.runtime.rememberUpdatedState import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Brush import androidx.compose.ui.graphics.Color import androidx.compose.ui.unit.Dp import com.abdownloadmanager.shared.util.ui.myColors @Composable fun BoxScope.HeaderFade( topHeight: Dp, color: Color = myColors.background, ) { Box( Modifier .align(Alignment.TopCenter) .fillMaxWidth() .height(topHeight) .background( Brush.verticalGradient( listOf( color, Color.Transparent, ) ) ) ) } @Composable fun BoxScope.FooterFade( bottomHeight: Dp, color: Color = myColors.background, ) { Box( Modifier .align(Alignment.BottomCenter) .fillMaxWidth() .height(bottomHeight) .background( Brush.verticalGradient( listOf( Color.Transparent, color, ) ) ) ) } @Composable fun rememberHeaderAlpha( listState: LazyListState, headerHeightPx: Float, ): State { val headerHeightPx by rememberUpdatedState(headerHeightPx) return remember { derivedStateOf { when { listState.firstVisibleItemIndex > 0 -> 1f headerHeightPx == 0f -> 1f else -> { val scrolled = listState.firstVisibleItemScrollOffset.toFloat() (scrolled / headerHeightPx).coerceIn(0f, 1f) } } } } } fun createAlphaForHeader( scrollOffset: Float, headerHeight: Float, ): Float { if (headerHeight == 0f) return 0f return (scrollOffset / headerHeight).coerceIn(0f..1f) } ================================================ FILE: android/app/src/main/kotlin/com/abdownloadmanager/android/ui/widget/ComposeWebView.kt ================================================ /* * Copyright 2021 The Android Open Source Project * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * https://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ package com.abdownloadmanager.android.ui.widget import android.content.Context import android.graphics.Bitmap import android.os.Bundle import android.view.ViewGroup.LayoutParams import android.webkit.WebChromeClient import android.webkit.WebResourceError import android.webkit.WebResourceRequest import android.webkit.WebView import android.webkit.WebViewClient import android.widget.FrameLayout import androidx.activity.compose.BackHandler import androidx.compose.foundation.layout.BoxWithConstraints import androidx.compose.runtime.Composable import androidx.compose.runtime.Immutable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.Stable import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateListOf import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.saveable.Saver import androidx.compose.runtime.saveable.mapSaver import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.setValue import androidx.compose.runtime.snapshotFlow import androidx.compose.runtime.snapshots.SnapshotStateList import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clipToBounds import androidx.compose.ui.viewinterop.AndroidView import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.launch import kotlinx.coroutines.withContext /** * A wrapper around the Android View WebView to provide a basic WebView composable. * * If you require more customisation you are most likely better rolling your own and using this * wrapper as an example. * * The WebView attempts to set the layoutParams based on the Compose modifier passed in. If it * is incorrectly sizing, use the layoutParams composable function instead. * * @param state The webview state holder where the Uri to load is defined. * @param modifier A compose modifier * @param captureBackPresses Set to true to have this Composable capture back presses and navigate * the WebView back. * @param navigator An optional navigator object that can be used to control the WebView's * navigation from outside the composable. * @param onCreated Called when the WebView is first created, this can be used to set additional * settings on the WebView. WebChromeClient and WebViewClient should not be set here as they will be * subsequently overwritten after this lambda is called. * @param onDispose Called when the WebView is destroyed. Provides a bundle which can be saved * if you need to save and restore state in this WebView. * @param client Provides access to WebViewClient via subclassing * @param chromeClient Provides access to WebChromeClient via subclassing * @param factory An optional WebView factory for using a custom subclass of WebView * @sample com.google.accompanist.sample.webview.BasicWebViewSample */ @Composable public fun WebView( state: WebViewState, modifier: Modifier = Modifier, captureBackPresses: Boolean = true, navigator: WebViewNavigator = rememberWebViewNavigator(), onCreated: (WebView) -> Unit = {}, onDispose: (WebView) -> Unit = {}, client: AccompanistWebViewClient = remember { AccompanistWebViewClient() }, chromeClient: AccompanistWebChromeClient = remember { AccompanistWebChromeClient() }, factory: ((Context) -> WebView)? = null, ) { BoxWithConstraints(modifier) { // WebView changes it's layout strategy based on // it's layoutParams. We convert from Compose Modifier to // layout params here. val width = if (constraints.hasFixedWidth) LayoutParams.MATCH_PARENT else LayoutParams.WRAP_CONTENT val height = if (constraints.hasFixedHeight) LayoutParams.MATCH_PARENT else LayoutParams.WRAP_CONTENT val layoutParams = FrameLayout.LayoutParams( width, height ) WebView( state, layoutParams, Modifier, captureBackPresses, navigator, onCreated, onDispose, client, chromeClient, factory ) } } /** * A wrapper around the Android View WebView to provide a basic WebView composable. * * If you require more customisation you are most likely better rolling your own and using this * wrapper as an example. * * The WebView attempts to set the layoutParams based on the Compose modifier passed in. If it * is incorrectly sizing, use the layoutParams composable function instead. * * @param state The webview state holder where the Uri to load is defined. * @param layoutParams A FrameLayout.LayoutParams object to custom size the underlying WebView. * @param modifier A compose modifier * @param captureBackPresses Set to true to have this Composable capture back presses and navigate * the WebView back. * @param navigator An optional navigator object that can be used to control the WebView's * navigation from outside the composable. * @param onCreated Called when the WebView is first created, this can be used to set additional * settings on the WebView. WebChromeClient and WebViewClient should not be set here as they will be * subsequently overwritten after this lambda is called. * @param onDispose Called when the WebView is destroyed. Provides a bundle which can be saved * if you need to save and restore state in this WebView. * @param client Provides access to WebViewClient via subclassing * @param chromeClient Provides access to WebChromeClient via subclassing * @param factory An optional WebView factory for using a custom subclass of WebView */ @Composable public fun WebView( state: WebViewState, layoutParams: FrameLayout.LayoutParams, modifier: Modifier = Modifier, captureBackPresses: Boolean = true, navigator: WebViewNavigator = rememberWebViewNavigator(), onCreated: (WebView) -> Unit = {}, onDispose: (WebView) -> Unit = {}, client: AccompanistWebViewClient = remember { AccompanistWebViewClient() }, chromeClient: AccompanistWebChromeClient = remember { AccompanistWebChromeClient() }, factory: ((Context) -> WebView)? = null, ) { val webView = state.webView BackHandler(captureBackPresses && navigator.canGoBack) { webView?.goBack() } webView?.let { wv -> LaunchedEffect(wv, navigator) { with(navigator) { wv.handleNavigationEvents() } } LaunchedEffect(wv, state) { snapshotFlow { state.content }.collect { content -> when (content) { is WebContent.Url -> { wv.loadUrl(content.url, content.additionalHttpHeaders) } is WebContent.Data -> { wv.loadDataWithBaseURL( content.baseUrl, content.data, content.mimeType, content.encoding, content.historyUrl ) } is WebContent.Post -> { wv.postUrl( content.url, content.postData ) } is WebContent.NavigatorOnly -> { // NO-OP } } } } } // Set the state of the client and chrome client // This is done internally to ensure they always are the same instance as the // parent Web composable client.state = state client.navigator = navigator chromeClient.state = state AndroidView( factory = { context -> (factory?.invoke(context) ?: WebView(context)).apply { onCreated(this) this.layoutParams = layoutParams state.viewState?.let { this.restoreState(it) } webChromeClient = chromeClient webViewClient = client }.also { state.webView = it } }, modifier = modifier.clipToBounds(), onRelease = { onDispose(it) } ) } /** * AccompanistWebViewClient * * A parent class implementation of WebViewClient that can be subclassed to add custom behaviour. * * As Accompanist Web needs to set its own web client to function, it provides this intermediary * class that can be overriden if further custom behaviour is required. */ public open class AccompanistWebViewClient : WebViewClient() { public open lateinit var state: WebViewState internal set public open lateinit var navigator: WebViewNavigator internal set override fun onPageStarted(view: WebView, url: String?, favicon: Bitmap?) { super.onPageStarted(view, url, favicon) state.loadingState = LoadingState.Loading(0.0f) state.errorsForCurrentRequest.clear() state.pageTitle = null state.pageIcon = null state.lastLoadedUrl = url } override fun onPageFinished(view: WebView, url: String?) { super.onPageFinished(view, url) state.loadingState = LoadingState.Finished } override fun doUpdateVisitedHistory(view: WebView, url: String?, isReload: Boolean) { super.doUpdateVisitedHistory(view, url, isReload) navigator.canGoBack = view.canGoBack() navigator.canGoForward = view.canGoForward() } override fun onReceivedError( view: WebView, request: WebResourceRequest?, error: WebResourceError? ) { super.onReceivedError(view, request, error) if (error != null) { state.errorsForCurrentRequest.add(WebViewError(request, error)) } } } /** * AccompanistWebChromeClient * * A parent class implementation of WebChromeClient that can be subclassed to add custom behaviour. * * As Accompanist Web needs to set its own web client to function, it provides this intermediary * class that can be overriden if further custom behaviour is required. */ public open class AccompanistWebChromeClient : WebChromeClient() { public open lateinit var state: WebViewState internal set override fun onReceivedTitle(view: WebView, title: String?) { super.onReceivedTitle(view, title) state.pageTitle = title } override fun onReceivedIcon(view: WebView, icon: Bitmap?) { super.onReceivedIcon(view, icon) state.pageIcon = icon } override fun onProgressChanged(view: WebView, newProgress: Int) { super.onProgressChanged(view, newProgress) if (state.loadingState is LoadingState.Finished) return state.loadingState = LoadingState.Loading(newProgress / 100.0f) } } public sealed class WebContent { public data class Url( val url: String, val additionalHttpHeaders: Map = emptyMap(), ) : WebContent() public data class Data( val data: String, val baseUrl: String? = null, val encoding: String = "utf-8", val mimeType: String? = null, val historyUrl: String? = null ) : WebContent() public data class Post( val url: String, val postData: ByteArray ) : WebContent() { override fun equals(other: Any?): Boolean { if (this === other) return true if (javaClass != other?.javaClass) return false other as Post if (url != other.url) return false if (!postData.contentEquals(other.postData)) return false return true } override fun hashCode(): Int { var result = url.hashCode() result = 31 * result + postData.contentHashCode() return result } } @Deprecated("Use state.lastLoadedUrl instead") public fun getCurrentUrl(): String? { return when (this) { is Url -> url is Data -> baseUrl is Post -> url is NavigatorOnly -> throw IllegalStateException("Unsupported") } } public object NavigatorOnly : WebContent() companion object { fun fromNullableUrl(url: String?): WebContent { return when (url) { null -> NavigatorOnly else -> Url(url) } } } } internal fun WebContent.withUrl(url: String) = when (this) { is WebContent.Url -> copy(url = url) else -> WebContent.Url(url) } /** * Sealed class for constraining possible loading states. * See [Loading] and [Finished]. */ public sealed class LoadingState { /** * Describes a WebView that has not yet loaded for the first time. */ public object Initializing : LoadingState() /** * Describes a webview between `onPageStarted` and `onPageFinished` events, contains a * [progress] property which is updated by the webview. */ public data class Loading(val progress: Float) : LoadingState() /** * Describes a webview that has finished loading content. */ public object Finished : LoadingState() } /** * A state holder to hold the state for the WebView. In most cases this will be remembered * using the rememberWebViewState(uri) function. */ @Stable public class WebViewState(webContent: WebContent) { public var lastLoadedUrl: String? by mutableStateOf(null) internal set /** * The content being loaded by the WebView */ public var content: WebContent by mutableStateOf(webContent) /** * Whether the WebView is currently [LoadingState.Loading] data in its main frame (along with * progress) or the data loading has [LoadingState.Finished]. See [LoadingState] */ public var loadingState: LoadingState by mutableStateOf(LoadingState.Initializing) internal set /** * Whether the webview is currently loading data in its main frame */ public val isLoading: Boolean get() = loadingState !is LoadingState.Finished /** * The title received from the loaded content of the current page */ public var pageTitle: String? by mutableStateOf(null) internal set /** * the favicon received from the loaded content of the current page */ public var pageIcon: Bitmap? by mutableStateOf(null) internal set /** * A list for errors captured in the last load. Reset when a new page is loaded. * Errors could be from any resource (iframe, image, etc.), not just for the main page. * For more fine grained control use the OnError callback of the WebView. */ public val errorsForCurrentRequest: SnapshotStateList = mutableStateListOf() /** * The saved view state from when the view was destroyed last. To restore state, * use the navigator and only call loadUrl if the bundle is null. * See WebViewSaveStateSample. */ public var viewState: Bundle? = null internal set // We need access to this in the state saver. An internal DisposableEffect or AndroidView // onDestroy is called after the state saver and so can't be used. internal var webView by mutableStateOf(null) } /** * Allows control over the navigation of a WebView from outside the composable. E.g. for performing * a back navigation in response to the user clicking the "up" button in a TopAppBar. * * @see [rememberWebViewNavigator] */ @Stable public class WebViewNavigator(private val coroutineScope: CoroutineScope) { private sealed interface NavigationEvent { object Back : NavigationEvent object Forward : NavigationEvent object Reload : NavigationEvent object StopLoading : NavigationEvent data class LoadUrl( val url: String, val additionalHttpHeaders: Map = emptyMap() ) : NavigationEvent data class LoadHtml( val html: String, val baseUrl: String? = null, val mimeType: String? = null, val encoding: String? = "utf-8", val historyUrl: String? = null ) : NavigationEvent data class PostUrl( val url: String, val postData: ByteArray ) : NavigationEvent { override fun equals(other: Any?): Boolean { if (this === other) return true if (javaClass != other?.javaClass) return false other as PostUrl if (url != other.url) return false if (!postData.contentEquals(other.postData)) return false return true } override fun hashCode(): Int { var result = url.hashCode() result = 31 * result + postData.contentHashCode() return result } } } private val navigationEvents: MutableSharedFlow = MutableSharedFlow(replay = 1) // Use Dispatchers.Main to ensure that the webview methods are called on UI thread internal suspend fun WebView.handleNavigationEvents(): Nothing = withContext(Dispatchers.Main) { navigationEvents.collect { event -> when (event) { is NavigationEvent.Back -> goBack() is NavigationEvent.Forward -> goForward() is NavigationEvent.Reload -> reload() is NavigationEvent.StopLoading -> stopLoading() is NavigationEvent.LoadHtml -> loadDataWithBaseURL( event.baseUrl, event.html, event.mimeType, event.encoding, event.historyUrl ) is NavigationEvent.LoadUrl -> { loadUrl(event.url, event.additionalHttpHeaders) } is NavigationEvent.PostUrl -> { postUrl(event.url, event.postData) } } } } /** * True when the web view is able to navigate backwards, false otherwise. */ public var canGoBack: Boolean by mutableStateOf(false) internal set /** * True when the web view is able to navigate forwards, false otherwise. */ public var canGoForward: Boolean by mutableStateOf(false) internal set public fun loadUrl(url: String, additionalHttpHeaders: Map = emptyMap()) { coroutineScope.launch { navigationEvents.emit( NavigationEvent.LoadUrl( url, additionalHttpHeaders ) ) } } public fun loadHtml( html: String, baseUrl: String? = null, mimeType: String? = null, encoding: String? = "utf-8", historyUrl: String? = null ) { coroutineScope.launch { navigationEvents.emit( NavigationEvent.LoadHtml( html, baseUrl, mimeType, encoding, historyUrl ) ) } } public fun postUrl( url: String, postData: ByteArray ) { coroutineScope.launch { navigationEvents.emit( NavigationEvent.PostUrl( url, postData ) ) } } /** * Navigates the webview back to the previous page. */ public fun navigateBack() { coroutineScope.launch { navigationEvents.emit(NavigationEvent.Back) } } /** * Navigates the webview forward after going back from a page. */ public fun navigateForward() { coroutineScope.launch { navigationEvents.emit(NavigationEvent.Forward) } } /** * Reloads the current page in the webview. */ public fun reload() { coroutineScope.launch { navigationEvents.emit(NavigationEvent.Reload) } } /** * Stops the current page load (if one is loading). */ public fun stopLoading() { coroutineScope.launch { navigationEvents.emit(NavigationEvent.StopLoading) } } } /** * Creates and remembers a [WebViewNavigator] using the default [CoroutineScope] or a provided * override. */ @Composable public fun rememberWebViewNavigator( coroutineScope: CoroutineScope = rememberCoroutineScope() ): WebViewNavigator = remember(coroutineScope) { WebViewNavigator(coroutineScope) } /** * A wrapper class to hold errors from the WebView. */ @Immutable public data class WebViewError( /** * The request the error came from. */ val request: WebResourceRequest?, /** * The error that was reported. */ val error: WebResourceError ) /** * Creates a WebView state that is remembered across Compositions. * * @param url The url to load in the WebView * @param additionalHttpHeaders Optional, additional HTTP headers that are passed to [WebView.loadUrl]. * Note that these headers are used for all subsequent requests of the WebView. */ @Composable public fun rememberWebViewState( url: String, additionalHttpHeaders: Map = emptyMap() ): WebViewState = // Rather than using .apply {} here we will recreate the state, this prevents // a recomposition loop when the webview updates the url itself. remember { WebViewState( WebContent.Url( url = url, additionalHttpHeaders = additionalHttpHeaders ) ) }.apply { this.content = WebContent.Url( url = url, additionalHttpHeaders = additionalHttpHeaders ) } /** * Creates a WebView state that is remembered across Compositions. * * @param data The uri to load in the WebView */ @Composable public fun rememberWebViewStateWithHTMLData( data: String, baseUrl: String? = null, encoding: String = "utf-8", mimeType: String? = null, historyUrl: String? = null ): WebViewState = remember { WebViewState(WebContent.Data(data, baseUrl, encoding, mimeType, historyUrl)) }.apply { this.content = WebContent.Data( data, baseUrl, encoding, mimeType, historyUrl ) } /** * Creates a WebView state that is remembered across Compositions. * * @param url The url to load in the WebView * @param postData The data to be posted to the WebView with the url */ @Composable public fun rememberWebViewState( url: String, postData: ByteArray ): WebViewState = // Rather than using .apply {} here we will recreate the state, this prevents // a recomposition loop when the webview updates the url itself. remember { WebViewState( WebContent.Post( url = url, postData = postData ) ) }.apply { this.content = WebContent.Post( url = url, postData = postData ) } /** * Creates a WebView state that is remembered across Compositions and saved * across activity recreation. * When using saved state, you cannot change the URL via recomposition. The only way to load * a URL is via a WebViewNavigator. * * @param data The uri to load in the WebView */ @Composable public fun rememberSaveableWebViewState(): WebViewState = rememberSaveable(saver = WebStateSaver) { WebViewState(WebContent.NavigatorOnly) } public val WebStateSaver: Saver = run { val pageTitleKey = "pagetitle" val lastLoadedUrlKey = "lastloaded" val stateBundle = "bundle" mapSaver( save = { val viewState = Bundle().apply { it.webView?.saveState(this) } mapOf( pageTitleKey to it.pageTitle, lastLoadedUrlKey to it.lastLoadedUrl, stateBundle to viewState ) }, restore = { WebViewState(WebContent.NavigatorOnly).apply { this.pageTitle = it[pageTitleKey] as String? this.lastLoadedUrl = it[lastLoadedUrlKey] as String? this.viewState = it[stateBundle] as Bundle? } } ) } ================================================ FILE: android/app/src/main/kotlin/com/abdownloadmanager/android/util/ABDMAppManager.kt ================================================ package com.abdownloadmanager.android.util import android.content.BroadcastReceiver import android.content.Context import android.content.Intent import android.content.IntentFilter import android.widget.Toast import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue import androidx.compose.runtime.rememberCoroutineScope import androidx.core.content.ContextCompat import com.abdownloadmanager.android.pages.onboarding.permissions.PermissionManager import com.abdownloadmanager.android.service.DownloadSystemService import com.abdownloadmanager.android.service.KeepAliveServiceReason import com.abdownloadmanager.android.storage.AppSettingsStorage import com.abdownloadmanager.android.util.notification.playNotificationSoundIfAllowed import com.abdownloadmanager.resources.Res import com.abdownloadmanager.shared.pagemanager.NotificationSender import com.abdownloadmanager.shared.ui.widget.MessageDialogType import com.abdownloadmanager.shared.ui.widget.NotificationManager import com.abdownloadmanager.shared.ui.widget.NotificationType import com.abdownloadmanager.shared.util.DownloadSystem import com.abdownloadmanager.shared.util.category.CategorySelectionMode import ir.amirab.downloader.DownloadManagerEvents import ir.amirab.downloader.NewDownloadItemProps import ir.amirab.downloader.downloaditem.contexts.ResumedBy import ir.amirab.downloader.downloaditem.contexts.User import ir.amirab.downloader.exception.TooManyErrorException import ir.amirab.downloader.queue.DefaultQueueInfo import ir.amirab.downloader.queue.activeQueuesFlow import ir.amirab.downloader.queue.queueModelsFlow import ir.amirab.downloader.utils.ExceptionUtils import ir.amirab.util.compose.StringSource import ir.amirab.util.compose.asStringSource import ir.amirab.util.compose.combineStringSources import ir.amirab.util.coroutines.launchWithDeferred import ir.amirab.util.guardedEntry import ir.amirab.util.suspendGuardedEntry import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Deferred import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.Job import kotlinx.coroutines.currentCoroutineContext import kotlinx.coroutines.delay import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.distinctUntilChanged import kotlinx.coroutines.flow.emptyFlow import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.flatMapLatest import kotlinx.coroutines.flow.flow import kotlinx.coroutines.flow.launchIn import kotlinx.coroutines.flow.onEach import kotlinx.coroutines.job import kotlinx.coroutines.launch import kotlinx.coroutines.withContext import org.koin.core.component.KoinComponent import java.util.UUID import kotlin.system.exitProcess class ABDMAppManager( private val context: Context, private val scope: CoroutineScope, val downloadSystem: DownloadSystem, val permissionManager: PermissionManager, val notificationManager: NotificationManager, val serviceNotificationManager: ABDMServiceNotificationManager, private val appSettingsStorage: AppSettingsStorage, ) : KoinComponent, NotificationSender { private var booted = guardedEntry() private var downloadSystemBooted = suspendGuardedEntry() fun isSoundAllowed(): Boolean { return appSettingsStorage.notificationSound.value } fun boot() { booted.action { registerAsFallbackNotification() } } fun canStartDownloadEngine(): Boolean { return permissionManager.isReady() } fun isDownloadSystemBooted(): Boolean { return downloadSystemBooted.isDone() } fun isBackgroundServiceRunning(): Boolean { return DownloadSystemService.isServiceRunning() } suspend fun startDownloadSystem() { downloadSystemBooted.action { downloadSystem.boot() registerReceivers() registerDownloadEventNotifications() } } private var shouldShowToastsNotifications = MutableStateFlow(true) fun setNotificationsHandledInUi(shownInUi: Boolean) { shouldShowToastsNotifications.value = !shownInUi } private fun registerAsFallbackNotification(): () -> Unit { val context = context var lastNotificationSound = 0L val job = scope.headlessComposeRuntime { val scope = rememberCoroutineScope() val notifications by notificationManager.activeNotificationList.collectAsState() val shouldShowNotifications by shouldShowToastsNotifications.collectAsState() if (!shouldShowNotifications) { return@headlessComposeRuntime } notifications .firstOrNull()?.let { notification -> DisposableEffect(notification) { val title = notification.title.getString() val description = notification.description.getString() val iconText = when (notification.notificationType) { NotificationType.Error -> "❌" NotificationType.Info -> "ℹ\uFE0F" is NotificationType.Loading -> "⏳" NotificationType.Success -> "✔\uFE0F" NotificationType.Warning -> "⚠\uFE0F" } val fullTitle = "$iconText $title - $description" val toastJob = scope.launch(Dispatchers.Main) { val toast = Toast.makeText( context, fullTitle, Toast.LENGTH_LONG, ) if (isSoundAllowed()) { val now = System.currentTimeMillis() val sinceLastSoundMillis = now - lastNotificationSound // don't repeatedly play notification! if (sinceLastSoundMillis > 5_000) { runCatching { playNotificationSoundIfAllowed(context) lastNotificationSound = now }.onFailure { it.printStackTrace() } } } toast.show() currentCoroutineContext().job.invokeOnCompletion { it?.let { toast.cancel() } } } onDispose { scope.launch(Dispatchers.Main) { toastJob.cancel() } } } } } return { job.cancel() } } private fun registerDownloadEventNotifications() { downloadSystem.downloadEvents.onEach { onNewDownloadEvent(it) }.launchIn(scope) } private fun onNewDownloadEvent(it: DownloadManagerEvents) { if (it.context[ResumedBy]?.by !is User) { //only notify events that is started by user return } if (it is DownloadManagerEvents.OnJobCanceled) { val exception = it.e if (ExceptionUtils.isNormalCancellation(exception)) { return } var isMaxTryReachedError = false val actualCause = if (exception is TooManyErrorException) { isMaxTryReachedError = true exception.findActualDownloadErrorCause() } else exception if (ExceptionUtils.isNormalCancellation(actualCause)) { return } val prefix = if (isMaxTryReachedError) { "Too Many Error: " } else { "Error: " }.asStringSource() val reason = actualCause.message?.asStringSource() ?: Res.string.unknown.asStringSource() sendNotification( "downloadId=${it.downloadItem.id}", description = it.downloadItem.name.asStringSource(), title = listOf(prefix, reason).combineStringSources(), type = NotificationType.Error, ) } if (it is DownloadManagerEvents.OnJobCompleted) { sendNotification( tag = "downloadId=${it.downloadItem.id}", description = it.downloadItem.name.asStringSource(), title = Res.string.finished.asStringSource(), type = NotificationType.Success, ) } } suspend fun awaitDownloadEngineBoot() { downloadSystemBooted.awaitDone() } private fun registerReceivers(): () -> Unit { val receiver = object : BroadcastReceiver() { override fun onReceive(context: Context, intent: Intent) { when (intent.action) { AndroidConstants.Intents.STOP_ACTION -> { intent .getLongExtra(AndroidConstants.Intents.TOGGLE_DOWNLOAD_ACTION_DOWNLOAD_ID, -1) .takeIf { it > -1 } ?.let { scope.launch { downloadSystem.manualPause(it) } } } AndroidConstants.Intents.RESUME_ACTION -> { intent .getLongExtra(AndroidConstants.Intents.TOGGLE_DOWNLOAD_ACTION_DOWNLOAD_ID, -1) .takeIf { it > -1 } ?.let { scope.launch { downloadSystem.userManualResume(it) } } } AndroidConstants.Intents.TOGGLE_ACTION -> { intent .getLongExtra(AndroidConstants.Intents.TOGGLE_DOWNLOAD_ACTION_DOWNLOAD_ID, -1) .takeIf { it > -1 } ?.let { scope.launch { TODO("Toggle action not implemented yet") } } } AndroidConstants.Intents.STOP_ALL_ACTION -> { scope.launch { downloadSystem.stopAnything() } } AndroidConstants.Intents.EXIT_ACTION -> { val job = scope.launch { downloadSystem.stopAnything() stopOurService() } job.invokeOnCompletion { exitProcess(0) } } } } } ContextCompat.registerReceiver( context, receiver, IntentFilter().apply { addAction(AndroidConstants.Intents.TOGGLE_ACTION) addAction(AndroidConstants.Intents.RESUME_ACTION) addAction(AndroidConstants.Intents.STOP_ACTION) addAction(AndroidConstants.Intents.STOP_ALL_ACTION) addAction(AndroidConstants.Intents.EXIT_ACTION) }, ContextCompat.RECEIVER_EXPORTED, ) return { for (receiver in listOf(receiver)) { context.unregisterReceiver(receiver) } } } suspend fun startOurService() { awaitDownloadEngineBoot() val intent = Intent(context, DownloadSystemService::class.java) withContext(Dispatchers.Main) { ContextCompat.startForegroundService(context, intent) } DownloadSystemService.awaitStart() autoStopService() } suspend fun stopOurService() { awaitDownloadEngineBoot() val intent = Intent(context, DownloadSystemService::class.java) withContext(Dispatchers.Main) { context.stopService(intent) } } fun startNewDownload( item: NewDownloadItemProps, categoryId: Long?, ): Deferred { return scope.launchWithDeferred { downloadSystem.addDownload( newDownload = item, queueId = DefaultQueueInfo.ID, categoryId = categoryId, ).also { downloadSystem.userManualResume(it) } } } fun addDownload( item: NewDownloadItemProps, queueId: Long?, categoryId: Long?, ): Deferred { return scope.launchWithDeferred { downloadSystem.addDownload( newDownload = item, queueId = queueId, categoryId = categoryId, ) } } fun addDownloads( items: List, categorySelectionMode: CategorySelectionMode?, queueId: Long?, ): Deferred> { return scope.launchWithDeferred { downloadSystem.addDownload( newItemsToAdd = items, queueId = queueId, categorySelectionMode = categorySelectionMode, ) } } override fun sendDialogNotification( title: StringSource, description: StringSource, type: MessageDialogType ) { sendNotification( title = title, description = description, type = when (type) { MessageDialogType.Info -> NotificationType.Info MessageDialogType.Error -> NotificationType.Error MessageDialogType.Success -> NotificationType.Success MessageDialogType.Warning -> NotificationType.Warning }, tag = UUID.randomUUID(), ) } override fun sendNotification( tag: Any, title: StringSource, description: StringSource, type: NotificationType ) { scope.launch { notificationManager.showNotification( title, description, delay = 5_000, type = type, ) } } /** * in case of the notification permission is granted recently * we ask service notification manager to repost the notification */ fun repostServiceNotification() { serviceNotificationManager.updateNotificationWithDefaultValue() } fun bootDownloadSystemAndService(): Boolean { if (isDownloadSystemBooted() && isBackgroundServiceRunning()) { return true } if (canStartDownloadEngine()) { scope.launch { startDownloadSystem() if (!isBackgroundServiceRunning()) { startOurService() } } return true } return false } private val mustStayAliveFlow = combine( downloadSystem.downloadMonitor.activeDownloadCount, downloadSystem.queueManager.activeQueuesFlow(), downloadSystem.queueManager.queueModelsFlow(), ApplicationBackgroundTracker.isInBackgroundFlow, ) { activeDownloads, activeQueues, queueModels, isInBackground -> if (activeQueues.isNotEmpty()) { return@combine KeepAliveServiceReason.ActiveQueue(activeQueues.map { it.getQueueModel() }) } if (activeDownloads > 0) { return@combine KeepAliveServiceReason.ActiveDownloads(activeDownloads) } val scheduledTimeQueue = queueModels.filter { it.scheduledTimes.enabledStartTime } if (scheduledTimeQueue.isNotEmpty()) { return@combine KeepAliveServiceReason.ScheduledQueues(scheduledTimeQueue) } if (!isInBackground) { return@combine KeepAliveServiceReason.AppIsInForeground } return@combine null } private var autoStopServiceJob: Job? = null @OptIn(ExperimentalCoroutinesApi::class) private fun autoStopService() { synchronized(this) { autoStopServiceJob?.cancel() autoStopServiceJob = scope.launch { mustStayAliveFlow .distinctUntilChanged() .onEach { serviceNotificationManager.setKeepAliveServiceReason(it) } .flatMapLatest { if (it == null) flow { // let it be null for 10 seconds delay(10_000) emit(Unit) } else emptyFlow() }.first() stopOurService() } } } } ================================================ FILE: android/app/src/main/kotlin/com/abdownloadmanager/android/util/ABDMServiceNotificationManager.kt ================================================ package com.abdownloadmanager.android.util import android.app.Notification import android.app.NotificationChannel import android.app.NotificationManager import android.app.PendingIntent import android.content.BroadcastReceiver import android.content.Context import android.content.Intent import android.content.IntentFilter import androidx.compose.runtime.Composable import androidx.compose.runtime.CompositionLocalProvider import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.collectAsState import androidx.compose.runtime.compositionLocalOf import androidx.compose.runtime.getValue import androidx.compose.runtime.key import androidx.compose.runtime.remember import androidx.core.app.NotificationCompat import androidx.core.app.NotificationManagerCompat import androidx.core.content.ContextCompat import com.abdownloadmanager.android.ui.MainActivity import com.abdownloadmanager.resources.Res import ir.amirab.util.compose.asStringSource import com.abdownloadmanager.android.R import com.abdownloadmanager.android.pages.singledownload.SingleDownloadPageActivity import com.abdownloadmanager.android.service.KeepAliveServiceReason import com.abdownloadmanager.shared.util.SizeAndSpeedUnitProvider import com.abdownloadmanager.shared.util.TimeNames import com.abdownloadmanager.shared.util.convertPositiveSpeedToHumanReadable import com.abdownloadmanager.shared.util.convertTimeRemainingToHumanReadable import ir.amirab.downloader.DownloadManagerMinimalControl import ir.amirab.downloader.downloaditem.DownloadJobStatus import ir.amirab.downloader.monitor.IDownloadMonitor import ir.amirab.downloader.monitor.ProcessingDownloadItemState import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Job import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.update class ABDMServiceNotificationManager( private val context: Context, private val downloadMonitor: IDownloadMonitor, private val scope: CoroutineScope, private val downloadEvents: DownloadManagerMinimalControl, private val sizeAndSpeedUnitProvider: SizeAndSpeedUnitProvider, ) { private val _keepAliveServiceReason: MutableStateFlow = MutableStateFlow(null) fun setKeepAliveServiceReason(reason: KeepAliveServiceReason?) { _keepAliveServiceReason.value = reason } val notificationCreationTime = System.currentTimeMillis() private val notificationManagerCompat by lazy { NotificationManagerCompat.from(context) } init { registerReceiver() } fun registerReceiver() { ContextCompat.registerReceiver( context, object : BroadcastReceiver() { override fun onReceive(context: Context, intent: Intent) { when (intent.action) { AndroidConstants.Intents.NOTIFICATION_DELETED -> { onNotificationDismissed() } } } }, IntentFilter().apply { this.addAction(AndroidConstants.Intents.NOTIFICATION_DELETED) }, ContextCompat.RECEIVER_EXPORTED, ) } fun updateNotificationWithDefaultValue() { notificationUpdateSignal.update { it + 1 } } fun onNotificationDismissed() { if (notificationUpdateJob?.isActive == true) { updateNotificationWithDefaultValue() } } fun initNotificationChannel() { val notificationChanel = NotificationChannel( AndroidConstants.NOTIFICATION_DOWNLOAD_CHANEL_ID, AndroidConstants.NOTIFICATION_DOWNLOAD_CHANEL_NAME, NotificationManager.IMPORTANCE_LOW, ) notificationChanel.setShowBadge(false) notificationManagerCompat.createNotificationChannel(notificationChanel) } private val notificationUpdateSignal = MutableStateFlow(0) val downloads = downloadMonitor .activeDownloadListFlow private var notificationUpdateJob: Job? = null fun startUpdatingNotifications() { synchronized(this) { notificationUpdateJob?.cancel() notificationUpdateJob = scope.headlessComposeRuntime { RenderNotifications(downloadMonitor) } } } fun stopUpdatingNotifications() { notificationUpdateJob?.cancel() notificationUpdateJob = null } private fun getNotificationIdForDownloadItem(downloadId: Long): Int { return AndroidConstants.SERVICE_NOTIFICATION_ID + 1 + downloadId.hashCode() } private fun dismissDownloadNotification(downloadId: Long) { notificationManagerCompat.cancel(getNotificationIdForDownloadItem(downloadId)) } fun dismissNotification() { notificationManagerCompat.cancel( AndroidConstants.SERVICE_NOTIFICATION_ID, ) } fun createMainNotification(): Notification { return createMainNotification(null, null) } fun createMainNotification( reason: KeepAliveServiceReason?, statusString: String?, ): Notification { val flagOfPendingIntent = PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT val serviceIsRunningText = Res.string.service_is_running.asStringSource().getString() val exit = Res.string.exit.asStringSource().getString() val stopAll = Res.string.stop_all.asStringSource().getString() val openMainActivityIntent = PendingIntent.getActivity( context, AndroidConstants.SERVICE_NOTIFICATION_ID, Intent(context, MainActivity::class.java), flagOfPendingIntent ) return NotificationCompat .Builder(context, AndroidConstants.NOTIFICATION_DOWNLOAD_CHANEL_ID) .setContentTitle(serviceIsRunningText) .setContentText(statusString) .setSmallIcon(R.drawable.ic_monochrome) // group // .setGroupSummary(true) // .setGroup(DOWNLOAD_GROUP_NAME) .setOnlyAlertOnce(true) .setOngoing(true) .setShowWhen(false) .setWhen(notificationCreationTime) .setPriority(NotificationCompat.PRIORITY_LOW) // prevent delete by user until we are active .setDeleteIntent( PendingIntent.getBroadcast( context, AndroidConstants.SERVICE_NOTIFICATION_ID, Intent(AndroidConstants.Intents.NOTIFICATION_DELETED), flagOfPendingIntent, ) ) // actions .setContentIntent(openMainActivityIntent) .addAction( 0, exit, PendingIntent.getBroadcast( context, AndroidConstants.SERVICE_NOTIFICATION_ID, Intent(AndroidConstants.Intents.EXIT_ACTION), flagOfPendingIntent, ) ) .addAction( 0, stopAll, PendingIntent.getBroadcast( context, AndroidConstants.SERVICE_NOTIFICATION_ID, Intent(AndroidConstants.Intents.STOP_ALL_ACTION), flagOfPendingIntent, ) ) .build() } fun createDownloadItemNotification( downloadItemState: ProcessingDownloadItemState ): Notification { val flagOfPendingIntent = PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT val title = downloadItemState.name val speedUnit = sizeAndSpeedUnitProvider.speedUnit.value val percent = downloadItemState.percent?.let { "$it%" } val eta = downloadItemState.remainingTime?.let { convertTimeRemainingToHumanReadable(it, TimeNames.ShortNames) } val speed = convertPositiveSpeedToHumanReadable(downloadItemState.speed, speedUnit) val statusString = listOfNotNull(speed, eta) .joinToString(" - ") .takeIf { it.isNotEmpty() } val openMainActivityIntent = PendingIntent.getActivity( context, AndroidConstants.SERVICE_NOTIFICATION_ID, SingleDownloadPageActivity.createIntent( context, downloadItemState.id, true ), flagOfPendingIntent ) val status = downloadItemState.status return NotificationCompat .Builder(context, AndroidConstants.NOTIFICATION_DOWNLOAD_CHANEL_ID) .setContentTitle(title) .setContentText(statusString) .setSubText(percent) .setProgress(100, downloadItemState.percent ?: 0, downloadItemState.percent == null) .setSmallIcon(R.drawable.ic_monochrome) .setGroup(DOWNLOAD_GROUP_NAME) .setOngoing(true) .setOnlyAlertOnce(true) .setShowWhen(false) .setWhen(notificationCreationTime) .setPriority(NotificationCompat.PRIORITY_LOW) .apply { if (status is DownloadJobStatus.IsActive) { addAction( 0, Res.string.pause.asStringSource().getString(), PendingIntent.getBroadcast( context, AndroidConstants.SERVICE_NOTIFICATION_ID, Intent(AndroidConstants.Intents.STOP_ACTION).apply { putExtra( AndroidConstants.Intents.TOGGLE_DOWNLOAD_ACTION_DOWNLOAD_ID, downloadItemState.id ) }, flagOfPendingIntent, ) ) } else if (status is DownloadJobStatus.CanBeResumed) { addAction( 0, Res.string.resume.asStringSource().getString(), PendingIntent.getBroadcast( context, AndroidConstants.SERVICE_NOTIFICATION_ID, Intent(AndroidConstants.Intents.RESUME_ACTION).apply { putExtra( AndroidConstants.Intents.TOGGLE_DOWNLOAD_ACTION_DOWNLOAD_ID, downloadItemState.id ) }, flagOfPendingIntent, ) ) } } .setContentIntent(openMainActivityIntent) .build() } @Composable fun RenderNotifications( downloadMonitor: IDownloadMonitor ) { val notFinishedDownloads by downloadMonitor.activeDownloadListFlow.collectAsState() val keepAliveServiceReason by _keepAliveServiceReason.collectAsState() val notifyUpdate by notificationUpdateSignal.collectAsState() CompositionLocalProvider( LocalNotificationUpdateSignal provides notifyUpdate ) { RenderMainNotification( reason = keepAliveServiceReason ) RenderDownloadItemNotifications( remember(notFinishedDownloads) { notFinishedDownloads.filter { it.status is DownloadJobStatus.IsActive } } ) } } @Composable fun RenderMainNotification( reason: KeepAliveServiceReason?, ) { val statusString = reason?.rememberReasonString() LaunchedEffect(reason, statusString, LocalNotificationUpdateSignal.current) { notificationManagerCompat.notify( AndroidConstants.SERVICE_NOTIFICATION_ID, createMainNotification(reason, statusString) ) } DisposableEffect(Unit) { onDispose { dismissNotification() } } } @Composable fun RenderDownloadItemNotifications( activeDownloads: List ) { for (downloadItemState in activeDownloads) { key(downloadItemState.id) { RenderDownloadItemNotification( downloadItemState ) } } } @Composable fun RenderDownloadItemNotification( iDownloadItemState: ProcessingDownloadItemState ) { LaunchedEffect(iDownloadItemState, LocalNotificationUpdateSignal.current) { notificationManagerCompat.notify( getNotificationIdForDownloadItem(iDownloadItemState.id), createDownloadItemNotification(iDownloadItemState) ) } DisposableEffect(iDownloadItemState.id) { onDispose { dismissDownloadNotification(iDownloadItemState.id) } } } companion object { private const val DOWNLOAD_GROUP_NAME = "Downloads" } } private val LocalNotificationUpdateSignal = compositionLocalOf { error("LocalNotificationUpdateSignal not provided") } ================================================ FILE: android/app/src/main/kotlin/com/abdownloadmanager/android/util/AndroidConstants.kt ================================================ package com.abdownloadmanager.android.util object AndroidConstants { const val SERVICE_NOTIFICATION_ID = 1 const val NOTIFICATION_DOWNLOAD_CHANEL_ID = "downloads" const val NOTIFICATION_DOWNLOAD_CHANEL_NAME = "Download Manager Service" const val NOTIFICATION_CRASH_REPORT_CHANEL_ID = "crashReport" const val NOTIFICATION_CRASH_REPORT_CHANEL_NAME = "Crash Report" object Intents { private const val prefix = "com.abdownloadmanager." const val STOP_ALL_ACTION = prefix + "STOP_ALL" const val STOP_ACTION = prefix + "STOP" const val RESUME_ACTION = prefix + "RESUME" const val TOGGLE_ACTION = prefix + "TOGGLE" const val NOTIFICATION_DELETED = prefix + "NOTIFICATION_DELETED" // download id const val TOGGLE_DOWNLOAD_ACTION_DOWNLOAD_ID = "downloadId" const val EXIT_ACTION = prefix + "EXIT" } } ================================================ FILE: android/app/src/main/kotlin/com/abdownloadmanager/android/util/AndroidDefinedPaths.kt ================================================ package com.abdownloadmanager.android.util import com.abdownloadmanager.shared.util.DefinedPaths import okio.Path class AndroidDefinedPaths( dataDir: Path, ) : DefinedPaths( dataDir = dataDir ) { val lastSavedLocationFile = pagesStateDir.resolve("lastSavedLocation.json") val onboardingFile = pagesStateDir.resolve("onboarding.json") val homePageFile = pagesStateDir.resolve("home.json") val browserBookmarksFile = pagesStateDir.resolve("browser_bookmarks.json") } ================================================ FILE: android/app/src/main/kotlin/com/abdownloadmanager/android/util/AndroidDownloadItemOpener.kt ================================================ package com.abdownloadmanager.android.util import com.abdownloadmanager.shared.util.DownloadItemOpener import com.abdownloadmanager.shared.util.DownloadSystem import ir.amirab.downloader.downloaditem.IDownloadItem import ir.amirab.util.osfileutil.FileUtils import java.io.File class AndroidDownloadItemOpener( private val downloadSystem: DownloadSystem ) : DownloadItemOpener { override suspend fun openDownloadItem(id: Long) { downloadSystem.getDownloadItemById(id)?.let { openDownloadItem(it) } } override suspend fun openDownloadItem(downloadItem: IDownloadItem) { try { FileUtils.openFile(File(downloadItem.folder, downloadItem.name)) } catch (e: Exception) { // toast something } } override suspend fun openDownloadItemFolder(id: Long) { downloadSystem.getDownloadItemById(id)?.let { openDownloadItemFolder(it) } } override suspend fun openDownloadItemFolder(downloadItem: IDownloadItem) { try { FileUtils.openFolderOfFile(File(downloadItem.folder, downloadItem.name)) } catch (e: Exception) { // toast something } } } ================================================ FILE: android/app/src/main/kotlin/com/abdownloadmanager/android/util/AndroidGlobalExceptionHandler.kt ================================================ package com.abdownloadmanager.android.util import android.app.NotificationChannel import android.app.NotificationManager import android.app.PendingIntent import android.content.Context import android.content.Intent import android.util.Log import androidx.core.app.NotificationCompat import androidx.core.app.NotificationManagerCompat import com.abdownloadmanager.android.R import com.abdownloadmanager.android.pages.crashreport.CrashReportActivity import kotlin.system.exitProcess class AndroidGlobalExceptionHandler( private val context: Context, private val defaultUncaughtExceptionHandler: Thread.UncaughtExceptionHandler?, ) : Thread.UncaughtExceptionHandler { private val crashNotificationManager = CrashNotificationManager(context) override fun uncaughtException(t: Thread, e: Throwable) { runCatching { handleUncaughtException(t, e) } defaultUncaughtExceptionHandler ?.uncaughtException(t, e) ?: run { Log.e("Crash", e.localizedMessage, e) exitProcess(1) } } private fun handleUncaughtException(t: Thread, e: Throwable) { val intent = CrashReportActivity .createIntent(context, e) .addFlags( Intent.FLAG_ACTIVITY_CLEAR_TOP or Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TASK ) if (ApplicationBackgroundTracker.isInBackground()) { // show a notification so user can press and see the crash screen crashNotificationManager.postNotificationAboutTheCrash(context, intent) } else { // in case we are in the foreground directly show the error context.startActivity(intent) } } private class CrashNotificationManager(private val context: Context) { val notificationManagerCompat by lazy { NotificationManagerCompat.from(context) } private var initialized = false fun initNotificationChannel() { if (initialized) { return } runCatching { val notificationChanel = NotificationChannel( AndroidConstants.NOTIFICATION_CRASH_REPORT_CHANEL_ID, AndroidConstants.NOTIFICATION_CRASH_REPORT_CHANEL_NAME, NotificationManager.IMPORTANCE_LOW, ) notificationChanel.setShowBadge(false) notificationManagerCompat.createNotificationChannel(notificationChanel) } initialized = true } fun postNotificationAboutTheCrash(context: Context, intent: Intent) { initNotificationChannel() val notificationId = 555 val pendingIntent = PendingIntent.getActivity( context, notificationId, intent, PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE ) val notification = NotificationCompat .Builder(context, AndroidConstants.NOTIFICATION_CRASH_REPORT_CHANEL_ID) .setSmallIcon(R.drawable.ic_monochrome) .setContentTitle("Application crashed!") .setSubText("Click to show info") .setGroup("Crash Report") .setPriority(NotificationCompat.PRIORITY_LOW) .setAutoCancel(true) .setContentIntent(pendingIntent) .build() runCatching { notificationManagerCompat.notify(notificationId, notification) } } } } ================================================ FILE: android/app/src/main/kotlin/com/abdownloadmanager/android/util/AndroidIntentUtils.kt ================================================ package com.abdownloadmanager.android.util import android.content.Context import android.content.Intent import androidx.core.content.FileProvider import java.io.File object AndroidIntentUtils { fun shareText(context: Context, text: String) { val intent = Intent(Intent.ACTION_SEND) intent.type = "text/plain" intent.putExtra(Intent.EXTRA_TEXT, text) context.startActivity(Intent.createChooser(intent, "Share Via")) } fun shareFiles(context: Context, files: List) { if (files.isEmpty()) return val uris = files.map { file -> FileProvider.getUriForFile( context, "${context.packageName}.provider", file ) } val intent = if (uris.size == 1) { Intent(Intent.ACTION_SEND).apply { type = "*/*" // You can detect type from file extension if you want putExtra(Intent.EXTRA_STREAM, uris[0]) } } else { Intent(Intent.ACTION_SEND_MULTIPLE).apply { type = "*/*" putParcelableArrayListExtra(Intent.EXTRA_STREAM, ArrayList(uris)) } } intent.addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION) context.startActivity(Intent.createChooser(intent, "Share via")) } } ================================================ FILE: android/app/src/main/kotlin/com/abdownloadmanager/android/util/AndroidUi.kt ================================================ package com.abdownloadmanager.android.util import com.abdownloadmanager.shared.ui.theme.ThemeManager import ir.amirab.util.compose.localizationmanager.LanguageManager import ir.amirab.util.guardedEntry import org.koin.core.component.KoinComponent import org.koin.core.component.inject object AndroidUi : KoinComponent { val themeManager: ThemeManager by inject() val languageManager: LanguageManager by inject() private var booted = guardedEntry() fun boot() { booted.action { themeManager.boot() languageManager.boot() } } } ================================================ FILE: android/app/src/main/kotlin/com/abdownloadmanager/android/util/AppInfo.kt ================================================ package com.abdownloadmanager.android.util import android.app.Application import com.abdownloadmanager.android.BuildConfig import com.abdownloadmanager.shared.util.AppVersion import com.abdownloadmanager.shared.util.SharedConstants import ir.amirab.util.platform.Platform import okio.Path.Companion.toOkioPath object AppInfo { val isInDebugMode: Boolean = BuildConfig.DEBUG lateinit var context: Application fun init(context: Application) { this.context = context } val platform = Platform.Android val version = AppVersion.get() val definedPaths by lazy { AndroidDefinedPaths( dataDir = context.filesDir.resolve( SharedConstants.dataDirName ).toOkioPath() ) } } ================================================ FILE: android/app/src/main/kotlin/com/abdownloadmanager/android/util/ApplicationBackgroundTracker.kt ================================================ package com.abdownloadmanager.android.util import android.app.Activity import android.app.Application import android.os.Bundle import ir.amirab.util.flow.mapStateFlow import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.update object ApplicationBackgroundTracker { fun startTracking(application: Application) { application.registerActivityLifecycleCallbacks(Tracker) } val isInBackgroundFlow = Tracker.count.mapStateFlow { it == 0 } fun isInBackground(): Boolean { return isInBackgroundFlow.value } } private object Tracker : Application.ActivityLifecycleCallbacks { val count = MutableStateFlow(0) override fun onActivityStarted(activity: Activity) { count.update { it + 1 } } override fun onActivityStopped(activity: Activity) { count.update { it - 1 } } override fun onActivityCreated(activity: Activity, savedInstanceState: Bundle?) { } override fun onActivityDestroyed(activity: Activity) { } override fun onActivityPaused(activity: Activity) { } override fun onActivityResumed(activity: Activity) { } override fun onActivitySaveInstanceState(activity: Activity, outState: Bundle) { } } ================================================ FILE: android/app/src/main/kotlin/com/abdownloadmanager/android/util/HeadlessComposeRuntime.kt ================================================ package com.abdownloadmanager.android.util import androidx.compose.runtime.AbstractApplier import androidx.compose.runtime.Composable import androidx.compose.runtime.Composition import androidx.compose.runtime.MonotonicFrameClock import androidx.compose.runtime.Recomposer import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Job import kotlinx.coroutines.launch fun CoroutineScope.headlessComposeRuntime( content: @Composable () -> Unit, ): Job { val effectCoroutineContext = coroutineContext + HeadlessDefaultMonotonicFrameClock val recomposer = Recomposer(effectCoroutineContext) val composition = Composition(UnitApplier, recomposer) composition.setContent(content) val job = launch(HeadlessDefaultMonotonicFrameClock) { try { recomposer.runRecomposeAndApplyChanges() } catch (e: Throwable) { e.printStackTrace() composition.dispose() } } return job } private object UnitApplier : AbstractApplier(Unit) { override fun insertBottomUp(index: Int, instance: Unit) {} override fun insertTopDown(index: Int, instance: Unit) {} override fun move(from: Int, to: Int, count: Int) {} override fun remove(index: Int, count: Int) {} override fun onClear() {} } private object HeadlessDefaultMonotonicFrameClock : MonotonicFrameClock { override suspend fun withFrameNanos(onFrame: (frameTimeNanos: Long) -> R): R { return onFrame(System.nanoTime()) } } ================================================ FILE: android/app/src/main/kotlin/com/abdownloadmanager/android/util/activity/ABDMActivity.kt ================================================ package com.abdownloadmanager.android.util.activity import android.content.Context import android.content.Intent import android.os.Bundle import androidx.activity.ComponentActivity import androidx.activity.SystemBarStyle import androidx.activity.compose.setContent import androidx.activity.enableEdgeToEdge import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.toArgb import androidx.core.view.WindowInsetsControllerCompat import com.abdownloadmanager.android.storage.AndroidOnBoardingStorage import com.abdownloadmanager.android.storage.HomePageStorage import com.abdownloadmanager.android.ui.ABDownloadManagerApplicationContent import com.abdownloadmanager.android.util.ABDMAppManager import com.abdownloadmanager.android.util.AndroidUi import com.abdownloadmanager.shared.repository.BaseAppRepository import com.abdownloadmanager.shared.storage.BaseAppSettingsStorage import com.abdownloadmanager.shared.ui.theme.ThemeManager import com.abdownloadmanager.shared.ui.widget.NotificationManager import com.abdownloadmanager.shared.util.perhostsettings.PerHostSettingsManager import com.abdownloadmanager.shared.util.ui.MyColors import com.arkivanov.decompose.ComponentContext import com.arkivanov.decompose.retainedComponent import ir.amirab.util.compose.IIconResolver import ir.amirab.util.compose.localizationmanager.LanguageManager import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.launch import org.koin.core.component.KoinComponent import org.koin.core.component.inject import kotlin.getValue abstract class ABDMActivity : ComponentActivity(), KoinComponent { val languageManager: LanguageManager by inject() val themeManager: ThemeManager by inject() val appSettingsStorage: BaseAppSettingsStorage by inject() val iconResolver: IIconResolver by inject() val appRepository: BaseAppRepository by inject() val notificationManager: NotificationManager by inject() val applicationScope: CoroutineScope by inject() val perHostSettingsManager: PerHostSettingsManager by inject() val abdmAppManager: ABDMAppManager by inject() val onBoardingStorage: AndroidOnBoardingStorage by inject() val homePageStorage: HomePageStorage by inject() open fun handleIntent(intent: Intent) {} override fun onNewIntent(intent: Intent) { super.onNewIntent(intent) handleIntent(intent) } override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) AndroidUi.boot() val isLight = themeManager.currentThemeColor.value.isLight val transparent = Color.Transparent.toArgb() val systemBarStyle = if (isLight) { SystemBarStyle.light(transparent, transparent) } else { SystemBarStyle.dark(transparent) } enableEdgeToEdge( statusBarStyle = systemBarStyle, navigationBarStyle = systemBarStyle, ) if (savedInstanceState == null) { handleIntent(intent) } } override fun onStart() { super.onStart() abdmAppManager.bootDownloadSystemAndService() } @Composable private fun UpdateSystemBarColors( myColors: MyColors, ) { val window = window val isLight = myColors.isLight LaunchedEffect(isLight) { val windowInsetsController = WindowInsetsControllerCompat(window, window.decorView) windowInsetsController.isAppearanceLightStatusBars = isLight windowInsetsController.isAppearanceLightNavigationBars = isLight } } fun setABDMContent( content: @Composable () -> Unit, ) { setContent { val theme by themeManager.currentThemeColor.collectAsState() UpdateSystemBarColors(theme) ABDownloadManagerApplicationContent( languageManager = languageManager, themeManager = themeManager, appSettingsStorage = appSettingsStorage, iconResolver = iconResolver, appRepository = appRepository, notificationManager = notificationManager, content = content, ) } } fun myRetainedComponent(factory: RetainedComponentContainer.(ComponentContext) -> T): RetainedComponentContainer { return retainedComponent { RetainedComponentContainer(it, factory) } .reinitialize(this) } } ================================================ FILE: android/app/src/main/kotlin/com/abdownloadmanager/android/util/activity/ActivityActions.kt ================================================ package com.abdownloadmanager.android.util.activity import android.content.Intent import com.abdownloadmanager.shared.util.mvi.ContainsEffects interface ActivityActions { fun startActivityAction(intent: Intent) fun finishActivityAction() } ================================================ FILE: android/app/src/main/kotlin/com/abdownloadmanager/android/util/activity/RetainedComponentContainer.kt ================================================ package com.abdownloadmanager.android.util.activity import android.content.Intent import androidx.activity.ComponentActivity import androidx.activity.compose.LocalActivity import androidx.compose.runtime.Composable import com.abdownloadmanager.shared.util.BaseComponent import com.abdownloadmanager.shared.util.mvi.ContainsEffects import com.abdownloadmanager.shared.util.mvi.HandleEffects import com.abdownloadmanager.shared.util.mvi.supportEffects import com.arkivanov.decompose.ComponentContext import com.arkivanov.decompose.childContext import java.lang.ref.WeakReference class RetainedComponentContainer( ctx: ComponentContext, factory: RetainedComponentContainer.(ComponentContext) -> T ) : BaseComponent(ctx), ContainsEffects by supportEffects(), ActivityActions { private var currentActivity: WeakReference = WeakReference(null) fun reinitialize(activity: ComponentActivity) = apply { this.currentActivity = WeakReference(activity) } fun getCurrentActivity(): ComponentActivity? { return currentActivity.get() } override fun startActivityAction(intent: Intent) { sendEffect(Effects.StartActivity(intent)) } override fun finishActivityAction() { sendEffect(Effects.FinishActivity) } // it's better to create scope for factory to prevent accidentally accessing this // for now make sure to not use [component] inside factory! val component: T by lazy { factory(childContext("main")) } sealed interface Effects { data class StartActivity(val intent: Intent) : Effects data object FinishActivity : Effects } } @Composable fun RetainedComponentContainer<*>.HandleActivityEffects() { val activity = LocalActivity.current HandleEffects(this) { when (it) { RetainedComponentContainer.Effects.FinishActivity -> { activity?.finish() } is RetainedComponentContainer.Effects.StartActivity -> { activity?.startActivity(it.intent) } } } } ================================================ FILE: android/app/src/main/kotlin/com/abdownloadmanager/android/util/activity/SerializableExtra.kt ================================================ package com.abdownloadmanager.android.util.activity import android.content.Intent import kotlinx.serialization.KSerializer import kotlinx.serialization.json.Json import kotlinx.serialization.serializer context(json: Json) fun Intent.getSerializedExtra(name: String, serializer: KSerializer): T? { return getStringExtra(name)?.let { json.decodeFromString(serializer, it) } } context(json: Json) fun Intent.putSerializedExtra(name: String, data: T, serializer: KSerializer) { putExtra( name, json.encodeToString(serializer, data) ) } context(json: Json) inline fun Intent.getSerializedExtra(name: String): T? { return getSerializedExtra(name, serializer()) } context(json: Json) inline fun Intent.putSerializedExtra(name: String, data: T) { putSerializedExtra(name, data, serializer()) } ================================================ FILE: android/app/src/main/kotlin/com/abdownloadmanager/android/util/compose/ObserveUiVisibility.kt ================================================ package com.abdownloadmanager.android.util.compose import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.rememberUpdatedState import androidx.compose.runtime.setValue import androidx.lifecycle.Lifecycle import androidx.lifecycle.compose.LocalLifecycleOwner import androidx.lifecycle.compose.currentStateAsState import androidx.lifecycle.repeatOnLifecycle import kotlinx.coroutines.awaitCancellation @Composable fun ObserveUiVisibility( onVisibilityChange: (isVisible: Boolean) -> Unit, ) { val onVisibilityChange by rememberUpdatedState(onVisibilityChange) val lifecycleOwner = LocalLifecycleOwner.current LaunchedEffect(lifecycleOwner) { lifecycleOwner.lifecycle.repeatOnLifecycle(Lifecycle.State.RESUMED) { try { onVisibilityChange(true) awaitCancellation() } finally { onVisibilityChange(false) } } } } @Composable fun rememberIsUiVisible(): Boolean { val lifecycleOwner = LocalLifecycleOwner.current val currentState by lifecycleOwner.lifecycle.currentStateAsState() return currentState.isAtLeast(Lifecycle.State.RESUMED) } ================================================ FILE: android/app/src/main/kotlin/com/abdownloadmanager/android/util/compose/useBack.kt ================================================ package com.abdownloadmanager.android.util.compose import androidx.activity.OnBackPressedDispatcher import androidx.activity.compose.LocalOnBackPressedDispatcherOwner import androidx.compose.runtime.Composable @Composable fun useBack(): OnBackPressedDispatcher? { return LocalOnBackPressedDispatcherOwner.current ?.onBackPressedDispatcher } ================================================ FILE: android/app/src/main/kotlin/com/abdownloadmanager/android/util/notification/playNotificationSoundIfAllowed.kt ================================================ package com.abdownloadmanager.android.util.notification import android.app.NotificationManager import android.content.Context import android.media.AudioAttributes import android.media.AudioManager import android.media.RingtoneManager import androidx.core.content.getSystemService fun playNotificationSoundIfAllowed( context: Context ) { if (isInDNDMode(context)) { return } if (isInSilentMode(context)) { return } val uri = RingtoneManager .getDefaultUri(RingtoneManager.TYPE_NOTIFICATION) ?: return val ringtone = RingtoneManager .getRingtone(context, uri) ?: return ringtone.audioAttributes = AudioAttributes.Builder() .setUsage(AudioAttributes.USAGE_NOTIFICATION_EVENT) .setContentType(AudioAttributes.CONTENT_TYPE_SONIFICATION) .build() ringtone.play() return } private fun isInDNDMode(context: Context): Boolean { val notificationManager = context.getSystemService() ?: return false return notificationManager.currentInterruptionFilter != NotificationManager.INTERRUPTION_FILTER_ALL } private fun isInSilentMode(context: Context): Boolean { val audioManager = context.getSystemService() ?: return false val volume = audioManager.getStreamVolume(AudioManager.STREAM_NOTIFICATION) return audioManager.ringerMode != AudioManager.RINGER_MODE_NORMAL || volume == 0 } ================================================ FILE: android/app/src/main/kotlin/com/abdownloadmanager/android/util/pagemanager/BrowserPageManager.kt ================================================ package com.abdownloadmanager.android.util.pagemanager interface IBrowserPageManager { fun openBrowser(url: String?) } ================================================ FILE: android/app/src/main/kotlin/com/abdownloadmanager/android/util/pagemanager/PermissionsPageManager.kt ================================================ package com.abdownloadmanager.android.util.pagemanager interface PermissionsPageManager { fun openPermissionsPage(openHomeAfterFinish: Boolean) fun closePermissionsPage() } ================================================ FILE: android/app/src/main/res/drawable/ic_launcher_background.xml ================================================ ================================================ FILE: android/app/src/main/res/drawable/ic_launcher_foreground.xml ================================================ ================================================ FILE: android/app/src/main/res/drawable/ic_launcher_monochrome.xml ================================================ ================================================ FILE: android/app/src/main/res/drawable/ic_monochrome.xml ================================================ ================================================ FILE: android/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml ================================================ ================================================ FILE: android/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml ================================================ ================================================ FILE: android/app/src/main/res/values/strings.xml ================================================ AB Download Manager AB DM AB DM Browser ================================================ FILE: android/app/src/main/res/values/theme.xml ================================================ ================================================ FILE: android/app/src/main/res/xml/provider_paths.xml ================================================ ================================================ FILE: build.gradle.kts ================================================ import buildlogic.CiUtils import buildlogic.versioning.getAppVersionString import io.github.z4kn4fein.semver.toVersion import io.github.z4kn4fein.semver.toVersionOrNull import ir.amirab.git_version.core.semanticVersionRegex import org.jetbrains.changelog.Changelog plugins { ir.amirab.`git-version-plugin` /** * retrieve latest versions of dependencies */ com.github.`ben-manes`.versions id(Plugins.changeLog) } val defaultSemVersion = "1.0.0" val fallBackVersion = "$defaultSemVersion-untagged" gitVersion { on { branch(".+") { "$defaultSemVersion-${it.refInfo.shortenName}-snapshot" } tag("v?${semanticVersionRegex}") { it.matchResult.groups.get("version")!!.value } commit { "$defaultSemVersion-sha.${it.refInfo.commitHash.take(5)}" } } } version = (gitVersion.getVersion() ?: fallBackVersion).toVersion() logger.lifecycle("version: $version") tasks.dependencyUpdates { revision = "release" outputFormatter = "html" rejectVersionIf { val candidateVersion = candidate.version.toVersionOrNull() ?: return@rejectVersionIf true !candidateVersion.isStable } } // ======= begin of GitHub action stuff val ciDir = CiUtils.getCiDir(project) changelog { path.set(rootProject.layout.projectDirectory.dir("CHANGELOG.md").asFile.path) version.set(getAppVersionString()) } val createChangeNoteForCi by tasks.registering { inputs.property("appVersion", getAppVersionString()) inputs.file(changelog.path) outputs.file(ciDir.changeNotesFile) doLast { val output = ciDir.changeNotesFile.get().asFile val bodyText = with(changelog) { getOrNull(getAppVersionString())?.let { item -> renderItem(item, Changelog.OutputType.MARKDOWN) } }.orEmpty() logger.lifecycle("changeNotes written in $output") output.writeText(bodyText) } } val createReleaseFolderForCi by tasks.registering { val createBinariesForCi = CiUtils.getCreateBinaryFolderForCiTaskName() dependsOn("desktop:app:$createBinariesForCi") val skipAndroidBuild = System.getenv("SKIP_ANDROID_BUILD") ?.toBoolean() ?: false if (!skipAndroidBuild) { dependsOn("android:app:$createBinariesForCi") } val shouldGenerateChangelog = true if (shouldGenerateChangelog) { dependsOn(createChangeNoteForCi) } } // ======= end of GitHub action stuff ================================================ FILE: buildSrc/build.gradle.kts ================================================ plugins{ `kotlin-dsl` } repositories { gradlePluginPortal() mavenCentral() google() } dependencies{ implementation(libs.pluginKotlin) implementation(libs.pluginAndroidGradle) implementation(libs.pluginComposeCompiler) implementation(libs.pluginKsp) implementation(libs.pluginSerialization) implementation(libs.pluginComposeMultiplatform) implementation(libs.pluginChangeLog) implementation(libs.pluginBuildConfig) implementation(libs.pluginAboutLibraries) implementation(libs.pluginGradleVersions) implementation(libs.semver) implementation("ir.amirab.util:platform:1") implementation("ir.amirab.plugin:git-version-plugin:1") implementation("ir.amirab.plugin:installer-plugin:1") implementation("ir.amirab.plugin:common-android:1") } ================================================ FILE: buildSrc/settings.gradle.kts ================================================ dependencyResolutionManagement{ versionCatalogs { create("libs"){ from(files("../gradle/libs.versions.toml")) } } } ================================================ FILE: buildSrc/src/main/kotlin/Plugins.kt ================================================ import ir.amirab.util.platform.Platform object MyPlugins { private const val namespace = "myPlugins" const val kotlin = "$namespace.kotlin" const val kotlinAndroid = "$namespace.kotlinAndroid" const val kotlinMultiplatform = "$namespace.kotlinMultiplatform" const val composeAndroid = "$namespace.composeAndroid" const val composeDesktop = "$namespace.composeDesktop" const val composeBase = "$namespace.composeBase" const val proguardDesktop = "$namespace.proguardDesktop" } object MyPlatform{ fun getPlatform() = Platform } object Plugins { object Kotlin { private const val baseName = "org.jetbrains.kotlin" const val serialization = "$baseName.plugin.serialization" } object Android { private const val baseName = "com.android" const val application = "$baseName.application" const val library = "$baseName.library" } const val ksp = "com.google.devtools.ksp" const val compose = "org.jetbrains.compose" const val composeCompiler = "org.jetbrains.kotlin.plugin.compose" const val changeLog = "org.jetbrains.changelog" const val buildConfig = "com.github.gmazzo.buildconfig" const val aboutLibraries = "com.mikepenz.aboutlibraries.plugin" const val aboutLibrariesAndroid = "com.mikepenz.aboutlibraries.plugin.android" const val multiplatformResources = "dev.icerock.mobile.multiplatform-resources" } ================================================ FILE: buildSrc/src/main/kotlin/buildlogic/CiDirs.kt ================================================ package buildlogic import org.gradle.api.file.Directory import org.gradle.api.provider.Provider class CiDirs(baseDir: Provider) { val releaseDir = baseDir.map { it.dir("ci-release") } val binariesDir = releaseDir.map { it.dir("binaries") } val changeNotesFile = releaseDir.map { it.file("release-notes.md") } } ================================================ FILE: buildSrc/src/main/kotlin/buildlogic/CiUtils.kt ================================================ package buildlogic import io.github.z4kn4fein.semver.Version import ir.amirab.installer.InstallerTargetFormat import ir.amirab.util.platform.Arch import ir.amirab.util.platform.Platform import org.gradle.api.Project import java.io.File object CiUtils { fun getTargetFileName( packageName: String, appVersion: Version, target: InstallerTargetFormat?, archName: String?, ): String { val fileExtension = when (target) { // we use archived for app image distribution ( app image is a folder actually so there is no installer so we zip it instead) null -> { when (Platform.getCurrentPlatform()) { Platform.Desktop.Linux -> "tar.gz" Platform.Desktop.MacOS -> "tar.gz" Platform.Desktop.Windows -> "zip" Platform.Android -> error("this can only be used with desktop formats") } } else -> target.fileExtensionWithoutDot() } val platformName = when (target) { null -> Platform.getCurrentPlatform() else -> { val packageFileExt = target.fileExtensionWithoutDot() requireNotNull(Platform.fromExecutableFileExtension(packageFileExt)) { "can't find platform name with this file extension: ${packageFileExt}" } } }.name.lowercase() val nameWithoutExtension = listOf( packageName, appVersion.toString(), platformName, archName?: "universal", ).joinToString("_") return "$nameWithoutExtension.${fileExtension}" } fun getFileOfPackagedTarget( baseOutputDir: File, target: InstallerTargetFormat, ): File { val folder = baseOutputDir // val folder = baseOutputDir.resolve(target.outputDirName) val exeFile = kotlin.runCatching { folder.walk().first { it.name.endsWith(target.fileExt) } }.onFailure { println("error when finding packaged app for $target in: $baseOutputDir") } return exeFile.getOrThrow() } fun getFileOfDistributedArchivedTarget( baseOutputDir: File, ): File { val folder = baseOutputDir val extension = when (Platform.getCurrentPlatform()) { Platform.Desktop.Linux, Platform.Desktop.MacOS -> "tar.gz" Platform.Android, Platform.Desktop.Windows -> "zip" } val archiveFile = kotlin.runCatching { folder.walk().first { it.name.endsWith(extension) } }.onFailure { println("error when finding archive of unpackaged app in: $baseOutputDir") } return archiveFile.getOrThrow() } fun copyAndHashToDestination( src: File, destinationFolder: File, name: String, ) { val destinationExeFile = destinationFolder.resolve(name) src.copyTo(destinationExeFile) val md5File = destinationFolder.resolve("$name.md5") md5File.writeText(HashUtils.md5(src)) } fun movePackagedAndCreateSignature( appVersion: Version, packageName: String, target: InstallerTargetFormat, basePackagedAppsDir: File, outputDir: File, ) { require(!outputDir.isFile) { "$outputDir is a file" } outputDir.mkdirs() require(outputDir.isDirectory) { "$outputDir is not directory" } val exeFile = getFileOfPackagedTarget( baseOutputDir = basePackagedAppsDir, target = target ) val arch = Arch.getCurrentArch().name val newName = getTargetFileName( packageName = packageName, appVersion = appVersion, target = target, archName = arch, ) copyAndHashToDestination( src = exeFile, destinationFolder = outputDir, name = newName, ) } fun getCiDir(project: Project): CiDirs { return CiDirs(project.rootProject.layout.buildDirectory) } fun getCreateBinaryFolderForCiTaskName(): String { return "createBinariesForCi" } /* fun moveAndCreateSignature( appVersion: Version, nativeDistributions: JvmApplicationDistributions, target: TargetFormat, path: File, output: File, ) { require(!output.isFile) { "$output is a file" } output.mkdirs() require(output.isDirectory) { "$output is not directory" } val folder = path.resolve(target.outputDirName) val exeFile = folder.walk().first { it.name.endsWith(target.fileExt) } val appName = requireNotNull(nativeDistributions.packageName){ "package name must not null" } val fileExtension = exeFile.extension val platformName = requireNotNull(Platform.fromExecutableFileExtension(fileExtension)){ "can't find platform name with this file extension :${fileExtension}" }.name.lowercase() val newName = "${appName}_${appVersion}_${platformName}.${fileExtension}" val destinationExeFile = output.resolve(newName) val md5File = output.resolve("$newName.md5") exeFile.copyTo(destinationExeFile, true) md5File.writeText(HashUtils.md5(exeFile)) } */ } private fun InstallerTargetFormat.fileExtensionWithoutDot() = fileExt.substring(".".length) ================================================ FILE: buildSrc/src/main/kotlin/buildlogic/HashUtils.kt ================================================ package buildlogic import java.io.File import java.security.MessageDigest // I should move these classes/objects somewhere organized object HashUtils { private fun ByteArray.toHexString(): String = joinToString("", transform = { "%02x".format(it) }) fun md5(file: File): String { val md = MessageDigest.getInstance("MD5") val digest = md.digest(file.readBytes()) return digest.toHexString() } } ================================================ FILE: buildSrc/src/main/kotlin/buildlogic/versioning/VersionUtil.kt ================================================ package buildlogic.versioning import io.github.z4kn4fein.semver.Version import ir.amirab.util.platform.Platform import org.gradle.api.Project import org.jetbrains.compose.desktop.application.dsl.TargetFormat fun Project.getAppVersion(): Version { return rootProject.version as Version } fun Project.getAppVersionString(): String { return rootProject.version.toString() } fun Version.convertToVersionCode(): Int { require(major in 0..1023) { "Major must be 0..1023" } require(minor in 0..1023) { "Minor must be 0..1023" } require(patch in 0..511) { "Patch must be 0..511" } return (major shl 19) or (minor shl 9) or patch } fun Project.getAppName(): String { return rootProject.name } fun Project.getPrettifiedAppName(): String { return "AB Download Manager" } fun Project.getAppDataDirName(): String { return ".abdm" } fun Project.getApplicationPackageName(): String { return "com.abdownloadmanager" } private fun guessTargetFormatBasedOnCurrentOs()= when (Platform.getCurrentPlatform()) { Platform.Desktop.Linux -> TargetFormat.Deb Platform.Desktop.MacOS -> TargetFormat.Dmg Platform.Desktop.Windows -> TargetFormat.Msi Platform.Android -> error("we are executing gradle in desktop :D") } fun Project.getAppVersionStringForPackaging(targetFormat: TargetFormat? = null): String { val v = getAppVersion() val simple = { v.run { "$major.$minor.$patch" } } val semantic = { v.toString() } val forRpm = { semantic().replace("-", "_") } return when (targetFormat?: guessTargetFormatBasedOnCurrentOs()) { TargetFormat.Rpm -> forRpm() TargetFormat.Deb, TargetFormat.AppImage -> semantic() TargetFormat.Msi, TargetFormat.Exe, TargetFormat.Dmg, TargetFormat.Pkg -> simple() } } ================================================ FILE: buildSrc/src/main/kotlin/myPlugins/composeAndroid.gradle.kts ================================================ package myPlugins plugins { id("myPlugins.kotlinAndroid") id("myPlugins.composeBase") } ================================================ FILE: buildSrc/src/main/kotlin/myPlugins/composeBase.gradle.kts ================================================ package myPlugins plugins { kotlin("plugin.compose") id("org.jetbrains.compose") } ================================================ FILE: buildSrc/src/main/kotlin/myPlugins/composeDesktop.gradle.kts ================================================ package myPlugins plugins { id("myPlugins.kotlin") id("myPlugins.composeBase") } dependencies { api(compose.desktop.currentOs){ exclude("org.jetbrains.compose.material") } } ================================================ FILE: buildSrc/src/main/kotlin/myPlugins/kotlin.gradle.kts ================================================ package myPlugins plugins { kotlin("jvm") } repositories { mavenCentral() google() maven("https://jitpack.io") } fun getOptIns(): Set = setOf( "androidx.compose.animation.ExperimentalAnimationApi", "androidx.compose.foundation.ExperimentalFoundationApi", "androidx.compose.ui.ExperimentalComposeUiApi", ) fun getFeatures(): Set = setOf( "context-parameters", ) kotlin { compilerOptions { val optIns = getOptIns().map { "-Xopt-in=$it" } val features = getFeatures().map { "-X$it" } freeCompilerArgs.set(optIns + features) } } ================================================ FILE: buildSrc/src/main/kotlin/myPlugins/kotlinAndroid.gradle.kts ================================================ package myPlugins plugins { kotlin("android") } repositories { mavenCentral() google() maven("https://jitpack.io") } fun getOptIns(): Set = setOf( "androidx.compose.animation.ExperimentalAnimationApi", "androidx.compose.foundation.ExperimentalFoundationApi", "androidx.compose.ui.ExperimentalComposeUiApi", ) fun getFeatures(): Set = setOf( "context-parameters", ) kotlin { compilerOptions { val optIns = getOptIns().map { "-Xopt-in=$it" } val features = getFeatures().map { "-X$it" } freeCompilerArgs.set(optIns + features) } } ================================================ FILE: buildSrc/src/main/kotlin/myPlugins/kotlinMultiplatform.gradle.kts ================================================ package myPlugins plugins { kotlin("multiplatform") } repositories { mavenCentral() google() maven("https://jitpack.io") } fun getOptIns(): Set = setOf( "androidx.compose.animation.ExperimentalAnimationApi", "androidx.compose.foundation.ExperimentalFoundationApi", "androidx.compose.ui.ExperimentalComposeUiApi", ) fun getFeatures(): Set = setOf( "context-parameters", ) kotlin { compilerOptions { val optIns = getOptIns().map { "-Xopt-in=$it" } val features = getFeatures().map { "-X$it" } freeCompilerArgs.set(optIns + features) } } ================================================ FILE: buildSrc/src/main/kotlin/myPlugins/proguardDesktop.gradle.kts ================================================ package myPlugins import org.jetbrains.compose.desktop.application.tasks.AbstractProguardTask import java.util.zip.ZipFile plugins { id("org.jetbrains.compose") } fun getProguardFileContent(file: File): List> { val list = ArrayList>() if (file.isFile) { if (file.name.endsWith(".jar", true)) { return runCatching { val zipFile = ZipFile(file) list.apply { addAll(zipFile.use { zFile -> zFile.entries().toList().filter { it.name.run { endsWith(".pro") // && (startsWith("META-INF/proguard") || !contains("/")) } }.map { val name = it.name.split("/").last() val content = zFile.getInputStream(it).reader().use { it.readText() } name to """ # - rules applied from $name $content """.trimIndent() } }) } }.getOrThrow() } } return list } val compileClasspathProvider = configurations.named("compileClasspath") val getProguardConfigurations by tasks.registering { dependsOn(compileClasspathProvider) val folder = layout.buildDirectory.map { it.dir("resolvedProguards") } inputs.files(compileClasspathProvider) outputs.dir(folder) doLast { val outputFolder = folder.get().asFile outputFolder.deleteRecursively() compileClasspathProvider.get().files.forEach { file -> val outPutOfPackage = outputFolder.resolve("${file.name}") for ((name, content) in getProguardFileContent(file)) { outPutOfPackage .resolve(name).also { it.parentFile.mkdirs() } .writeText(content) } } } } tasks.withType { dependsOn(getProguardConfigurations) } ================================================ FILE: compositeBuilds/plugins/common-android/build.gradle.kts ================================================ plugins { `kotlin-dsl` } repositories { mavenCentral() google() } version = 1 group = "ir.amirab.plugin" dependencies { implementation(libs.pluginAndroidGradle) implementation(libs.handlebarsJava) implementation(libs.okio.okio) } ================================================ FILE: compositeBuilds/plugins/common-android/src/main/kotlin/ir/amirab/plugin/common_android/task/EnableFileTypesGeneratorForManifest.kt ================================================ package ir.amirab.plugin.common_android.task import org.gradle.api.Action import org.gradle.api.Project import org.gradle.api.file.RegularFile import org.gradle.api.plugins.ExtensionAware import org.gradle.internal.extensions.stdlib.capitalized import org.gradle.kotlin.dsl.register private fun Project.androidComponents(configure: Action): Unit = (this as ExtensionAware).extensions.configure("androidComponents", configure) fun Project.androidEnableFileTypesGeneratorForManifest( targetActivityClass: String, fileTypesFile: RegularFile, ) { androidComponents { onVariants{variant -> val taskName = "generate${variant.name.capitalized()}FileTypesManifest" val task = tasks.register(taskName) { extensionsFile.set(fileTypesFile) targetActivity.set(targetActivityClass) } variant.sources.manifests.addGeneratedManifestFile(task, GenerateFileTypesManifest::outputFile) } } } ================================================ FILE: compositeBuilds/plugins/common-android/src/main/kotlin/ir/amirab/plugin/common_android/task/FileTypesIntentFilterGenerator.kt ================================================ package ir.amirab.plugin.common_android.task import com.github.jknack.handlebars.Context import com.github.jknack.handlebars.Handlebars import okio.FileSystem import okio.Path.Companion.toPath import org.gradle.api.DefaultTask import org.gradle.api.file.RegularFileProperty import org.gradle.api.tasks.Input import org.gradle.api.tasks.InputFile import org.gradle.api.tasks.OutputFile import org.gradle.api.tasks.TaskAction import org.gradle.kotlin.dsl.property internal abstract class GenerateFileTypesManifest : DefaultTask() { @get:InputFile val extensionsFile: RegularFileProperty = project.objects.fileProperty() @get:Input val targetActivity = project.objects.property() @get:OutputFile val outputFile: RegularFileProperty = project.objects.fileProperty() @TaskAction fun generate() { val output = outputFile.get().asFile output.parentFile.mkdirs() val extensionList = extensionsFile.asFile.get() .bufferedReader() .lineSequence() .map { it.trim() } .filterNot { it.isEmpty() } .filterNot { it.startsWith("#") } .toList() val templateFile = "ir/amirab/plugin/common_android/AndroidManifest.xml.hbs".toPath() val templateContent = FileSystem.RESOURCES.read(templateFile) { readUtf8() } val patterns = generatePatterns(extensionList) val handlebars = Handlebars() val manifestContent = handlebars.compileInline(templateContent) .apply( Context.newContext( mapOf( "activityName" to targetActivity.get(), "patterns" to patterns, ) ) ) output.writeText(manifestContent) } private fun generatePatterns(extensions: List): List { return extensions.flatMap { generatePatternsForExtension(it) } } private fun generatePatternsForExtension( extension: String, repeat: Int = 4 ): List { val first = """\\.$extension""" return buildList{ add(".*$first") repeat(repeat){ add(".*${"""\\..*""".repeat(it)}$first.*") } } } } ================================================ FILE: compositeBuilds/plugins/common-android/src/main/kotlin/ir/amirab/plugin/common_android/task/SignApkTask.kt ================================================ package ir.amirab.plugin.common_android.task import okio.Path.Companion.toPath import org.gradle.api.DefaultTask import org.gradle.api.GradleException import org.gradle.api.file.DirectoryProperty import org.gradle.api.provider.Property import org.gradle.api.tasks.Input import org.gradle.api.tasks.InputDirectory import org.gradle.api.tasks.OutputDirectory import org.gradle.api.tasks.TaskAction import org.gradle.internal.os.OperatingSystem import org.gradle.process.ExecOperations import java.io.File import java.io.FileOutputStream import java.util.Base64 import javax.inject.Inject sealed interface KeystoreContent { companion object { fun fromUri( uriString: String, ): KeystoreContent { val splitIndex = uriString.indexOf(':') if (splitIndex == -1) { throw GradleException("Invalid KeystoreContent it should be :") } val type = uriString.substring(0, splitIndex) val data = uriString.substring(splitIndex + 1) return when (type) { "file" -> { FromFile(File(data)) } "base64" -> { FromBase64(data) } else -> error("please provide file or Base64 ( base64:abcdefg or file:/path/to/file )") } } } fun getContent(): ByteArray data class FromFile(private val file: File) : KeystoreContent { override fun getContent(): ByteArray { return file.readBytes() } } data class FromBase64(private val base64Content: String) : KeystoreContent { override fun getContent(): ByteArray { return Base64.getDecoder().decode(base64Content) } } } abstract class SignApkTask : DefaultTask() { @get:Inject internal abstract val execOps: ExecOperations @get:InputDirectory abstract val inputDir: DirectoryProperty @get:OutputDirectory abstract val outputDIr: DirectoryProperty @get:Input abstract val keystoreUri: Property @get:Input abstract val keystorePassword: Property @get:Input abstract val keyAlias: Property @get:Input abstract val keyPassword: Property @get:Input abstract val platformToolsVersion: Property private fun getApkSignerFile(): String { val androidHome = System.getenv("ANDROID_HOME")?.toPath() ?: throw GradleException("ANDROID HOME environment variable is not set.") val dir = androidHome / "build-tools" / platformToolsVersion.get() val name = if (OperatingSystem.current().isWindows) { "apksigner.bat" } else { "apksigner" } return (dir / name).toString() } @TaskAction fun sign() { val inputDir = inputDir.get().asFile if (!inputDir.exists()) { throw IllegalArgumentException("Input APK does not exist: ${inputDir.absolutePath}") } val outputDir = outputDIr.get().asFile val keystorePassword: String = keystorePassword.get() val keyPassword: String = keyPassword.get() val keyAlias: String = keyAlias.get() val tempKeyStoreFile = getKeyStoreFile() try { inputDir.listFiles().filter { it.name.endsWith(".apk") }.forEach { signSingleApk( inputApk = it, outputApk = outputDir.resolve(it.name), keystoreFile = tempKeyStoreFile, keystorePassword = keystorePassword, keyAlias = keyAlias, keyPassword = keyPassword ) } } finally { tempKeyStoreFile.delete() } } private fun getKeyStoreFile(): File { val keystoreContent = KeystoreContent.fromUri( keystoreUri.get(), ) // Decode base64 keystore to temp file val tempKeystore = File.createTempFile("keystore", ".jks") FileOutputStream(tempKeystore).use { fos -> fos.write(keystoreContent.getContent()) } return tempKeystore } fun signSingleApk( inputApk: File, outputApk: File, keystoreFile: File, keystorePassword: String, keyAlias: String, keyPassword: String, ) { logger.lifecycle("Signing APK: $inputApk") logger.lifecycle("Output APK: $outputApk") execOps.exec { commandLine( getApkSignerFile(), "sign", "--ks", keystoreFile, "--ks-pass", "pass:$keystorePassword", "--key-pass", "pass:$keyPassword", "--ks-key-alias", keyAlias, "--out", outputApk.absolutePath, inputApk.absolutePath, ) } logger.lifecycle("Signed APK generated at: $outputApk") } } ================================================ FILE: compositeBuilds/plugins/common-android/src/main/resources/ir/amirab/plugin/common_android/AndroidManifest.xml.hbs ================================================ {{~#each patterns}} {{~/each}} ================================================ FILE: compositeBuilds/plugins/git-version-plugin/build.gradle.kts ================================================ plugins { `kotlin-dsl` } repositories { mavenCentral() } version = 1 group = "ir.amirab.plugin" dependencies { implementation(libs.semver) implementation(libs.jgit) } gradlePlugin { plugins { create("git-version-plugin") { id = "ir.amirab.git-version-plugin" implementationClass = "ir.amirab.git_version.GitVersionPlugin" } } } ================================================ FILE: compositeBuilds/plugins/git-version-plugin/src/main/kotlin/ir/amirab/git_version/GitVersionPlugin.kt ================================================ package ir.amirab.git_version import ir.amirab.git_version.core.GitVersionExtension import org.gradle.api.Plugin import org.gradle.api.Project import org.slf4j.Logger class GitVersionPlugin: Plugin { override fun apply(target: Project) { val gitVersionExtension = GitVersionExtension() target.extensions.add("gitVersion", gitVersionExtension) gitVersionExtension.currentWorkingDirectory = target.rootDir gitVersionExtension.setLogger(target.logger) } } ================================================ FILE: compositeBuilds/plugins/git-version-plugin/src/main/kotlin/ir/amirab/git_version/core/CiReferenceProvider.kt ================================================ package ir.amirab.git_version.core import ir.amirab.git_version.core.CiReferenceProvider.Companion.getEnv import ir.amirab.git_version.core.CiReferenceProvider.Companion.safeEnv interface CiReferenceProvider { fun isAvailable(): Boolean fun getSha(): String? fun getRef(): String? fun getReference(): GitReference? { return refOrNull(getRef(), getSha()) } companion object { var envProvider: (String) -> String? = { System.getenv(it) } fun getEnv(string: String): String? { return envProvider(string) } fun safeEnv(string: String): String? { return getEnv(string)?.takeIf { it.isNotBlank() } } private val registeredItems = linkedSetOf() private fun builtIns(): Set { return setOf( GithubCiReferenceProvider, GitlabCiReferenceProvider, CircleCiReferenceProvider, JenkinsCiReferenceProvider, ) } init { builtIns().forEach { add(it) } } fun add(ciReferenceProvider: CiReferenceProvider) { registeredItems.add(ciReferenceProvider) } fun isInCi() = getAll().any { it.isAvailable() } fun getAll(): Set { return registeredItems } } } private fun refOrNull(ref: String?, sha: String? = null): GitReference? { return ref?.let { GitReference.of(ref, sha) } } object GithubCiReferenceProvider : CiReferenceProvider { override fun isAvailable(): Boolean { return getEnv("GITHUB_CI")?.lowercase() == "true" } override fun getRef(): String? { return safeEnv("GITHUB_REF") } override fun getSha(): String? { return safeEnv("GITHUB_SHA") } } object GitlabCiReferenceProvider : CiReferenceProvider { override fun isAvailable(): Boolean { return getEnv("GITLAB_CI")?.lowercase() == "true" } override fun getRef(): String? { return safeEnv("CI_COMMIT_BRANCH") ?: safeEnv("CI_COMMIT_TAG") ?: safeEnv("CI_MERGE_REQUEST_SOURCE_BRANCH_NAME") } override fun getSha(): String? { return null } } object CircleCiReferenceProvider : CiReferenceProvider { override fun isAvailable(): Boolean { return getEnv("CIRCLECI")?.lowercase() == "true" } override fun getRef(): String? { return safeEnv("CIRCLE_BRANCH") ?: safeEnv("CIRCLE_TAG") } override fun getSha(): String? { return null } } object JenkinsCiReferenceProvider : CiReferenceProvider { override fun isAvailable(): Boolean { return safeEnv("JENKINS_HOME") != null } override fun getRef(): String? { return safeEnv("BRANCH_NAME") ?: safeEnv("TAG_NAME") } override fun getSha(): String? { return null } } ================================================ FILE: compositeBuilds/plugins/git-version-plugin/src/main/kotlin/ir/amirab/git_version/core/GitStatus.kt ================================================ package ir.amirab.git_version.core import org.eclipse.jgit.lib.Constants.* import org.eclipse.jgit.lib.ObjectId import org.eclipse.jgit.lib.Ref import org.eclipse.jgit.lib.Repository import org.eclipse.jgit.lib.Repository.shortenRefName import org.eclipse.jgit.revwalk.RevTag import org.eclipse.jgit.revwalk.RevWalk private fun Repository.fullBranchOrNull(): String? { return fullBranch.takeIf { !ObjectId.isId(it) } } private fun Repository.tagsPointAt(pointAtThisObject: ObjectId): List { return refDatabase.getRefsByPrefix(R_TAGS).filter { it.toString() refDatabase.peel(it).run { peeledObjectId ?: objectId } == pointAtThisObject } } class GitStatus( val repository: Repository, ) { val head = repository.resolve(HEAD) val branch: GitReference.BranchInfo? by lazy { repository.fullBranchOrNull()?.let { GitReference.BranchInfo(it, head.name) } } val tags by lazy { RevWalk(repository).use { revWalk -> repository.tagsPointAt(head).map { val f = revWalk.parseAny(it.objectId) GitReference.TagInfo( fullName = it.name, commitHash = head.name, createdAt = (f as? RevTag)?.taggerIdent?.`when`?.time, ) } } } fun isDetached() = branch == null } sealed interface GitReference { val commitHash: String? sealed class SymbolicReference : GitReference { abstract val fullName: String val shortenName by lazy { shortenRefName(fullName) } } data class TagInfo( override val fullName: String, override val commitHash: String? = null, val createdAt: Long? = null, ) : SymbolicReference() data class BranchInfo( override val fullName: String, override val commitHash: String? = null, ) : SymbolicReference() data class ShaReference( override val commitHash: String, ) : GitReference companion object { fun of( ref: String, sha: String?, ): GitReference { return when { ref.startsWith(R_TAGS) -> TagInfo( fullName = ref, commitHash = sha, ) ref.startsWith(R_REMOTES) || ref.startsWith(R_HEADS) -> BranchInfo(ref, sha) else -> error("'$ref' is not a valid ref name") } } } } ================================================ FILE: compositeBuilds/plugins/git-version-plugin/src/main/kotlin/ir/amirab/git_version/core/SemanticVersionSelector.kt ================================================ package ir.amirab.git_version.core import io.github.z4kn4fein.semver.Version import io.github.z4kn4fein.semver.toVersionOrNull import org.intellij.lang.annotations.Language class SelectBestSemanticVersion : TagSelector { private val regex by lazy { "$semanticVersionRegex$$".toRegex() } private fun GitReference.TagInfo.toVersionOrNull(): Version? { return regex.find(shortenName)?.groups?.get("version")?.value?.toVersionOrNull() } override fun select( tags: List ): GitReference.TagInfo? { return tags .map { it to it.toVersionOrNull() } .sortedByDescending { it.second } // .also { // println(it.map { it.second }) // } .firstOrNull()?.first } } /** * Note: add ending manually */ @Language("RegExp") val semanticVersionRegex = """(?(?0|[1-9]\d*)\.(?0|[1-9]\d*)\.(?0|[1-9]\d*)(?:-(?(?:0|[1-9]\d*|\d*[a-zA-Z-][0-9a-zA-Z-]*)(?:\.(?:0|[1-9]\d*|\d*[a-zA-Z-][0-9a-zA-Z-]*))*))?(?:\+(?[0-9a-zA-Z-]+(?:\.[0-9a-zA-Z-]+)*))?)""" ================================================ FILE: compositeBuilds/plugins/git-version-plugin/src/main/kotlin/ir/amirab/git_version/core/TagSelector.kt ================================================ package ir.amirab.git_version.core fun interface TagSelector { fun select(tags: List): GitReference.TagInfo? } ================================================ FILE: compositeBuilds/plugins/git-version-plugin/src/main/kotlin/ir/amirab/git_version/core/Utils.kt ================================================ package ir.amirab.git_version.core fun String.toSlug() = replace("/", "-") ================================================ FILE: compositeBuilds/plugins/git-version-plugin/src/main/kotlin/ir/amirab/git_version/core/extension.kt ================================================ package ir.amirab.git_version.core import org.eclipse.jgit.lib.RepositoryBuilder import org.intellij.lang.annotations.Language import org.slf4j.Logger import org.slf4j.LoggerFactory import org.slf4j.helpers.NOPLogger import java.io.File import kotlin.math.log open class MatchedRef( val refInfo: T ) class MatchedRefWithResult( refInfo: T, val matchResult: MatchResult, ) : MatchedRef(refInfo) class ResolvedScope() { private val tagFilter = linkedMapOf) -> String?>() private val branchFilter = linkedMapOf) -> String?>() private var _commit: (MatchedRef) -> String? = { null } //should match entire tag name fun branch( @Language("RegExp") regex: String, function: (MatchedRefWithResult) -> String? ) { branchFilter[regex] = function } //should match entire tag name fun tag( @Language("RegExp") regex: String, function: (MatchedRefWithResult) -> String? ) { tagFilter[regex] = function } fun commit(function: (MatchedRef) -> String?) { _commit = function } private fun matchTag(it: GitReference.TagInfo): String? { for ((regex, matchedRef) in tagFilter) { val matched = regex.toRegex().matchEntire(it.shortenName) if (matched != null) { matchedRef(MatchedRefWithResult(it, matched))?.let { it -> return it } ?: continue } } return null } private fun matchBranch(it: GitReference.BranchInfo): String? { for ((regex, matchedRef) in branchFilter) { val matched = regex.toRegex().matchEntire(it.shortenName) if (matched != null) { matchedRef(MatchedRefWithResult(it, matched))?.let { it -> return it } ?: continue } } return null } private fun matchCommit(it: GitReference.ShaReference): String? { return _commit(MatchedRef(it)) } fun match(gitReference: GitReference): String? { return when (gitReference) { is GitReference.BranchInfo -> matchBranch(gitReference) is GitReference.TagInfo -> matchTag(gitReference) is GitReference.ShaReference -> matchCommit(gitReference) } } } class GitVersionExtension { var preferTag: Boolean = false var preferCi: Boolean = true var checkCi: Boolean = true var tagSelector: TagSelector = SelectBestSemanticVersion() val transform: (String) -> String = { it.toSlug() } var currentWorkingDirectory: File = File(".") private var _logger:Logger?=null fun setLogger(logger: Logger){ _logger= logger } fun getLogger():Logger{ if (_logger==null){ _logger=object :NOPLogger(){} } return _logger!! } private val repository by lazy { RepositoryBuilder() .findGitDir(currentWorkingDirectory) .build() } private val refHandlers = ResolvedScope() fun on(block: ResolvedScope.() -> Unit) { refHandlers.apply(block) } operator fun invoke(block: GitVersionExtension.() -> Unit) = apply { block() } private fun tryGetVersionFromCi(): GitReference? { return CiReferenceProvider.getAll().firstOrNull { it.isAvailable() }?.getReference() } private fun tryGetVersionFromGit( status: GitStatus = GitStatus(repository) ): GitReference? { val getTag = { status.tags .also { getLogger().info("${it.count()} tags found. ${it.map { it.shortenName }.joinToString(" , ")}") } .let { tagSelector.select(it) } } val getBranch = { status.branch?.also { getLogger().info("branch ${it.shortenName} detected") } } return if (preferTag) { getTag() ?: getBranch() } else { getBranch() ?: getTag() } } fun getBestReference(): GitReference? { val ci = { if (checkCi) tryGetVersionFromCi() else null } val gitStatus by lazy { GitStatus(repository) } val git = { tryGetVersionFromGit(gitStatus) } return when { preferCi -> ci() ?: git() else -> git() ?: ci() } ?: GitReference.ShaReference(gitStatus.head.name) } fun getVersion() = getBestReference() ?.let(refHandlers::match) ?.let(transform) } ================================================ FILE: compositeBuilds/plugins/installer-plugin/build.gradle.kts ================================================ plugins { `kotlin-dsl` } repositories { mavenCentral() } version = 1 group = "ir.amirab.plugin" dependencies { implementation("ir.amirab.util:platform:1") implementation(libs.handlebarsJava) } gradlePlugin { plugins { create("installer-plugin") { id = "ir.amirab.installer-plugin" implementationClass = "ir.amirab.installer.InstallerPlugin" } } } ================================================ FILE: compositeBuilds/plugins/installer-plugin/src/main/kotlin/ir/amirab/installer/InstallerPlugin.kt ================================================ package ir.amirab.installer import ir.amirab.installer.extensiion.InstallerPluginExtension import ir.amirab.installer.tasks.macos.CreateDmgTask import ir.amirab.installer.tasks.windows.NsisTask import ir.amirab.installer.utils.Constants import ir.amirab.util.platform.Platform import org.gradle.api.Plugin import org.gradle.api.Project import org.gradle.kotlin.dsl.create import org.gradle.kotlin.dsl.register class InstallerPlugin : Plugin { override fun apply(target: Project) { val extension = target.extensions.create("installerPlugin", InstallerPluginExtension::class) target.afterEvaluate { registerTasks(target, extension) } } private fun registerTasks( project: Project, extension: InstallerPluginExtension ) { val windowConfig = extension.windowsConfig val macosConfig = extension.macosConfig val createInstallerTaskName = Constants.CREATE_INSTALLER_TASK_NAME val createInstallerNsisTaskName = "${createInstallerTaskName}Nsis" val createInstallerDmgTaskName = "${createInstallerTaskName}Dmg" if (windowConfig != null) { project.tasks .register(createInstallerNsisTaskName) .configure { dependsOn(extension.taskDependencies.toTypedArray()) this.nsisTemplate.set(requireNotNull(windowConfig.nsisTemplate) { "Nsis Template not provided" }) this.commonParams.set(windowConfig) this.extraParams.set(windowConfig.extraParams) this.destFolder.set(extension.outputFolder.get().asFile) this.outputFileName.set(requireNotNull(windowConfig.outputFileName) { " outputFileName not provided " }) this.sourceFolder.set(requireNotNull(windowConfig.inputDir) { "inputDir not provided" }) } } if (macosConfig != null) { project.tasks .register(createInstallerDmgTaskName) .configure { dependsOn(extension.taskDependencies.toTypedArray()) this.appName.set(requireNotNull(macosConfig.appName) { "appName not provided" }) this.appFileName.set(requireNotNull(macosConfig.appFileName) { "iconFile not provided" }) this.backgroundImage.set(requireNotNull(macosConfig.backgroundImage) { "backgroundImage not provided" }) this.outputFileName.set(requireNotNull(macosConfig.outputFileName) { "outputFileName not provided" }) this.inputDir.set(requireNotNull(macosConfig.inputDir) { "inputDir not provided" }) this.licenseFile.set(requireNotNull(macosConfig.licenseFile) { "licenseFile not provided" }) this.destFolder.set(requireNotNull(extension.outputFolder.get().asFile) { "outputFolder not provided" }) this.volumeIcon.set(requireNotNull(macosConfig.volumeIcon) { "volumeIcon not provided" }) this.iconSize.set(macosConfig.iconSize) this.windowHeight.set(macosConfig.windowHeight) this.windowWidth.set(macosConfig.windowWidth) this.folderOffsetX.set(macosConfig.folderOffsetX) this.appOffsetX.set(macosConfig.appOffsetX) this.iconsY.set(macosConfig.iconsY) this.windowX.set(macosConfig.windowX) this.windowY.set(macosConfig.windowY) } } project.tasks.register(createInstallerTaskName) { // when we want to create installer we need to prepare its input first! when (val platform = Platform.getCurrentPlatform()) { Platform.Desktop.Linux -> { // nothing yet } Platform.Desktop.MacOS -> { if (macosConfig != null) { dependsOn(createInstallerDmgTaskName) } } Platform.Desktop.Windows -> { if (windowConfig != null) { dependsOn(createInstallerNsisTaskName) } } else -> error("unsupported platform: $platform") } } } } ================================================ FILE: compositeBuilds/plugins/installer-plugin/src/main/kotlin/ir/amirab/installer/InstallerTargetFormat.kt ================================================ package ir.amirab.installer import ir.amirab.util.platform.Platform enum class InstallerTargetFormat( val id: String, val targetOS: Platform, ) { Deb("deb", Platform.Desktop.Linux), Rpm("rpm", Platform.Desktop.Linux), Dmg("dmg", Platform.Desktop.MacOS), Pkg("pkg", Platform.Desktop.MacOS), Exe("exe", Platform.Desktop.Windows), Msi("msi", Platform.Desktop.Windows), Apk("apk", Platform.Android); val isCompatibleWithCurrentOS: Boolean by lazy { isCompatibleWith(Platform.getCurrentPlatform()) } fun isCompatibleWith(os: Platform): Boolean = os == targetOS val outputDirName: String get() = id val fileExt: String get() { return ".$id" } } ================================================ FILE: compositeBuilds/plugins/installer-plugin/src/main/kotlin/ir/amirab/installer/extensiion/InstallerPluginExtension.kt ================================================ package ir.amirab.installer.extensiion import ir.amirab.installer.InstallerTargetFormat import ir.amirab.installer.utils.Constants import ir.amirab.util.platform.Platform import org.gradle.api.Project import org.gradle.api.Task import org.gradle.api.file.DirectoryProperty import org.gradle.api.tasks.TaskProvider import java.io.File import java.io.Serializable import javax.inject.Inject abstract class InstallerPluginExtension { @get:Inject internal abstract val project: Project abstract val outputFolder: DirectoryProperty internal val taskDependencies = mutableListOf() fun dependsOn(vararg tasks: Any) { taskDependencies.addAll(tasks) } internal var windowsConfig: WindowsConfig? = null private set internal var macosConfig: MacosConfig? = null private set fun windows( config: WindowsConfig.() -> Unit ) { if (Platform.getCurrentPlatform() != Platform.Desktop.Windows) return val windowsConfig = if (this.windowsConfig == null) { WindowsConfig().also { this.windowsConfig = it } } else { this.windowsConfig!! } windowsConfig.config() } fun macos( config: MacosConfig.() -> Unit ) { if (Platform.getCurrentPlatform() != Platform.Desktop.MacOS) return val macosConfig = if (this.macosConfig == null) { MacosConfig().also { this.macosConfig = it } } else { this.macosConfig!! } macosConfig.config() } val createInstallerTask: TaskProvider by lazy { project.tasks.named(Constants.CREATE_INSTALLER_TASK_NAME) } fun isThisPlatformSupported() = when (Platform.getCurrentPlatform()) { Platform.Desktop.Windows -> windowsConfig != null Platform.Desktop.MacOS -> macosConfig != null else -> false } fun getCreatedInstallerTargetFormats(): List { return buildList { when (Platform.getCurrentPlatform()) { Platform.Desktop.Windows -> { if (windowsConfig != null) { add(InstallerTargetFormat.Exe) } } Platform.Desktop.MacOS -> { if (macosConfig != null) { add(InstallerTargetFormat.Dmg) } } else -> {} } } } } data class WindowsConfig( var appName: String? = null, var appDisplayName: String? = null, var appVersion: String? = null, var appDisplayVersion: String? = null, var appDataDirName: String? = null, var iconFile: File? = null, var licenceFile: File? = null, var outputFileName: String? = null, var inputDir: File? = null, var nsisTemplate: File? = null, var extraParams: Map = emptyMap() ) : Serializable data class MacosConfig( var appName: String? = null, var appFileName: String? = null, var outputFileName: String? = null, var inputDir: File? = null, /** * Displays an image larger than the window size with proper scaling. * * **Important:** Ensure the image’s aspect ratio is preserved exactly. * Standard scaling methods can cause the background to render larger than expected * when the window is resized, breaking the intended alignment. * * **Recommended approach:** Use the original image as the base layer when creating a new one. * This helps maintain correct scaling and positioning across different window sizes. */ var backgroundImage: File? = null, var volumeIcon: File? = null, var iconSize: Int = 100, var licenseFile: File? = null, var windowWidth: Int = 600, var windowHeight: Int = 400, var iconsY: Int = 150, var appOffsetX: Int = 100, var folderOffsetX: Int = 450, var windowX: Int = 150, var windowY: Int = 200, ) : Serializable ================================================ FILE: compositeBuilds/plugins/installer-plugin/src/main/kotlin/ir/amirab/installer/tasks/macos/CreateDmgTask.kt ================================================ package ir.amirab.installer.tasks.macos import org.gradle.api.DefaultTask import org.gradle.api.GradleException import org.gradle.api.file.DirectoryProperty import org.gradle.api.provider.Property import org.gradle.api.tasks.Input import org.gradle.api.tasks.InputDirectory import org.gradle.api.tasks.InputFile import org.gradle.api.tasks.Internal import org.gradle.api.tasks.OutputDirectory import org.gradle.api.tasks.TaskAction import org.gradle.process.ExecOperations import java.io.File import javax.inject.Inject abstract class CreateDmgTask : DefaultTask() { @get:Inject abstract val execOps: ExecOperations @get:InputDirectory abstract val inputDir: DirectoryProperty @get:OutputDirectory abstract val destFolder: DirectoryProperty @get:Input abstract val appName: Property @get:Input abstract val iconSize: Property @get:Input abstract val windowWidth: Property @get:Input abstract val windowHeight: Property @get:Input abstract val iconsY: Property @get:Input abstract val appOffsetX: Property @get:Input abstract val folderOffsetX: Property @get:Input abstract val windowX: Property @get:Input abstract val windowY: Property @get:Input abstract val appFileName: Property @get:InputFile abstract val backgroundImage: Property @get:InputFile abstract val volumeIcon: Property @get:InputFile abstract val licenseFile: Property @get:Input abstract val outputFileName: Property @get:Internal abstract val dmgExecutable: Property init { dmgExecutable.convention( project.provider { val process = ProcessBuilder("which", "create-dmg") .redirectErrorStream(true) .start() val path = process.inputStream.bufferedReader().readText().trim() if (path.isBlank()) { throw GradleException("create-dmg not found in PATH. Please install it or check your PATH.") } File(path) } ) } private fun createDmgContext(): Map { val outputFileNameWithExt = outputFileName.get() + ".dmg" return mapOf( "input_dir" to inputDir.get().asFile.absolutePath.asQuoted(), "output_file" to destFolder.file(outputFileNameWithExt) .get().asFile.absolutePath.asQuoted(), "background_image" to backgroundImage.get().absolutePath.asQuoted(), "volume_icon" to volumeIcon.get().absolutePath.asQuoted(), "icon_file" to appFileName.get().asQuoted(), "app_name" to appName.get().asQuoted(), "icon_size" to iconSize.get(), "window_width" to windowWidth.get(), "window_height" to windowHeight.get(), "icons_y" to iconsY.get(), "app_offset_x" to appOffsetX.get(), "folder_offset_x" to folderOffsetX.get(), "window_x" to windowX.get(), "window_y" to windowY.get(), "license_file" to licenseFile.get().absolutePath ) } private fun String.asQuoted() = "\"${this}\"" @TaskAction fun run() { val executable = dmgExecutable.get() val context = createDmgContext() // Use launchctl to run in the user's GUI session. // This is required because create-dmg uses AppleScript to manipulate Finder, // which only works inside an active user session with GUI access. val fullCommand = buildString { append("launchctl asuser $(id -u) ") append("${executable.absolutePath.asQuoted()} ") append("--volname ${context["app_name"]} ") append("--background ${context["background_image"]} ") append("--window-size ${context["window_width"]} ${context["window_height"]} ") append("--icon-size ${context["icon_size"]} ") append("--icon ${context["icon_file"]} ${context["app_offset_x"]} ${context["icons_y"]} ") append("--app-drop-link ${context["folder_offset_x"]} ${context["icons_y"]} ") append("--eula ${context["license_file"]} ") append("--volicon ${context["volume_icon"]} ") append("--window-pos ${context["window_x"]} ${context["window_y"]} ") append("${context["output_file"]} ") append("${context["input_dir"]}") } logger.debug("Creating DMG with shell command: {}", fullCommand) execOps.exec { commandLine("sh", "-c", fullCommand) isIgnoreExitValue = false } } } ================================================ FILE: compositeBuilds/plugins/installer-plugin/src/main/kotlin/ir/amirab/installer/tasks/windows/NsisTask.kt ================================================ package ir.amirab.installer.tasks.windows import com.github.jknack.handlebars.Context import com.github.jknack.handlebars.Handlebars import ir.amirab.installer.extensiion.WindowsConfig import org.gradle.api.DefaultTask import org.gradle.api.file.DirectoryProperty import org.gradle.api.provider.MapProperty import org.gradle.api.provider.Property import org.gradle.api.tasks.Input import org.gradle.api.tasks.InputDirectory import org.gradle.api.tasks.InputFile import org.gradle.api.tasks.Internal import org.gradle.api.tasks.OutputDirectory import org.gradle.api.tasks.TaskAction import org.gradle.kotlin.dsl.mapProperty import org.gradle.process.ExecOperations import java.io.ByteArrayInputStream import java.io.File import javax.inject.Inject abstract class NsisTask : DefaultTask() { @get:Inject abstract val execOps: ExecOperations @get:InputDirectory abstract val sourceFolder: DirectoryProperty @get:OutputDirectory abstract val destFolder: DirectoryProperty @get:Input abstract val outputFileName: Property @get:InputFile abstract val nsisTemplate: Property @get:Input abstract val commonParams: Property @get:Input val extraParams: MapProperty = project.objects.mapProperty() @get:Internal abstract val nsisExecutable: Property init { nsisExecutable.convention( project.provider { File("C:\\Program Files (x86)\\NSIS\\makensis.exe") } ) } private fun createHandleBarContext(): Context { val commonParams = commonParams.get() val common = mapOf( "app_name" to commonParams.appName!!, "app_display_name" to commonParams.appDisplayName!!, "app_version" to commonParams.appVersion!!, "app_display_version" to commonParams.appDisplayVersion!!, "app_data_dir_name" to commonParams.appDataDirName!!, "license_file" to commonParams.licenceFile!!, "icon_file" to commonParams.iconFile!!, ) val overrides = mapOf( "input_dir" to sourceFolder.get().asFile.absolutePath, "output_file" to "${destFolder.file(outputFileName).get().asFile.path}.exe", ) return Context.newContext( extraParams .get() .plus(common) .plus(overrides) ) } @TaskAction fun run() { val executable = nsisExecutable.get() val scriptTemplate = nsisTemplate.get() val handlebars = Handlebars() val context = createHandleBarContext() val script = handlebars.compileInline( scriptTemplate.readText() ).apply(context) logger.debug("NSIS Script:") logger.debug(script) execOps.exec { executable( executable, ) args("-") standardInput = ByteArrayInputStream(script.toByteArray()) } } } ================================================ FILE: compositeBuilds/plugins/installer-plugin/src/main/kotlin/ir/amirab/installer/utils/Contants.kt ================================================ package ir.amirab.installer.utils internal object Constants { const val CREATE_INSTALLER_TASK_NAME = "createInstaller" } ================================================ FILE: compositeBuilds/plugins/settings.gradle.kts ================================================ dependencyResolutionManagement{ versionCatalogs { create("libs"){ from(files("../../gradle/libs.versions.toml")) } } } include("git-version-plugin") include("installer-plugin") include("common-android") ================================================ FILE: compositeBuilds/shared/README.md ================================================ ### Note this is a shared module that used in both `buildSrc` and my main build here how I can add dependency to each of my composite modules ```kts implementation("$definedGroupId:$definedProjectName:$projectVersion") ``` the benefit of this solution is two things 1. I can use shared code in both `buildSrc` and `root project's mainBuild` 2. I can move this module in separate repository without any modifications ================================================ FILE: compositeBuilds/shared/build.gradle.kts ================================================ plugins { // this project is used in both gradle and main project // the gradle version usually is lower than our project kotlin version // so we use the version that is used in gradle to fix version compatibility kotlin("jvm") version embeddedKotlinVersion apply false } ================================================ FILE: compositeBuilds/shared/platform/build.gradle.kts ================================================ plugins{ kotlin("multiplatform") } repositories{ mavenCentral() } kotlin { jvm("desktop") } version=1 group="ir.amirab.util" ================================================ FILE: compositeBuilds/shared/platform/src/commonMain/kotlin/ir/amirab/util/platform/Arch.kt ================================================ package ir.amirab.util.platform sealed class Arch(val name: String) { data object X64 : Arch("x64") data object Arm64 : Arch("arm64") data object X32 : Arch("x32") data object Arm32 : Arch("arm32") override fun toString(): String { return name } companion object : ArchFinder by JvmArchFinder() { private val DefinedArchStrings = mapOf( X64 to listOf( "amd64", "x64", "x86_64" ), Arm64 to listOf( "arm64", "aarch64" ), X32 to listOf( "x86", "i686" ), Arm32 to listOf( "armv8l", "armv7l", "arm" ), ) fun fromString(archName: String): Arch? { val a = archName.lowercase() return DefinedArchStrings.entries.firstOrNull { a in it.value }?.key } } } interface ArchFinder { fun getCurrentArch(): Arch } private class JvmArchFinder : ArchFinder { private val _arch by lazy { getCurrentArchFromJVMProperty() } private fun getCurrentArchFromJVMProperty(): Arch { val osString = System.getProperty("os.arch").lowercase() return requireNotNull(Arch.fromString(osString)) { "this arch is not recognized: $osString" } } override fun getCurrentArch(): Arch { return _arch } } ================================================ FILE: compositeBuilds/shared/platform/src/commonMain/kotlin/ir/amirab/util/platform/Platform.kt ================================================ package ir.amirab.util.platform import ir.amirab.util.platform.Platform.Android import ir.amirab.util.platform.Platform.Desktop sealed class Platform(val name: String) { data object Android : Platform("Android") sealed class Desktop(name: String) : DesktopPlatform, Platform(name) { data object Windows : Desktop("Windows") data object Linux : Desktop("Linux") data object MacOS : Desktop("Mac") } override fun toString(): String { return name } companion object : PlatformFInder by JvmPlatformFinder() { fun fromString(platformName: String): Platform? { return when (platformName.lowercase()) { "windows" -> Desktop.Windows "linux" -> Desktop.Linux "mac" -> Desktop.MacOS "android" -> Android else -> null } } fun fromExecutableFileExtension(fileExtension: String): Platform? { return when (fileExtension.lowercase()) { "exe", "msi" -> Desktop.Windows "deb", "rpm" -> Desktop.Linux "dmg", "pkg" -> Desktop.MacOS "apk" -> Android else -> null } } } } interface PlatformFInder { fun getCurrentPlatform(): Platform } private class JvmPlatformFinder : PlatformFInder { private val _platform by lazy { getCurrentPlatformFromJVMProperty() } private fun isAndroid(): Boolean { val vm = System.getProperty("java.vm.name")?.lowercase().orEmpty() val vendor = System.getProperty("java.vendor")?.lowercase().orEmpty() val isAndroid = "android" in vendor || "dalvik" in vm return isAndroid } private fun getCurrentPlatformFromJVMProperty(): Platform { val osString = System.getProperty("os.name").orEmpty().lowercase() if (isAndroid()) { return Android } return when { osString.contains("windows") -> Desktop.Windows osString.contains("linux") -> Desktop.Linux osString.contains("mac") || osString.contains("darwin") -> Desktop.MacOS else -> error("this platform is not detected: $osString") } } override fun getCurrentPlatform(): Platform { return _platform } } sealed interface DesktopPlatform /** * use this only in desktop environments */ fun PlatformFInder.asDesktop(): Desktop { val platform = getCurrentPlatform() if (platform is Desktop) { return platform } else { error("Current platform is not a desktop platform") } } fun PlatformFInder.isWindows(): Boolean { return getCurrentPlatform() == Desktop.Windows } fun PlatformFInder.isMac(): Boolean { return getCurrentPlatform() == Desktop.MacOS } fun PlatformFInder.isLinux(): Boolean { return getCurrentPlatform() == Desktop.Linux } fun PlatformFInder.isAndroid(): Boolean { return getCurrentPlatform() == Android } ================================================ FILE: compositeBuilds/shared/settings.gradle.kts ================================================ dependencyResolutionManagement{ versionCatalogs { create("libs"){ from(files("../../gradle/libs.versions.toml")) } } } rootProject.name = "shared-code-between-gradle-and-app" include("platform") ================================================ FILE: crowdin.yml ================================================ "project_id_env": "CROWDIN_PROJECT_ID" "api_token_env": "CROWDIN_PERSONAL_TOKEN" "base_path": "." "base_url": "https://api.crowdin.com" "preserve_hierarchy": true files: [ { "source": "/shared/resources/src/commonMain/resources/com/abdownloadmanager/resources/locales/en_US.properties", "translation": "/shared/resources/src/commonMain/resources/com/abdownloadmanager/resources/locales/%locale_with_underscore%.properties", } ] ================================================ FILE: desktop/app/build.gradle.kts ================================================ import buildlogic.* import buildlogic.versioning.* import ir.amirab.installer.InstallerTargetFormat import org.jetbrains.changelog.Changelog import org.jetbrains.compose.desktop.application.dsl.TargetFormat import ir.amirab.util.platform.Platform import org.jetbrains.compose.desktop.application.dsl.TargetFormat.* import com.mikepenz.aboutlibraries.plugin.DuplicateMode import com.mikepenz.aboutlibraries.plugin.DuplicateRule import ir.amirab.util.platform.Arch plugins { id(MyPlugins.kotlin) id(MyPlugins.composeDesktop) id(Plugins.Kotlin.serialization) id(Plugins.ksp) id(Plugins.aboutLibraries) id("ir.amirab.installer-plugin") // id(MyPlugins.proguardDesktop) } dependencies { implementation(libs.decompose) implementation(libs.decompose.jbCompose) implementation(libs.koin.core) implementation(libs.kotlin.serialization.json) implementation(libs.kotlin.coroutines.core) implementation(libs.kotlin.coroutines.swing) implementation(libs.kotlin.datetime) implementation(libs.compose.reorderable) implementation(libs.http4k.core) implementation(libs.http4k.client.okhttp) implementation(libs.arrow.core) implementation(libs.arrow.optics) ksp(libs.arrow.opticKsp) implementation(libs.androidx.datastore) implementation(libs.aboutLibraries.core) implementation(libs.markdownRenderer.core) implementation(libs.composeFileKit) { exclude(group = "net.java.dev.jna") } implementation(libs.proxyVole) { exclude(group = "net.java.dev.jna") } implementation(libs.jna.core) implementation(libs.jna.platform) implementation(project(":downloader:core")) implementation(project(":downloader:monitor")) implementation(project(":integration:server")) implementation(project(":desktop:shared")) implementation(project(":desktop:app-utils")) implementation(libs.composeNativeTray) implementation(project(":shared:app")) implementation(project(":shared:utils")) implementation(project(":shared:updater")) implementation(project(":shared:nanohttp4k")) implementation(project(":desktop:mac_utils")) } aboutLibraries { export { prettyPrint = true } library { duplicationMode = DuplicateMode.MERGE duplicationRule = DuplicateRule.SIMPLE } } tasks.processResources { from(tasks.named("exportLibraryDefinitions")) } val desktopPackageName = "com.abdownloadmanager.desktop" compose { desktop { application { // val getProguardConfigurationsTask = tasks.getProguardConfigurations.get() buildTypes.release.proguard { isEnabled.set(false) // obfuscate.set(false) // optimize.set(true) // configurationFiles.from( // project.fileTree("proguard"), // getProguardConfigurationsTask.outputs.files.asFileTree.filter { // !it.name.contains("r8") // }, // ) } // Define the main class for the application. mainClass = "$desktopPackageName.AppKt" nativeDistributions { modules( "java.instrument", "jdk.unsupported", "jdk.accessibility", ) targetFormats(Msi, Deb) if (Platform.getCurrentPlatform() == Platform.Desktop.Linux) { // filekit library requires this module in linux. modules("jdk.security.auth") } packageVersion = getAppVersionStringForPackaging() packageName = getAppName() vendor = "abdownloadmanager.com" appResourcesRootDir.set(project.layout.projectDirectory.dir("resources")) val menuGroupName = getPrettifiedAppName() licenseFile.set(rootProject.file("LICENSE")) linux { debPackageVersion = getAppVersionStringForPackaging(Deb) rpmPackageVersion = getAppVersionStringForPackaging(Rpm) appCategory = "Network" iconFile = project.file("icons/icon.png") menuGroup = menuGroupName shortcut = true } macOS { pkgPackageVersion = getAppVersionStringForPackaging(Pkg) dmgPackageVersion = getAppVersionStringForPackaging(Dmg) iconFile = project.file("icons/icon.icns") infoPlist { extraKeysRawXml = """ LSUIElement true """.trimIndent() } jvmArgs("-Dapple.awt.enableTemplateImages=true") } windows { exePackageVersion = getAppVersionStringForPackaging(Exe) msiPackageVersion = getAppVersionStringForPackaging(Msi) upgradeUuid = properties["INSTALLER.WINDOWS.UPGRADE_UUID"]?.toString() iconFile = project.file("icons/icon.ico") console = false dirChooser = true shortcut = true menuGroup = menuGroupName menu = true } } } } } installerPlugin { dependsOn("createReleaseDistributable") outputFolder.set(layout.buildDirectory.dir("custom-installer")) windows { appName = getAppName() appDisplayName = getPrettifiedAppName() appVersion = getAppVersionStringForPackaging(Exe) appDisplayVersion = getAppVersionString() appDataDirName = getAppDataDirName() inputDir = project.file("build/compose/binaries/main-release/app/${getAppName()}") outputFileName = getAppName() licenceFile = rootProject.file("LICENSE") iconFile = project.file("icons/icon.ico") nsisTemplate = project.file("resources/installer/nsis-script-template.nsi") extraParams = mapOf( "app_publisher" to "abdownloadmanager.com", "app_version_with_build" to "${getAppVersionStringForPackaging(Exe)}.0", "source_code_url" to "https://github.com/amir1376/ab-download-manager", "project_website" to "www.abdownloadmanager.com", "copyright" to "© 2024-present AB Download Manager App", "header_image_file" to project.file("resources/installer/abdm-header-image.bmp"), "sidebar_image_file" to project.file("resources/installer/abdm-sidebar-image.bmp") ) } macos { appName = getAppName() inputDir = project.file("build/compose/binaries/main-release/app/") appFileName = "${getAppName()}.app" backgroundImage = project.file("resources/installer/dmg_background.png") outputFileName = getAppName() licenseFile = rootProject.file("LICENSE") volumeIcon = project.file("icons/icon.icns") } } // ======= begin of GitHub action stuff val ciDir = CiUtils.getCiDir(project) val appPackageNameByComposePlugin get() = requireNotNull(compose.desktop.application.nativeDistributions.packageName) { "compose.desktop.application.nativeDistributions.packageName must not be null!" } val distributableAppArchiveDir: Provider = project.layout.buildDirectory.dir("dist/archives") fun AbstractArchiveTask.fromAppImagePath() { from(tasks.named("createReleaseDistributable")) destinationDirectory.set(distributableAppArchiveDir) } /** * gradle 9 removes file permissions and timestamp by default in archive tasks!. but we want them! */ fun AbstractArchiveTask.preserveFileAttributes() { // Make file order based on the file system isReproducibleFileOrder = false // Use file timestamps from the file system isPreserveFileTimestamps = true // Use permissions from the file system useFileSystemPermissions() } val createDistributableAppArchiveTar by tasks.registering(Tar::class) { preserveFileAttributes() archiveFileName.set("app.tar.gz") compression = Compression.GZIP fromAppImagePath() } val createDistributableAppArchiveZip by tasks.registering(Zip::class) { preserveFileAttributes() archiveFileName.set("app.zip") fromAppImagePath() } val createDistributableAppArchive by tasks.registering { when (Platform.getCurrentPlatform()) { Platform.Desktop.Linux, Platform.Desktop.MacOS -> dependsOn(createDistributableAppArchiveTar) Platform.Desktop.Windows -> dependsOn(createDistributableAppArchiveZip) Platform.Android -> error("this task is used for desktop only") } } tasks.register(CiUtils.getCreateBinaryFolderForCiTaskName()) { if (installerPlugin.isThisPlatformSupported()) { dependsOn(installerPlugin.createInstallerTask) inputs.dir(installerPlugin.outputFolder) } dependsOn(createDistributableAppArchive) inputs.property("appVersion", getAppVersionString()) inputs.dir(distributableAppArchiveDir) outputs.dir(ciDir.binariesDir) doLast { val output = ciDir.binariesDir.get().asFile val packageName = appPackageNameByComposePlugin if (installerPlugin.isThisPlatformSupported()) { val targets = installerPlugin.getCreatedInstallerTargetFormats() for (target in targets) { CiUtils.movePackagedAndCreateSignature( appVersion = getAppVersion(), packageName = packageName, target = target, basePackagedAppsDir = installerPlugin.outputFolder.get().asFile, outputDir = output, ) } logger.lifecycle("app packages for '${targets.joinToString(", ") { it.name }}' written in $output using the installer plugin") } val appArchiveDistributableDir = distributableAppArchiveDir.get().asFile CiUtils.copyAndHashToDestination( distributableAppArchiveDir.get().asFile.resolve( CiUtils.getFileOfDistributedArchivedTarget( appArchiveDistributableDir, ) ), output, CiUtils.getTargetFileName( packageName, getAppVersion(), null, // this is not an installer (it will be automatically converted to current os name Arch.getCurrentArch().name ) ) logger.lifecycle("distributable app archive written in ${output}") } } // ======= end of GitHub action stuff fun TargetFormat.toInstallerTargetFormat(): InstallerTargetFormat { return when (this) { AppImage -> error("$this is not recognized as installer") Deb -> InstallerTargetFormat.Deb Rpm -> InstallerTargetFormat.Rpm Dmg -> InstallerTargetFormat.Dmg Pkg -> InstallerTargetFormat.Pkg Exe -> InstallerTargetFormat.Exe Msi -> InstallerTargetFormat.Msi } } ================================================ FILE: desktop/app/gradle.properties ================================================ INSTALLER.WINDOWS.UPGRADE_UUID=2b3aeac1-271a-4f05-b26e-8e0d2a81b215 ================================================ FILE: desktop/app/proguard/decompose.pro ================================================ -dontwarn com.arkivanov.decompose.** -keep class * implements com.arkivanov.decompose.mainthread.MainThreadChecker ================================================ FILE: desktop/app/proguard/main.pro ================================================ #-dontobfuscate -keep class kotlinx.coroutines.swing.** ================================================ FILE: desktop/app/proguard/okhttp.pro ================================================ -dontwarn okhttp3.internal.platform.** -dontwarn okio.** ================================================ FILE: desktop/app/resources/common/app.properties ================================================ app.debug="false" ================================================ FILE: desktop/app/resources/installer/nsis-script-template.nsi ================================================ Unicode True RequestExecutionLevel user SetCompressor /SOLID lzma !include "LogicLib.nsh" !include "MUI2.nsh" !define APP_PUBLISHER "{{ app_publisher }}" !define APP_NAME "{{ app_name }}" !define APP_DISPLAY_NAME "{{ app_display_name }}" !define APP_DATA_DIR_NAME "{{ app_data_dir_name }}" !define APP_VERSION "{{ app_version }}" !define APP_VERSION_WITH_BUILD "{{ app_version_with_build }}" !define APP_DISPLAY_VERSION "{{ app_display_version }}" !define SOURCE_CODE_URL "{{ source_code_url }}" !define PROJECT_WEBSITE "{{ project_website }}" !define COPYRIGHT "{{ copyright }}" !define INPUT_DIR "{{ input_dir }}" !define LICENSE_FILE "{{ license_file }}" !define MAIN_BINARY_NAME "${APP_NAME}" !define SIDEBAR_IMAGE "{{ sidebar_image_file }}" !define HEADER_IMAGE "{{ header_image_file }}" !define ICON_FILE "{{ icon_file }}" !define REG_UNINSTALL_KEY "Software\Microsoft\Windows\CurrentVersion\Uninstall\${APP_NAME}" !define REG_RUN_KEY "Software\Microsoft\Windows\CurrentVersion\Run\${APP_NAME}" !define REG_APP_KEY "Software\${APP_NAME}" ; icon for this installer! Icon "${ICON_FILE}" !define MUI_ICON "${ICON_FILE}" !define MUI_UNICON "${ICON_FILE}" !if "${SIDEBAR_IMAGE}" != "" !define MUI_WELCOMEFINISHPAGE_BITMAP "${SIDEBAR_IMAGE}" !define MUI_UNWELCOMEFINISHPAGE_BITMAP "${SIDEBAR_IMAGE}" !endif !if "${HEADER_IMAGE}" != "" !define MUI_HEADERIMAGE !define MUI_HEADERIMAGE_BITMAP "${HEADER_IMAGE}" !define MUI_UNHEADERIMAGE !define MUI_UNHEADERIMAGE_BITMAP "${HEADER_IMAGE}" !endif VIProductVersion "${APP_VERSION_WITH_BUILD}" VIAddVersionKey "ProductName" "${APP_DISPLAY_NAME}" VIAddVersionKey "FileDescription" "${APP_DISPLAY_NAME}" VIAddVersionKey "LegalCopyright" "${COPYRIGHT}" VIAddVersionKey "FileVersion" "${APP_VERSION_WITH_BUILD}" VIAddVersionKey "ProductVersion" "${APP_VERSION_WITH_BUILD}" Name "${APP_DISPLAY_NAME}" OutFile "{{ output_file }}" InstallDir "$LOCALAPPDATA\${APP_NAME}" !define INSTALL_DIR `$INSTDIR` Function .onInit ; Call RestorePreviousInstallLocation FunctionEnd ; configure instfiles page !define MUI_FINISHPAGE_NOAUTOCLOSE !define MUI_INSTFILESPAGE_NOAUTOCLOSE ; configure finish page !define MUI_FINISHPAGE_LINK "Open project in GitHub" !define MUI_FINISHPAGE_LINK_LOCATION "${SOURCE_CODE_URL}" !define MUI_FINISHPAGE_RUN !define MUI_FINISHPAGE_RUN_FUNCTION RunMainBinary ;Installation Pages !insertmacro MUI_PAGE_WELCOME !insertmacro MUI_PAGE_LICENSE "${LICENSE_FILE}" !insertmacro MUI_PAGE_COMPONENTS !insertmacro MUI_PAGE_INSTFILES !insertmacro MUI_PAGE_FINISH ;Uninstallation Pages !insertmacro MUI_UNPAGE_WELCOME !insertmacro MUI_UNPAGE_COMPONENTS !insertmacro MUI_UNPAGE_CONFIRM !insertmacro MUI_UNPAGE_INSTFILES !insertmacro MUI_UNPAGE_FINISH ; set language !insertmacro MUI_LANGUAGE "English" ; a macro clear files to cleanup installation folder !macro clearFiles RmDir /r "${INSTALL_DIR}\app" RmDir /r "${INSTALL_DIR}\runtime" Delete "${INSTALL_DIR}\${MAIN_BINARY_NAME}.exe" Delete "${INSTALL_DIR}\${MAIN_BINARY_NAME}.ico" Delete "${INSTALL_DIR}\uninstall.exe" RmDir "${INSTALL_DIR}" !macroend Function RunMainBinary Exec "${INSTALL_DIR}\${MAIN_BINARY_NAME}.exe" FunctionEnd !macro GetBestExecutableName result StrCpy ${result} "${MAIN_BINARY_NAME}.exe" !macroend ; Function RestorePreviousInstallLocation ; ReadRegStr $4 SHCTX "${REG_APP_KEY}" "InstallPath" ; ${if} $4 != "" ; StrCpy $INSTDIR $4 ; ${endif} ; FunctionEnd ; I should improve this. !macro closeApp !insertmacro GetBestExecutableName $1 DetailPrint "Stopping Executable $1" ; I don't wanna kill myself! ${If} "$EXEFILE" != "$1" ExecWait 'taskkill /F /IM "$1"' $0 ${Else} DetailPrint "It seems that installer file name is same as app executable name" DetailPrint "Please close app manually" ; don't sleep the script for nothing. StrCpy $0 "1" ${EndIf} ${If} $0 == "0" Sleep 500 BringToFront ; when we sleep it seems that window goes down DetailPrint "Current app stopped successfully" ${Endif} !macroend !macro CreateStartMenu createDirectory "$SMPROGRAMS\${APP_DISPLAY_NAME}" createShortCut "$SMPROGRAMS\${APP_DISPLAY_NAME}\${APP_DISPLAY_NAME}.lnk" "${INSTALL_DIR}\${MAIN_BINARY_NAME}.exe" !macroend !macro RemoveStartMenu RmDir /r "$SMPROGRAMS\${APP_DISPLAY_NAME}" !macroend !macro RemoveUserData RMDir /r "$PROFILE\${APP_DATA_DIR_NAME}" RmDir /r "${INSTALL_DIR}\${APP_DATA_DIR_NAME}" !macroend !macro CreateDesktopShortcut CreateShortcut "$DESKTOP\${APP_DISPLAY_NAME}.lnk" "${INSTALL_DIR}\${MAIN_BINARY_NAME}.exe" !macroend !macro RemoveDesktopShortCut Delete "$DESKTOP\${APP_DISPLAY_NAME}.lnk" !macroend Function .onInstSuccess ; Check if the installer is running in silent mode ${If} ${Silent} ; In silent mode, always run the app Call RunMainBinary ${Endif} FunctionEnd Section "${APP_DISPLAY_NAME}" SectionInstType RO DetailPrint "Closing app (if any)" !insertmacro closeApp DetailPrint "clearing old app (if any)" !insertmacro clearFiles DetailPrint "writing new data" SetOutPath "${INSTALL_DIR}" CreateDirectory "${INSTALL_DIR}" WriteUninstaller "${INSTALL_DIR}\uninstall.exe" File /nonfatal /r "${INPUT_DIR}\" ; Registry information for add/remove programs WriteRegStr SHCTX "${REG_UNINSTALL_KEY}" "DisplayName" "${APP_DISPLAY_NAME}" WriteRegStr SHCTX "${REG_UNINSTALL_KEY}" "DisplayIcon" "$\"${INSTALL_DIR}\${MAIN_BINARY_NAME}.exe$\"" WriteRegStr SHCTX "${REG_UNINSTALL_KEY}" "DisplayVersion" "${APP_VERSION}" WriteRegStr SHCTX "${REG_UNINSTALL_KEY}" "Publisher" "${APP_PUBLISHER}" WriteRegStr SHCTX "${REG_UNINSTALL_KEY}" "InstallLocation" "$\"${INSTALL_DIR}$\"" WriteRegStr SHCTX "${REG_UNINSTALL_KEY}" "UninstallString" "$\"${INSTALL_DIR}\uninstall.exe$\"" WriteRegDWORD SHCTX "${REG_UNINSTALL_KEY}" "NoModify" "1" WriteRegDWORD SHCTX "${REG_UNINSTALL_KEY}" "NoRepair" "1" ; Registry keys for app installation path and version WriteRegStr SHCTX "${REG_APP_KEY}" "InstallPath" "${INSTALL_DIR}" WriteRegStr SHCTX "${REG_APP_KEY}" "Version" "${APP_VERSION}" SectionEnd Section "Start Menu" !insertmacro CreateStartMenu SectionEnd Section "Desktop Shortcut" !insertmacro CreateDesktopShortcut SectionEnd Section /o "un.Remove User Data" !insertmacro RemoveUserData SectionEnd Section "Uninstall" SectionInstType RO !insertmacro closeApp !insertmacro clearFiles !insertmacro RemoveStartMenu !insertmacro RemoveDesktopShortCut DeleteRegKey SHCTX "${REG_UNINSTALL_KEY}" DeleteRegKey SHCTX "${REG_APP_KEY}" ; remove auto start on boot registry DeleteRegValue SHCTX "${REG_RUN_KEY}" "${APP_NAME}" SectionEnd ================================================ FILE: desktop/app/src/main/kotlin/com/abdownloadmanager/desktop/App.kt ================================================ /* * This Kotlin source file was generated by the Gradle 'init' task. */ package com.abdownloadmanager.desktop import com.abdownloadmanager.UpdateManager import com.abdownloadmanager.desktop.di.Di import com.abdownloadmanager.desktop.repository.AppRepository import com.abdownloadmanager.desktop.ui.Ui import com.abdownloadmanager.desktop.utils.* import com.abdownloadmanager.desktop.utils.renderapi.CustomRenderApi import com.abdownloadmanager.desktop.utils.singleInstance.AnotherInstanceIsRunning import com.abdownloadmanager.desktop.utils.singleInstance.MutableSingleInstanceServerHandler import com.abdownloadmanager.desktop.utils.singleInstance.SingleInstanceUtil import com.abdownloadmanager.integration.Integration import com.abdownloadmanager.shared.util.AppVersion import com.abdownloadmanager.shared.util.DownloadSystem import com.abdownloadmanager.shared.util.appinfo.PreviousVersion import kotlinx.coroutines.runBlocking import org.koin.core.component.KoinComponent import org.koin.core.component.inject import kotlin.system.exitProcess class App : AutoCloseable, KoinComponent { private val downloadSystem: DownloadSystem by inject() private val appRepository: AppRepository by inject() private val integration: Integration by inject() private val previousVersion: PreviousVersion by inject() private val updateManager: UpdateManager by inject() private val keepAwakeManager: KeepAwakeManager by inject() private val customRenderApi: CustomRenderApi by inject() //TODO Setup Native Messaging Feature //private val browserNativeMessaging: NativeMessaging by inject() fun start( appArguments: AppArguments, singleInstanceServerHandler: MutableSingleInstanceServerHandler, globalAppExceptionHandler: GlobalAppExceptionHandler, ) { try { runBlocking { //make sure to not get any dependency until boot the DI Container Di.boot() // it's better to organize these list of boot functions in a separate class // boot configs from the storage so download manager can use them on boot! customRenderApi.boot() appRepository.boot() integration.boot() downloadSystem.boot() previousVersion.boot() keepAwakeManager.boot() //TODO Setup Native Messaging Feature //waiting for compose kmp to add multi launcher to nativeDistributions,the PR is already exists but not merger //or maybe I should use a custom solution //browserNativeMessaging.boot() SingleInstanceServerInitializer.boot(singleInstanceServerHandler) Ui.boot(appArguments, globalAppExceptionHandler) } } catch (e: Exception) { globalAppExceptionHandler.onProcessIsUseless() throw e } } override fun close() { //nothing yet! } } fun main(args: Array) { try { AppArguments.init(args) AppProperties.boot() val appArguments = AppArguments.get() if (appArguments.version) { dispatchVersionAndExit() } val singleInstance = SingleInstanceUtil(AppInfo.definedPaths.configDir) if (appArguments.exit) { exitExistingProcessAndExit(singleInstance) } if (appArguments.startIfNotStarted && !AppInfo.isInIDE()) { startAndWaitForRunIfNotRunning(singleInstance) } if (appArguments.getIntegrationPort) { dispatchIntegrationPortAndExit(singleInstance) } //going to start main app defaultApp( singleInstance = singleInstance, appArguments = appArguments, ) } catch (e: Throwable) { System.err.println("Fail to start the ${AppInfo.displayName} app because:") e.printStackTrace() exitProcess(-1) } } private fun startAppInAnotherProcess() { val exeFile = requireNotNull(AppInfo.exeFile) val cmd = listOf( exeFile, AppArguments.Args.BACKGROUND ).joinToString(" ").also { // println("executing $it") } Runtime.getRuntime().exec(cmd) } private fun dispatchVersionAndExit(): Nothing { print(AppInfo.version) exitProcess(0) } private fun exitExistingProcessAndExit(singleInstance: SingleInstanceUtil): Nothing { singleInstance.sendToInstance(Commands.exit) exitProcess(0) } private fun dispatchIntegrationPortAndExit(singleInstance: SingleInstanceUtil): Nothing { val port = singleInstance.sendToInstance(Commands.getIntegrationPort) .orElse { IntegrationPortBroadcaster.INTEGRATION_UNKNOWN } print(port) exitProcess(0) } private fun startAndWaitForRunIfNotRunning( singleInstance: SingleInstanceUtil, howMuchWait: Long = 10_000, initialDelay: Long = 0, eachTimeDelay: Long = 500L, ) { val deadLine = System.currentTimeMillis() + howMuchWait if (initialDelay > 0) { Thread.sleep(initialDelay) } var firstLoop = true while (true) { val isReady: Boolean = singleInstance .sendToInstance(Commands.isReady) .orElse { // println("or else $it") false } // println("isReady: $isReady") if (isReady) { return } if (firstLoop) { startAppInAnotherProcess() // println("send start signal") } if (System.currentTimeMillis() >= deadLine) { // println("dead line reached") //deadline reached exiting now exitProcess(1) } Thread.sleep(eachTimeDelay) firstLoop = false } } private fun defaultApp( appArguments: AppArguments, singleInstance: SingleInstanceUtil, ) { val singleInstanceServerHandler by lazy { MutableSingleInstanceServerHandler() } try { singleInstance.lockInstance { singleInstanceServerHandler } } catch (e: AnotherInstanceIsRunning) { println("instance already running") singleInstance.sendToInstance(Commands.showUserThatAppIsRunning) return } if (AppInfo.isInIDE()) { println("app version ${AppVersion.get()} is started") println("it seems we are in ide") } val globalExceptionHandler = createAndSetGlobalExceptionHandler() App().use { it.start( appArguments = appArguments, globalAppExceptionHandler = globalExceptionHandler, singleInstanceServerHandler = singleInstanceServerHandler, ) } } ================================================ FILE: desktop/app/src/main/kotlin/com/abdownloadmanager/desktop/AppArguments.kt ================================================ package com.abdownloadmanager.desktop data class AppArguments( val getIntegrationPort: Boolean, val startIfNotStarted: Boolean, val startSilent: Boolean, val debug: Boolean, val version: Boolean, val exit: Boolean, ) { companion object { private lateinit var instance: AppArguments fun get() = instance /** * Initial me on app startup */ fun init(args: Array) { instance = create(args) } private fun create(args: Array): AppArguments { return AppArguments( getIntegrationPort = args.contains(Args.GET_INTEGRATION_PORT), startIfNotStarted = args.contains(Args.START_IF_NOT_STARTED), startSilent = args.contains(Args.BACKGROUND), debug = args.contains(Args.DEBUG), version = args.contains(Args.VERSION), exit = args.contains(Args.EXIT), ) } } object Args { const val START_IF_NOT_STARTED = "--start-if-not-started" const val BACKGROUND = "--background" const val GET_INTEGRATION_PORT = "--get-integration-port" const val DEBUG = "--debug" const val VERSION = "--version" const val EXIT = "--exit" } } ================================================ FILE: desktop/app/src/main/kotlin/com/abdownloadmanager/desktop/AppComponent.kt ================================================ package com.abdownloadmanager.desktop import com.abdownloadmanager.UpdateManager import ir.amirab.util.desktop.poweraction.PowerActionConfig import com.abdownloadmanager.shared.pages.adddownload.AddDownloadComponent import com.abdownloadmanager.shared.pages.adddownload.AddDownloadConfig import com.abdownloadmanager.shared.pages.adddownload.AddDownloadCredentialsInUiProps import com.abdownloadmanager.shared.pages.adddownload.ImportOptions import com.abdownloadmanager.desktop.pages.addDownload.multiple.DesktopAddMultiDownloadComponent import com.abdownloadmanager.desktop.pages.addDownload.single.DesktopAddSingleDownloadComponent import com.abdownloadmanager.desktop.pages.batchdownload.DesktopBatchDownloadComponent import com.abdownloadmanager.shared.pages.category.CategoryComponent import com.abdownloadmanager.desktop.pages.category.DesktopCategoryDialogManager import com.abdownloadmanager.desktop.pages.editdownload.DesktopEditDownloadComponent import com.abdownloadmanager.desktop.pages.enterurl.DesktopEnterNewURLComponent import com.abdownloadmanager.desktop.pages.checksum.DesktopFileChecksumComponent import com.abdownloadmanager.desktop.pages.home.HomeComponent import com.abdownloadmanager.desktop.pages.perhostsettings.DesktopPerHostSettingsComponent import com.abdownloadmanager.desktop.pages.queue.QueuesComponent import com.abdownloadmanager.desktop.pages.settings.DesktopSettingsComponent import com.abdownloadmanager.desktop.pages.poweractionalert.PowerActionComponent import com.abdownloadmanager.desktop.pages.singleDownloadPage.DesktopSingleDownloadComponent import com.abdownloadmanager.desktop.repository.AppRepository import com.abdownloadmanager.desktop.storage.AppSettingsStorage import com.abdownloadmanager.desktop.storage.DesktopExtraDownloadItemSettings import com.abdownloadmanager.desktop.storage.PageStatesStorage import com.abdownloadmanager.desktop.ui.widget.MessageDialogModel import com.abdownloadmanager.shared.ui.widget.MessageDialogType import com.abdownloadmanager.shared.ui.widget.NotificationModel import com.abdownloadmanager.shared.ui.widget.NotificationType import com.abdownloadmanager.desktop.utils.* import com.abdownloadmanager.shared.util.mvi.ContainsEffects import com.abdownloadmanager.shared.util.mvi.supportEffects import com.arkivanov.decompose.ComponentContext import com.arkivanov.decompose.router.children.ChildNavState import com.arkivanov.decompose.router.pages.Pages import com.arkivanov.decompose.router.pages.PagesNavigation import com.arkivanov.decompose.router.pages.childPages import com.arkivanov.decompose.router.pages.navigate import com.arkivanov.decompose.router.slot.* import ir.amirab.downloader.DownloadManagerEvents import ir.amirab.downloader.downloaditem.contexts.ResumedBy import ir.amirab.downloader.downloaditem.contexts.User import ir.amirab.downloader.queue.DefaultQueueInfo import ir.amirab.downloader.utils.ExceptionUtils import com.abdownloadmanager.integration.Integration import com.abdownloadmanager.integration.IntegrationResult import com.abdownloadmanager.resources.* import com.abdownloadmanager.shared.downloaderinui.DownloaderInUiRegistry import com.abdownloadmanager.shared.pagemanager.AboutPageManager import com.abdownloadmanager.shared.pagemanager.AddDownloadDialogManager import com.abdownloadmanager.shared.pagemanager.BatchDownloadPageManager import com.abdownloadmanager.shared.pagemanager.DownloadDialogManager import com.abdownloadmanager.shared.pagemanager.EditDownloadDialogManager import com.abdownloadmanager.shared.pagemanager.EnterNewURLDialogManager import com.abdownloadmanager.shared.pagemanager.ExitApplicationRequestManager import com.abdownloadmanager.shared.pagemanager.FileChecksumDialogManager import com.abdownloadmanager.shared.pagemanager.NotificationSender import com.abdownloadmanager.shared.pagemanager.OpenSourceLibrariesPageManager import com.abdownloadmanager.shared.pagemanager.PerHostSettingsPageManager import com.abdownloadmanager.shared.pagemanager.QueuePageManager import com.abdownloadmanager.shared.pagemanager.SettingsPageManager import com.abdownloadmanager.shared.pagemanager.TranslatorsPageManager import com.abdownloadmanager.shared.pages.updater.UpdateComponent import com.abdownloadmanager.shared.storage.ExtraDownloadSettingsStorage import com.abdownloadmanager.shared.util.BaseComponent import com.abdownloadmanager.shared.util.DownloadItemOpener import com.abdownloadmanager.shared.util.DownloadSystem import com.abdownloadmanager.shared.util.FileIconProvider import com.abdownloadmanager.shared.util.category.CategoryManager import com.abdownloadmanager.shared.util.category.CategorySelectionMode import com.abdownloadmanager.shared.util.category.DefaultCategories import com.abdownloadmanager.shared.util.perhostsettings.PerHostSettingsManager import com.abdownloadmanager.shared.util.subscribeAsStateFlow import com.arkivanov.decompose.childContext import ir.amirab.downloader.NewDownloadItemProps import ir.amirab.downloader.destination.IncompleteFileUtil import ir.amirab.downloader.downloaditem.DownloadStatus import ir.amirab.downloader.downloaditem.IDownloadItem import ir.amirab.downloader.exception.TooManyErrorException import ir.amirab.downloader.monitor.isDownloadActiveFlow import ir.amirab.downloader.queue.QueueManager import ir.amirab.util.compose.IIconResolver import ir.amirab.util.compose.StringSource import ir.amirab.util.compose.asStringSource import ir.amirab.util.compose.combineStringSources import ir.amirab.util.coroutines.launchWithDeferred import ir.amirab.util.flow.mapStateFlow import ir.amirab.util.osfileutil.FileUtils import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Deferred import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.flow.* import kotlinx.coroutines.launch import kotlinx.coroutines.withContext import kotlinx.serialization.Serializable import org.koin.core.component.KoinComponent import org.koin.core.component.inject import java.awt.Toolkit import kotlin.system.exitProcess sealed interface AppEffects { data class SimpleNotificationNotification( val notificationModel: NotificationModel, ) : AppEffects } class AppComponent( ctx: ComponentContext, ) : BaseComponent(ctx), DesktopDownloadDialogManager, DesktopAddDownloadDialogManager, DesktopCategoryDialogManager, EditDownloadDialogManager, FileChecksumDialogManager, QueuePageManager, NotificationSender, DownloadItemOpener, PerHostSettingsPageManager, PowerActionManager, EnterNewURLDialogManager, SettingsPageManager, OpenSourceLibrariesPageManager, TranslatorsPageManager, AboutPageManager, BatchDownloadPageManager, ExitApplicationRequestManager, ContainsEffects by supportEffects(), KoinComponent { val applicationScope: CoroutineScope by inject() val appRepository: AppRepository by inject() val appSettings: AppSettingsStorage by inject() val downloaderInUiRegistry: DownloaderInUiRegistry by inject() private val queueManager: QueueManager by inject() private val defaultCategories: DefaultCategories by inject() private val integration: Integration by inject() private val perHostSettingsManager: PerHostSettingsManager by inject() val iconFromUriResolver: IIconResolver by inject() val updaterManager: UpdateManager by inject() val extraDownloadSettingStorage: ExtraDownloadSettingsStorage by inject() val useSystemTray = appSettings.useSystemTray fun openHome() { scope.launch { showHomeSlot.value.child?.instance.let { if (it != null) { it.bringToFront() } else { showHome.activate(HomePageConfig()) } } } } fun activateHomeIfNotOpen() { scope.launch { showHomeSlot.value.child?.instance.let { if (it == null) { showHome.activate(HomePageConfig()) } } } } fun closeHome() { scope.launch { showHome.dismiss() } } @Serializable class HomePageConfig private val showHome = SlotNavigation() val showHomeSlot = childSlot( showHome, serializer = null, key = "home", childFactory = { _: HomePageConfig, componentContext: ComponentContext -> HomeComponent( ctx = componentContext, downloadItemOpener = this, downloadDialogManager = this, enterNewURLDialogManager = this, desktopAddDownloadDialogManager = this, fileChecksumDialogManager = this, categoryDialogManager = this, notificationSender = this, editDownloadDialogManager = this, queuePageManager = this, categoryManager = categoryManager, downloadSystem = downloadSystem, queueManager = queueManager, defaultCategories = defaultCategories, fileIconProvider = fileIconProvider, ) } ).subscribeAsStateFlow() class QueuePageConfig( val selectedQueue: Long? = null ) private val showQueues = SlotNavigation() val showQueuesSlot = childSlot( showQueues, serializer = null, key = "queues", childFactory = { config: QueuePageConfig, componentContext: ComponentContext -> QueuesComponent(componentContext, this::closeQueues).apply { config.selectedQueue?.let { onQueueSelected(it) } } } ).subscribeAsStateFlow() class BatchDownloadConfig private val batchDownload = SlotNavigation() val batchDownloadSlot = childSlot( batchDownload, serializer = null, key = "batchDownload", childFactory = { _: BatchDownloadConfig, componentContext: ComponentContext -> DesktopBatchDownloadComponent( ctx = componentContext, onClose = this::closeBatchDownload, importLinks = { openAddDownloadDialog( it.mapNotNull { downloaderInUiRegistry .bestMatchForThisLink(it) ?.createMinimumCredentials(it) ?.let { credentials -> AddDownloadCredentialsInUiProps( credentials = credentials, ) } } ) } ) } ).subscribeAsStateFlow() private val editDownload = SlotNavigation() val editDownloadSlot = childSlot( editDownload, serializer = null, key = "editDownload", childFactory = { editDownloadConfig: Long, componentContext: ComponentContext -> DesktopEditDownloadComponent( ctx = componentContext, onRequestClose = { closeEditDownloadDialog() }, onEdited = { updater, downloadJobExtraConfig -> scope.launch { downloadSystem.editDownload( id = editDownloadConfig, applyUpdate = updater, downloadJobExtraConfig = downloadJobExtraConfig ) closeEditDownloadDialog() } }, downloadId = editDownloadConfig, acceptEdit = downloadSystem.downloadMonitor .isDownloadActiveFlow(editDownloadConfig) .mapStateFlow { !it }, downloadSystem = downloadSystem, downloaderInUiRegistry = downloaderInUiRegistry, iconProvider = fileIconProvider, ) } ).subscribeAsStateFlow() override fun openEditDownloadDialog(id: Long) { val currentComponent = editDownloadSlot.value.child?.instance if (currentComponent != null && currentComponent.downloadId == id) { currentComponent.bringToFront() } else { editDownload.activate(id) } } override fun closeEditDownloadDialog() { editDownload.dismiss() } override fun openSettings() { scope.launch { showSettingSlot.value.child?.instance.let { if (it != null) { it.toFront() } else { showSettingWindow.activate(AppSettingPageConfig()) } } } } override fun closeSettings() { scope.launch { showSettingWindow.dismiss() } } class AppSettingPageConfig val showSettingWindow = SlotNavigation() val showSettingSlot = childSlot( showSettingWindow, serializer = null, key = "settings", childFactory = { configuration: AppSettingPageConfig, componentContext: ComponentContext -> DesktopSettingsComponent( componentContext, this ) } ).subscribeAsStateFlow() private val pageStatesStorage: PageStatesStorage by inject() val downloadSystem: DownloadSystem by inject() private val fileIconProvider: FileIconProvider by inject() private val addDownloadPageControl = PagesNavigation() val _openedAddDownloadDialogs = childPages( key = "openedAddDownloadDialogs", source = addDownloadPageControl, serializer = null, initialPages = { Pages() }, pageStatus = { _, _ -> ChildNavState.Status.RESUMED }, childFactory = { config, ctx -> val component: AddDownloadComponent = when (config) { is AddDownloadConfig.SingleAddConfig -> { DesktopAddSingleDownloadComponent( ctx = ctx, onRequestClose = { closeAddDownloadDialog(config.id) }, onRequestAddToQueue = { item, queueId, categoryId -> addDownload( item = item, queueId = queueId, categoryId = categoryId, ) }, categoryDialogManager = this, onRequestDownload = { item, categoryId -> startNewDownload( item = item, categoryId = categoryId, ) }, openExistingDownload = { openDownloadDialog(it) }, downloadItemOpener = this, updateExistingDownloadCredentials = { id, newCredentials, downloadJobExtraConfig -> scope.launch { downloadSystem.downloadManager.updateDownloadItem( id = id, downloadJobExtraConfig = downloadJobExtraConfig, updater = { it.withCredentials(newCredentials) } ) openDownloadDialog(id) } }, id = config.id, importOptions = config.importOptions, initialCredentials = config.newDownload, downloaderInUi = requireNotNull( downloaderInUiRegistry.getDownloaderOf(config.newDownload.credentials) ), lastSavedLocationsStorage = pageStatesStorage, appScope = applicationScope, appSettings = appSettings, appRepository = appRepository, perHostSettingsManager = perHostSettingsManager, downloadSystem = downloadSystem, iconProvider = fileIconProvider, categoryManager = categoryManager, queueManager = queueManager, ) } is AddDownloadConfig.MultipleAddConfig -> { DesktopAddMultiDownloadComponent( ctx = ctx, id = config.id, onRequestClose = { closeAddDownloadDialog(config.id) }, onRequestAdd = { items, queueId, categorySelectionMode -> addDownloads( items = items, queueId = queueId, categorySelectionMode = categorySelectionMode ) }, lastSavedLocationsStorage = pageStatesStorage, perHostSettingsManager = perHostSettingsManager, downloadSystem = downloadSystem, fileIconProvider = fileIconProvider, appRepository = appRepository, downloaderInUiRegistry = downloaderInUiRegistry, queueManager = queueManager, categoryManager = categoryManager, categoryDialogManager = this, ).apply { addItems(config.newDownloads) } } else -> error("should not happened") } component } ).subscribeAsStateFlow() override val openedAddDownloadDialogs = _openedAddDownloadDialogs.map { it.items.mapNotNull { it.instance } } .stateIn(scope, SharingStarted.Eagerly, emptyList()) private val downloadDialogControl = PagesNavigation() private val _openedDownloadDialogs = childPages( key = "openedDownloadDialogs", source = downloadDialogControl, serializer = null, initialPages = { Pages() }, pageStatus = { _, _ -> ChildNavState.Status.RESUMED }, childFactory = { cfg, ctx -> DesktopSingleDownloadComponent( ctx = ctx, downloadItemOpener = this, onDismiss = { closeDownloadDialog(listOf(cfg.id)) }, downloadId = cfg.id, downloadSystem = downloadSystem, appSettings = appSettings, appRepository = appRepository, applicationScope = applicationScope, fileIconProvider = fileIconProvider, extraDownloadSettingsStorage = extraDownloadSettingStorage, ) } ).subscribeAsStateFlow() override val openedDownloadDialogs = _openedDownloadDialogs .map { it.items.mapNotNull { it.instance } } .stateIn(scope, SharingStarted.Eagerly, emptyList()) private val categoryManager: CategoryManager by inject() private val categoryPageControl = PagesNavigation() private val _openedCategoryDialogs = childPages( key = "openedCategoryDialogs", source = categoryPageControl, serializer = null, initialPages = { Pages() }, pageStatus = { _, _ -> ChildNavState.Status.RESUMED }, childFactory = { cfg, ctx -> CategoryComponent( ctx = ctx, close = { closeCategoryDialog(cfg) }, submit = { submittedCategory -> if (submittedCategory.id < 0) { categoryManager.addCustomCategory(submittedCategory) } else { categoryManager.updateCategory( submittedCategory.id ) { submittedCategory.copy( items = it.items ) } } closeCategoryDialog(cfg) }, id = cfg ) } ).subscribeAsStateFlow() override val openedCategoryDialogs: StateFlow> = _openedCategoryDialogs .map { it.items.mapNotNull { it.instance } }.stateIn(scope, SharingStarted.Eagerly, emptyList()) override fun openCategoryDialog(categoryId: Long) { scope.launch { val component = openedCategoryDialogs.value.find { it.id == categoryId } if (component != null) { // component.bringToFront() } else { categoryPageControl.navigate { val newItems = (it.items.toSet() + categoryId).toList() val copy = it.copy( items = newItems, selectedIndex = newItems.lastIndex ) copy } } } } override fun closeCategoryDialog(categoryId: Long) { scope.launch { categoryPageControl.navigate { val newItems = it.items.filter { config -> config != categoryId } it.copy(items = newItems, selectedIndex = newItems.lastIndex) } } } override fun closeCategoryDialog() { scope.launch { categoryPageControl.navigate { Pages() } } } init { downloadSystem.downloadEvents .filterIsInstance() .onEach { closeDownloadDialog(listOf(it.downloadItem.id)) }.launchIn(scope) } override fun sendNotification(tag: Any, title: StringSource, description: StringSource, type: NotificationType) { beep() showNotification(tag = tag, title = title, description = description, type = type) } override fun sendDialogNotification( title: StringSource, description: StringSource, type: MessageDialogType, ) { beep() newDialogMessage(MessageDialogModel(title = title, description = description, type = type)) } private fun beep() { if (appSettings.notificationSound.value) { Toolkit.getDefaultToolkit().beep() } } private fun showNotification( tag: Any, title: StringSource, description: StringSource, type: NotificationType = NotificationType.Info, ) { sendEffect( AppEffects.SimpleNotificationNotification( NotificationModel( tag = tag, initialTitle = title, initialDescription = description, initialNotificationType = type ) ) ) } init { downloadSystem .downloadEvents .onEach { onNewDownloadEvent(it) } .launchIn(scope) // IntegrationPortBroadcaster.cleanOnClose() integration .integrationStatus .onEach { when (it) { is IntegrationResult.Fail -> { IntegrationPortBroadcaster.setIntegrationPortInFile(null) sendDialogNotification( title = Res.string.cant_run_browser_integration.asStringSource(), type = MessageDialogType.Error, description = it.throwable.localizedMessage.asStringSource() ) } IntegrationResult.Inactive -> { IntegrationPortBroadcaster.setIntegrationPortInFile(null) } is IntegrationResult.Success -> { IntegrationPortBroadcaster.setIntegrationPortInFile(it.port) } } }.launchIn(scope) } private fun onNewDownloadEvent(it: DownloadManagerEvents) { if (it.context[ResumedBy]?.by !is User) { //only notify events that is started by user return } // or // val qm = downloadSystem.queueManager // val queueId = qm.findItemInQueue(it.downloadItem.id) // if (queueId != null) { // return@onEach // // skip download events when download is triggered by queue //// if (qm.getQueue(queue).isQueueActive){ //// return@onEach //// } // } if (it is DownloadManagerEvents.OnJobCanceled) { val exception = it.e if (ExceptionUtils.isNormalCancellation(exception)) { return } var isMaxTryReachedError = false val actualCause = if (exception is TooManyErrorException) { isMaxTryReachedError = true exception.findActualDownloadErrorCause() } else exception if (ExceptionUtils.isNormalCancellation(actualCause)) { return } val prefix = if (isMaxTryReachedError) { "Too Many Error: " } else { "Error: " }.asStringSource() val reason = actualCause.message?.asStringSource() ?: Res.string.unknown.asStringSource() sendNotification( "downloadId=${it.downloadItem.id}", title = it.downloadItem.name.asStringSource(), description = listOf(prefix, reason).combineStringSources(), type = NotificationType.Error, ) } if (it is DownloadManagerEvents.OnJobCompleted) { sendNotification( tag = "downloadId=${it.downloadItem.id}", title = it.downloadItem.name.asStringSource(), description = Res.string.finished.asStringSource(), type = NotificationType.Success, ) if (appSettings.showDownloadCompletionDialog.value) { openDownloadDialog(it.downloadItem.id) } } if (it is DownloadManagerEvents.OnJobStarting) { if (appSettings.showDownloadProgressDialog.value) { openDownloadDialog(it.downloadItem.id) } } } override suspend fun openDownloadItem(id: Long) { val item = downloadSystem.getDownloadItemById(id) if (item == null) { sendNotification( Res.string.open_file, Res.string.cant_open_file.asStringSource(), Res.string.download_item_not_found.asStringSource(), NotificationType.Error, ) return } openDownloadItem(item) } override suspend fun openDownloadItem(downloadItem: IDownloadItem) { runCatching { withContext(Dispatchers.IO) { FileUtils.openFile(downloadSystem.getDownloadFile(downloadItem)) } }.onFailure { sendNotification( Res.string.open_file, Res.string.cant_open_file.asStringSource(), it.localizedMessage?.asStringSource() ?: Res.string.unknown_error.asStringSource(), NotificationType.Error, ) println("Can't open file:${it.message}") } } override suspend fun openDownloadItemFolder(id: Long) { val item = downloadSystem.getDownloadItemById(id) if (item == null) { sendNotification( Res.string.open_folder, Res.string.cant_open_folder.asStringSource(), Res.string.download_item_not_found.asStringSource(), NotificationType.Error, ) return } openDownloadItemFolder(item) } override suspend fun openDownloadItemFolder(downloadItem: IDownloadItem) { runCatching { withContext(Dispatchers.IO) { val file = downloadSystem.getDownloadFile(downloadItem) if (file.exists()) { FileUtils.openFolderOfFile(file) } else { val incompleteFile = IncompleteFileUtil.addIncompleteIndicator(file, downloadItem.id) if (incompleteFile.exists() && downloadItem.status != DownloadStatus.Completed) { FileUtils.openFolderOfFile(incompleteFile) } else { FileUtils.openFolder(file.parentFile) } } } }.onFailure { sendNotification( Res.string.open_folder, Res.string.cant_open_folder.asStringSource(), it.localizedMessage?.asStringSource() ?: Res.string.unknown_error.asStringSource(), NotificationType.Error, ) println("Can't open folder:${it.message}") } } fun externalCredentialComingIntoApp( list: List, options: ImportOptions ) { val editDownloadComponent = editDownloadSlot.value.child?.instance if (editDownloadComponent != null) { list.firstOrNull()?.let { editDownloadComponent.importCredential( it.credentials ) editDownloadComponent.bringToFront() } } else { openAddDownloadDialog(list, options) } } override fun openAddDownloadDialog( links: List, importOptions: ImportOptions, ) { scope.launch { //remove duplicates val addDownloadCredentialsProps = links.distinctBy { it.credentials } addDownloadPageControl.navigate { val newItems = buildList { addAll(it.items) if (addDownloadCredentialsProps.size > 1) { add( AddDownloadConfig.MultipleAddConfig( addDownloadCredentialsProps, importOptions, ) ) } else { add( AddDownloadConfig.SingleAddConfig( addDownloadCredentialsProps.first(), importOptions, ) ) } } val copy = it.copy( items = newItems, selectedIndex = newItems.lastIndex ) copy } } } override fun closeAddDownloadDialog(dialogId: String) { scope.launch { addDownloadPageControl.navigate { val newItems = it.items.filter { config -> config.id != dialogId } it.copy(items = newItems, selectedIndex = newItems.lastIndex) } } } override fun closeAddDownloadDialog() { scope.launch { addDownloadPageControl.navigate { Pages() } } } override fun openDownloadDialog(id: Long) { scope.launch { val component = openedDownloadDialogs.value.find { it.downloadId == id } if (component != null) { component.bringToFront() } else { downloadDialogControl.navigate { val newItems = (it.items.toSet() + DesktopSingleDownloadComponent.Config(id)).toList() val copy = it.copy( items = newItems, selectedIndex = newItems.lastIndex ) copy } } } } override fun closeDownloadDialog(ids: List) { scope.launch { downloadDialogControl.navigate { val newItems = it.items.filter { config -> config.id !in ids } it.copy(items = newItems, selectedIndex = newItems.lastIndex) } } } override fun closeDownloadDialog() { scope.launch { downloadDialogControl.navigate { Pages() } } } private val fileChecksumPagesControl = SlotNavigation() val openedFileChecksumDialog = childSlot( key = "openedFileChecksumPage", source = fileChecksumPagesControl, serializer = null, childFactory = { config, ctx -> DesktopFileChecksumComponent( ctx = ctx, id = config.id, itemIds = config.itemIds, closeComponent = { closeFileChecksumPage(config.id) }, downloadSystem = downloadSystem, ) } ).subscribeAsStateFlow() override fun openFileChecksumPage(ids: List) { scope.launch { val instance = openedFileChecksumDialog.value.child?.instance if (instance?.itemIds == ids) { instance.bringToFront() } else { fileChecksumPagesControl.navigate { DesktopFileChecksumComponent.Config(itemIds = ids) } } } } override fun closeFileChecksumPage(dialogId: String) { scope.launch { fileChecksumPagesControl.dismiss() } } fun addDownloads( items: List, categorySelectionMode: CategorySelectionMode?, queueId: Long?, ): Deferred> { return scope.launchWithDeferred { downloadSystem.addDownload( newItemsToAdd = items, queueId = queueId, categorySelectionMode = categorySelectionMode, ) } } fun addDownload( item: NewDownloadItemProps, queueId: Long?, categoryId: Long?, ): Deferred { return scope.launchWithDeferred { downloadSystem.addDownload( newDownload = item, queueId = queueId, categoryId = categoryId, ) } } fun startNewDownload( item: NewDownloadItemProps, categoryId: Long?, ): Deferred { return scope.launchWithDeferred { downloadSystem.addDownload( newDownload = item, queueId = DefaultQueueInfo.ID, categoryId = categoryId, ).also { downloadSystem.userManualResume(it) } } } private val _showConfirmExitDialog = MutableStateFlow(false) val showConfirmExitDialog = _showConfirmExitDialog.asStateFlow() fun exitAppAsync() { scope.launch { exitApp() } } suspend fun exitApp() { downloadSystem.stopAnything() exitProcess(0) } fun closeConfirmExit() { _showConfirmExitDialog.value = false } override suspend fun requestExitApp() { val hasActiveDownloads = downloadSystem.downloadMonitor.activeDownloadCount.value > 0 if (hasActiveDownloads) { _showConfirmExitDialog.value = true return } exitApp() } override fun openAboutPage() { showAboutPage.update { true } } fun closeAbout() { showAboutPage.update { false } } override fun openOpenSourceLibrariesPage() { showOpenSourceLibraries.update { true } } fun closeOpenSourceLibraries() { showOpenSourceLibraries.update { false } } override fun openTranslatorsPage() { showTranslators.update { true } } override fun closeTranslatorsPage() { showTranslators.update { false } } override fun openQueues( openQueueId: Long?, ) { scope.launch { showQueuesSlot.value.child?.instance.let { if (it != null) { it.bringToFront() if (openQueueId != null) { it.onQueueSelected(openQueueId) } } else { showQueues.activate( QueuePageConfig( selectedQueue = openQueueId ) ) } } } } override fun closeQueues() { showQueues.dismiss() } var showCreateQueueDialog = MutableStateFlow(false) private set override fun closeNewQueueDialog() { showCreateQueueDialog.update { false } } override fun openNewQueueDialog() { showCreateQueueDialog.update { true } } fun createNewQueue(name: String) { scope.launch { downloadSystem.addQueue(name) } } override fun openBatchDownloadPage() { scope.launch { batchDownloadSlot.value.child?.instance.let { if (it != null) { it.bringToFront() } else { batchDownload.activate(BatchDownloadConfig()) } } } } override fun closeBatchDownload() { batchDownload.dismiss() } val enterNewURLWindow = SlotNavigation() val enterNewURLWindowSlot = childSlot( enterNewURLWindow, serializer = null, key = "enterNewURLWindow", childFactory = { configuration: DesktopEnterNewURLComponent.Config, componentContext: ComponentContext -> DesktopEnterNewURLComponent( ctx = componentContext, config = configuration, downloaderInUiRegistry = downloaderInUiRegistry, onCloseRequest = { closeEnterNewURLWindow() }, onRequestFinished = { credentials -> scope.launch { openAddDownloadDialog( links = listOf( AddDownloadCredentialsInUiProps( credentials = credentials ) ), ) } } ) } ).subscribeAsStateFlow() override fun openEnterNewURLWindow() { scope.launch { enterNewURLWindowSlot.value.child?.instance.let { if (it != null) { it.bringToFront() } else { enterNewURLWindow.activate( DesktopEnterNewURLComponent.Config ) } } } } override fun closeEnterNewURLWindow() { scope.launch { enterNewURLWindow.dismiss() } } val dialogMessages: MutableStateFlow> = MutableStateFlow(emptyList()) private fun newDialogMessage(msgDialogModel: MessageDialogModel) { dialogMessages.update { it .filter { item -> item.id != msgDialogModel.id } .plus(msgDialogModel) } } fun onDismissDialogMessage(msgDialogModel: MessageDialogModel) { dialogMessages.update { it.filter { item -> msgDialogModel.id != item.id } } } fun isReady(): Boolean { return listOf( IntegrationPortBroadcaster.isInitialized(), ).all { it } } val powerActionNavigation = SlotNavigation() val openedPowerAction = childSlot( source = powerActionNavigation, key = "powerAction", serializer = null, childFactory = { config, ctx -> PowerActionComponent( ctx = ctx, powerActionConfig = config.powerActionConfig, powerActionDelay = config.powerActionDelay, powerActionReason = config.powerActionReason, close = ::dismissPowerAction, onBeforePowerAction = { downloadSystem.stopAnything() }, ) } ).subscribeAsStateFlow() override fun initiatePowerAction( powerActionConfig: PowerActionConfig, reason: PowerActionComponent.PowerActionReason, ) { scope.launch { powerActionNavigation.activate( PowerActionComponent.Config( powerActionConfig = powerActionConfig, powerActionReason = reason, ) ) } } override fun dismissPowerAction() { scope.launch { powerActionNavigation.dismiss() } } val updater = UpdateComponent( childContext("updater"), this, updaterManager, ) private val perHostSettings = SlotNavigation() val perHostSettingsSlot = childSlot( perHostSettings, serializer = null, key = "perHostSettings", childFactory = { cfg: DesktopPerHostSettingsComponent.Config, componentContext: ComponentContext -> DesktopPerHostSettingsComponent( ctx = componentContext, closeRequested = this::closePerHostSettings, appScope = applicationScope, perHostSettingsManager = perHostSettingsManager, appRepository = appRepository, ).apply { cfg.openedHost?.let(this::onHostSelected) } } ).subscribeAsStateFlow() override fun openPerHostSettings( openedHost: String? ) { scope.launch { perHostSettingsSlot.value.child?.instance.let { component -> if (component != null) { component.bringToFront() openedHost?.let { component.onHostSelected(it) } } else { perHostSettings.activate(DesktopPerHostSettingsComponent.Config(openedHost)) } } } } override fun closePerHostSettings() { perHostSettings.dismiss { } } val showAboutPage = MutableStateFlow(false) val showOpenSourceLibraries = MutableStateFlow(false) val showTranslators = MutableStateFlow(false) val theme = appRepository.theme val uiScale = appRepository.uiScale } interface DesktopDownloadDialogManager : DownloadDialogManager { val openedDownloadDialogs: StateFlow> fun closeDownloadDialog(ids: List) } interface DesktopAddDownloadDialogManager : AddDownloadDialogManager { val openedAddDownloadDialogs: StateFlow> fun closeAddDownloadDialog(dialogId: String) } interface PowerActionManager { fun initiatePowerAction( powerActionConfig: PowerActionConfig, reason: PowerActionComponent.PowerActionReason, ) fun dismissPowerAction() } ================================================ FILE: desktop/app/src/main/kotlin/com/abdownloadmanager/desktop/SingleInstanceServerInitializer.kt ================================================ package com.abdownloadmanager.desktop import com.abdownloadmanager.desktop.utils.IntegrationPortBroadcaster import com.abdownloadmanager.desktop.utils.singleInstance.Command import com.abdownloadmanager.desktop.utils.singleInstance.MutableSingleInstanceServerHandler import kotlinx.coroutines.runBlocking import org.koin.core.component.KoinComponent import org.koin.core.component.inject object Commands { val isReady = Command("isReady") val showUserThatAppIsRunning = Command("showUserThatAppIsRunning") val getIntegrationPort = Command("getIntegrationPort") val exit = Command("exit") } object SingleInstanceServerInitializer:KoinComponent { private val appComponent by inject () fun boot(mutableHandler: MutableSingleInstanceServerHandler){ mutableHandler.add(Commands.showUserThatAppIsRunning){ kotlin.runCatching { appComponent.openHome() } } mutableHandler.add(Commands.getIntegrationPort){ IntegrationPortBroadcaster .getIntegrationPort().let { it?:-1 } } mutableHandler.add(Commands.isReady){ appComponent.isReady() } mutableHandler.add(Commands.exit) { runBlocking { appComponent.exitApp() } } } } ================================================ FILE: desktop/app/src/main/kotlin/com/abdownloadmanager/desktop/actions/DesktopActionFactories.kt ================================================ package com.abdownloadmanager.desktop.actions import com.abdownloadmanager.desktop.DesktopDownloadDialogManager import com.abdownloadmanager.shared.action.createStopAllAction import com.abdownloadmanager.shared.util.DownloadSystem import ir.amirab.downloader.queue.DownloadQueue import ir.amirab.util.compose.action.AnAction import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.flow.StateFlow fun createDesktopStopAllAction( scope: CoroutineScope, downloadSystem: DownloadSystem, desktopDownloadDialogManager: DesktopDownloadDialogManager, activeQueuesFlow: StateFlow> ): AnAction { return createStopAllAction( scope = scope, downloadSystem = downloadSystem, activeQueuesFlow = activeQueuesFlow, extraJobs = { val activeDownloadIds = downloadSystem.downloadMonitor.activeDownloadListFlow.value.map { it.id } desktopDownloadDialogManager.closeDownloadDialog(activeDownloadIds) } ) } ================================================ FILE: desktop/app/src/main/kotlin/com/abdownloadmanager/desktop/actions/dev.kt ================================================ package com.abdownloadmanager.desktop.actions import com.abdownloadmanager.desktop.AppComponent import com.abdownloadmanager.desktop.di.Di import com.abdownloadmanager.desktop.pages.poweractionalert.PowerActionComponent import com.abdownloadmanager.shared.util.ui.icon.MyIcons import com.abdownloadmanager.shared.ui.widget.MessageDialogType import com.abdownloadmanager.resources.Res import com.abdownloadmanager.shared.action.createDummyExceptionAction import com.abdownloadmanager.shared.action.createDummyMessageAction import ir.amirab.util.compose.action.AnAction import ir.amirab.util.compose.action.MenuItem import ir.amirab.util.compose.action.simpleAction import ir.amirab.util.compose.asStringSource import ir.amirab.util.desktop.poweraction.PowerActionConfig import org.koin.core.component.get private val appComponent = Di.get() val dummyMessage = createDummyMessageAction(appComponent) val dummyException = createDummyExceptionAction() val shutdown = simpleAction( Res.string.shutdown_now.asStringSource(), MyIcons.exit, ) { appComponent.initiatePowerAction( PowerActionConfig(PowerActionConfig.Type.Shutdown, false), PowerActionComponent.PowerActionReason.Unknown ) } ================================================ FILE: desktop/app/src/main/kotlin/com/abdownloadmanager/desktop/actions/main.kt ================================================ package com.abdownloadmanager.desktop.actions import com.abdownloadmanager.desktop.AppComponent import com.abdownloadmanager.shared.util.SharedConstants import com.abdownloadmanager.desktop.di.Di import com.abdownloadmanager.shared.util.ui.icon.MyIcons import com.abdownloadmanager.desktop.utils.AppInfo import com.abdownloadmanager.desktop.utils.DesktopEntryCreator import com.abdownloadmanager.desktop.utils.isAppInstalled import com.abdownloadmanager.desktop.window.Browser import ir.amirab.util.compose.action.MenuItem import ir.amirab.util.compose.action.buildMenu import ir.amirab.util.compose.action.simpleAction import com.abdownloadmanager.shared.util.getIcon import com.abdownloadmanager.shared.util.getName import com.abdownloadmanager.resources.Res import com.abdownloadmanager.shared.action.createCheckForUpdateAction import com.abdownloadmanager.shared.action.createDownloadFromClipboardAction import com.abdownloadmanager.shared.action.createNewDownloadAction import com.abdownloadmanager.shared.action.createNewQueueAction import com.abdownloadmanager.shared.action.createOpenAboutPage import com.abdownloadmanager.shared.action.createOpenBatchDownloadAction import com.abdownloadmanager.shared.action.createOpenOpenSourceThirdPartyLibrariesPage import com.abdownloadmanager.shared.action.createOpenQueuesAction import com.abdownloadmanager.shared.action.createOpenSettingsAction import com.abdownloadmanager.shared.action.createOpenTranslatorsPageAction import com.abdownloadmanager.shared.action.createPerHostSettingsPage import com.abdownloadmanager.shared.action.createRequestExitAction import com.abdownloadmanager.shared.action.createStartQueueGroupAction import com.abdownloadmanager.shared.action.createStopQueueGroupAction import ir.amirab.downloader.queue.activeQueuesFlow import ir.amirab.util.URLOpener import ir.amirab.util.compose.asStringSource import ir.amirab.util.desktop.PlatformAppActivator import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.flow.* import org.koin.core.component.get private val appComponent = Di.get() private val scope = Di.get() private val downloadSystem = appComponent.downloadSystem private val activeQueuesFlow = downloadSystem .queueManager .activeQueuesFlow() .stateIn( scope, SharingStarted.WhileSubscribed(), emptyList() ) // desktop val stopAllAction = createDesktopStopAllAction(scope, downloadSystem, appComponent, activeQueuesFlow) val newDownloadAction = createNewDownloadAction(appComponent) val newDownloadFromClipboardAction = createDownloadFromClipboardAction(appComponent) val createDesktopEntryAction = simpleAction( Res.string.create_desktop_entry.asStringSource(), MyIcons.applicationFile, checkEnable = MutableStateFlow(AppInfo.isAppInstalled()) ) { DesktopEntryCreator.createLinuxDesktopEntry() } val showDownloadList = simpleAction( Res.string.show_downloads.asStringSource(), MyIcons.download, ) { PlatformAppActivator.active() appComponent.openHome() } val browserIntegrations = MenuItem.SubMenu( title = Res.string.download_browser_integration.asStringSource(), icon = MyIcons.download, items = buildMenu { for (browserExtension in SharedConstants.browserIntegrations) { item( title = browserExtension.type.getName().asStringSource(), icon = browserExtension.type.getIcon(), onClick = { val browser = Browser.getBrowserByType(browserExtension.type) val success = browser?.openLink(browserExtension.url) == true if (!success) { URLOpener.openUrl(browserExtension.url) } } ) } } ) // commonUsage but with desktop implementations val newQueueAction = createNewQueueAction(scope, appComponent) val openQueuesAction = createOpenQueuesAction(appComponent) val openTranslators = createOpenTranslatorsPageAction(appComponent) val openAboutAction = createOpenAboutPage(appComponent) val checkForUpdateAction = createCheckForUpdateAction(appComponent.updater) val gotoSettingsAction = createOpenSettingsAction(appComponent) val perHostSettings = createPerHostSettingsPage(appComponent) val requestExitAction = createRequestExitAction(scope, appComponent) val startQueueGroupAction = createStartQueueGroupAction(scope, appComponent.downloadSystem.queueManager) val stopQueueGroupAction = createStopQueueGroupAction(scope, activeQueuesFlow) val batchDownloadAction = createOpenBatchDownloadAction(appComponent) val openOpenSourceThirdPartyLibraries = createOpenOpenSourceThirdPartyLibrariesPage(appComponent) ================================================ FILE: desktop/app/src/main/kotlin/com/abdownloadmanager/desktop/actions/onevennts/CleanExtraSettingsOnDownloadFinish.kt ================================================ package com.abdownloadmanager.desktop.actions.onevennts import com.abdownloadmanager.shared.storage.IExtraDownloadSettingsStorage import com.abdownloadmanager.shared.util.ondownloadcompletion.OnDownloadCompletionAction import ir.amirab.downloader.downloaditem.IDownloadItem class CleanExtraSettingsOnDownloadFinish( private val storage: IExtraDownloadSettingsStorage<*> ) : OnDownloadCompletionAction { override suspend fun onDownloadCompleted(downloadItem: IDownloadItem) { storage.deleteExtraDownloadItemSettings(downloadItem.id) } } ================================================ FILE: desktop/app/src/main/kotlin/com/abdownloadmanager/desktop/actions/onevennts/getOnDownloadCompletionAction.kt ================================================ package com.abdownloadmanager.desktop.actions.onevennts import com.abdownloadmanager.desktop.PowerActionManager import ir.amirab.util.desktop.poweraction.PowerActionConfig import com.abdownloadmanager.desktop.pages.poweractionalert.PowerActionComponent import com.abdownloadmanager.desktop.storage.DesktopExtraDownloadItemSettings import com.abdownloadmanager.shared.storage.ExtraDownloadSettingsStorage import com.abdownloadmanager.shared.util.ondownloadcompletion.OnDownloadCompletionAction import com.abdownloadmanager.shared.util.ondownloadcompletion.OnDownloadCompletionActionProvider import ir.amirab.downloader.downloaditem.IDownloadItem import org.koin.core.component.KoinComponent import org.koin.core.component.inject import kotlin.getValue class DesktopOnDownloadCompletionActionProvider( private val extraDownloadSettingsStorage: ExtraDownloadSettingsStorage, ) : OnDownloadCompletionActionProvider, KoinComponent { // TODO: BUG // at the moment if I move this to constructor the DI halts // probably due to Circular Dependency Exception // I need to redesign the dependency graph to prevent these sorts of issues! private val powerActionManager: PowerActionManager by inject() override suspend fun getOnDownloadCompletionAction(downloadItem: IDownloadItem): List { val downloadId = downloadItem.id val extraDownloadItemSettings = extraDownloadSettingsStorage.getExtraDownloadItemSettings(downloadId) return buildList { extraDownloadItemSettings.getPowerActionConfigOnFinish()?.let { add(PowerActionOnDownloadFinish(powerActionManager, it)) } add( CleanExtraSettingsOnDownloadFinish(extraDownloadSettingsStorage) ) } } } class PowerActionOnDownloadFinish( val powerActionManager: PowerActionManager, val powerActionConfig: PowerActionConfig, ) : OnDownloadCompletionAction { override suspend fun onDownloadCompleted(downloadItem: IDownloadItem) { powerActionManager.initiatePowerAction( powerActionConfig, PowerActionComponent.PowerActionReason.DownloadFinished, ) } } ================================================ FILE: desktop/app/src/main/kotlin/com/abdownloadmanager/desktop/actions/onevennts/getOnQueueEventActions.kt ================================================ package com.abdownloadmanager.desktop.actions.onevennts import com.abdownloadmanager.desktop.PowerActionManager import ir.amirab.util.desktop.poweraction.PowerActionConfig import com.abdownloadmanager.desktop.pages.poweractionalert.PowerActionComponent import com.abdownloadmanager.desktop.storage.DesktopExtraQueueSettings import com.abdownloadmanager.shared.storage.IExtraQueueSettingsStorage import com.abdownloadmanager.shared.util.onqueuecompletion.OnQueueCompletionActionProvider import com.abdownloadmanager.shared.util.onqueuecompletion.OnQueueEventAction import org.koin.core.component.KoinComponent import org.koin.core.component.inject import kotlin.getValue class DesktopOnQueueEventActionProvider( private val desktopExtraQueueSettingsStorage: IExtraQueueSettingsStorage, ) : OnQueueCompletionActionProvider, KoinComponent { // TODO: BUG // at the moment if I move this to constructor the DI halts // probably due to Circular Dependency but no exception is thrown // I need to redesign the dependency graph to prevent these sorts of issues! private val powerActionManager: PowerActionManager by inject() override suspend fun getOnQueueEventActions(queueId: Long): List { return desktopExtraQueueSettingsStorage.getExtraQueueSettings(queueId).let { buildList { it.getPowerActionConfigOnFinish()?.let { powerAction -> add( PowerActionOnQueueFinishOrTimeEnd( powerActionManager, powerAction, ) ) } } } } } class PowerActionOnQueueFinishOrTimeEnd( private val powerActionManager: PowerActionManager, private val powerActionConfig: PowerActionConfig, ) : OnQueueEventAction { override suspend fun onQueueCompleted(queueId: Long) { powerActionManager.initiatePowerAction( powerActionConfig, PowerActionComponent.PowerActionReason.QueueWorkFinished ) } override suspend fun onQueueEndTimeReached(queueId: Long) { powerActionManager.initiatePowerAction( powerActionConfig, PowerActionComponent.PowerActionReason.QueueEndTimeReached ) } } ================================================ FILE: desktop/app/src/main/kotlin/com/abdownloadmanager/desktop/di/Di.kt ================================================ package com.abdownloadmanager.desktop.di import com.abdownloadmanager.github.GithubApi import com.abdownloadmanager.UpdateDownloadLocationProvider import com.abdownloadmanager.UpdateManager import com.abdownloadmanager.desktop.DesktopAddDownloadDialogManager import com.abdownloadmanager.desktop.AppArguments import com.abdownloadmanager.integration.IntegrationHandler import com.abdownloadmanager.desktop.AppComponent import com.abdownloadmanager.desktop.DesktopDownloadDialogManager import com.abdownloadmanager.shared.pagemanager.EditDownloadDialogManager import com.abdownloadmanager.shared.pagemanager.FileChecksumDialogManager import com.abdownloadmanager.shared.pagemanager.NotificationSender import com.abdownloadmanager.shared.pagemanager.PerHostSettingsPageManager import com.abdownloadmanager.shared.pagemanager.QueuePageManager import com.abdownloadmanager.shared.util.SharedConstants import com.abdownloadmanager.desktop.PowerActionManager import com.abdownloadmanager.desktop.actions.onevennts.DesktopOnDownloadCompletionActionProvider import com.abdownloadmanager.desktop.actions.onevennts.DesktopOnQueueEventActionProvider import com.abdownloadmanager.desktop.integration.IntegrationHandlerImp import com.abdownloadmanager.desktop.pages.category.DesktopCategoryDialogManager import com.abdownloadmanager.desktop.pages.settings.FontManager import com.abdownloadmanager.shared.ui.theme.ThemeManager import ir.amirab.downloader.queue.QueueManager import com.abdownloadmanager.desktop.repository.AppRepository import com.abdownloadmanager.desktop.storage.* import com.abdownloadmanager.shared.util.ui.icon.MyIcons import com.abdownloadmanager.shared.util.ui.theme.ISystemThemeDetector import com.abdownloadmanager.desktop.utils.* import com.abdownloadmanager.desktop.utils.native_messaging.NativeMessaging import com.abdownloadmanager.desktop.utils.native_messaging.NativeMessagingManifestApplier import com.abdownloadmanager.desktop.utils.proxy.AutoConfigurableProxyProviderForDesktop import com.abdownloadmanager.desktop.utils.proxy.DesktopSystemProxySelectorProvider import com.abdownloadmanager.desktop.utils.proxy.ProxyCachingConfig import com.abdownloadmanager.desktop.utils.renderapi.CustomRenderApi import com.abdownloadmanager.integration.HLSDownloadCredentialsFromIntegration import com.abdownloadmanager.integration.HttpDownloadCredentialsFromIntegration import com.abdownloadmanager.integration.IDownloadCredentialsFromIntegration import com.arkivanov.decompose.DefaultComponentContext import com.arkivanov.essenty.lifecycle.LifecycleRegistry import ir.amirab.downloader.DownloadManagerMinimalControl import ir.amirab.downloader.DownloadSettings import ir.amirab.downloader.connection.HttpDownloaderClient import ir.amirab.downloader.connection.OkHttpHttpDownloaderClient import ir.amirab.downloader.db.* import ir.amirab.downloader.monitor.DownloadMonitor import ir.amirab.downloader.utils.IDiskStat import com.abdownloadmanager.integration.Integration import com.abdownloadmanager.resources.ABDMLanguageResources import com.abdownloadmanager.shared.downloaderinui.DownloaderInUiRegistry import com.abdownloadmanager.shared.downloaderinui.hls.HLSDownloaderInUi import com.abdownloadmanager.shared.downloaderinui.http.HttpDownloaderInUi import com.abdownloadmanager.shared.pagemanager.SettingsPageManager import com.abdownloadmanager.shared.repository.BaseAppRepository import com.abdownloadmanager.shared.storage.BaseAppSettingsStorage import com.abdownloadmanager.shared.storage.ExtraDownloadSettingsStorage import com.abdownloadmanager.shared.storage.ExtraQueueSettingsStorage import com.abdownloadmanager.shared.storage.IExtraDownloadSettingsStorage import com.abdownloadmanager.shared.storage.IExtraQueueSettingsStorage import com.abdownloadmanager.shared.storage.PerHostSettingsDatastoreStorage import com.abdownloadmanager.shared.storage.ProxyDatastoreStorage import com.abdownloadmanager.shared.ui.theme.ThemeSettingsStorage import com.abdownloadmanager.shared.ui.widget.NotificationManager import com.abdownloadmanager.shared.updater.UpdateDownloaderViaDownloadSystem import com.abdownloadmanager.shared.util.AppVersion import com.abdownloadmanager.shared.util.DefinedPaths import com.abdownloadmanager.shared.util.DesktopDiskStat import com.abdownloadmanager.shared.util.DesktopSystemThemeDetector import com.abdownloadmanager.shared.util.SizeAndSpeedUnitProvider import com.abdownloadmanager.shared.util.UserAgentProviderFromSettings import com.abdownloadmanager.shared.util.* import com.abdownloadmanager.updateapplier.DesktopDirectLinkUpdateApplier import com.abdownloadmanager.updateapplier.UpdateApplier import ir.amirab.downloader.DownloadManager import ir.amirab.util.config.datastore.createMapConfigDatastore import kotlinx.coroutines.* import kotlinx.serialization.json.Json import okhttp3.Dispatcher import okhttp3.OkHttpClient import org.koin.core.component.KoinComponent import org.koin.core.context.startKoin import org.koin.dsl.bind import org.koin.dsl.module import com.abdownloadmanager.updatechecker.GithubUpdateChecker import com.abdownloadmanager.updatechecker.UpdateChecker import ir.amirab.util.AppVersionTracker import com.abdownloadmanager.shared.util.appinfo.PreviousVersion import com.abdownloadmanager.shared.util.autoremove.RemovedDownloadsFromDiskTracker import com.abdownloadmanager.shared.util.category.* import com.abdownloadmanager.shared.util.ondownloadcompletion.OnDownloadCompletionActionProvider import com.abdownloadmanager.shared.util.ondownloadcompletion.OnDownloadCompletionActionRunner import com.abdownloadmanager.shared.util.onqueuecompletion.OnQueueEventActionRunner import com.abdownloadmanager.shared.util.onqueuecompletion.OnQueueCompletionActionProvider import com.abdownloadmanager.shared.util.perhostsettings.IPerHostSettingsStorage import com.abdownloadmanager.shared.util.perhostsettings.PerHostSettingsItem import com.abdownloadmanager.shared.util.perhostsettings.PerHostSettingsManager import com.abdownloadmanager.shared.util.ui.IMyIcons import com.abdownloadmanager.shared.util.proxy.IProxyStorage import com.abdownloadmanager.shared.util.proxy.ProxyData import com.abdownloadmanager.shared.util.proxy.ProxyManager import com.arkivanov.essenty.lifecycle.Lifecycle import ir.amirab.downloader.DownloaderRegistry import ir.amirab.downloader.connection.UserAgentProvider import ir.amirab.downloader.connection.proxy.AutoConfigurableProxyProvider import ir.amirab.downloader.connection.proxy.ProxyStrategyProvider import ir.amirab.downloader.connection.proxy.SystemProxySelectorProvider import ir.amirab.downloader.downloaditem.DownloadJob import ir.amirab.downloader.downloaditem.IDownloadCredentials import ir.amirab.downloader.downloaditem.IDownloadItem import ir.amirab.downloader.downloaditem.hls.HLSDownloader import ir.amirab.downloader.downloaditem.http.HttpDownloadCredentials import ir.amirab.downloader.downloaditem.http.HttpDownloadItem import ir.amirab.downloader.downloaditem.http.HttpDownloader import ir.amirab.downloader.monitor.DownloadItemStateFactory import ir.amirab.downloader.monitor.IDownloadMonitor import ir.amirab.downloader.queue.ManualDownloadQueue import ir.amirab.downloader.utils.EmptyFileCreator import ir.amirab.util.compose.IIconResolver import ir.amirab.util.compose.localizationmanager.LanguageManager import ir.amirab.util.compose.localizationmanager.LanguageSourceProvider import ir.amirab.util.compose.localizationmanager.LanguageStorage import ir.amirab.util.config.datastore.kotlinxSerializationDataStore import ir.amirab.util.desktop.DesktopUtils import ir.amirab.util.startup.AbstractStartupManager import ir.amirab.util.startup.Startup import kotlinx.serialization.modules.SerializersModule import kotlinx.serialization.modules.polymorphic import okhttp3.Protocol import okhttp3.internal.tls.OkHostnameVerifier val downloaderModule = module { single { val definedPaths = get() DownloadQueueFileStorageDatabase( queueFolder = get().registerAndGet( definedPaths.queuesDir ), fileSaver = get(), ) } single { val definedPaths = get() DownloadListFileStorage( downloadListFolder = get().registerAndGet( definedPaths.downloadListDir ), fileSaver = get(), ) } single { TransactionalFileSaver(get()) } single { val definedPaths = get() PartListFileStorage( get().registerAndGet( definedPaths.partsDir ), get() ) } single { DesktopDiskStat() } single { DesktopSystemThemeDetector() } single { QueueManager(get(), get()) } single { DownloadFoldersRegistry() } single { DownloadSettings( 8, ) } single { ProxyManager( get() ) }.bind() single { ProxyCachingConfig.default() } single { AutoConfigurableProxyProviderForDesktop(get()) } single { DesktopSystemProxySelectorProvider(get()) } single { UserAgentProviderFromSettings(get()) } single { OkHttpHttpDownloaderClient( get(), get(), get(), get(), get(), ) } single { val downloadSettings: DownloadSettings = get() EmptyFileCreator( diskStat = get(), useSparseFile = { downloadSettings.useSparseFileAllocation } ) } single { HLSDownloader(inject()) } single { HLSDownloaderInUi(get(), get()) } single { HttpDownloader(inject()) } single { HttpDownloaderInUi(get(), get()) } single { DownloaderInUiRegistry().apply { add(get()) add(get()) } }.bind>() single { DownloaderRegistry().apply { add(get()) add(get()) } } single { val definedPaths = get() DownloadManager( get(), get(), get(), get(), get(), get().registerAndGet( definedPaths.downloadDataDir ) ) }.bind(DownloadManagerMinimalControl::class) single { ManualDownloadQueue(get(), get()) } single { DownloadMonitor( downloadManager = get(), manualDownloadQueue = get(), downloadItemStateFactory = inject(), ) } } val downloadSystemModule = module { single { val definedPaths = get() get().registerAndGet(definedPaths.categoriesDir) CategoryFileStorage( file = definedPaths.categoriesFile.toFile(), fileSaver = get() ) }.bind() single { FileIconProviderUsingCategoryIcons( get(), get(), get(), get(), ) }.bind() single { DefaultCategories( icons = get(), getDefaultDownloadFolder = { get().defaultDownloadFolder.value } ) } single { DownloadManagerCategoryItemProvider(get()) }.bind() single { CategoryManager( categoryStorage = get(), scope = get(), defaultCategoriesFactory = get(), categoryItemProvider = get(), ) } single { DownloadSystem( get(), get(), get(), get(), get(), get(), get(), get(), get(), get(), get(), get(), ) } single { val definedPaths = get() val extraDownloadSettingsStorageFolder = get().registerAndGet( definedPaths.extraDownloadSettings ) ExtraDownloadSettingsStorage( extraDownloadSettingsStorageFolder, get(), DesktopExtraDownloadItemSettings ) }.bind>() single { val definedPaths = get() val extraQueueSettingsStorageFolder = get().registerAndGet( definedPaths.extraQueueSettings ) ExtraQueueSettingsStorage( extraQueueSettingsStorageFolder, get(), DesktopExtraQueueSettings ) }.apply { bind>() } single { DesktopOnDownloadCompletionActionProvider(get()) } single { DesktopOnQueueEventActionProvider(get()) } single { OnDownloadCompletionActionRunner( downloadManagerMinimalControl = get(), scope = get(), onDownloadCompletionActionProvider = get(), ) } single { OnQueueEventActionRunner( queueManager = get(), scope = get(), onQueueCompletionActionProvider = get(), ) } } val coroutineModule = module { single { CoroutineScope(SupervisorJob()) } } val jsonModule = module { single { val downloaderRegistry: DownloaderRegistry by inject() Json { this.encodeDefaults = true this.prettyPrint = true this.ignoreUnknownKeys = true this.serializersModule = SerializersModule { polymorphic(IDownloadItem::class) { downloaderRegistry.getAll().forEach { subclass(it.downloadItemClass, it.downloadItemSerializer) } defaultDeserializer { HttpDownloadItem.serializer() } } polymorphic(IDownloadCredentials::class) { downloaderRegistry.getAll().forEach { subclass(it.downloadCredentialsClass, it.downloadCredentialsSerializer) } defaultDeserializer { HttpDownloadCredentials.serializer() } } // TODO remove this later polymorphic(IDownloadCredentialsFromIntegration::class) { subclass( HttpDownloadCredentialsFromIntegration::class, HttpDownloadCredentialsFromIntegration.serializer() ) subclass( HLSDownloadCredentialsFromIntegration::class, HLSDownloadCredentialsFromIntegration.serializer() ) defaultDeserializer { HttpDownloadCredentialsFromIntegration.serializer() } } } } } } val integrationModule = module { single { IntegrationHandlerImp() } single { Integration(get(), get(), get(), AppInfo.isInDebugMode()) } } val updaterModule = module { single { val definedPaths = get() UpdateDownloadLocationProvider { definedPaths.updateDownloadLocation.toFile() } } single { val definedPaths = get() definedPaths.updateDownloadLocation DesktopDirectLinkUpdateApplier( installationFolder = AppInfo.installationFolder, updateFolder = definedPaths.updateDir.toString(), logDir = definedPaths.logDir.toString(), appName = AppInfo.name, updatePreparer = UpdateDownloaderViaDownloadSystem( get(), get(), ), ) } single { GithubUpdateChecker( AppVersion.get(), githubApi = GithubApi( owner = SharedConstants.projectGithubOwner, repo = SharedConstants.projectGithubRepo, client = OkHttpClient .Builder() .build() ) ) } single { UpdateManager( updateChecker = get(), updateApplier = get(), appVersionTracker = get(), ) } } val startUpModule = module { single { Startup.getStartUpManagerForDesktop( name = AppInfo.displayName, path = AppInfo.exeFile, args = listOf(AppArguments.Args.BACKGROUND), packageName = AppInfo.packageName, ) }.apply { bind() } } val nativeMessagingModule = module { single { NativeMessaging(NativeMessagingManifestApplier.getForCurrentPlatform()) } } val appModule = module { includes(downloaderModule) includes(downloadSystemModule) includes(coroutineModule) includes(jsonModule) includes(integrationModule) includes(updaterModule) includes(startUpModule) includes(nativeMessagingModule) // single { // NetworkChecker(get()) // } single { AppInfo.definedPaths }.bind() single { AppRepository( get(), get(), get(), get(), get(), get(), get(), get(), ) }.apply { bind() bind() } single { ThemeManager(get(), get(), get()) } single { FontManager(get()) } single { LanguageManager( get(), LanguageSourceProvider( ABDMLanguageResources.defaultLanguageResource, ABDMLanguageResources.languages, ) ) } single { MyIcons }.apply { bind() bind() } single { val definedPaths = get() ProxyDatastoreStorage( kotlinxSerializationDataStore( definedPaths.proxySettingsFile.toFile(), get(), ProxyData::default, ) ) }.bind() single { val definedPaths = get() AppSettingsStorage( createMapConfigDatastore( definedPaths.appSettingsFile.toFile(), get(), ) ) }.apply { bind() bind() bind() } single { val definedPaths = get() PageStatesStorage( createMapConfigDatastore( definedPaths.pageStatesStorageFile.toFile(), get(), ) ) } single { val lifecycle = LifecycleRegistry( Lifecycle.State.RESUMED ) val context = DefaultComponentContext(lifecycle) runBlocking { withContext(Dispatchers.Main) { AppComponent(context) } } }.apply { bind() bind() bind() bind() bind() bind() bind() bind() bind() bind() bind() } single { RemovedDownloadsFromDiskTracker( get(), get(), get(), ) } single { val definedPaths = get() PreviousVersion( systemPath = definedPaths.systemDir.toFile(), currentVersion = AppInfo.version, ) } single { AppVersionTracker( previousVersion = { // it MUST be booted first get().get() }, currentVersion = AppInfo.version, ) } single { val appSettingsStorage: AppSettingsStorage = get() AppSSLFactoryProvider( ignoreSSLCertificates = appSettingsStorage.ignoreSSLCertificates ) } single { val appSettingsStorage: AppSettingsStorage = get() AppHostNameVerifier( delegateHostnameVerifier = OkHostnameVerifier, ignoreHostNameVerification = appSettingsStorage.ignoreSSLCertificates ) } single { val appSSLFactoryProvider: AppSSLFactoryProvider = get() val appHostNameVerifier: AppHostNameVerifier = get() OkHttpClient .Builder() .protocols(listOf(Protocol.HTTP_1_1)) .dispatcher(Dispatcher().apply { //bypass limit on concurrent connections! maxRequests = Int.MAX_VALUE maxRequestsPerHost = Int.MAX_VALUE }) .sslSocketFactory( appSSLFactoryProvider.createSSLSocketFactory(), appSSLFactoryProvider.trustManager, ) .hostnameVerifier(appHostNameVerifier) .build() } single { KeepAwakeManager( DesktopUtils.keepAwakeService(), get(), get(), ) } single { val definedPaths = get() PerHostSettingsDatastoreStorage( kotlinxSerializationDataStore>( definedPaths.perHostSettingsFile.toFile(), get(), ::emptyList, ) ) } single { PerHostSettingsManager(get()) } single { NotificationManager() } single { val definedPaths = get() CustomRenderApi(definedPaths.renderApiFile) } } object Di : KoinComponent { fun boot() { startKoin { modules(appModule) } } } ================================================ FILE: desktop/app/src/main/kotlin/com/abdownloadmanager/desktop/integration/IntegrationHandlerImp.kt ================================================ package com.abdownloadmanager.desktop.integration import com.abdownloadmanager.desktop.AppComponent import com.abdownloadmanager.shared.pages.adddownload.AddDownloadCredentialsInUiProps import com.abdownloadmanager.shared.pages.adddownload.ImportOptions import com.abdownloadmanager.shared.pages.adddownload.SilentImportOptions import com.abdownloadmanager.desktop.repository.AppRepository import com.abdownloadmanager.shared.util.DownloadSystem import com.abdownloadmanager.integration.IntegrationHandler import com.abdownloadmanager.integration.HttpDownloadCredentialsFromIntegration import com.abdownloadmanager.integration.NewDownloadTask import com.abdownloadmanager.integration.ApiQueueModel import com.abdownloadmanager.integration.AddDownloadOptionsFromIntegration import com.abdownloadmanager.integration.HLSDownloadCredentialsFromIntegration import com.abdownloadmanager.integration.IDownloadCredentialsFromIntegration import com.abdownloadmanager.shared.downloaderinui.BasicDownloadItem import com.abdownloadmanager.shared.downloaderinui.DownloaderInUiRegistry import ir.amirab.downloader.downloaditem.hls.HLSDownloadCredentials import ir.amirab.downloader.NewDownloadItemProps import ir.amirab.downloader.downloaditem.EmptyContext import ir.amirab.downloader.downloaditem.http.HttpDownloadCredentials import ir.amirab.downloader.queue.QueueManager import ir.amirab.downloader.utils.OnDuplicateStrategy import org.koin.core.component.KoinComponent import org.koin.core.component.inject class IntegrationHandlerImp : IntegrationHandler, KoinComponent { val appComponent by inject() val downloadSystem by inject() val queueManager by inject() val appSettings by inject() private val downloaderInUiRegistry by inject() override suspend fun addDownload( list: List, options: AddDownloadOptionsFromIntegration, ) { appComponent.externalCredentialComingIntoApp( list.map { convertToDownloadSystemCredentials(it) }, options = ImportOptions( silentImport = if (options.silentAdd) { SilentImportOptions( silentDownload = options.silentStart ) } else null ) ) } override fun listQueues(): List { return queueManager.getAll().map { downloadQueue -> val queueModel = downloadQueue.getQueueModel() ApiQueueModel(id = queueModel.id, name = queueModel.name) } } override suspend fun addDownloadTask(task: NewDownloadTask) { val addDownloaderInUiProps = convertToDownloadSystemCredentials(task.downloadSource) val downloaderInUi = downloaderInUiRegistry.getDownloaderOf( addDownloaderInUiProps.credentials ) ?: error("Downloader for ${addDownloaderInUiProps.credentials::class.qualifiedName} not found") val downloadItem = downloaderInUi.createBareDownloadItem( addDownloaderInUiProps.credentials, basicDownloadItem = BasicDownloadItem( folder = task.folder ?: appSettings.saveLocation.value, name = task.name ?: addDownloaderInUiProps.extraConfig.suggestedName ?: task.downloadSource.link.substringAfterLast("/"), ), ) val id = downloadSystem.addDownload( newDownload = NewDownloadItemProps( downloadItem = downloadItem, onDuplicateStrategy = OnDuplicateStrategy.default(), extraConfig = null, context = EmptyContext, ), queueId = task.queueId, categoryId = null ) if (task.queueId != null) { val queue = queueManager.getQueue(task.queueId!!) queue.start() } else { downloadSystem.userManualResume(id) } } companion object { private fun convertToDownloadSystemCredentials(it: IDownloadCredentialsFromIntegration): AddDownloadCredentialsInUiProps { val credentials = when (it) { is HttpDownloadCredentialsFromIntegration -> { HttpDownloadCredentials( link = it.link, headers = it.headers, downloadPage = it.downloadPage, ) } is HLSDownloadCredentialsFromIntegration -> { HLSDownloadCredentials( link = it.link, headers = it.headers, downloadPage = it.downloadPage, ) } } return AddDownloadCredentialsInUiProps( credentials = credentials, extraConfig = AddDownloadCredentialsInUiProps.Configs( suggestedName = it.suggestedName, ) ) } } } ================================================ FILE: desktop/app/src/main/kotlin/com/abdownloadmanager/desktop/pages/about/AboutDialog.kt ================================================ package com.abdownloadmanager.desktop.pages.about import com.abdownloadmanager.desktop.AppComponent import com.abdownloadmanager.desktop.window.custom.CustomWindow import com.abdownloadmanager.desktop.window.custom.WindowTitle import androidx.compose.runtime.Composable import androidx.compose.runtime.collectAsState import androidx.compose.ui.Alignment import androidx.compose.ui.unit.DpSize import androidx.compose.ui.unit.dp import androidx.compose.ui.window.WindowPosition import androidx.compose.ui.window.rememberWindowState import com.abdownloadmanager.desktop.window.custom.WindowIcon import com.abdownloadmanager.shared.util.ui.icon.MyIcons import com.abdownloadmanager.shared.util.ui.theme.LocalUiScale import com.abdownloadmanager.resources.Res import ir.amirab.util.compose.resources.myStringResource import ir.amirab.util.desktop.screen.applyUiScale @Composable fun ShowAboutDialog(appComponent: AppComponent) { if (appComponent.showAboutPage.collectAsState().value) { AboutDialog( onClose = { appComponent.closeAbout() }, onRequestShowOpenSourceLibraries = { appComponent.openOpenSourceLibrariesPage() }, onRequestShowTranslators = { appComponent.openTranslatorsPage() } ) } } @Composable fun AboutDialog( onClose: () -> Unit, onRequestShowOpenSourceLibraries: () -> Unit, onRequestShowTranslators: () -> Unit, ) { CustomWindow( resizable = false, onRequestToggleMaximize = null, alwaysOnTop = false, onRequestMinimize = null, state = rememberWindowState( position = WindowPosition.Aligned(Alignment.Center), size = DpSize(600.dp, 310.dp) .applyUiScale(LocalUiScale.current) ), onCloseRequest = onClose ) { WindowTitle(myStringResource(Res.string.about)) WindowIcon(MyIcons.info) AboutPage( onRequestShowOpenSourceLibraries = onRequestShowOpenSourceLibraries, onRequestShowTranslators = onRequestShowTranslators ) } } ================================================ FILE: desktop/app/src/main/kotlin/com/abdownloadmanager/desktop/pages/about/AboutPage.kt ================================================ package com.abdownloadmanager.desktop.pages.about import androidx.compose.foundation.* import com.abdownloadmanager.shared.util.ui.icon.MyIcons import com.abdownloadmanager.shared.util.ui.myColors import com.abdownloadmanager.shared.util.ui.theme.myTextSizes import com.abdownloadmanager.shared.ui.widget.Text import com.abdownloadmanager.desktop.utils.AppInfo import com.abdownloadmanager.shared.util.ui.WithContentAlpha import androidx.compose.foundation.layout.* import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.runtime.* import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.* import androidx.compose.ui.graphics.Brush import androidx.compose.ui.graphics.SolidColor import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp import com.abdownloadmanager.shared.util.SharedConstants import com.abdownloadmanager.shared.util.ui.widget.MyIcon import com.abdownloadmanager.shared.ui.widget.IconActionButton import com.abdownloadmanager.shared.ui.widget.Tooltip import com.abdownloadmanager.shared.util.div import com.abdownloadmanager.resources.Res import com.abdownloadmanager.shared.ui.widget.ActionButton import com.abdownloadmanager.shared.ui.widget.LinkText import com.abdownloadmanager.shared.util.ui.LocalContentColor import com.abdownloadmanager.shared.util.ui.theme.myShapes import ir.amirab.util.URLOpener import ir.amirab.util.HttpUrlUtils import ir.amirab.util.compose.IconSource import ir.amirab.util.compose.StringSource import ir.amirab.util.compose.asStringSource import ir.amirab.util.compose.resources.myStringResource @Composable fun AboutPage( onRequestShowOpenSourceLibraries: () -> Unit, onRequestShowTranslators: () -> Unit, ) { Box { BackgroundEffects() RenderAppInfo( modifier = Modifier, onRequestShowOpenSourceLibraries = onRequestShowOpenSourceLibraries, onRequestShowTranslators = onRequestShowTranslators, ) } } @Composable private fun AppIconAndVersion( modifier: Modifier, ) { Column( horizontalAlignment = Alignment.CenterHorizontally, modifier = modifier.padding( horizontal = 24.dp, vertical = 8.dp, ) ) { val shape = RoundedCornerShape(16.dp) Image( MyIcons.appIcon.rememberPainter(), null, Modifier .shadow(12.dp, shape, spotColor = myColors.primary) .clip(shape) .border( 1.dp, Brush.linearGradient( listOf(myColors.primary, myColors.secondary) ), shape ) .background(myColors.surface) .padding(16.dp) .size(52.dp) ) Spacer(Modifier.size(16.dp)) Column( horizontalAlignment = Alignment.Start ) { Text( AppInfo.displayName, fontSize = myTextSizes.lg, fontWeight = FontWeight.Bold, ) Spacer(Modifier.height(2.dp)) WithContentAlpha(0.75f) { Text( myStringResource( Res.string.version_n, Res.string.version_n_createArgs( value = AppInfo.version.toString(), ) ), fontSize = myTextSizes.base, ) } } } } @Composable private fun RenderAppInfo( modifier: Modifier, onRequestShowOpenSourceLibraries: () -> Unit, onRequestShowTranslators: () -> Unit, ) { Row( modifier.fillMaxSize(), ) { Column( Modifier.width(250.dp), verticalArrangement = Arrangement.SpaceBetween, ) { AppIconAndVersion(Modifier.fillMaxWidth()) Spacer(Modifier.weight(1f)) Column( Modifier .fillMaxWidth(), horizontalAlignment = Alignment.CenterHorizontally, ) { Text( myStringResource(Res.string.developed_with_love_for_you), ) Spacer(Modifier.height(8.dp)) DonateButton() Spacer(Modifier.height(8.dp)) Spacer( Modifier .fillMaxWidth() .background(myColors.onBackground / 0.05f) .height(1.dp) ) Spacer(Modifier.height(8.dp)) val websiteUrl = SharedConstants.projectWebsite val websiteDisplayName = remember(websiteUrl) { HttpUrlUtils.getHost(websiteUrl) ?: websiteUrl } LinkText( text = websiteDisplayName, link = websiteUrl, showExternalIndicator = false, ) Spacer(Modifier.height(16.dp)) } } Spacer( Modifier .fillMaxHeight() .width(1.dp) .background(myColors.onBackground.copy(0.15f)) ) Column( Modifier.weight(1f) ) { CreditsSection( modifier = Modifier.fillMaxWidth().weight(1f), onRequestShowOpenSourceLibraries = onRequestShowOpenSourceLibraries, onRequestShowTranslators = onRequestShowTranslators, ) Spacer(Modifier.height(1.dp).fillMaxWidth().background(myColors.onBackground / 0.15f)) SocialAndLinks( Modifier .fillMaxWidth() .background(myColors.surface / 0.5f) .padding(top = 12.dp) .padding(bottom = 16.dp) .wrapContentWidth(), horizontalPadding = 8.dp, ) } } } @Composable private fun SocialAndLinks( modifier: Modifier = Modifier, horizontalPadding: Dp, ) { Row( horizontalArrangement = Arrangement.spacedBy(8.dp), modifier = modifier .padding( horizontal = horizontalPadding, ) ) { SocialSmallButton( MyIcons.earth, Res.string.visit_the_project_website.asStringSource(), onClick = { URLOpener.openUrl(SharedConstants.projectWebsite) } ) SocialSmallButton( MyIcons.openSource, Res.string.view_the_source_code.asStringSource(), onClick = { URLOpener.openUrl(SharedConstants.projectSourceCode) } ) SocialSmallButton( MyIcons.speaker, Res.string.channel.asStringSource(), onClick = { URLOpener.openUrl(SharedConstants.telegramChannelUrl) } ) SocialSmallButton( MyIcons.group, Res.string.group.asStringSource(), onClick = { URLOpener.openUrl(SharedConstants.telegramGroupUrl) } ) SocialSmallButton( MyIcons.language, Res.string.translators_contribute_title.asStringSource(), onClick = { URLOpener.openUrl(SharedConstants.projectTranslations) } ) } } @Composable private fun CreditsSection( modifier: Modifier = Modifier, onRequestShowOpenSourceLibraries: () -> Unit, onRequestShowTranslators: () -> Unit, ) { Column( modifier .verticalScroll(rememberScrollState()) .padding(horizontal = 16.dp) .padding(vertical = 8.dp), verticalArrangement = Arrangement.spacedBy(8.dp) ) { val itemModifier = Modifier.fillMaxWidth() AboutPageListItemButton( itemModifier, icon = MyIcons.hearth, title = Res.string.this_is_a_free_and_open_source_software.asStringSource(), description = Res.string.view_the_source_code.asStringSource(), onClick = { URLOpener.openUrl(AppInfo.sourceCode) } ) AboutPageListItemButton( itemModifier, icon = MyIcons.openSource, title = Res.string.powered_by_open_source_software.asStringSource(), description = Res.string.view_the_open_source_licenses.asStringSource(), onClick = { onRequestShowOpenSourceLibraries() } ) AboutPageListItemButton( itemModifier, icon = MyIcons.language, title = Res.string.localized_by_translators.asStringSource(), description = Res.string.meet_the_translators.asStringSource(), onClick = { onRequestShowTranslators() } ) } } @Composable private fun SocialSmallButton( icon: IconSource, title: StringSource, onClick: () -> Unit, ) { Tooltip(title) { IconActionButton( icon, contentDescription = title, onClick = onClick, ) } } @Composable private fun AboutPageListItemButton( modifier: Modifier, icon: IconSource, title: StringSource, description: StringSource, onClick: () -> Unit, ) { val shape = myShapes.defaultRounded Row( modifier .border(1.dp, myColors.onBackground / 0.15f, shape) .clip(shape) .clickable(onClick = onClick) .background(myColors.surface / 0.5f) .padding( horizontal = 8.dp, vertical = 8.dp, ), verticalAlignment = Alignment.CenterVertically, ) { MyIcon( icon = icon, contentDescription = null, modifier = Modifier.size(24.dp) ) Spacer(Modifier.width(8.dp)) Column { Text( title.rememberString(), fontSize = myTextSizes.base, fontWeight = FontWeight.Bold, ) Spacer(Modifier.height(2.dp)) Text(description.rememberString()) } } } @Composable private fun BoxScope.BackgroundEffects() { Box( Modifier .align(Alignment.TopCenter) .offset(x = (-50).dp, y = (-148).dp) .fillMaxWidth(0.5f) .height(250.dp) .blur( 56.dp, edgeTreatment = BlurredEdgeTreatment.Unbounded ) .clip(CircleShape) .background( myColors.primary / 0.15f ) ) Box( Modifier .align(Alignment.BottomStart) .size(220.dp) .offset(x = (-64).dp, y = (+128).dp) .blur( 56.dp, edgeTreatment = BlurredEdgeTreatment.Unbounded ) .clip(CircleShape) .background( myColors.secondaryVariant / 0.15f ) ) Box( Modifier .align(Alignment.BottomEnd) .size(220.dp) .offset(x = 32.dp, y = (-32).dp) .blur( 56.dp, edgeTreatment = BlurredEdgeTreatment.Unbounded ) .clip(CircleShape) .background( myColors.secondary / 0.15f ) ) } @Composable private fun DonateButton() { ActionButton( backgroundColor = SolidColor(LocalContentColor.current / 0.05f), start = { MyIcon( MyIcons.hearth, null, modifier = Modifier.size(16.dp), tint = myColors.error, ) Spacer(Modifier.width(8.dp)) }, text = myStringResource(Res.string.donate), onClick = { URLOpener.openUrl(SharedConstants.donateLink) } ) } ================================================ FILE: desktop/app/src/main/kotlin/com/abdownloadmanager/desktop/pages/addDownload/ShowAddDownloadDialogs.kt ================================================ package com.abdownloadmanager.desktop.pages.addDownload import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue import androidx.compose.runtime.key import androidx.compose.runtime.remember import androidx.compose.ui.Alignment import androidx.compose.ui.unit.DpSize import androidx.compose.ui.unit.dp import androidx.compose.ui.window.WindowPosition import androidx.compose.ui.window.rememberWindowState import com.abdownloadmanager.desktop.DesktopAddDownloadDialogManager import com.abdownloadmanager.desktop.pages.addDownload.multiple.DesktopAddMultiDownloadComponent import com.abdownloadmanager.desktop.pages.addDownload.multiple.AddMultiItemPage import com.abdownloadmanager.desktop.pages.addDownload.single.AddDownloadPage import com.abdownloadmanager.shared.pages.adddownload.single.BaseAddSingleDownloadComponent import com.abdownloadmanager.desktop.window.custom.CustomWindow import com.abdownloadmanager.desktop.window.custom.WindowIcon import com.abdownloadmanager.desktop.window.custom.WindowTitle import com.abdownloadmanager.resources.Res import com.abdownloadmanager.shared.pages.adddownload.AddDownloadComponent import com.abdownloadmanager.shared.util.ui.icon.MyIcons import com.abdownloadmanager.shared.util.ui.theme.LocalUiScale import ir.amirab.util.compose.resources.myStringResource import ir.amirab.util.desktop.PlatformAppActivator import ir.amirab.util.desktop.screen.applyUiScale import java.awt.Dimension @Composable fun ShowAddDownloadDialogs(component: DesktopAddDownloadDialogManager) { val openedAddDownloadDialogs = component.openedAddDownloadDialogs.collectAsState().value for (addDownloadComponent in openedAddDownloadDialogs) { key(addDownloadComponent.id) { AddDownloadWindow( addDownloadComponent = addDownloadComponent, onRequestClose = { component.closeAddDownloadDialog(addDownloadComponent.id) } ) } } } @Composable private fun AddDownloadWindow( addDownloadComponent: AddDownloadComponent, onRequestClose: () -> Unit, ) { val shouldShowWindow by addDownloadComponent.shouldShowWindow.collectAsState() if (!shouldShowWindow) return val uiScale = LocalUiScale.current when (addDownloadComponent) { is BaseAddSingleDownloadComponent -> { val h = 265.applyUiScale(uiScale) val w = 500.applyUiScale(uiScale) val size = remember { DpSize( height = h.dp, width = w.dp, ) } val state = rememberWindowState( size = size, position = WindowPosition(Alignment.Center) ) CustomWindow( state = state, onCloseRequest = onRequestClose, alwaysOnTop = true, ) { LaunchedEffect(Unit) { window.minimumSize = Dimension(w, h) PlatformAppActivator.active() } // BringToFront() WindowTitle(myStringResource(Res.string.add_download)) WindowIcon(MyIcons.appIcon) AddDownloadPage(addDownloadComponent) } } is DesktopAddMultiDownloadComponent -> { val h = 450 val w = 800 val state = rememberWindowState( height = h.dp, width = w.dp, position = WindowPosition(Alignment.Center) ) CustomWindow( state = state, onCloseRequest = onRequestClose, alwaysOnTop = true, ) { LaunchedEffect(Unit) { window.minimumSize = Dimension(w, h) PlatformAppActivator.active() } // BringToFront() WindowTitle(myStringResource(Res.string.add_download)) WindowIcon(MyIcons.appIcon) AddMultiItemPage(addDownloadComponent) } } } } //it seems not affect at all //@Composable //private fun WindowScope.BringToFront() { // LaunchedEffect(Unit) { // window.toFront() // window.requestFocus() // } //} ================================================ FILE: desktop/app/src/main/kotlin/com/abdownloadmanager/desktop/pages/addDownload/multiple/AddMultiItemPage.kt ================================================ package com.abdownloadmanager.desktop.pages.addDownload.multiple import androidx.compose.foundation.background import androidx.compose.foundation.layout.* import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.onClick import androidx.compose.runtime.* import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.unit.dp import com.abdownloadmanager.shared.ui.widget.* import com.abdownloadmanager.desktop.pages.addDownload.shared.CategoryAddButton import com.abdownloadmanager.desktop.pages.addDownload.shared.CategorySelect import com.abdownloadmanager.desktop.pages.addDownload.shared.ExtraConfig import com.abdownloadmanager.desktop.pages.addDownload.shared.LocationTextField import com.abdownloadmanager.desktop.pages.addDownload.shared.ShowAddToQueueDialog import com.abdownloadmanager.desktop.pages.home.sections.SearchBox import com.abdownloadmanager.shared.util.ui.myColors import com.abdownloadmanager.shared.util.ui.theme.myTextSizes import com.abdownloadmanager.shared.util.div import com.abdownloadmanager.resources.Res import com.abdownloadmanager.shared.downloaderinui.DownloadSize import com.abdownloadmanager.shared.downloaderinui.rememberString import com.abdownloadmanager.shared.util.category.Category import com.abdownloadmanager.shared.util.ui.WithContentAlpha import com.abdownloadmanager.shared.util.ui.icon.MyIcons import com.abdownloadmanager.shared.util.ui.widget.MyIcon import ir.amirab.util.compose.resources.myStringResource import ir.amirab.util.ifThen @Composable fun AddMultiItemPage( addMultiDownloadComponent: DesktopAddMultiDownloadComponent, ) { Column(Modifier) { Column( Modifier .padding(horizontal = 16.dp) .padding(top = 8.dp) .weight(1f) ) { WithContentAlpha(1f) { Text( myStringResource(Res.string.add_multi_download_page_header), fontSize = myTextSizes.base ) } Spacer(Modifier.height(8.dp)) AddMultiDownloadTable( Modifier.weight(1f), addMultiDownloadComponent, ) } Footer( Modifier, addMultiDownloadComponent, ) } val currentDownloadConfigurableList by addMultiDownloadComponent.currentDownloadConfigurableList.collectAsState() currentDownloadConfigurableList?.let { ExtraConfig( onDismiss = { addMultiDownloadComponent.openConfigurableList(null) }, configurables = it ) } if (addMultiDownloadComponent.showAddToQueue) { ShowAddToQueueDialog( queueList = addMultiDownloadComponent.queueList.collectAsState().value, onQueueSelected = { queue, startQueue -> addMultiDownloadComponent.requestAddDownloads( queue, startQueue ) }, onClose = { addMultiDownloadComponent.closeAddToQueue() } ) } } @Composable fun Footer( modifier: Modifier = Modifier, component: DesktopAddMultiDownloadComponent, ) { Column(modifier) { Spacer( Modifier .fillMaxWidth() .height(1.dp) .background(myColors.onBackground / 0.15f) ) Column( Modifier .fillMaxWidth() .background(myColors.surface / 0.5f) ) { Row( Modifier .padding(horizontal = 16.dp) .padding(vertical = 16.dp), verticalAlignment = Alignment.CenterVertically, ) { SaveSettings( modifier = Modifier.fillMaxWidth().weight(1f), component = component, ) Spacer(Modifier.width(8.dp)) Column( Modifier.align(Alignment.Bottom) .width(IntrinsicSize.Max), ) { val filterText by component.filterText.collectAsState() SearchBox( text = filterText, onTextChange = component::setFilterText, placeholder = myStringResource(Res.string.search), modifier = Modifier .fillMaxWidth() .align(Alignment.End), textPadding = PaddingValues( vertical = 6.dp, horizontal = 8.dp ), ) Spacer(Modifier.height(8.dp)) Row( horizontalArrangement = Arrangement.End, ) { PrimaryMainActionButton( text = myStringResource(Res.string.add), onClick = { component.openAddToQueueDialog() }, enabled = component.canClickAdd, modifier = Modifier, ) Spacer(Modifier.width(8.dp)) ActionButton( text = myStringResource(Res.string.cancel), onClick = { component.requestClose() }, modifier = Modifier, ) } } } Spacer( Modifier .fillMaxWidth() .height(2.dp) .background( myColors.surface ) ) Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.Start) { SelectionDetail( modifier = Modifier.padding(horizontal = 12.dp, vertical = 4.dp), totalDownloadItems = component.totalList.size, selectedDownloadItems = component.selectionList.size, sizes = component.selectedTotalSize.collectAsState().value, ) } } } } @Composable private fun SaveSettings( modifier: Modifier, component: DesktopAddMultiDownloadComponent, ) { val selectedCategory by component.selectedCategory.collectAsState() val folder by component.folder.collectAsState() Column(modifier) { Text("${myStringResource(Res.string.save_to)}:") Spacer(Modifier.height(8.dp)) Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween) { CategorySaveOption(selectedCategory, component) Spacer(Modifier.width(8.dp)) LocationSaveOption(component, folder) Spacer(Modifier) } } } @Composable private fun SelectionDetail( modifier: Modifier, totalDownloadItems: Int, selectedDownloadItems: Int, sizes: List, ) { val selectionCount = "$selectedDownloadItems / $totalDownloadItems" Row( modifier, horizontalArrangement = Arrangement.spacedBy(4.dp), verticalAlignment = Alignment.CenterVertically ) { WithContentAlpha(.25f) { MyIcon( icon = MyIcons.activeCount, contentDescription = null, modifier = Modifier.size(16.dp) ) } Text(selectionCount, fontSize = myTextSizes.base) val sizes = sizes.ifThen(sizes.isEmpty()){ listOf(DownloadSize.Bytes.Zero) } WithContentAlpha(.25f) { MyIcon( icon = MyIcons.data, contentDescription = null, modifier = Modifier.size(16.dp) ) } for (sizeType in sizes) { val sizeString = sizeType.rememberString() Text(sizeString, fontSize = myTextSizes.base) } } } @Composable private fun RowScope.LocationSaveOption( component: DesktopAddMultiDownloadComponent, folder: String ) { val allItemsInSameLocation by component.allInSameLocation.collectAsState() SaveOption( title = myStringResource(Res.string.all_items_in_one_Location), selectedHelp = myStringResource(Res.string.all_items_in_one_Location_description), unselectedHelp = myStringResource(Res.string.unselected_all_items_in_specific_location_description), selected = allItemsInSameLocation, onSelectedChange = { component.setAllItemsInSameLocation(it) }, selectedContent = { LocationTextField( text = folder, setText = { component.setFolder(it) }, modifier = Modifier.fillMaxWidth(), lastUsedLocations = component.lastUsedLocations.collectAsState().value, onRequestRemoveSaveLocation = component::removeFromLastDownloadLocation ) } ) } @Composable private fun RowScope.CategorySaveOption( selectedCategory: Category?, component: DesktopAddMultiDownloadComponent ) { SaveOption( title = myStringResource(Res.string.all_items_in_one_category), selectedHelp = myStringResource(Res.string.all_items_in_one_category_description), unselectedHelp = myStringResource(Res.string.each_item_on_its_own_category_description), selected = selectedCategory != null, onSelectedChange = { if (it) { component.setSelectedCategory(component.categories.value.firstOrNull()) } else { component.setSelectedCategory(null) } component.setAlsoAutoCategorize(!it) }, selectedContent = { Row( Modifier.height(IntrinsicSize.Max).fillMaxWidth(), verticalAlignment = Alignment.CenterVertically, ) { CategorySelect( categories = component.categories.collectAsState().value, modifier = Modifier.weight(1f), selectedCategory = component.selectedCategory.collectAsState().value, onCategorySelected = { component.setSelectedCategory(it) } ) Spacer(Modifier.width(8.dp)) CategoryAddButton( Modifier.fillMaxHeight(), enabled = true, onClick = { component.onRequestAddCategory() }, ) } } ) } @Composable private fun RowScope.SaveOption( title: String, selectedHelp: String, unselectedHelp: String, selected: Boolean, onSelectedChange: (Boolean) -> Unit, selectedContent: @Composable () -> Unit ) { ExpandableItem( modifier = Modifier.fillMaxWidth().weight(1f), isExpanded = selected, header = { Row( modifier = Modifier.onClick { onSelectedChange(!selected) }, verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(8.dp) ) { CheckBox( value = selected, onValueChange = onSelectedChange ) Text(title) Help(if (selected) selectedHelp else unselectedHelp) } }, body = { Column { Spacer(Modifier.height(8.dp)) selectedContent() } } ) } ================================================ FILE: desktop/app/src/main/kotlin/com/abdownloadmanager/desktop/pages/addDownload/multiple/AddMultiItemTable.kt ================================================ package com.abdownloadmanager.desktop.pages.addDownload.multiple import com.abdownloadmanager.shared.util.ui.WithContentAlpha import com.abdownloadmanager.shared.util.ui.myColors import com.abdownloadmanager.shared.util.ui.theme.myTextSizes import com.abdownloadmanager.shared.ui.widget.table.customtable.styled.MyStyledTableHeader import androidx.compose.foundation.* import androidx.compose.foundation.interaction.MutableInteractionSource import androidx.compose.foundation.layout.* import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.runtime.* import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.alpha import androidx.compose.ui.draw.clip import androidx.compose.ui.graphics.Color import androidx.compose.ui.input.key.Key import androidx.compose.ui.input.key.isCtrlPressed import androidx.compose.ui.input.key.key import androidx.compose.ui.input.key.onKeyEvent import androidx.compose.ui.input.pointer.PointerButton import androidx.compose.ui.input.pointer.isShiftPressed import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp import com.abdownloadmanager.shared.ui.widget.CheckBox import com.abdownloadmanager.shared.ui.widget.Text import com.abdownloadmanager.shared.ui.widget.table.customtable.CellSize import com.abdownloadmanager.shared.ui.widget.table.customtable.CustomCellRenderer import com.abdownloadmanager.shared.ui.widget.table.customtable.Table import com.abdownloadmanager.shared.ui.widget.table.customtable.TableCell import com.abdownloadmanager.resources.Res import com.abdownloadmanager.shared.downloaderinui.add.NewDownloadInputsUniqueIdType import com.abdownloadmanager.shared.pages.adddownload.multiple.NewMultiDownloadState import com.abdownloadmanager.shared.ui.widget.table.customtable.SortableCell import com.abdownloadmanager.shared.util.FileIconProvider import com.abdownloadmanager.shared.util.ui.widget.MyIcon import ir.amirab.util.compose.StringSource import ir.amirab.util.compose.asStringSource @Composable fun AddMultiDownloadTable( modifier: Modifier, component: DesktopAddMultiDownloadComponent, ) { var isCtrlPressed by remember { mutableStateOf(false) } val lastSelectedId = component.lastSelectedId val context = AddMultiItemListContext(component, component.isAllFilteredSelected.collectAsState().value) val iconProvider = component.fileIconProvider val list by component.filteredList.collectAsState() CompositionLocalProvider( LocalAddMultiItemListContext provides context, ) { val itemHorizontalPadding = 16.dp Table( key = { it.id }, tableState = component.tableState, list = list, modifier = modifier .onKeyEvent { isCtrlPressed = it.isCtrlPressed false } .onKeyEvent { if (it.key == Key.Escape) { context.changeAllSelection(false) true } else { false } } .onKeyEvent { if (isCtrlPressed && it.key == Key.A) { context.changeAllSelection(true) true } else { false } }, wrapHeader = { MyStyledTableHeader( itemHorizontalPadding = itemHorizontalPadding, content = it ) }, wrapItem = { _, item, content -> val shape = RoundedCornerShape(12.dp) WithContentAlpha(1f) { val isSelected = remember(item, component.selectionList) { component.isSelected(item.id) } CompositionLocalProvider( LocalIsChecked provides isSelected, ) { val itemInteractionSource = remember { MutableInteractionSource() } Box( Modifier .widthIn(min = getTableSize().visibleWidth) .onClick( interactionSource = itemInteractionSource ) { if (isCtrlPressed) { context.select(item.id, !isSelected) } else { context.changeAllSelection(false) context.select(item.id, true) } } .onClick( enabled = lastSelectedId != null, keyboardModifiers = { this.isShiftPressed } ) { val lastSelected = lastSelectedId ?: return@onClick val currentId = item.id val ids = component.tableState.getARangeOfItems( list = list, id = { it.id }, fromItem = lastSelected, toItem = currentId, ) context.newSelection(ids, true) } .onClick( matcher = PointerMatcher.mouse(PointerButton.Secondary) ) { component.openConfigurableList(item.id) } .fillMaxWidth() .padding(vertical = 1.dp) .clip(shape) .indication( interactionSource = itemInteractionSource, indication = LocalIndication.current ) .hoverable(itemInteractionSource) .let { if (isSelected) { val selectionColor = myColors.onBackground it .border( 1.dp, myColors.selectionGradient(0.10f, 0.05f, selectionColor), shape ) .background(myColors.selectionGradient(0.15f, 0f, selectionColor)) } else { it.border(1.dp, Color.Transparent) } } .padding(vertical = 8.dp, horizontal = itemHorizontalPadding) ) { content() } } } } ) { cell, item -> when (cell) { AddMultiItemTableCells.Check -> { CheckCell( newMultiDownloadState = item, onCheckedChange = { dc, b -> component.setSelect(item.id, b) } ) } AddMultiItemTableCells.Name -> { NameCell( item = item, iconProvider = iconProvider, ) } AddMultiItemTableCells.Link -> { LinkCell(item) } AddMultiItemTableCells.SizeCell -> { SizeCell(item) } } } } } private val LocalIsChecked = compositionLocalOf { error("LocalIsChecked not provided") } private val LocalAddMultiItemListContext = compositionLocalOf { error("LocalAddMultiItemListContext not provided") } class AddMultiItemListContext( val component: DesktopAddMultiDownloadComponent, val isAllSelected: Boolean, ) { fun changeAllSelection(boolean: Boolean) { component.selectAll(boolean) } fun select(id: NewDownloadInputsUniqueIdType, boolean: Boolean) { component.setSelect(id, boolean) } fun newSelection(ids: List, boolean: Boolean) { component.resetSelectionTo(ids, boolean) } } sealed class AddMultiItemTableCells : TableCell { companion object { fun all(): List { return listOf( Check, Name, SizeCell, Link, ) } } data object Check : AddMultiItemTableCells(), CustomCellRenderer { override val id: String = "#" override val name: StringSource = "#".asStringSource() override val size: CellSize = CellSize.Fixed(26.dp) @Composable override fun drawHeader() { val context = LocalAddMultiItemListContext.current CheckBox( context.isAllSelected, { context.component.selectAll(!context.isAllSelected) }, size = 12.dp ) } } data object Name : AddMultiItemTableCells(), SortableCell { override val id: String = "Name" override val name: StringSource = Res.string.name.asStringSource() override val size: CellSize = CellSize.Resizeable(120.dp..1000.dp, 350.dp) override fun comparator(): Comparator { return compareBy { it.name } } } data object Link : AddMultiItemTableCells(), SortableCell { override val id: String = "Link" override val name: StringSource = Res.string.link.asStringSource() override val size: CellSize = CellSize.Resizeable(120.dp..2000.dp, 240.dp) override fun comparator(): Comparator { return compareBy { it.link } } } data object SizeCell : AddMultiItemTableCells(), SortableCell { override val id: String = "Size" override val name: StringSource = Res.string.size.asStringSource() override val size: CellSize = CellSize.Resizeable(100.dp..180.dp, 100.dp) override fun comparator(): Comparator { return compareBy { it.size } } } } @Composable private fun CellText( text: String, ) { Text( text, fontSize = myTextSizes.base, maxLines = 1, overflow = TextOverflow.Ellipsis, ) } @Composable private fun NameCell( item: NewMultiDownloadState, iconProvider: FileIconProvider, ) { val name = item.name val icon = iconProvider.rememberIcon(name) Row( verticalAlignment = Alignment.CenterVertically, ) { MyIcon( icon = icon, contentDescription = null, modifier = Modifier.size(16.dp).alpha(0.75f) ) Spacer(Modifier.width(8.dp)) CellText(name) } } @Composable private fun LinkCell( item: NewMultiDownloadState, ) { CellText(item.link) } @Composable private fun SizeCell( multiDownloadState: NewMultiDownloadState, ) { CellText( multiDownloadState.sizeString.rememberString() ) } @Composable private fun CheckCell( onCheckedChange: (NewMultiDownloadState, Boolean) -> Unit, newMultiDownloadState: NewMultiDownloadState, ) { val isChecked = LocalIsChecked.current CheckBox( value = isChecked, onValueChange = { onCheckedChange(newMultiDownloadState, it) }, modifier = Modifier, size = 12.dp, ) } ================================================ FILE: desktop/app/src/main/kotlin/com/abdownloadmanager/desktop/pages/addDownload/multiple/DesktopAddMultiDownloadComponent.kt ================================================ package com.abdownloadmanager.desktop.pages.addDownload.multiple import com.abdownloadmanager.shared.ui.widget.table.customtable.TableState import com.abdownloadmanager.shared.util.DownloadSystem import com.abdownloadmanager.desktop.repository.AppRepository import com.abdownloadmanager.shared.downloaderinui.DownloaderInUiRegistry import com.abdownloadmanager.shared.pagemanager.CategoryDialogManager import com.abdownloadmanager.shared.pages.adddownload.multiple.BaseAddMultiDownloadComponent import com.abdownloadmanager.shared.pages.adddownload.multiple.OnRequestAdd import com.abdownloadmanager.shared.storage.ILastSavedLocationsStorage import com.abdownloadmanager.shared.util.FileIconProvider import com.abdownloadmanager.shared.util.category.CategoryManager import com.abdownloadmanager.shared.util.perhostsettings.PerHostSettingsManager import com.arkivanov.decompose.ComponentContext import ir.amirab.downloader.queue.QueueManager class DesktopAddMultiDownloadComponent( ctx: ComponentContext, id: String, onRequestClose: () -> Unit, onRequestAdd: OnRequestAdd, private val categoryDialogManager: CategoryDialogManager, lastSavedLocationsStorage: ILastSavedLocationsStorage, perHostSettingsManager: PerHostSettingsManager, downloadSystem: DownloadSystem, fileIconProvider: FileIconProvider, appRepository: AppRepository, downloaderInUiRegistry: DownloaderInUiRegistry, queueManager: QueueManager, categoryManager: CategoryManager, ) : BaseAddMultiDownloadComponent( ctx = ctx, id = id, lastSavedLocationsStorage = lastSavedLocationsStorage, onRequestAdd = onRequestAdd, onRequestClose = onRequestClose, perHostSettingsManager = perHostSettingsManager, downloadSystem = downloadSystem, appRepository = appRepository, fileIconProvider = fileIconProvider, downloaderInUiRegistry = downloaderInUiRegistry, queueManager = queueManager, categoryManager = categoryManager, ) { override fun getCategoryPageManager(): CategoryDialogManager { return categoryDialogManager } val tableState = TableState( cells = AddMultiItemTableCells.all(), forceVisibleCells = listOf( AddMultiItemTableCells.Check, AddMultiItemTableCells.Name, ) ) } ================================================ FILE: desktop/app/src/main/kotlin/com/abdownloadmanager/desktop/pages/addDownload/shared/CategorySelect.kt ================================================ package com.abdownloadmanager.desktop.pages.addDownload.shared import androidx.compose.foundation.background import androidx.compose.foundation.border import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.* import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.runtime.* import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.alpha import androidx.compose.ui.draw.clip import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.* import com.abdownloadmanager.resources.Res import com.abdownloadmanager.shared.util.ui.icon.MyIcons import com.abdownloadmanager.shared.util.ui.myColors import ir.amirab.util.ifThen import com.abdownloadmanager.shared.ui.widget.Text import com.abdownloadmanager.shared.util.div import com.abdownloadmanager.shared.util.category.Category import com.abdownloadmanager.shared.util.category.rememberIconPainter import com.abdownloadmanager.shared.util.ui.theme.myShapes import com.abdownloadmanager.shared.util.ui.theme.myTextSizes import com.abdownloadmanager.shared.util.ui.widget.MyIcon import ir.amirab.util.compose.resources.myStringResource @Composable fun CategorySelect( modifier: Modifier = Modifier, enabled: Boolean = true, categories: List, selectedCategory: Category?, onCategorySelected: (Category) -> Unit, ) { var isSelectionOpen by remember { mutableStateOf(false) } val closeDialog = { isSelectionOpen = false } DialogDropDown( selectedItem = selectedCategory, possibleItems = categories, onItemSelected = onCategorySelected, enabled = enabled, renderItem = { RenderCategory( category = it, modifier = Modifier, ) }, dropdownOpen = isSelectionOpen, onRequestCloseDropDown = { closeDialog() }, onRequestOpenDropDown = { isSelectionOpen = true }, modifier = modifier, renderEmpty = { Column( modifier = Modifier.fillMaxSize().wrapContentSize(), horizontalAlignment = Alignment.CenterHorizontally, ) { MyIcon(MyIcons.info, null, Modifier.size(64.dp)) Spacer(Modifier.height(16.dp)) Text( myStringResource(Res.string.no_categories_found), fontWeight = FontWeight.Bold, fontSize = myTextSizes.lg, ) } }, dropDownSize = DpSize(220.dp, 220.dp), ) } @Composable private fun RenderCategory( modifier: Modifier, category: Category, ) { Row( modifier, verticalAlignment = Alignment.CenterVertically, ) { val icon = category.rememberIconPainter() val iconModifier = Modifier.size(16.dp) if (icon != null) { MyIcon( icon, null, iconModifier, ) } else { Spacer(iconModifier) } Spacer(Modifier.width(8.dp)) Text( category.name, softWrap = false, maxLines = 1, modifier = Modifier.weight(1f) ) } } @Composable fun CategoryAddButton( modifier: Modifier, enabled: Boolean = true, onClick: () -> Unit, ) { val borderColor = myColors.onBackground / 0.1f val background = myColors.surface / 50 val shape = myShapes.defaultRounded Box( modifier .clip(shape) .ifThen(!enabled) { alpha(0.5f) } .border(1.dp, borderColor, shape) .background(background) .clickable( enabled = enabled ) { onClick() } .aspectRatio(1f) // .padding(horizontal = 8.dp) ) { MyIcon( MyIcons.add, contentDescription = "Add Category", Modifier .align(Alignment.Center) .size(16.dp) ) } } ================================================ FILE: desktop/app/src/main/kotlin/com/abdownloadmanager/desktop/pages/addDownload/shared/DialogDropDown.kt ================================================ package com.abdownloadmanager.desktop.pages.addDownload.shared import androidx.compose.animation.AnimatedVisibility import androidx.compose.animation.fadeIn import androidx.compose.animation.fadeOut import androidx.compose.foundation.background import androidx.compose.foundation.border import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.* import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.items import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.runtime.* import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.alpha import androidx.compose.ui.draw.clip import androidx.compose.ui.graphics.Brush import androidx.compose.ui.graphics.Color import androidx.compose.ui.unit.DpOffset import androidx.compose.ui.unit.DpSize import androidx.compose.ui.unit.dp import androidx.compose.ui.window.rememberDialogState import com.abdownloadmanager.desktop.window.custom.BaseOptionDialog import com.abdownloadmanager.shared.util.ui.icon.MyIcons import com.abdownloadmanager.shared.util.ui.theme.LocalUiScale import com.abdownloadmanager.shared.util.ui.myColors import ir.amirab.util.ifThen import com.abdownloadmanager.shared.ui.widget.Text import com.abdownloadmanager.shared.util.div import com.abdownloadmanager.desktop.window.moveSafe import com.abdownloadmanager.resources.Res import com.abdownloadmanager.shared.util.ui.WithContentAlpha import com.abdownloadmanager.shared.util.ui.theme.myShapes import com.abdownloadmanager.shared.util.ui.widget.MyIcon import ir.amirab.util.compose.resources.myStringResource import ir.amirab.util.desktop.screen.applyUiScale import java.awt.MouseInfo @Composable fun DialogDropDown( selectedItem: T?, possibleItems: List, onItemSelected: (T) -> Unit, modifier: Modifier, enabled: Boolean = true, dropdownOpen: Boolean, onRequestOpenDropDown: () -> Unit, onRequestCloseDropDown: () -> Unit, dropDownSize: DpSize = DpSize(220.dp, 250.dp), renderItem: @Composable (T) -> Unit, renderEmpty: @Composable () -> Unit, ) { Column(modifier) { DropDownHeader( item = selectedItem, enabled = enabled, onClick = onRequestOpenDropDown, renderItem = renderItem ) if (dropdownOpen) { DropDownContent( closeDialog = onRequestCloseDropDown, dropDownSize = dropDownSize, possibleItems = possibleItems, selectedItem = selectedItem, onItemSelected = onItemSelected, drawOnEmpty = renderEmpty, renderItem = renderItem, ) } } } @Composable fun DropDownContent( closeDialog: () -> Unit, dropDownSize: DpSize, possibleItems: List, selectedItem: T?, onItemSelected: (T) -> Unit, drawOnEmpty: @Composable () -> Unit, renderItem: @Composable (T) -> Unit, ) { BaseOptionDialog( onCloseRequest = closeDialog, state = rememberDialogState( size = dropDownSize.applyUiScale(LocalUiScale.current) ), resizeable = true, content = { LaunchedEffect(window) { window.moveSafe( MouseInfo.getPointerInfo().location.run { DpOffset( x = x.dp, y = y.dp ) } ) } val shape = myShapes.defaultRounded Box( Modifier .clip(shape) .border(2.dp, myColors.onBackground / 10, shape) .background( Brush.linearGradient( listOf( myColors.surface, myColors.background, ) ) ) ) { val listState = rememberLazyListState() LazyColumn( Modifier .fillMaxSize() .padding(8.dp), state = listState, ) { items(possibleItems) { val isSelected = it == selectedItem WithContentAlpha( if (isSelected) 1f else 0.75f ) { Row( Modifier .clip(shape) .clickable { onItemSelected(it) closeDialog() } .padding( vertical = 8.dp, horizontal = 8.dp ) ) { Box( Modifier.weight(1f) ) { renderItem(it) } val selectedIconModifier = Modifier.size(16.dp) if (isSelected) { MyIcon( MyIcons.check, null, selectedIconModifier, ) } else { Spacer(selectedIconModifier) } } } } } if (possibleItems.isEmpty()) { Box(Modifier.padding().fillMaxSize()) { drawOnEmpty() } } AnimatedVisibility( visible = listState.canScrollForward, modifier = Modifier.matchParentSize(), enter = fadeIn(), exit = fadeOut(), ) { Spacer( Modifier .fillMaxSize() .background( Brush.verticalGradient( colorStops = arrayOf( 0f to Color.Transparent, 0.8f to Color.Transparent, 1f to myColors.background, ) ) ) ) } } } ) } @Composable private fun DropDownHeader( item: T?, enabled: Boolean, onClick: () -> Unit, renderItem: @Composable (T) -> Unit, ) { val borderColor = myColors.onBackground / 0.1f val background = myColors.surface / 50 val shape = myShapes.defaultRounded Row( Modifier .height(IntrinsicSize.Max) .clip(shape) .ifThen(!enabled) { alpha(0.5f) } .border(1.dp, borderColor, shape) .background(background) .clickable( enabled = enabled ) { onClick() } .padding(horizontal = 8.dp) ) { val contentModifier = Modifier .padding(vertical = 8.dp) .weight(1f) if (item != null) { Box(contentModifier) { renderItem(item) } } else { Text( myStringResource(Res.string.no_category_selected), contentModifier ) } Spacer( Modifier .padding(horizontal = 8.dp) .fillMaxHeight().padding(vertical = 1.dp) .width(1.dp) .background(borderColor) ) MyIcon( MyIcons.down, null, Modifier .align(Alignment.CenterVertically) .size(16.dp), ) } } ================================================ FILE: desktop/app/src/main/kotlin/com/abdownloadmanager/desktop/pages/addDownload/shared/ExtraConfig.kt ================================================ package com.abdownloadmanager.desktop.pages.addDownload.shared import com.abdownloadmanager.shared.ui.configurable.RenderConfigurable import com.abdownloadmanager.desktop.window.custom.BaseOptionDialog import com.abdownloadmanager.shared.util.ui.myColors import com.abdownloadmanager.shared.ui.widget.Text import com.abdownloadmanager.shared.util.ui.WithContentColor import com.abdownloadmanager.shared.util.div import com.abdownloadmanager.desktop.window.moveSafe import androidx.compose.foundation.* import androidx.compose.foundation.layout.* import androidx.compose.foundation.window.WindowDraggableArea import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip import androidx.compose.ui.graphics.Brush import androidx.compose.ui.unit.DpOffset import androidx.compose.ui.unit.DpSize import androidx.compose.ui.unit.dp import androidx.compose.ui.window.rememberDialogState import com.abdownloadmanager.shared.ui.configurable.Configurable import com.abdownloadmanager.shared.ui.configurable.ConfigurableUiProps import com.abdownloadmanager.shared.util.ui.MultiplatformVerticalScrollbar import com.abdownloadmanager.shared.util.ui.theme.LocalUiScale import com.abdownloadmanager.shared.util.ui.theme.myShapes import ir.amirab.util.desktop.screen.applyUiScale import java.awt.Dimension import java.awt.MouseInfo @Composable fun ExtraConfig( onDismiss: () -> Unit, configurables: List>, ) { val h = 250 val w = 350 val state = rememberDialogState( size = DpSize( height = h.dp, width = w.dp, ).applyUiScale(LocalUiScale.current), ) BaseOptionDialog(onDismiss, state) { LaunchedEffect(window){ window.moveSafe( MouseInfo.getPointerInfo().location.run { DpOffset( x = x.dp, y = y.dp ) } ) } val shape = myShapes.defaultRounded Column( Modifier .fillMaxSize() .clip(shape) .border(2.dp, myColors.onBackground / 10, shape) .background( Brush.linearGradient( listOf( myColors.surface, myColors.background, ) ) ) ) { WithContentColor(myColors.onBackground) { LaunchedEffect(w, h) { window.minimumSize = Dimension(w, h) } Column { WindowDraggableArea(Modifier.fillMaxWidth()) { Text( "Extra Config", Modifier .padding(vertical = 8.dp) .fillMaxWidth() .wrapContentWidth() ) } Divider() Box { val scrollState = rememberScrollState() Column( Modifier.verticalScroll(scrollState) ) { for ((index, cfg) in configurables.withIndex()) { RenderConfigurable( cfg, ConfigurableUiProps( itemPaddingValues = PaddingValues(vertical = 8.dp, horizontal = 32.dp) ) ) if (index != configurables.lastIndex) { Divider() } } } MultiplatformVerticalScrollbar( rememberScrollbarAdapter(scrollState), Modifier.fillMaxHeight() .align(Alignment.CenterEnd) ) } } } } } } @Composable private fun Divider() { Spacer( Modifier.fillMaxWidth() .height(1.dp) .background(myColors.onBackground / 10), ) } ================================================ FILE: desktop/app/src/main/kotlin/com/abdownloadmanager/desktop/pages/addDownload/shared/LocationTextField.kt ================================================ package com.abdownloadmanager.desktop.pages.addDownload.shared import com.abdownloadmanager.shared.ui.widget.MyTextFieldWithIcons import com.abdownloadmanager.shared.ui.widget.MyTextFieldIcon import com.abdownloadmanager.shared.util.ui.icon.MyIcons import com.abdownloadmanager.shared.util.ui.myColors import com.abdownloadmanager.shared.util.ui.theme.myTextSizes import com.abdownloadmanager.shared.ui.widget.Text import com.abdownloadmanager.shared.ui.widget.menu.custom.MyDropDown import androidx.compose.foundation.background import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.* import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.verticalScroll import androidx.compose.runtime.* import androidx.compose.ui.Modifier import androidx.compose.ui.draw.alpha import androidx.compose.ui.draw.clip import androidx.compose.ui.layout.onGloballyPositioned import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp import com.abdownloadmanager.resources.Res import com.abdownloadmanager.shared.util.ui.theme.myShapes import com.abdownloadmanager.shared.util.ui.widget.MyIcon import ir.amirab.util.compose.resources.myStringResource import com.abdownloadmanager.desktop.ui.util.rememberMyDirectoryPickerLauncher import java.io.File @Composable fun LocationTextField( modifier: Modifier, text: String, setText: (String) -> Unit, errorText: String? = null, lastUsedLocations: List = emptyList(), onRequestRemoveSaveLocation: (String) -> Unit, ) { var showLastUsedLocations by remember { mutableStateOf(false) } val downloadLauncherFolderPickerLauncher = rememberMyDirectoryPickerLauncher( title = myStringResource(Res.string.download_location), initialDirectory = remember(text) { runCatching { File(text).canonicalPath }.getOrNull() }, attachToWindow = true ) { directory -> directory?.let(setText) } var widthForDropDown by remember { mutableStateOf(0.dp) } val density = LocalDensity.current Box(modifier) { MyTextFieldWithIcons( text, setText, myStringResource(Res.string.location), modifier = Modifier .fillMaxWidth() .onGloballyPositioned { widthForDropDown = with(density) { it.size.width.toDp() } }, errorText = errorText, end = { Row { MyTextFieldIcon(MyIcons.folder) { downloadLauncherFolderPickerLauncher.launch() } MyTextFieldIcon(MyIcons.down) { showLastUsedLocations = !showLastUsedLocations } } } ) if (showLastUsedLocations) { ShowSuggestions( width = { widthForDropDown }, suggestions = lastUsedLocations, onSuggestionSelected = { setText(it) showLastUsedLocations = false }, onDismiss = { showLastUsedLocations = false }, onRequestRemove = onRequestRemoveSaveLocation ) } } } @Composable private fun ShowSuggestions( width: () -> Dp, suggestions: List, onRequestRemove: (String) -> Unit, onSuggestionSelected: (String) -> Unit, onDismiss: () -> Unit, ) { MyDropDown(onDismiss) { Column( Modifier .width(width()) .clip(myShapes.defaultRounded) .background(myColors.surface) .verticalScroll(rememberScrollState()) ) { for (l in suggestions) { Row( Modifier.height(IntrinsicSize.Max) ) { Text( text = l, modifier = Modifier .weight(1f) .clickable { onSuggestionSelected(l) } .padding(vertical = 4.dp, horizontal = 4.dp), fontSize = myTextSizes.sm ) MyIcon( MyIcons.clear, null, Modifier .fillMaxHeight() .clickable { onRequestRemove(l) } .wrapContentHeight() .padding(horizontal = 2.dp) .size(12.dp) .alpha(0.25f) ) } } } } } ================================================ FILE: desktop/app/src/main/kotlin/com/abdownloadmanager/desktop/pages/addDownload/shared/SelectQueue.kt ================================================ package com.abdownloadmanager.desktop.pages.addDownload.shared import com.abdownloadmanager.desktop.actions.newQueueAction import com.abdownloadmanager.desktop.window.custom.BaseOptionDialog import com.abdownloadmanager.shared.util.ui.icon.MyIcons import com.abdownloadmanager.shared.util.ui.myColors import com.abdownloadmanager.shared.util.ui.theme.myTextSizes import com.abdownloadmanager.shared.ui.widget.ActionButton import com.abdownloadmanager.shared.ui.widget.IconActionButton import com.abdownloadmanager.shared.ui.widget.Text import com.abdownloadmanager.shared.util.ui.WithContentColor import com.abdownloadmanager.shared.util.div import com.abdownloadmanager.desktop.window.moveSafe import androidx.compose.foundation.* import androidx.compose.foundation.layout.* import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.window.WindowDraggableArea import androidx.compose.runtime.* import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip import androidx.compose.ui.graphics.Brush import androidx.compose.ui.unit.DpOffset import androidx.compose.ui.unit.DpSize import androidx.compose.ui.unit.dp import androidx.compose.ui.window.rememberDialogState import com.abdownloadmanager.shared.util.ui.theme.LocalUiScale import com.abdownloadmanager.resources.Res import com.abdownloadmanager.shared.ui.widget.CheckBox import com.abdownloadmanager.shared.util.ui.MultiplatformVerticalScrollbar import com.abdownloadmanager.shared.util.ui.theme.myShapes import ir.amirab.util.compose.resources.myStringResource import ir.amirab.downloader.queue.DownloadQueue import ir.amirab.util.compose.asStringSource import ir.amirab.util.desktop.screen.applyUiScale import java.awt.MouseInfo @Composable fun ShowAddToQueueDialog( queueList: List, onQueueSelected: (Long?, Boolean) -> Unit, onClose: () -> Unit, ) { val h = 210 val w = 250 val state = rememberDialogState( size = DpSize( height = h.dp, width = w.dp, ).applyUiScale(LocalUiScale.current), ) val close = { onClose() } val (startQueue, setStartQueue) = remember { mutableStateOf(false) } BaseOptionDialog( onCloseRequest = close, state = state, resizeable = false, ) { LaunchedEffect(window) { window.moveSafe( MouseInfo.getPointerInfo().location.run { DpOffset( x = x.dp, y = y.dp ) } ) } val shape = myShapes.defaultRounded Column( Modifier .fillMaxSize() .clip(shape) .border(2.dp, myColors.onBackground / 10, shape) .background( Brush.linearGradient( listOf( myColors.surface, myColors.background, ) ) ) ) { WithContentColor(myColors.onBackground) { Column( Modifier.fillMaxWidth() ) { WindowDraggableArea(Modifier.fillMaxWidth()) { Text( myStringResource(Res.string.select_queue), modifier = Modifier .padding(vertical = 8.dp) .fillMaxWidth() .wrapContentWidth(), fontSize = myTextSizes.lg, ) } Divider() Column( Modifier .padding(horizontal = 8.dp) .padding(bottom = 8.dp) ) { val addToQueueModifier = Modifier.fillMaxWidth() Spacer(Modifier.height(8.dp)) Box( Modifier .border(1.dp, myColors.onBackground / 5, shape) .padding(1.dp) .weight(1f) ) { val scrollState = rememberScrollState() Column( modifier = Modifier .verticalScroll(scrollState) ) { for (q in queueList) { key(q.id) { val queueModel by q.queueModel.collectAsState() QueueItemToSelect( modifier = addToQueueModifier, name = queueModel.name, onSelect = { onQueueSelected(queueModel.id, startQueue) } ) } } } MultiplatformVerticalScrollbar( rememberScrollbarAdapter(scrollState), Modifier.fillMaxHeight() .align(Alignment.CenterEnd) ) } Spacer(Modifier.height(4.dp)) Row( verticalAlignment = Alignment.CenterVertically, modifier = Modifier .onClick { setStartQueue(!startQueue) } .padding(vertical = 4.dp) .padding(start = 2.dp) ) { CheckBox( size = 14.dp, value = startQueue, onValueChange = setStartQueue ) Spacer(Modifier.width(4.dp)) Text(myStringResource(Res.string.start_queue)) } Row( modifier = Modifier .fillMaxWidth() .padding(vertical = 8.dp), verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.SpaceBetween ) { IconActionButton( MyIcons.add, contentDescription = Res.string.add_new_queue.asStringSource(), onClick = newQueueAction ) ActionButton( text = myStringResource(Res.string.without_queue), modifier = Modifier, onClick = { onQueueSelected(null, startQueue) } ) } } } } } } } @Composable fun QueueItemToSelect( modifier: Modifier, name: String, onSelect: () -> Unit, ) { Row( modifier .clickable(onClick = onSelect) .padding(vertical = 4.dp) .padding(horizontal = 4.dp) ) { Text( "$name", fontSize = myTextSizes.base, ) } } @Composable private fun Divider() { Spacer( Modifier.fillMaxWidth() .height(1.dp) .background(myColors.onBackground / 10), ) } ================================================ FILE: desktop/app/src/main/kotlin/com/abdownloadmanager/desktop/pages/addDownload/single/AddDownloadPage.kt ================================================ package com.abdownloadmanager.desktop.pages.addDownload.single import com.abdownloadmanager.shared.util.ui.WithContentAlpha import com.abdownloadmanager.shared.util.ui.WithContentColor import com.abdownloadmanager.desktop.window.custom.BaseOptionDialog import com.abdownloadmanager.shared.util.ui.widget.MyIcon import com.abdownloadmanager.shared.util.ui.icon.MyIcons import com.abdownloadmanager.shared.util.ui.myColors import com.abdownloadmanager.shared.util.ui.theme.myTextSizes import com.abdownloadmanager.desktop.window.moveSafe import androidx.compose.animation.* import androidx.compose.foundation.* import androidx.compose.foundation.layout.* import androidx.compose.foundation.window.WindowDraggableArea import androidx.compose.runtime.* import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip import androidx.compose.ui.graphics.* import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.* import androidx.compose.ui.window.* import arrow.core.Some import com.abdownloadmanager.shared.ui.widget.* import com.abdownloadmanager.desktop.pages.addDownload.shared.* import com.abdownloadmanager.shared.util.mvi.HandleEffects import com.abdownloadmanager.resources.Res import com.abdownloadmanager.shared.downloaderinui.add.CanAddResult import com.abdownloadmanager.shared.pages.adddownload.single.BaseAddSingleDownloadComponent import com.abdownloadmanager.shared.util.ClipboardUtil import com.abdownloadmanager.shared.util.div import com.abdownloadmanager.shared.util.ui.theme.myShapes import ir.amirab.util.compose.resources.myStringResource import ir.amirab.downloader.utils.OnDuplicateStrategy import ir.amirab.util.compose.asStringSource import java.awt.MouseInfo @Composable fun AddDownloadPage( component: BaseAddSingleDownloadComponent, ) { val onDuplicateStrategy by component.onDuplicateStrategy.collectAsState() Column( Modifier .padding(horizontal = 32.dp) .padding(top = 8.dp, bottom = 16.dp) ) { val credentials by component.credentials.collectAsState() fun setLink(link: String) { component.setCredentials( credentials.copy(link = Some(link)) ) } HandleEffects(component) { when (it) { is BaseAddSingleDownloadComponent.Effects.Common -> { when (it) { is BaseAddSingleDownloadComponent.Effects.Common.SuggestUrl -> { setLink(it.link) } } } is BaseAddSingleDownloadComponent.Effects.Platform -> { // support platform effects if any } } } UrlTextField( text = credentials.link, setText = { setLink(it) }, modifier = Modifier ) Row( ) { val canAddResult by component.canAddResult.collectAsState() Column(Modifier.weight(1f)) { val useCategory by component.useCategory.collectAsState() Spacer(Modifier.size(8.dp)) Row( modifier = Modifier.height(IntrinsicSize.Max), verticalAlignment = Alignment.CenterVertically, ) { Row( verticalAlignment = Alignment.CenterVertically, modifier = Modifier .onClick { component.setUseCategory(!useCategory) } .padding(vertical = 4.dp) ) { CheckBox( size = 16.dp, value = useCategory, onValueChange = { component.setUseCategory(it) } ) Spacer(Modifier.width(4.dp)) Text(myStringResource(Res.string.use_category)) } Spacer(Modifier.width(8.dp)) CategorySelect( modifier = Modifier.weight(1f), enabled = useCategory, categories = component.categories.collectAsState().value, selectedCategory = component.selectedCategory.collectAsState().value, onCategorySelected = { component.setSelectedCategory(it) }, ) Spacer(Modifier.width(8.dp)) CategoryAddButton( enabled = useCategory, modifier = Modifier.fillMaxHeight(), onClick = { component.addNewCategory() }, ) } Spacer(Modifier.size(8.dp)) LocationTextField( modifier = Modifier.fillMaxWidth(), text = component.folder.collectAsState().value, setText = { component.setFolder(it) }, errorText = when (canAddResult) { CanAddResult.CantWriteInThisFolder -> myStringResource(Res.string.cant_write_to_this_folder) else -> null }, lastUsedLocations = component.lastUsedLocations.collectAsState().value, onRequestRemoveSaveLocation = component::removeFromLastDownloadLocation, ) val name by component.name.collectAsState() Spacer(Modifier.size(8.dp)) NameTextField( text = name, setText = { component.setName(it) }, errorText = when (canAddResult) { is CanAddResult.DownloadAlreadyExists -> { if (onDuplicateStrategy == null) { myStringResource(Res.string.download_already_exists) } else { null } } CanAddResult.InvalidFileName -> myStringResource(Res.string.invalid_file_name) else -> null }.takeIf { name.isNotEmpty() } ) } Spacer(Modifier.size(24.dp)) Column( horizontalAlignment = Alignment.CenterHorizontally, modifier = Modifier .align(Alignment.Top) .width(IntrinsicSize.Max) ) { RenderFileTypeAndSize(component) RenderResumeSupport(component) ConfigActionsButtons(component) } } Spacer(Modifier.weight(1f)) MainActionButtons(component) if (component.showSolutionsOnDuplicateDownloadUi) { ShowSolutionsOnDuplicateDownload(component) } if (component.shouldShowAddToQueue) { ShowAddToQueueDialog( queueList = component.queues.collectAsState().value, onClose = { component.shouldShowAddToQueue = false }, onQueueSelected = { queue, startQueue -> component.onRequestAddToQueue(queue, startQueue) } ) } if (component.showMoreSettings) { ExtraConfig( onDismiss = { component.showMoreSettings = false }, configurables = component.configurables, ) } } } @Composable private fun ShowSolutionsOnDuplicateDownload(component: BaseAddSingleDownloadComponent) { val h = 250 val w = 300 val state = rememberDialogState( size = DpSize( height = Dp.Unspecified, width = Dp.Unspecified, ), ) val close = { component.showSolutionsOnDuplicateDownloadUi = false } val onDuplicateStrategy by component.onDuplicateStrategy.collectAsState() BaseOptionDialog( onCloseRequest = close, state = state, resizeable = false, ) { LaunchedEffect(window) { window.moveSafe( MouseInfo.getPointerInfo().location.run { DpOffset( x = x.dp, y = y.dp ) } ) } val shape = myShapes.defaultRounded Column( Modifier .clip(shape) .border(2.dp, myColors.onBackground / 10, shape) .background( Brush.linearGradient( listOf( myColors.surface, myColors.background, ) ) ) ) { WithContentColor(myColors.onBackground) { Column( Modifier.widthIn(max = 300.dp) ) { WindowDraggableArea(Modifier) { Column( Modifier.padding(vertical = 8.dp, horizontal = 16.dp) ) { Text( myStringResource(Res.string.select_a_solution), Modifier, fontSize = myTextSizes.base ) Spacer(Modifier.height(8.dp)) WithContentAlpha(0.75f) { Text( myStringResource(Res.string.select_download_strategy_description), Modifier, fontSize = myTextSizes.sm, ) } } } Column( Modifier .padding(horizontal = 8.dp) .padding(bottom = 8.dp) ) { Spacer(Modifier.height(4.dp)) Divider() Spacer(Modifier.height(4.dp)) Column { OnDuplicateStrategySolutionItem( isSelected = onDuplicateStrategy == OnDuplicateStrategy.AddNumbered, title = myStringResource(Res.string.download_strategy_add_a_numbered_file), description = myStringResource(Res.string.download_strategy_add_a_numbered_file_description), ) { component.setOnDuplicateStrategy(OnDuplicateStrategy.AddNumbered) close() } OnDuplicateStrategySolutionItem( isSelected = onDuplicateStrategy == OnDuplicateStrategy.OverrideDownload, title = myStringResource(Res.string.download_strategy_override_existing_file), description = myStringResource(Res.string.download_strategy_override_existing_file_description), ) { component.setOnDuplicateStrategy(OnDuplicateStrategy.OverrideDownload) close() } OnDuplicateStrategySolutionItem( isSelected = null, title = myStringResource(Res.string.download_strategy_update_download_link), description = myStringResource(Res.string.download_strategy_update_download_link_description), ) { component.updateDownloadCredentialsOfOriginalDownload() close() } OnDuplicateStrategySolutionItem( isSelected = null, title = myStringResource(Res.string.download_strategy_show_downloaded_file), description = myStringResource(Res.string.download_strategy_show_downloaded_file_description), ) { component.openDownloadFileForCurrentLink() close() } } } } } } } } @Composable private fun OnDuplicateStrategySolutionItem( title: String, description: String, isSelected: Boolean?, onClick: () -> Unit, ) { Row( verticalAlignment = Alignment.CenterVertically, modifier = Modifier .fillMaxWidth() .clickable(onClick = onClick) .padding(8.dp) ) { isSelected?.let { CheckBox(isSelected, { onClick() }, size = 12.dp) } Spacer(Modifier.width(8.dp)) Column { Text( title, fontSize = myTextSizes.base, fontWeight = FontWeight.Bold ) Spacer(Modifier.height(4.dp)) WithContentAlpha(0.7f) { Text( text = description, fontSize = myTextSizes.sm, modifier = Modifier ) } } } } @Composable private fun Divider() { Spacer( Modifier.fillMaxWidth() .height(1.dp) .background(myColors.onBackground / 10), ) } @Composable fun RenderResumeSupport(component: BaseAddSingleDownloadComponent) { val fileInfo by component.linkResponseInfo.collectAsState() Row( verticalAlignment = Alignment.CenterVertically, modifier = Modifier.height(16.dp) ) { val lineModifier = Modifier.weight(1f) .height(1.dp) .background(myColors.onBackground / 10) Box(lineModifier) val canAddToDownloads by component.canAddToDownloads.collectAsState() AnimatedVisibility( visible = canAddToDownloads && fileInfo != null, ) { fileInfo?.let { fileInfo -> if (fileInfo.resumeSupport) { val iconModifier = Modifier .padding(horizontal = 2.dp) .size(10.dp) if (fileInfo.resumeSupport) { MyIcon( icon = MyIcons.check, contentDescription = null, modifier = iconModifier, tint = myColors.success ) } else { MyIcon( icon = MyIcons.clear, contentDescription = null, modifier = iconModifier, tint = myColors.error, ) } } } } Box(lineModifier) } } @Composable private fun MainConfigActionButton( text: String, modifier: Modifier, enabled: Boolean = true, onClick: () -> Unit, ) { ActionButton(text, modifier, enabled, onClick) } @Composable fun ConfigActionsButtons(component: BaseAddSingleDownloadComponent) { val responseInfo by component.linkResponseInfo.collectAsState() Row { IconActionButton(MyIcons.refresh, Res.string.refresh.asStringSource()) { component.refresh() } Spacer(Modifier.width(6.dp)) IconActionButton( MyIcons.settings, Res.string.settings.asStringSource(), indicateActive = component.showMoreSettings, requiresAttention = responseInfo?.requireBasicAuth ?: false ) { component.showMoreSettings = true } } } @Composable private fun MainActionButtons(component: BaseAddSingleDownloadComponent) { Row { val onDuplicateStrategy by component.onDuplicateStrategy.collectAsState() val canAddResult by component.canAddResult.collectAsState() if (canAddResult is CanAddResult.DownloadAlreadyExists && onDuplicateStrategy == null) { MainConfigActionButton( text = myStringResource(Res.string.show_solutions), modifier = Modifier, onClick = { component.showSolutionsOnDuplicateDownloadUi = true }, ) if (component.shouldShowOpenFile.collectAsState().value) { Spacer(Modifier.width(8.dp)) MainConfigActionButton( text = myStringResource(Res.string.open_file), modifier = Modifier, onClick = { component.openExistingFile() }, ) } } else { val canAddToDownloads by component.canAddToDownloads.collectAsState() MainConfigActionButton( text = myStringResource(Res.string.add), modifier = Modifier, enabled = canAddToDownloads, onClick = { component.shouldShowAddToQueue = true }, ) Spacer(Modifier.width(8.dp)) PrimaryMainActionButton( text = myStringResource(Res.string.download), modifier = Modifier, enabled = canAddToDownloads, onClick = { component.onRequestDownload() }, ) if (onDuplicateStrategy != null) { Spacer(Modifier.width(8.dp)) MainConfigActionButton( text = myStringResource(Res.string.change_solution), modifier = Modifier, onClick = { component.showSolutionsOnDuplicateDownloadUi = true }, ) } } // Spacer(Modifier.weight(1f)) Spacer(Modifier.weight(1f)) MainConfigActionButton( text = myStringResource(Res.string.cancel), modifier = Modifier, onClick = { component.onRequestClose() }, ) } } @Composable fun RenderFileTypeAndSize( component: BaseAddSingleDownloadComponent, ) { val isLinkLoading by component.isLinkLoading.collectAsState() val fileInfo by component.linkResponseInfo.collectAsState() val fileIconProvider = component.iconProvider val iconModifier = Modifier.size(16.dp) Box(Modifier.padding(top = 16.dp)) { AnimatedContent( targetState = isLinkLoading, transitionSpec = { fadeIn() togetherWith fadeOut() } ) { loading -> if (loading) { LoadingIndicator(iconModifier) } else { // val extension = getExtension(fileInfo?.fileName ?: usersSetFileName) ?: "unknown" val downloadItem by component.downloadItem.collectAsState() val icon = fileIconProvider.rememberIcon(downloadItem.name) // val bitmap = FileIconProvider.getIconOfFileExtension(extension) AnimatedContent( fileInfo, ) { fileInfo -> Row( verticalAlignment = Alignment.CenterVertically, ) { WithContentAlpha(1f) { if (fileInfo != null) { if (fileInfo.requiresAuth) { MyIcon( MyIcons.lock, null, iconModifier, tint = myColors.error ) } MyIcon( icon, null, iconModifier ) val size = component.getLengthString() Spacer(Modifier.width(8.dp)) Text( size.rememberString(), fontSize = myTextSizes.sm, ) } else { MyIcon( icon = MyIcons.question, contentDescription = null, modifier = iconModifier, ) } } } } } } } } fun getExtension(s: String): String? { if (s.isBlank()) return null return s.substringAfterLast(".", "") .takeIf { it.isNotBlank() } } @Composable private fun UrlTextField( text: String, setText: (String) -> Unit, errorText: String? = null, modifier: Modifier = Modifier, ) { MyTextFieldWithIcons( text, setText, myStringResource(Res.string.download_link), modifier = modifier.fillMaxWidth(), end = { MyTextFieldIcon(MyIcons.paste) { setText( ClipboardUtil.read() .orEmpty() ) } }, errorText = errorText ) } @Composable private fun NameTextField( text: String, setText: (String) -> Unit, errorText: String? = null, ) { MyTextFieldWithIcons( text, setText, myStringResource(Res.string.name), modifier = Modifier.fillMaxWidth(), errorText = errorText, ) } ================================================ FILE: desktop/app/src/main/kotlin/com/abdownloadmanager/desktop/pages/addDownload/single/DesktopAddSingleDownloadComponent.kt ================================================ package com.abdownloadmanager.desktop.pages.addDownload.single import com.abdownloadmanager.shared.action.createNewQueueAction import com.abdownloadmanager.shared.downloaderinui.DownloaderInUi import com.abdownloadmanager.shared.pagemanager.CategoryDialogManager import com.abdownloadmanager.shared.pagemanager.QueuePageManager import com.abdownloadmanager.shared.pages.adddownload.AddDownloadCredentialsInUiProps import com.abdownloadmanager.shared.pages.adddownload.ImportOptions import com.abdownloadmanager.shared.pages.adddownload.single.BaseAddSingleDownloadComponent import com.abdownloadmanager.shared.pages.adddownload.single.OnRequestAddSingleItem import com.abdownloadmanager.shared.pages.adddownload.single.OnRequestDownloadSingleItem import com.abdownloadmanager.shared.pages.category.CategoryComponent import com.abdownloadmanager.shared.repository.BaseAppRepository import com.abdownloadmanager.shared.storage.BaseAppSettingsStorage import com.abdownloadmanager.shared.storage.ILastSavedLocationsStorage import com.abdownloadmanager.shared.util.DownloadItemOpener import com.abdownloadmanager.shared.util.DownloadSystem import com.abdownloadmanager.shared.util.FileIconProvider import com.abdownloadmanager.shared.util.category.CategoryManager import com.abdownloadmanager.shared.util.perhostsettings.PerHostSettingsManager import com.abdownloadmanager.shared.util.subscribeAsStateFlow import com.arkivanov.decompose.ComponentContext import com.arkivanov.decompose.router.slot.SlotNavigation import com.arkivanov.decompose.router.slot.activate import com.arkivanov.decompose.router.slot.childSlot import com.arkivanov.decompose.router.slot.dismiss import ir.amirab.downloader.downloaditem.DownloadJobExtraConfig import ir.amirab.downloader.downloaditem.IDownloadCredentials import ir.amirab.downloader.queue.QueueManager import kotlinx.coroutines.CoroutineScope class DesktopAddSingleDownloadComponent( ctx: ComponentContext, onRequestClose: () -> Unit, onRequestDownload: OnRequestDownloadSingleItem, onRequestAddToQueue: OnRequestAddSingleItem, openExistingDownload: (Long) -> Unit, updateExistingDownloadCredentials: (Long, IDownloadCredentials, DownloadJobExtraConfig?) -> Unit, downloadItemOpener: DownloadItemOpener, lastSavedLocationsStorage: ILastSavedLocationsStorage, queueManager: QueueManager, categoryManager: CategoryManager, downloadSystem: DownloadSystem, appSettings: BaseAppSettingsStorage, iconProvider: FileIconProvider, appScope: CoroutineScope, appRepository: BaseAppRepository, perHostSettingsManager: PerHostSettingsManager, importOptions: ImportOptions, id: String, downloaderInUi: DownloaderInUi, initialCredentials: AddDownloadCredentialsInUiProps, private val categoryDialogManager: CategoryDialogManager, ) : BaseAddSingleDownloadComponent( ctx = ctx, onRequestClose = onRequestClose, onRequestDownload = onRequestDownload, onRequestAddToQueue = onRequestAddToQueue, openExistingDownload = openExistingDownload, updateExistingDownloadCredentials = updateExistingDownloadCredentials, downloadItemOpener = downloadItemOpener, lastSavedLocationsStorage = lastSavedLocationsStorage, importOptions = importOptions, id = id, downloaderInUi = downloaderInUi, initialCredentials = initialCredentials, queueManager = queueManager, categoryManager = categoryManager, downloadSystem = downloadSystem, appSettings = appSettings, iconProvider = iconProvider, appScope = appScope, appRepository = appRepository, perHostSettingsManager = perHostSettingsManager, ) { override fun getCategoryPageManager(): CategoryDialogManager { return categoryDialogManager } } ================================================ FILE: desktop/app/src/main/kotlin/com/abdownloadmanager/desktop/pages/batchdownload/BatchDownloadWindow.kt ================================================ package com.abdownloadmanager.desktop.pages.batchdownload import androidx.compose.runtime.Composable import androidx.compose.ui.Alignment import androidx.compose.ui.unit.DpSize import androidx.compose.ui.unit.dp import androidx.compose.ui.window.WindowPosition import androidx.compose.ui.window.rememberWindowState import com.abdownloadmanager.desktop.window.custom.CustomWindow import com.abdownloadmanager.shared.pages.batchdownload.BaseBatchDownloadComponent import com.abdownloadmanager.shared.util.ui.theme.LocalUiScale import com.abdownloadmanager.shared.util.mvi.HandleEffects import ir.amirab.util.desktop.screen.applyUiScale @Composable fun BatchDownloadWindow(desktopBatchDownloadComponent: DesktopBatchDownloadComponent) { CustomWindow( state = rememberWindowState( size = DpSize(500.dp, 420.dp) .applyUiScale(LocalUiScale.current), position = WindowPosition(Alignment.Center) ), onCloseRequest = desktopBatchDownloadComponent.onClose ) { HandleEffects(desktopBatchDownloadComponent) { when (it) { DesktopBatchDownloadComponent.Effects.BringToFront -> window.toFront() is BaseBatchDownloadComponent.Effects.PlatformEffects -> { // } } } BatchDownload(desktopBatchDownloadComponent) } } ================================================ FILE: desktop/app/src/main/kotlin/com/abdownloadmanager/desktop/pages/batchdownload/BatchDownnload.kt ================================================ package com.abdownloadmanager.desktop.pages.batchdownload import androidx.compose.animation.AnimatedVisibility import androidx.compose.foundation.* import androidx.compose.foundation.layout.* import androidx.compose.foundation.text.KeyboardOptions import androidx.compose.runtime.* import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip import androidx.compose.ui.focus.FocusRequester import androidx.compose.ui.focus.focusRequester import androidx.compose.ui.unit.dp import com.abdownloadmanager.desktop.pages.batchdownload.WildcardSelect.* import com.abdownloadmanager.desktop.window.custom.WindowTitle import com.abdownloadmanager.shared.util.ui.icon.MyIcons import com.abdownloadmanager.shared.ui.widget.* import com.abdownloadmanager.shared.util.ui.myColors import com.abdownloadmanager.shared.util.ClipboardUtil import com.abdownloadmanager.resources.Res import com.abdownloadmanager.shared.pages.batchdownload.BatchDownloadValidationResult import com.abdownloadmanager.shared.pages.batchdownload.WildcardLength import com.abdownloadmanager.shared.util.ui.LocalContentColor import com.abdownloadmanager.shared.util.ui.MultiplatformVerticalScrollbar import com.abdownloadmanager.shared.util.ui.theme.myShapes import ir.amirab.util.compose.resources.myStringResource import ir.amirab.util.compose.StringSource import ir.amirab.util.compose.asStringSource @Composable fun BatchDownload( component: DesktopBatchDownloadComponent, ) { WindowTitle(myStringResource(Res.string.batch_download)) val link by component.link.collectAsState() val setLink = component::setLink val start by component.start.collectAsState() val setStart = component::setStart val end by component.end.collectAsState() val setEnd = component::setEnd val scrollState = rememberScrollState() val scrollAdapter = rememberScrollbarAdapter(scrollState) val validationResult by component.validationResult.collectAsState() val linkFocusRequester = remember { FocusRequester() } LaunchedEffect(Unit) { linkFocusRequester.requestFocus() } Column(Modifier.padding(16.dp)) { Row(Modifier.weight(1f)) { Column( modifier = Modifier .weight(1f) .verticalScroll(scrollState) ) { LabeledContent( label = { Text(myStringResource(Res.string.batch_download_link_help)) }, content = { MyTextFieldWithIcons( text = link, onTextChange = setLink, placeholder = "https://example.com/photo-*.png", modifier = Modifier .focusRequester(linkFocusRequester) .fillMaxWidth(), start = { MyTextFieldIcon(MyIcons.link) }, end = { MyTextFieldIcon(MyIcons.paste) { val v = ClipboardUtil.read() if (v != null) { setLink(v) } } }, errorText = when (val v = validationResult) { BatchDownloadValidationResult.URLInvalid -> { myStringResource(Res.string.invalid_url) } is BatchDownloadValidationResult.MaxRangeExceed -> myStringResource( Res.string.list_is_too_large_maximum_n_items_allowed, Res.string.list_is_too_large_maximum_n_items_allowed_createArgs( count = v.allowed.toString() ) ) BatchDownloadValidationResult.Others -> null BatchDownloadValidationResult.Ok -> null } ) } ) Spacer(Modifier.height(8.dp)) LabeledContent( label = { Text(myStringResource(Res.string.enter_range)) }, content = { Row( verticalAlignment = Alignment.CenterVertically ) { MyTextFieldWithIcons( text = start, onTextChange = setStart, placeholder = "", modifier = Modifier.width(90.dp), start = { Text( "${myStringResource(Res.string.range_from)}:", Modifier.padding(horizontal = 8.dp) ) } ) Spacer(Modifier.width(8.dp)) Text("...") Spacer(Modifier.width(8.dp)) MyTextFieldWithIcons( text = end, onTextChange = setEnd, placeholder = "", modifier = Modifier.width(90.dp), start = { Text( "${myStringResource(Res.string.range_to)}:", Modifier.padding(horizontal = 8.dp) ) } ) } } ) Spacer(Modifier.height(8.dp)) LabeledContent( label = { Text(myStringResource(Res.string.batch_download_wildcard_length)) }, content = { WildcardLengthUi( component.wildcardLength.collectAsState().value, component::setWildCardLength ) } ) Spacer(Modifier.height(8.dp)) Row( verticalAlignment = Alignment.CenterVertically ) { val lineModifier = Modifier .height(1.dp) .padding(horizontal = 5.dp) .background(LocalContentColor.current.copy(0.05f)) Spacer(Modifier.padding(vertical = 4.dp).fillMaxWidth().then(lineModifier)) } Spacer(Modifier.height(8.dp)) LabeledContent( label = { Text(myStringResource(Res.string.first_link)) }, content = { LinkPreview(component.startLinkResult.collectAsState().value) } ) Spacer(Modifier.height(8.dp)) LabeledContent( label = { Text(myStringResource(Res.string.last_link)) }, content = { LinkPreview(component.endLinkResult.collectAsState().value) } ) } MultiplatformVerticalScrollbar(scrollAdapter, Modifier.fillMaxHeight()) } Spacer(Modifier.height(8.dp)) Row( modifier = Modifier.fillMaxWidth().wrapContentWidth(Alignment.End) ) { ActionButton( text = myStringResource(Res.string.ok), enabled = component.canConfirm.collectAsState().value, onClick = component::confirm ) Spacer(Modifier.width(8.dp)) ActionButton(myStringResource(Res.string.close), onClick = component.onClose) } } } @Composable fun LinkPreview(link: String) { Text( link, Modifier .fillMaxWidth() .clip(myShapes.defaultRounded) .background(myColors.surface) .padding(vertical = 4.dp, horizontal = 6.dp) ) } enum class WildcardSelect( val text: StringSource, ) { Auto(Res.string.auto.asStringSource()), Unspecified(Res.string.unspecified.asStringSource()), Custom(Res.string.custom.asStringSource()); companion object { fun fromWildcardLength(wildcardLength: WildcardLength): WildcardSelect { return when (wildcardLength) { WildcardLength.Auto -> Auto is WildcardLength.Custom -> Custom WildcardLength.Unspecified -> Unspecified } } } } @Composable private fun WildcardLengthUi( wildcardLength: WildcardLength, onChangeWildcardLength: (WildcardLength) -> Unit, ) { var customLength by remember { mutableStateOf(2) } Row( verticalAlignment = Alignment.CenterVertically ) { Multiselect( selections = entries, selectedItem = WildcardSelect.fromWildcardLength(wildcardLength), onSelectionChange = { onChangeWildcardLength( when (it) { Auto -> WildcardLength.Auto Unspecified -> WildcardLength.Unspecified Custom -> WildcardLength.Custom(customLength) } ) }, render = { Text(it.text.rememberString()) } ) AnimatedVisibility(wildcardLength is WildcardLength.Custom) { Row { Spacer(Modifier.width(8.dp)) IntTextField( value = customLength, onValueChange = { customLength = it onChangeWildcardLength( WildcardLength.Custom(it) ) }, range = 1..10, keyboardOptions = KeyboardOptions.Default, modifier = Modifier.width(72.dp) ) } } } } @Composable private fun LabeledContent( label: @Composable () -> Unit, content: @Composable () -> Unit, ) { Column { label() Spacer(Modifier.height(8.dp)) content() } } ================================================ FILE: desktop/app/src/main/kotlin/com/abdownloadmanager/desktop/pages/batchdownload/DesktopBatchDownloadComponent.kt ================================================ package com.abdownloadmanager.desktop.pages.batchdownload import com.abdownloadmanager.shared.pages.batchdownload.BaseBatchDownloadComponent import com.arkivanov.decompose.ComponentContext class DesktopBatchDownloadComponent( ctx: ComponentContext, onClose: () -> Unit, importLinks: (List) -> Unit, ) : BaseBatchDownloadComponent( ctx = ctx, onClose = onClose, importLinks = importLinks ) { fun bringToFront() { sendEffect(Effects.BringToFront) } sealed interface Effects : BaseBatchDownloadComponent.Effects.PlatformEffects { data object BringToFront : Effects } } ================================================ FILE: desktop/app/src/main/kotlin/com/abdownloadmanager/desktop/pages/category/DesktopCategoryDialogManager.kt ================================================ package com.abdownloadmanager.desktop.pages.category import com.abdownloadmanager.shared.pagemanager.CategoryDialogManager import com.abdownloadmanager.shared.pages.category.CategoryComponent import kotlinx.coroutines.flow.StateFlow interface DesktopCategoryDialogManager : CategoryDialogManager { val openedCategoryDialogs: StateFlow> fun closeCategoryDialog(categoryId: Long) } ================================================ FILE: desktop/app/src/main/kotlin/com/abdownloadmanager/desktop/pages/category/NewCategoryPage.kt ================================================ package com.abdownloadmanager.desktop.pages.category import androidx.compose.foundation.* import androidx.compose.foundation.layout.* import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.runtime.* import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip import androidx.compose.ui.unit.dp import com.abdownloadmanager.shared.ui.widget.MyTextFieldIcon import com.abdownloadmanager.desktop.window.custom.WindowTitle import com.abdownloadmanager.shared.util.ui.icon.MyIcons import com.abdownloadmanager.shared.ui.widget.* import com.abdownloadmanager.shared.util.ui.myColors import ir.amirab.util.ifThen import com.abdownloadmanager.shared.util.div import com.abdownloadmanager.resources.Res import com.abdownloadmanager.shared.pages.category.CategoryComponent import com.abdownloadmanager.shared.util.ui.widget.MyIcon import ir.amirab.util.compose.IconSource import ir.amirab.util.compose.resources.myStringResource import com.abdownloadmanager.desktop.ui.util.rememberMyDirectoryPickerLauncher import java.io.File @Composable fun NewCategory( categoryComponent: CategoryComponent, ) { WindowTitle( myStringResource( if (categoryComponent.isEditMode) { Res.string.edit_category } else { Res.string.add_category } ) ) Column( modifier = Modifier .padding(horizontal = 32.dp) .padding(vertical = 16.dp) ) { Column( Modifier .weight(1f) .verticalScroll(rememberScrollState()) ) { Row { CategoryIcon( iconSource = categoryComponent.icon.collectAsState().value, onChange = categoryComponent::setIcon ) Spacer(Modifier.width(16.dp)) CategoryName( modifier = Modifier.weight(1f), name = categoryComponent.name.collectAsState().value, onNameChanged = categoryComponent::setName ) } Spacer(Modifier.height(12.dp)) CategoryAutoTypes( types = categoryComponent.types.collectAsState().value, onTypesChanged = categoryComponent::setTypes, enabled = categoryComponent.typesEnabled.collectAsState().value, setEnabled = categoryComponent::setTypesEnabled ) Spacer(Modifier.height(12.dp)) CategoryAutoUrls( urlPatterns = categoryComponent.urlPatterns.collectAsState().value, onUrlPatternChanged = categoryComponent::setUrlPatterns, enabled = categoryComponent.urlPatternsEnabled.collectAsState().value, setEnabled = categoryComponent::setUrlPatternsEnabled ) Spacer(Modifier.height(12.dp)) CategoryDefaultPath( path = categoryComponent.path.collectAsState().value, onPathChanged = categoryComponent::setPath, defaultDownloadLocation = categoryComponent.defaultDownloadLocation.collectAsState().value, checked = categoryComponent.usePath.collectAsState().value, setChecked = categoryComponent::setUsePath ) } Spacer(Modifier.height(12.dp)) Row(Modifier.fillMaxWidth().wrapContentWidth(Alignment.End)) { ActionButton( myStringResource( when (categoryComponent.isEditMode) { true -> Res.string.change false -> Res.string.add } ), enabled = categoryComponent.canSubmit.collectAsState().value, onClick = { categoryComponent.submit() } ) Spacer(Modifier.width(8.dp)) ActionButton( myStringResource(Res.string.cancel), onClick = { categoryComponent.close() } ) } } } @Composable fun CategoryDefaultPath( defaultDownloadLocation: String, path: String, onPathChanged: (String) -> Unit, checked: Boolean, setChecked: (Boolean) -> Unit, ) { val initialDirectory = remember(path, defaultDownloadLocation) { path .takeIf { it.isNotBlank() } ?.let { runCatching { File(path).canonicalPath }.getOrNull() } ?: defaultDownloadLocation } val downloadFolderPickerLauncher = rememberMyDirectoryPickerLauncher( title = myStringResource(Res.string.category_download_location), initialDirectory = initialDirectory, attachToWindow = true ) { directory -> directory?.let(onPathChanged) } OptionalWithLabel( label = myStringResource(Res.string.category_download_location), helpText = myStringResource(Res.string.category_download_location_description), enabled = checked, setEnabled = setChecked, ) { MyTextFieldWithIcons( text = path, onTextChange = onPathChanged, modifier = Modifier.fillMaxWidth(), enabled = checked, placeholder = "", errorText = null, end = { MyTextFieldIcon( MyIcons.folder, enabled = checked, ) { downloadFolderPickerLauncher.launch() } } ) } } @Composable fun CategoryAutoTypes( enabled: Boolean, setEnabled: (Boolean) -> Unit, types: String, onTypesChanged: (String) -> Unit, ) { OptionalWithLabel( label = myStringResource(Res.string.category_file_types), helpText = myStringResource(Res.string.category_file_types_description), enabled = enabled, setEnabled = setEnabled, ) { MyTextFieldWithIcons( text = types, onTextChange = onTypesChanged, modifier = Modifier.fillMaxWidth(), placeholder = "ext1 ext2 ext3", enabled = enabled, singleLine = false, ) } } @Composable fun CategoryAutoUrls( enabled: Boolean, setEnabled: (Boolean) -> Unit, urlPatterns: String, onUrlPatternChanged: (String) -> Unit, ) { OptionalWithLabel( label = myStringResource(Res.string.category_url_patterns), helpText = myStringResource(Res.string.category_url_patterns_description), enabled = enabled, setEnabled = setEnabled ) { MyTextFieldWithIcons( text = urlPatterns, onTextChange = onUrlPatternChanged, modifier = Modifier.fillMaxWidth(), placeholder = "dl.example.com/pics example.com/*/path", enabled = enabled, singleLine = false, ) } } @Composable fun CategoryName( name: String, onNameChanged: (String) -> Unit, modifier: Modifier = Modifier, ) { WithLabel( myStringResource(Res.string.category_name), modifier, ) { MyTextFieldWithIcons( text = name, onTextChange = onNameChanged, modifier = Modifier.fillMaxWidth(), placeholder = "Something...", ) } } @Composable private fun WithLabel( label: String, modifier: Modifier = Modifier, helpText: String? = null, content: @Composable () -> Unit, ) { Column(modifier) { Row(verticalAlignment = Alignment.CenterVertically) { Text(label) helpText?.let { Spacer(Modifier.width(8.dp)) Help(helpText) } } Spacer(Modifier.height(8.dp)) content() } } @Composable private fun OptionalWithLabel( label: String, modifier: Modifier = Modifier, enabled: Boolean, setEnabled: (Boolean) -> Unit, helpText: String? = null, content: @Composable () -> Unit, ) { Column(modifier) { Row(verticalAlignment = Alignment.CenterVertically) { Row( modifier = Modifier.onClick { setEnabled(!enabled) }, verticalAlignment = Alignment.CenterVertically, ) { CheckBox(enabled, setEnabled, size = 16.dp) Spacer(Modifier.width(8.dp)) Text(label) } helpText?.let { Spacer(Modifier.width(8.dp)) Help(helpText) } } Spacer(Modifier.height(8.dp)) content() } } @Composable private fun CategoryIcon( iconSource: IconSource?, onChange: (IconSource) -> Unit, ) { var showIconPicker by remember { mutableStateOf(false) } WithLabel( myStringResource(Res.string.icon) ) { RenderIcon( icon = iconSource, requiresAttention = iconSource == null, onClick = { showIconPicker = !showIconPicker } ) if (showIconPicker) { IconPick( selectedIcon = iconSource, icons = listOf( MyIcons.pictureFile, MyIcons.musicFile, MyIcons.zipFile, MyIcons.videoFile, MyIcons.applicationFile, MyIcons.documentFile, MyIcons.otherFile, MyIcons.file, MyIcons.folder, MyIcons.browserIntegration, MyIcons.appearance, MyIcons.settings, MyIcons.search, MyIcons.info, MyIcons.check, MyIcons.link, MyIcons.download, MyIcons.speaker, MyIcons.group, MyIcons.activeCount, MyIcons.speed, MyIcons.resume, MyIcons.pause, MyIcons.stop, MyIcons.queue, MyIcons.remove, MyIcons.clear, MyIcons.add, MyIcons.paste, MyIcons.copy, MyIcons.refresh, MyIcons.share, MyIcons.lock, MyIcons.question, MyIcons.verticalDirection, MyIcons.downloadEngine, MyIcons.network, MyIcons.externalLink, ), onSelected = { onChange(it) showIconPicker = false }, onCancel = { showIconPicker = false } ) } } } @Composable private fun RenderIcon( icon: IconSource?, indicateActive: Boolean = false, requiresAttention: Boolean = false, onClick: () -> Unit, ) { val shape = RoundedCornerShape(10.dp) Box( Modifier .border( 1.dp, myColors.onBackground / 10, shape ) .ifThen(indicateActive || requiresAttention) { border( 1.dp, myColors.primary / if (indicateActive) 1f else alphaFlicker(), shape ) } .clip(shape) .background(myColors.surface) .clickable { onClick() } .padding(6.dp) ) { val modifier = Modifier .size(20.dp) if (icon != null) { MyIcon( icon, null, modifier, ) } else { Spacer(modifier) } } } ================================================ FILE: desktop/app/src/main/kotlin/com/abdownloadmanager/desktop/pages/category/ShowCategoryDialogs.kt ================================================ package com.abdownloadmanager.desktop.pages.category import androidx.compose.runtime.Composable import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue import androidx.compose.ui.Alignment import androidx.compose.ui.unit.DpSize import androidx.compose.ui.unit.dp import androidx.compose.ui.window.WindowPosition import androidx.compose.ui.window.rememberWindowState import com.abdownloadmanager.desktop.window.custom.CustomWindow import com.abdownloadmanager.shared.pages.category.CategoryComponent import com.abdownloadmanager.shared.util.ui.theme.LocalUiScale import ir.amirab.util.desktop.screen.applyUiScale @Composable fun ShowCategoryDialogs(dialogManager: DesktopCategoryDialogManager) { val dialogs by dialogManager.openedCategoryDialogs.collectAsState() for (d in dialogs) { CategoryDialog(d) } } @Composable private fun CategoryDialog( component: CategoryComponent, ) { CustomWindow( onCloseRequest = { component.close() }, alwaysOnTop = true, state = rememberWindowState( size = DpSize(350.dp, 400.dp).applyUiScale(LocalUiScale.current), position = WindowPosition.Aligned(Alignment.Center), ) ) { NewCategory(component) } } ================================================ FILE: desktop/app/src/main/kotlin/com/abdownloadmanager/desktop/pages/checksum/DesktopFileChecksumComponent.kt ================================================ package com.abdownloadmanager.desktop.pages.checksum import com.abdownloadmanager.shared.pages.checksum.BaseFileChecksumComponent import com.abdownloadmanager.shared.util.DownloadSystem import com.arkivanov.decompose.ComponentContext import java.util.UUID class DesktopFileChecksumComponent( ctx: ComponentContext, id: String, itemIds: List, closeComponent: () -> Unit, downloadSystem: DownloadSystem ) : BaseFileChecksumComponent( ctx = ctx, id = id, itemIds = itemIds, closeComponent = closeComponent, downloadSystem = downloadSystem, ) { fun bringToFront() { sendEffect(Effects.BringToFront) } data class Config( val id: String = UUID.randomUUID().toString(), override val itemIds: List, ) : BaseFileChecksumComponent.Config sealed interface Effects : BaseFileChecksumComponent.Effects.Platform { data object BringToFront : Effects } } ================================================ FILE: desktop/app/src/main/kotlin/com/abdownloadmanager/desktop/pages/checksum/FileChecksumPage.kt ================================================ package com.abdownloadmanager.desktop.pages.checksum import androidx.compose.animation.core.* import androidx.compose.foundation.* import androidx.compose.foundation.interaction.MutableInteractionSource import androidx.compose.foundation.layout.* import androidx.compose.foundation.shape.CircleShape import androidx.compose.runtime.* import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip import androidx.compose.ui.draw.shadow import androidx.compose.ui.geometry.Offset import androidx.compose.ui.graphics.Brush import androidx.compose.ui.graphics.Color import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp import com.abdownloadmanager.shared.ui.configurable.item.FileChecksumConfigurable import com.abdownloadmanager.shared.ui.configurable.RenderSpinner import com.abdownloadmanager.shared.util.ClipboardUtil import com.abdownloadmanager.shared.ui.configurable.RenderConfigurable import com.abdownloadmanager.desktop.window.custom.WindowTitle import com.abdownloadmanager.resources.Res import com.abdownloadmanager.shared.pages.checksum.ChecksumStatus import com.abdownloadmanager.shared.pages.checksum.DownloadItemWithChecksum import com.abdownloadmanager.shared.ui.configurable.ConfigurableUiProps import com.abdownloadmanager.shared.ui.widget.ActionButton import com.abdownloadmanager.shared.ui.widget.Help import com.abdownloadmanager.shared.ui.widget.Text import com.abdownloadmanager.shared.ui.widget.table.customtable.styled.MyStyledTableHeader import com.abdownloadmanager.shared.ui.widget.menu.custom.WithContextMenu import com.abdownloadmanager.shared.ui.widget.menu.custom.MyDropDown import com.abdownloadmanager.shared.ui.widget.table.customtable.CellSize import com.abdownloadmanager.shared.ui.widget.table.customtable.Table import com.abdownloadmanager.shared.ui.widget.table.customtable.TableCell import com.abdownloadmanager.shared.ui.widget.table.customtable.TableState import com.abdownloadmanager.shared.util.FileChecksum import com.abdownloadmanager.shared.util.FileChecksumAlgorithm import com.abdownloadmanager.shared.util.div import com.abdownloadmanager.shared.util.rememberDotLoading import com.abdownloadmanager.shared.util.ui.WithContentColor import com.abdownloadmanager.shared.util.ui.icon.MyIcons import com.abdownloadmanager.shared.util.ui.myColors import com.abdownloadmanager.shared.util.ui.theme.myShapes import com.abdownloadmanager.shared.util.ui.theme.myTextSizes import com.abdownloadmanager.shared.util.ui.widget.MyIcon import ir.amirab.util.compose.IconSource import ir.amirab.util.compose.StringSource import ir.amirab.util.compose.action.MenuItem import ir.amirab.util.compose.action.buildMenu import ir.amirab.util.compose.asStringSource import ir.amirab.util.compose.resources.myStringResource import kotlinx.coroutines.flow.MutableStateFlow @Composable fun FileChecksumPage(component: DesktopFileChecksumComponent) { WindowTitle(myStringResource(Res.string.file_checksum_page)) val horizontalPadding = 16.dp Column { Table( modifier = Modifier.weight(1f).fillMaxWidth(), list = component.state.collectAsState().value.items, tableState = remember { TableState(FileChecksumTableCells.cells) }, wrapHeader = { MyStyledTableHeader( itemHorizontalPadding = horizontalPadding, content = it, ) }, wrapItem = { index, item, content -> Box(Modifier.padding(horizontal = horizontalPadding).let { val mutableInteractionSource = remember { MutableInteractionSource() } it.indication(mutableInteractionSource, LocalIndication.current) .hoverable(mutableInteractionSource) }) { content() } }, renderCell = { cell, item -> when (cell) { FileChecksumTableCells.Name -> { FileChecksumTableCellRenderers.RenderName(item) } FileChecksumTableCells.Status -> { FileChecksumTableCellRenderers.RenderStatus(item) } FileChecksumTableCells.Algorithm -> { FileChecksumTableCellRenderers.RenderAlgorithm(item) } FileChecksumTableCells.CalculatedChecksum -> { FileChecksumTableCellRenderers.RenderCalculatedChecksum(item) } FileChecksumTableCells.SavedChecksum -> { FileChecksumTableCellRenderers.RenderSavedChecksum( item = item, onRequestSaveNewChecksum = { component.updateChecksum(item.downloadItem.id, it) } ) } } }) Actions( Modifier, component, ) } } @Composable private fun Actions( modifier: Modifier, component: DesktopFileChecksumComponent, ) { val uiState by component.state.collectAsState() Column(modifier) { Spacer( Modifier.fillMaxWidth().height(1.dp).background(myColors.onBackground / 0.15f) ) Row( Modifier.fillMaxWidth().background(myColors.surface / 0.5f).padding(horizontal = 16.dp) .padding(vertical = 16.dp), verticalAlignment = Alignment.CenterVertically, ) { Row( verticalAlignment = Alignment.CenterVertically, ) { Row( verticalAlignment = Alignment.CenterVertically, ) { Text( text = myStringResource(Res.string.file_checksum_page_file_checksum_default_algorithm) ) Spacer(Modifier.width(8.dp)) Help( myStringResource(Res.string.file_checksum_page_file_checksum_default_algorithm_help) ) } Spacer(Modifier.size(8.dp)) RenderSpinner( modifier = Modifier, possibleValues = FileChecksumAlgorithm.all(), value = uiState.defaultAlgorithm, enabled = !uiState.isChecking, onSelect = { component.onAlgorithmChange(it) }, render = { Text(it.algorithm) }) } Spacer(Modifier.weight(1f)) Row { ActionButton( myStringResource(Res.string.start), onClick = component::onRequestStartCheck, enabled = !uiState.isChecking ) Spacer(Modifier.width(8.dp)) ActionButton( myStringResource(Res.string.close), onClick = component::onRequestClose, ) } } } } private data object FileChecksumTableCellRenderers { private val itemVerticalPadding = 8.dp @Composable private fun CellContent( content: @Composable () -> Unit ) { Box( modifier = Modifier.padding(vertical = itemVerticalPadding) ) { content() } } @Composable fun RenderName(item: DownloadItemWithChecksum) { CellContent { SimpleText(item.downloadItem.name) } } @Composable fun RenderStatus(item: DownloadItemWithChecksum) { CellContent { when (val status = item.checksumStatus) { is ChecksumStatus.Checking -> { RenderCheckingStatus(status.percent) } ChecksumStatus.Error.DownloadNotFinished -> { RenderErrorStatus(myStringResource(Res.string.download_not_finished)) } is ChecksumStatus.Error.Exception -> { RenderErrorStatus(status.t.localizedMessage ?: status.t::class.simpleName.orEmpty()) } ChecksumStatus.Error.FileNotFound -> { RenderErrorStatus(myStringResource(Res.string.file_not_found)) } is ChecksumStatus.Finished -> { RenderFinishedStatus( status = status, ) } ChecksumStatus.Waiting -> { RenderWaitingStatus() } } } } @Composable fun RenderAlgorithm(item: DownloadItemWithChecksum) { CellContent { SimpleText(item.algorithm) } } @Composable fun RenderCalculatedChecksum(item: DownloadItemWithChecksum) { val calculatedChecksum = item.calculatedChecksum WithContextMenu( menuProvider = { buildMenu { if (calculatedChecksum != null) { item( title = Res.string.copy.asStringSource(), icon = MyIcons.copy, onClick = { ClipboardUtil.copy(calculatedChecksum) } ) } } } ) { if (calculatedChecksum != null) { SimpleText(calculatedChecksum) } else if (item.isProcessing) { //shimmer ShimmerEffect( centerColor = myColors.onBackground / 0.4f, surroundingColor = myColors.onBackground / 0.1f, modifier = Modifier .fillMaxWidth() .clip(myShapes.defaultRounded) .height(myTextSizes.base.value.dp) ) } else if (item.isError) { SimpleText("!") } } } @Composable fun RenderSavedChecksum( item: DownloadItemWithChecksum, onRequestSaveNewChecksum: (FileChecksum?) -> Unit ) { fun createMenu( item: DownloadItemWithChecksum, onRequestEdit: (Boolean) -> Unit, ): List { val savedChecksum = item.savedChecksum return buildMenu { if (savedChecksum != null) { item( title = Res.string.copy.asStringSource(), icon = MyIcons.copy, onClick = { ClipboardUtil.copy(savedChecksum) }, ) } item( title = Res.string.edit.asStringSource(), icon = MyIcons.edit, onClick = { onRequestEdit(true) }, ) } } var edit by remember { mutableStateOf(false) } WithContextMenu( menuProvider = { createMenu( item = item, onRequestEdit = { edit = it } ) } ) { Column(Modifier.fillMaxSize()) { CellContent { SimpleText(item.savedChecksum.orEmpty()) } if (edit) { ChecksumEditDropDown( item = item, onRequestSaveNewChecksum = { onRequestSaveNewChecksum(it) edit = false }, onCloseRequest = { edit = false }, ) } } } } @Composable private fun ChecksumEditDropDown( item: DownloadItemWithChecksum, onCloseRequest: () -> Unit, onRequestSaveNewChecksum: (FileChecksum?) -> Unit, ) { val editChecksumFlow = remember { MutableStateFlow(FileChecksum(item.algorithm, item.savedChecksum.orEmpty())) } val fileChecksumConfigurable = remember { FileChecksumConfigurable( title = Res.string.download_item_settings_file_checksum.asStringSource(), description = Res.string.download_item_settings_file_checksum_description.asStringSource(), backedBy = editChecksumFlow, describe = { "".asStringSource() }, ) } MyDropDown( onDismissRequest = onCloseRequest, anchor = Alignment.BottomEnd, alignment = Alignment.BottomStart, ) { val shape = myShapes.defaultRounded Column( Modifier .shadow(24.dp) .clip(shape) .width(350.dp) .border(1.dp, myColors.surface, shape) .background(myColors.menuGradientBackground) .padding(horizontal = 16.dp, vertical = 12.dp) ) { RenderConfigurable(fileChecksumConfigurable, ConfigurableUiProps()) ActionButton( text = myStringResource(Res.string.update), modifier = Modifier .align(Alignment.End), onClick = { val newChecksum = editChecksumFlow.value onRequestSaveNewChecksum( newChecksum.takeIf { it?.value?.isNotEmpty() ?: false } ) } ) } } } @Composable private fun ShimmerEffect( modifier: Modifier = Modifier, centerColor: Color = Color.Gray, surroundingColor: Color = Color.Gray, ) { val transition = rememberInfiniteTransition() val translateAnim = transition.animateFloat( initialValue = 0f, targetValue = 1000f, animationSpec = infiniteRepeatable( animation = tween( durationMillis = 3000, easing = LinearEasing ) ) ) val brush = Brush.linearGradient( colors = listOf( surroundingColor, centerColor, surroundingColor, ), start = Offset(0f, 0f), end = Offset(translateAnim.value, 0f) ) Box( modifier = modifier .background(brush = brush) ) } @Composable private fun RenderErrorStatus(message: String) { IconWithText( icon = MyIcons.info, text = message, color = myColors.error, ) } @Composable private fun RenderFinishedStatus( status: ChecksumStatus.Finished, ) { val text: StringSource val color: Color val icon: IconSource when (status) { ChecksumStatus.Finished.Done -> { text = Res.string.done.asStringSource() icon = MyIcons.check color = myColors.info } ChecksumStatus.Finished.Matches -> { text = Res.string.matches.asStringSource() icon = MyIcons.check color = myColors.success } ChecksumStatus.Finished.NotMatches -> { text = Res.string.not_matches.asStringSource() icon = MyIcons.info color = myColors.warning } } IconWithText( icon = icon, text = text.rememberString(), color = color, ) } @Composable private fun IconWithText( icon: IconSource, text: String, color: Color, ) { WithContentColor(color) { Row( verticalAlignment = Alignment.CenterVertically, ) { MyIcon( icon, modifier = Modifier.size(16.dp), contentDescription = null, ) Spacer(Modifier.width(2.dp)) SimpleText(text) } } } @Composable private fun RenderCheckingStatus(percent: Int) { Column { ProgressStatus(percent, myColors.primaryGradient) } } @Composable private fun RenderWaitingStatus() { Row { SimpleText("${myStringResource(Res.string.waiting)} ${rememberDotLoading()}") } } @Composable private fun ProgressStatus( percent: Int?, background: Brush = myColors.primaryGradient, ) { Box( Modifier.fillMaxWidth().clip(CircleShape).background(myColors.surface) ) { if (percent != null) { val w = (percent / 100f).coerceIn(0f..1f) Spacer( Modifier.height(5.dp).fillMaxWidth( animateFloatAsState( w, tween(100) ).value ).background(background) ) } } } @Composable private fun SimpleText(string: String, modifier: Modifier = Modifier) { Text( string, modifier = modifier, maxLines = 1, overflow = TextOverflow.Ellipsis, ) } } private sealed class FileChecksumTableCells : TableCell { data object Name : FileChecksumTableCells() { override val id: String = "name" override val name: StringSource = Res.string.name.asStringSource() override val size: CellSize = CellSize.Resizeable(100.dp..1000.dp, 300.dp) } data object Status : FileChecksumTableCells() { override val id: String = "status" override val name: StringSource = Res.string.status.asStringSource() override val size: CellSize = CellSize.Resizeable(100.dp..1000.dp, 150.dp) } data object Algorithm : FileChecksumTableCells() { override val id: String = "algorithm" override val name: StringSource = Res.string.checksum_algorithm.asStringSource() override val size: CellSize = CellSize.Resizeable(60.dp..300.dp, 60.dp) } data object SavedChecksum : FileChecksumTableCells() { override val id: String = "saved_checksum" override val name: StringSource = Res.string.saved_checksum.asStringSource() override val size: CellSize = CellSize.Resizeable(100.dp..1000.dp, 150.dp) } data object CalculatedChecksum : FileChecksumTableCells() { override val id: String = "calculated_checksum" override val name: StringSource = Res.string.calculated_checksum.asStringSource() override val size: CellSize = CellSize.Resizeable(100.dp..1000.dp, 150.dp) } companion object { val cells = listOf( Name, Status, Algorithm, CalculatedChecksum, SavedChecksum, ) } } ================================================ FILE: desktop/app/src/main/kotlin/com/abdownloadmanager/desktop/pages/checksum/FileChecksumWindow.kt ================================================ package com.abdownloadmanager.desktop.pages.checksum import androidx.compose.runtime.Composable import androidx.compose.runtime.collectAsState import androidx.compose.ui.Alignment import androidx.compose.ui.unit.DpSize import androidx.compose.ui.unit.dp import androidx.compose.ui.window.WindowPosition import androidx.compose.ui.window.rememberWindowState import com.abdownloadmanager.desktop.AppComponent import com.abdownloadmanager.desktop.window.custom.CustomWindow import com.abdownloadmanager.shared.pages.checksum.BaseFileChecksumComponent import com.abdownloadmanager.shared.util.mvi.HandleEffects import com.abdownloadmanager.shared.util.ui.theme.LocalUiScale import ir.amirab.util.desktop.screen.applyUiScale @Composable fun FileChecksumWindow( component: AppComponent ) { component.openedFileChecksumDialog.collectAsState().value.child?.instance?.let { FileChecksumWindow(it) } } @Composable fun FileChecksumWindow( component: DesktopFileChecksumComponent ) { val uiScale = LocalUiScale.current val state = rememberWindowState( position = WindowPosition.Aligned(Alignment.Center), size = DpSize(900.dp, 400.dp).applyUiScale(uiScale) ) CustomWindow( state = state, onCloseRequest = component::onRequestClose ) { HandleEffects(component) { when (it) { is BaseFileChecksumComponent.Effects.Platform -> { when (it as DesktopFileChecksumComponent.Effects) { DesktopFileChecksumComponent.Effects.BringToFront -> { state.isMinimized = false window.toFront() } } } } } FileChecksumPage(component) } } ================================================ FILE: desktop/app/src/main/kotlin/com/abdownloadmanager/desktop/pages/confirmexit/ConfirmExit.kt ================================================ package com.abdownloadmanager.desktop.pages.confirmexit import androidx.compose.runtime.Composable import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue import com.abdownloadmanager.desktop.AppComponent import com.abdownloadmanager.desktop.ui.widget.ConfirmDialog import com.abdownloadmanager.desktop.ui.widget.ConfirmDialogType import com.abdownloadmanager.resources.Res import ir.amirab.util.compose.asStringSource @Composable fun ConfirmExit(appComponent: AppComponent) { val showExitDialog by appComponent.showConfirmExitDialog.collectAsState() if (showExitDialog) { ConfirmDialog( Res.string.confirm_exit.asStringSource(), Res.string.confirm_exit_description.asStringSource(), onCancel = appComponent::closeConfirmExit, onConfirm = appComponent::exitAppAsync, type = ConfirmDialogType.Warning, ) } } ================================================ FILE: desktop/app/src/main/kotlin/com/abdownloadmanager/desktop/pages/credits/translators/TranslatorsPage.kt ================================================ package com.abdownloadmanager.desktop.pages.credits.translators import androidx.compose.foundation.* import androidx.compose.foundation.interaction.MutableInteractionSource import androidx.compose.foundation.layout.* import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.runtime.Composable import androidx.compose.runtime.remember import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp import com.abdownloadmanager.desktop.di.Di import com.abdownloadmanager.shared.ui.widget.MaybeLinkText import com.abdownloadmanager.shared.util.ui.myColors import com.abdownloadmanager.shared.util.ui.theme.myTextSizes import com.abdownloadmanager.shared.ui.widget.Text import com.abdownloadmanager.shared.ui.widget.table.customtable.Table import com.abdownloadmanager.shared.ui.widget.table.customtable.TableState import com.abdownloadmanager.shared.ui.widget.table.customtable.styled.MyStyledTableHeader import com.abdownloadmanager.desktop.utils.AppInfo import com.abdownloadmanager.shared.util.div import com.abdownloadmanager.resources.Res import com.abdownloadmanager.shared.pages.credits.translators.LanguageTranslationInfo import com.abdownloadmanager.shared.pages.credits.translators.TranslatorData import com.abdownloadmanager.shared.ui.widget.PrimaryMainActionButton import com.abdownloadmanager.shared.util.ui.LocalContentColor import com.abdownloadmanager.shared.util.ui.WithContentAlpha import ir.amirab.util.URLOpener import ir.amirab.util.compose.localizationmanager.LanguageNameProvider import ir.amirab.util.compose.localizationmanager.MyLocale import ir.amirab.util.compose.resources.myStringResource import kotlinx.serialization.json.Json import okio.FileSystem import okio.Path.Companion.toPath import org.koin.core.component.get @Composable internal fun Translators(modifier: Modifier) { Column( modifier ) { TranslatorsTable( Modifier .fillMaxWidth() .padding(horizontal = 8.dp) .weight(1f) ) ContributionNotice( modifier = Modifier, onUserWantsToContribute = { URLOpener.openUrl(AppInfo.translationsUrl) } ) } } @Composable private fun ContributionNotice( modifier: Modifier, onUserWantsToContribute: () -> Unit, ) { Column(modifier) { Spacer( Modifier .fillMaxWidth() .height(1.dp) .background(myColors.onBackground / 0.15f) ) Column( Modifier .fillMaxWidth() .background(myColors.surface / 0.5f) .padding(horizontal = 32.dp) .padding(vertical = 16.dp), ) { Text( myStringResource(Res.string.translators_page_thanks), modifier = Modifier, fontSize = myTextSizes.lg, fontWeight = FontWeight.Bold, ) Spacer( Modifier .fillMaxWidth() .padding(vertical = 8.dp) .height(1.dp) .background(myColors.surface) ) Row( verticalAlignment = Alignment.CenterVertically, ) { Column( Modifier.weight(1f) ) { Text( myStringResource(Res.string.translators_contribute_title), fontSize = myTextSizes.lg, fontWeight = FontWeight.Bold, ) Spacer(Modifier.height(4.dp)) Text( myStringResource(Res.string.translators_contribute_description), fontSize = myTextSizes.base, color = LocalContentColor.current / 0.75f ) } Spacer(Modifier.width(32.dp)) PrimaryMainActionButton( text = myStringResource(Res.string.contribute), onClick = onUserWantsToContribute, modifier = Modifier, enabled = true, ) } } } } @Composable private fun TranslatorsTable( modifier: Modifier, ) { val tableState = remember { TableState( cells = TranslatorsCells.all() ) } val itemHorizontalPadding = 16.dp Table( modifier = modifier, list = rememberLanguageTranslationInfo(), listState = rememberLazyListState(), tableState = tableState, wrapHeader = { MyStyledTableHeader( itemHorizontalPadding = itemHorizontalPadding, content = it, ) }, wrapItem = { index, _, rowContent -> val interactionSource = remember { MutableInteractionSource() } Box( Modifier .widthIn(getTableSize().visibleWidth) .hoverable(interactionSource) .indication( interactionSource, LocalIndication.current, ) .background( if (index % 2 == 0) Color.Transparent else myColors.surface / 0.35f ) .padding(vertical = 12.dp, horizontal = itemHorizontalPadding) ) { rowContent() } }, renderCell = { libraryCell, translationInfo -> when (libraryCell) { TranslatorsCells.LanguageName -> { Column { WithContentAlpha(1f) { Text( translationInfo.nativeName, fontSize = myTextSizes.base, overflow = TextOverflow.Ellipsis, maxLines = 1 ) } Spacer(Modifier.height(4.dp)) WithContentAlpha(0.75f) { Row( verticalAlignment = Alignment.CenterVertically, ) { Text( translationInfo.englishName, maxLines = 1, overflow = TextOverflow.Ellipsis, fontSize = myTextSizes.base, ) Spacer(Modifier.width(4.dp)) Text( translationInfo.locale, maxLines = 1, overflow = TextOverflow.Ellipsis, fontSize = myTextSizes.base, color = myColors.primary, modifier = Modifier .background(myColors.primary / 10) .padding(vertical = 0.dp, horizontal = 4.dp) ) } } } } TranslatorsCells.Translators -> { Column( verticalArrangement = Arrangement.spacedBy(8.dp) ) { translationInfo.translators.forEach { MaybeLinkText( it.name, it.link, ) } } } } }, ) } private fun convertLanguageToMyLocale(language: String): MyLocale { return language.split("-").run { MyLocale( languageCode = get(0), countryCode = getOrNull(1) ) } } @Composable private fun rememberLanguageTranslationInfo(): List { return remember { val json = Di.get() val translatorData = FileSystem.RESOURCES.read( "/com/abdownloadmanager/resources/credits/translators.json".toPath(), { readUtf8() } ).let { json.decodeFromString(it) } translatorData.map { val name = LanguageNameProvider.getName(convertLanguageToMyLocale(it.key)) LanguageTranslationInfo( locale = it.key, englishName = name.englishName, nativeName = name.nativeName, translators = it.value, ) } } } ================================================ FILE: desktop/app/src/main/kotlin/com/abdownloadmanager/desktop/pages/credits/translators/TranslatorsTable.kt ================================================ package com.abdownloadmanager.desktop.pages.credits.translators import com.abdownloadmanager.shared.ui.widget.table.customtable.CellSize import com.abdownloadmanager.shared.ui.widget.table.customtable.SortableCell import com.abdownloadmanager.shared.ui.widget.table.customtable.TableCell import androidx.compose.ui.unit.dp import com.abdownloadmanager.resources.Res import com.abdownloadmanager.shared.pages.credits.translators.LanguageTranslationInfo import ir.amirab.util.compose.StringSource import ir.amirab.util.compose.asStringSource sealed interface TranslatorsCells : TableCell { data object LanguageName : TranslatorsCells, SortableCell { override fun comparator(): Comparator = compareBy { it.locale } override val id: String = "language" override val name: StringSource = Res.string.language.asStringSource() override val size: CellSize = CellSize.Resizeable(100.dp..1000.dp, 200.dp) } data object Translators : TranslatorsCells { override val id: String = "translators" override val name: StringSource = Res.string.translators.asStringSource() override val size: CellSize = CellSize.Resizeable(100.dp..1000.dp, 350.dp) } companion object { fun all() = listOf( LanguageName, Translators, ) } } ================================================ FILE: desktop/app/src/main/kotlin/com/abdownloadmanager/desktop/pages/credits/translators/TranslatorsWindow.kt ================================================ package com.abdownloadmanager.desktop.pages.credits.translators import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.runtime.Composable import androidx.compose.runtime.collectAsState import androidx.compose.ui.Modifier import androidx.compose.ui.unit.DpSize import androidx.compose.ui.unit.dp import androidx.compose.ui.window.rememberWindowState import com.abdownloadmanager.desktop.AppComponent import com.abdownloadmanager.desktop.window.custom.CustomWindow import com.abdownloadmanager.desktop.window.custom.WindowTitle import com.abdownloadmanager.resources.Res import ir.amirab.util.compose.resources.myStringResource @Composable fun ShowTranslators( appComponent: AppComponent, ) { TranslatorsWindow( isVisible = appComponent.showTranslators.collectAsState().value, onRequestClose = { appComponent.closeTranslatorsPage() } ) } @Composable private fun TranslatorsWindow( isVisible: Boolean, onRequestClose: () -> Unit, ) { if (!isVisible) return CustomWindow( onCloseRequest = onRequestClose, state = rememberWindowState( size = DpSize(650.dp, 500.dp) ) ) { WindowTitle(myStringResource(Res.string.meet_the_translators)) Translators( modifier = Modifier.fillMaxSize(), ) } } ================================================ FILE: desktop/app/src/main/kotlin/com/abdownloadmanager/desktop/pages/editdownload/DesktopEditDownloadComponent.kt ================================================ package com.abdownloadmanager.desktop.pages.editdownload import com.abdownloadmanager.shared.downloaderinui.DownloaderInUiRegistry import com.abdownloadmanager.shared.pages.editdownload.BaseEditDownloadComponent import com.abdownloadmanager.shared.util.mvi.ContainsEffects import com.abdownloadmanager.shared.util.mvi.supportEffects import com.abdownloadmanager.shared.util.DownloadSystem import com.abdownloadmanager.shared.util.FileIconProvider import com.arkivanov.decompose.ComponentContext import ir.amirab.downloader.downloaditem.DownloadJobExtraConfig import ir.amirab.downloader.downloaditem.IDownloadItem import kotlinx.coroutines.flow.* import org.koin.core.component.KoinComponent sealed interface EditDownloadPageEffects { data object BringToFront : EditDownloadPageEffects } class DesktopEditDownloadComponent( ctx: ComponentContext, onRequestClose: () -> Unit, downloadId: Long, acceptEdit: StateFlow, onEdited: ((IDownloadItem) -> Unit, DownloadJobExtraConfig?) -> Unit, downloadSystem: DownloadSystem, downloaderInUiRegistry: DownloaderInUiRegistry, iconProvider: FileIconProvider, ) : BaseEditDownloadComponent( ctx = ctx, downloadSystem = downloadSystem, downloaderInUiRegistry = downloaderInUiRegistry, iconProvider = iconProvider, onEdited = onEdited, onRequestClose = onRequestClose, downloadId = downloadId, acceptEdit = acceptEdit, ), ContainsEffects by supportEffects(), KoinComponent { fun bringToFront() { sendEffect(EditDownloadPageEffects.BringToFront) } } ================================================ FILE: desktop/app/src/main/kotlin/com/abdownloadmanager/desktop/pages/editdownload/EditDownload.kt ================================================ package com.abdownloadmanager.desktop.pages.editdownload import androidx.compose.runtime.Composable import com.abdownloadmanager.shared.util.ui.WithContentAlpha import ir.amirab.util.compose.IconSource import com.abdownloadmanager.shared.util.ui.widget.MyIcon import com.abdownloadmanager.shared.util.ui.icon.MyIcons import com.abdownloadmanager.shared.util.ui.myColors import com.abdownloadmanager.shared.util.ui.theme.myTextSizes import androidx.compose.animation.* import androidx.compose.animation.core.animateDpAsState import androidx.compose.foundation.* import androidx.compose.foundation.layout.* import androidx.compose.runtime.* import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip import androidx.compose.ui.draw.shadow import androidx.compose.ui.focus.FocusRequester import androidx.compose.ui.focus.focusRequester import androidx.compose.ui.graphics.* import androidx.compose.ui.input.pointer.PointerIcon import androidx.compose.ui.input.pointer.pointerHoverIcon import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.* import androidx.compose.ui.window.* import com.abdownloadmanager.shared.ui.widget.* import com.abdownloadmanager.desktop.pages.addDownload.shared.ExtraConfig import com.abdownloadmanager.desktop.window.custom.CustomWindow import com.abdownloadmanager.desktop.window.custom.WindowTitle import com.abdownloadmanager.shared.util.ui.theme.LocalUiScale import ir.amirab.util.ifThen import com.abdownloadmanager.shared.util.mvi.HandleEffects import com.abdownloadmanager.resources.Res import com.abdownloadmanager.shared.downloaderinui.edit.CanEditDownloadResult import com.abdownloadmanager.shared.downloaderinui.edit.CanEditWarnings import com.abdownloadmanager.shared.downloaderinui.edit.EditDownloadInputs import com.abdownloadmanager.shared.downloaderinui.edit.TAEditDownloadInputs import com.abdownloadmanager.shared.util.ClipboardUtil import com.abdownloadmanager.shared.util.FileIconProvider import com.abdownloadmanager.shared.util.ui.WithContentColor import com.abdownloadmanager.shared.util.div import com.abdownloadmanager.shared.util.ui.theme.myShapes import ir.amirab.util.URLOpener import ir.amirab.util.compose.resources.myStringResource import ir.amirab.util.compose.asStringSource import ir.amirab.util.desktop.screen.applyUiScale @Composable fun EditDownloadWindow( component: DesktopEditDownloadComponent, ) { CustomWindow( state = rememberWindowState( size = DpSize(450.dp, 230.dp) .applyUiScale(LocalUiScale.current), position = WindowPosition.Aligned(Alignment.Center) ), alwaysOnTop = true, onCloseRequest = { component.onRequestClose() }, ) { HandleEffects(component) { when (it) { EditDownloadPageEffects.BringToFront -> { window.toFront() } } } EditDownloadPage(component) } } @Composable fun EditDownloadPage( component: DesktopEditDownloadComponent, ) { WindowTitle(myStringResource(Res.string.edit_download_title)) component.editDownloadUiChecker.collectAsState().value?.let { editDownloadUiChecker -> Column( Modifier .padding(horizontal = 32.dp) .padding(top = 8.dp, bottom = 16.dp) ) { val canAddResult by editDownloadUiChecker.canEditDownloadResult.collectAsState() val link by editDownloadUiChecker.link.collectAsState() fun setLink(link: String) { editDownloadUiChecker.setLink(link) } val linkFocus = remember { FocusRequester() } LaunchedEffect(Unit) { linkFocus.requestFocus() } UrlTextField( text = link, setText = { setLink(it) }, modifier = Modifier.focusRequester(linkFocus), errorText = when (canAddResult) { CanEditDownloadResult.InvalidURL -> Res.string.invalid_url else -> null }?.takeIf { link.isNotEmpty() }?.asStringSource()?.rememberString() // ATTENTION DO NOT use composable functions in when branches // it seems buggy (compose won't render ui properly) // stranger part is that in this case if we use ? before takeIf then it will work! (`}.takeIf {` is buggy but `}?.takeIf {` works!) // maybe there is a bug in compose compiler, or maybe I'm missed something. if you read this ,and you know why! please let me know! ) Row { Column(Modifier.weight(1f)) { val name by editDownloadUiChecker.name.collectAsState() Spacer(Modifier.size(8.dp)) NameTextField( text = name, setText = { editDownloadUiChecker.setName(it) }, errorText = when (canAddResult) { CanEditDownloadResult.FileNameAlreadyExists -> Res.string.file_name_already_exists CanEditDownloadResult.InvalidFileName -> Res.string.invalid_file_name else -> null }?.takeIf { name.isNotEmpty() }?.asStringSource()?.rememberString() ) Spacer(Modifier.size(8.dp)) BrowserImportButton(component, editDownloadUiChecker) } Spacer(Modifier.size(24.dp)) Column( horizontalAlignment = Alignment.CenterHorizontally, modifier = Modifier .align(Alignment.Top) .width(IntrinsicSize.Max) ) { RenderFileTypeAndSize(component.iconProvider, editDownloadUiChecker) RenderResumeSupport(editDownloadUiChecker) ConfigActionsButtons(editDownloadUiChecker) } } Spacer(Modifier.weight(1f)) MainActionButtons(component, editDownloadUiChecker) if (editDownloadUiChecker.showMoreSettings.collectAsState().value) { ExtraConfig( onDismiss = { editDownloadUiChecker.setShowMoreSettings(false) }, configurables = editDownloadUiChecker.configurableList, ) } } } } @Composable fun BrowserImportButton( component: DesktopEditDownloadComponent, downloadUiState: EditDownloadInputs<*, *, *, *, *, *>, ) { val credentialsImportedFromExternal by component.credentialsImportedFromExternal.collectAsState() val downloadPage = downloadUiState.currentDownloadItem.collectAsState().value.downloadPage Column { Row( verticalAlignment = Alignment.CenterVertically, ) { ActionButton( myStringResource(Res.string.edit_download_update_from_download_page), enabled = downloadPage != null, onClick = { downloadPage?.let { URLOpener.openUrl(it) } }, // borderColor = when (credentialsImportedFromExternal) { // true -> SolidColor(myColors.success) // false -> SolidColor(myColors.onBackground / 10) // }, contentPadding = PaddingValues( vertical = 6.dp, horizontal = animateDpAsState( if (credentialsImportedFromExternal) 8.dp else 16.dp ).value, ), end = { AnimatedVisibility(credentialsImportedFromExternal) { Row { Spacer(Modifier.width(8.dp)) MyIcon( MyIcons.check, null, Modifier.size(16.dp), tint = myColors.success, ) } } } ) Spacer(Modifier.width(8.dp)) Help(myStringResource(Res.string.edit_download_update_from_download_page_description)) } } } @Composable private fun RenderResumeSupport( editDownloadUiChecker: TAEditDownloadInputs, ) { val fileInfo by editDownloadUiChecker.responseInfo.collectAsState() Row( verticalAlignment = Alignment.CenterVertically, modifier = Modifier.height(16.dp) ) { val lineModifier = Modifier.weight(1f) .height(1.dp) .background(myColors.onBackground / 10) Box(lineModifier) val canEditDownload by editDownloadUiChecker.canEdit.collectAsState() AnimatedVisibility( visible = canEditDownload && fileInfo != null, ) { fileInfo?.let { fileInfo -> if (fileInfo.resumeSupport) { val iconModifier = Modifier .padding(horizontal = 2.dp) .size(10.dp) if (fileInfo.resumeSupport) { MyIcon( icon = MyIcons.check, contentDescription = null, modifier = iconModifier, tint = myColors.success ) } else { MyIcon( icon = MyIcons.clear, contentDescription = null, modifier = iconModifier, tint = myColors.error, ) } } } } Box(lineModifier) } } @Composable private fun MainConfigActionButton( text: String, modifier: Modifier, enabled: Boolean = true, onClick: () -> Unit, ) { ActionButton(text, modifier, enabled, onClick) } @Composable fun ConfigActionsButtons( editDownloadUiChecker: TAEditDownloadInputs, ) { val showMoreSettings by editDownloadUiChecker.showMoreSettings.collectAsState() val requiresAuth = editDownloadUiChecker.responseInfo.collectAsState().value?.requireBasicAuth ?: false Row { IconActionButton(MyIcons.refresh, Res.string.refresh.asStringSource()) { editDownloadUiChecker.refresh() } Spacer(Modifier.width(6.dp)) IconActionButton( MyIcons.settings, Res.string.settings.asStringSource(), indicateActive = showMoreSettings, requiresAttention = requiresAuth ) { editDownloadUiChecker.setShowMoreSettings(true) } } } @Composable private fun MainActionButtons( component: DesktopEditDownloadComponent, editDownloadUiChecker: TAEditDownloadInputs, ) { Row { val canEditResult by editDownloadUiChecker.canEditDownloadResult.collectAsState() val canEdit = run { val canBeEdited = editDownloadUiChecker.canEdit.collectAsState().value val componentAllowsEdit = component.acceptEdit.collectAsState().value canBeEdited && componentAllowsEdit } val warnings = (canEditResult as? CanEditDownloadResult.CanEdit)?.warnings.orEmpty() Spacer(Modifier.width(8.dp)) var showWarningPrompt by remember { mutableStateOf(false) } Box { if (showWarningPrompt) { WarningPrompt( warnings = warnings, onClose = { showWarningPrompt = false }, onConfirm = { if (canEdit) { component.onRequestEdit() } } ) } PrimaryMainActionButton( text = myStringResource(Res.string.change), modifier = Modifier, enabled = canEdit, onClick = { if (warnings.isNotEmpty()) { showWarningPrompt = true } else { component.onRequestEdit() } }, ) } // Spacer(Modifier.weight(1f)) Spacer(Modifier.weight(1f)) MainConfigActionButton( text = myStringResource(Res.string.cancel), modifier = Modifier, onClick = { component.onRequestClose() }, ) } } @Composable fun WarningPrompt( warnings: List, onClose: () -> Unit, onConfirm: () -> Unit, ) { Popup( popupPositionProvider = rememberMyComponentRectPositionProvider( anchor = Alignment.TopStart, alignment = Alignment.TopEnd, ), onDismissRequest = onClose ) { val shape = myShapes.defaultRounded Box( Modifier .padding(vertical = 4.dp) .widthIn(max = 240.dp) .shadow(24.dp) .clip(shape) .border(1.dp, myColors.surface, shape) .background(myColors.menuGradientBackground) .padding(8.dp) ) { WithContentColor(myColors.onSurface) { Column { Text( myStringResource(Res.string.warning), fontWeight = FontWeight.Bold, color = myColors.warning ) Spacer(Modifier.height(4.dp)) warnings.forEach { Text( it.asStringSource().rememberString(), fontSize = myTextSizes.base, ) } Text(myStringResource(Res.string.warning_you_may_have_to_restart_the_download_later)) Spacer(Modifier.height(8.dp)) ActionButton( modifier = Modifier.align(Alignment.CenterHorizontally), text = myStringResource(Res.string.change_anyway), onClick = onConfirm, borderColor = SolidColor(myColors.error), contentColor = myColors.error, ) } } } } } @Composable private fun RenderFileTypeAndSize( iconProvider: FileIconProvider, editDownloadUiChecker: TAEditDownloadInputs, ) { val isLinkLoading by editDownloadUiChecker.isLinkLoading.collectAsState() val fileInfo by editDownloadUiChecker.responseInfo.collectAsState() val iconModifier = Modifier.size(16.dp) Box(Modifier.padding(top = 16.dp)) { AnimatedContent( targetState = isLinkLoading, transitionSpec = { fadeIn() togetherWith fadeOut() } ) { loading -> if (loading) { LoadingIndicator(iconModifier) } else { val icon = iconProvider.rememberIcon(editDownloadUiChecker.name.collectAsState().value) AnimatedContent( fileInfo, ) { fileInfo -> Row( verticalAlignment = Alignment.CenterVertically, ) { WithContentAlpha(1f) { if (fileInfo != null) { if (fileInfo.requiresAuth) { MyIcon( MyIcons.lock, null, iconModifier, tint = myColors.error ) } MyIcon( icon, null, iconModifier ) val size by editDownloadUiChecker.lengthStringFlow.collectAsState() Spacer(Modifier.width(8.dp)) Text( size.rememberString(), fontSize = myTextSizes.sm, ) } else { MyIcon( icon = MyIcons.question, contentDescription = null, modifier = iconModifier, ) } } } } } } } } @Composable private fun MyTextFieldIcon( icon: IconSource, onClick: (() -> Unit)? = null, ) { MyIcon(icon, null, Modifier .fillMaxHeight() .ifThen(onClick != null) { pointerHoverIcon(PointerIcon.Default) .clickable { onClick?.invoke() } } .wrapContentHeight() .padding(horizontal = 8.dp) .size(16.dp)) } @Composable private fun UrlTextField( text: String, setText: (String) -> Unit, errorText: String? = null, modifier: Modifier = Modifier, ) { MyTextFieldWithIcons( text, setText, myStringResource(Res.string.download_link), modifier = modifier.fillMaxWidth(), start = { MyTextFieldIcon(MyIcons.link) }, end = { MyTextFieldIcon(MyIcons.paste) { setText( ClipboardUtil.read() .orEmpty() ) } }, errorText = errorText ) } @Composable private fun NameTextField( text: String, setText: (String) -> Unit, errorText: String? = null, ) { MyTextFieldWithIcons( text, setText, myStringResource(Res.string.name), modifier = Modifier.fillMaxWidth(), errorText = errorText, ) } ================================================ FILE: desktop/app/src/main/kotlin/com/abdownloadmanager/desktop/pages/enterurl/DesktopEnterNewURLComponent.kt ================================================ package com.abdownloadmanager.desktop.pages.enterurl import com.abdownloadmanager.shared.downloaderinui.DownloaderInUiRegistry import com.abdownloadmanager.shared.pages.enterurl.BaseEnterNewURLComponent import com.arkivanov.decompose.ComponentContext import ir.amirab.downloader.downloaditem.IDownloadCredentials class DesktopEnterNewURLComponent( ctx: ComponentContext, config: Config, downloaderInUiRegistry: DownloaderInUiRegistry, onCloseRequest: () -> Unit, onRequestFinished: (IDownloadCredentials) -> Unit, ) : BaseEnterNewURLComponent( ctx = ctx, config = config, downloaderInUiRegistry = downloaderInUiRegistry, onCloseRequest = onCloseRequest, onRequestFinished = onRequestFinished, ) { sealed interface Effects : BaseEnterNewURLComponent.Effects.PlatformEffects { data object BringToFront : Effects } data object Config : BaseEnterNewURLComponent.Config fun bringToFront() { sendEffect(Effects.BringToFront) } } ================================================ FILE: desktop/app/src/main/kotlin/com/abdownloadmanager/desktop/pages/enterurl/EnterNewDownloadWindow.kt ================================================ package com.abdownloadmanager.desktop.pages.enterurl import androidx.compose.runtime.Composable import androidx.compose.ui.Alignment import androidx.compose.ui.unit.DpSize import androidx.compose.ui.unit.dp import androidx.compose.ui.window.WindowPosition import androidx.compose.ui.window.rememberWindowState import com.abdownloadmanager.desktop.AppComponent import com.abdownloadmanager.desktop.window.custom.CustomWindow import com.abdownloadmanager.desktop.window.custom.WindowTitle import com.abdownloadmanager.resources.Res import com.abdownloadmanager.shared.util.mvi.HandleEffects import com.abdownloadmanager.shared.util.rememberChild import com.abdownloadmanager.shared.util.ui.theme.LocalUiScale import ir.amirab.util.compose.resources.myStringResource import ir.amirab.util.desktop.screen.applyUiScale @Composable fun EnterNewDownloadWindow( appComponent: AppComponent ) { val child = appComponent.enterNewURLWindowSlot.rememberChild() child?.let { EnterNewDownloadWindow(child) } } @Composable private fun EnterNewDownloadWindow( component: DesktopEnterNewURLComponent, ) { val windowState = rememberWindowState( size = DpSize(400.dp, 150.dp) .applyUiScale(LocalUiScale.current), position = WindowPosition.Aligned(Alignment.Center) ) CustomWindow( state = windowState, onCloseRequest = component::close ) { WindowTitle( myStringResource(Res.string.new_download) ) HandleEffects(component) { when (it) { DesktopEnterNewURLComponent.Effects.BringToFront -> { windowState.isMinimized = false window.toFront() } else -> {} } } EnterNewURLPage( component, ) } } ================================================ FILE: desktop/app/src/main/kotlin/com/abdownloadmanager/desktop/pages/enterurl/EnterNewURLPage.kt ================================================ package com.abdownloadmanager.desktop.pages.enterurl import androidx.compose.foundation.background import androidx.compose.foundation.border import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.IntrinsicSize import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.width import androidx.compose.foundation.layout.widthIn import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip import androidx.compose.ui.focus.FocusRequester import androidx.compose.ui.focus.focusRequester import androidx.compose.ui.graphics.Brush import androidx.compose.ui.unit.DpOffset import androidx.compose.ui.unit.DpSize import androidx.compose.ui.unit.dp import androidx.compose.ui.window.rememberDialogState import com.abdownloadmanager.shared.ui.widget.MyTextFieldWithIcons import com.abdownloadmanager.shared.ui.widget.MyTextFieldIcon import com.abdownloadmanager.shared.util.ClipboardUtil import com.abdownloadmanager.desktop.window.custom.BaseOptionDialog import com.abdownloadmanager.desktop.window.moveSafe import com.abdownloadmanager.resources.Res import com.abdownloadmanager.shared.downloaderinui.TADownloaderInUI import com.abdownloadmanager.shared.pages.enterurl.DownloaderSelection import com.abdownloadmanager.shared.ui.widget.ActionButton import com.abdownloadmanager.shared.ui.widget.Text import com.abdownloadmanager.shared.util.div import com.abdownloadmanager.shared.util.ui.WithContentColor import com.abdownloadmanager.shared.util.ui.icon.MyIcons import com.abdownloadmanager.shared.util.ui.myColors import com.abdownloadmanager.shared.util.ui.theme.myShapes import com.abdownloadmanager.shared.util.ui.widget.MyIcon import ir.amirab.util.compose.resources.myStringResource import java.awt.MouseInfo @Composable fun EnterNewURLPage(component: DesktopEnterNewURLComponent) { val linkFocus = remember { FocusRequester() } LaunchedEffect(Unit) { linkFocus.requestFocus() component.onPageOpen() } val text by component.url.collectAsState() Column { Column( Modifier .padding(top = 8.dp) .padding(horizontal = 16.dp) ) { UrlTextField( text = text, setText = component::setURL, modifier = Modifier .focusRequester(linkFocus) .fillMaxWidth() ) } Spacer(Modifier.weight(1f)) Column { Spacer( Modifier .fillMaxWidth() .height(1.dp) .background(myColors.onBackground / 0.15f) ) Row( Modifier .fillMaxWidth() .background(myColors.surface / 0.5f) .padding(horizontal = 16.dp) .padding(vertical = 8.dp), verticalAlignment = Alignment.CenterVertically, ) { DownloaderSelectionSection(component) Spacer(Modifier.weight(1f)) Actions(component) } } } } @Composable private fun DownloaderSelectionSection( component: DesktopEnterNewURLComponent, ) { val downloaderSelection = component.downloaderSelection.collectAsState().value val bestDownloader = component.bestDownloader.collectAsState().value var isSelecting by remember { mutableStateOf(false) } val selectedName = rememberDownloaderSelectionItemString( downloaderSelection, bestDownloader ) ActionButton( text = selectedName, end = { Row( Modifier.align(Alignment.CenterVertically) ) { Spacer(Modifier.width(4.dp)) MyIcon(MyIcons.down, null, Modifier.size(12.dp)) } }, onClick = { isSelecting = !isSelecting } ) if (isSelecting) { val state = rememberDialogState( size = DpSize.Unspecified, ) BaseOptionDialog( onCloseRequest = { isSelecting = false }, state = state, resizeable = false, ) { LaunchedEffect(window) { window.moveSafe( MouseInfo.getPointerInfo().location.run { DpOffset( x = x.dp, y = y.dp ) } ) } val shape = myShapes.defaultRounded Column( Modifier .clip(shape) .border(2.dp, myColors.onBackground / 10, shape) .background( Brush.linearGradient( listOf( myColors.surface, myColors.background, ) ) ) ) { WithContentColor(myColors.onBackground) { Column( Modifier .widthIn(min = 100.dp, max = 300.dp) .width(IntrinsicSize.Max) ) { component.possibleValues.onEach { val text = rememberDownloaderSelectionItemString( it, bestDownloader, ) Text( text, Modifier .fillMaxWidth() .clickable { component.selectDownloader(it) isSelecting = false } .padding(vertical = 8.dp, horizontal = 16.dp) ) } } } } } } } @Composable private fun rememberDownloaderSelectionItemString( downloaderSelection: DownloaderSelection, bestDownloader: TADownloaderInUI?, ): String { return when (downloaderSelection) { DownloaderSelection.Auto -> { val autoText = myStringResource(Res.string.auto) val bestDownloaderName = bestDownloader?.name?.rememberString() buildString { append(autoText) if (bestDownloader != null) { append(" ($bestDownloaderName)") } } } is DownloaderSelection.Fixed -> { downloaderSelection.downloaderInUi.name.rememberString() } } } @Composable private fun Actions( component: DesktopEnterNewURLComponent, ) { ActionButton( myStringResource(Res.string.ok), enabled = component.canAdd.collectAsState().value, onClick = { component.newDownloadEntered() } ) Spacer(Modifier.width(8.dp)) ActionButton( myStringResource(Res.string.cancel), onClick = component::close ) } @Composable private fun UrlTextField( text: String, setText: (String) -> Unit, errorText: String? = null, modifier: Modifier = Modifier, ) { MyTextFieldWithIcons( text, setText, myStringResource(Res.string.download_link), modifier = modifier.fillMaxWidth(), start = { MyIcon( MyIcons.link, null, Modifier.padding(horizontal = 8.dp) .size(16.dp), ) }, end = { MyTextFieldIcon(MyIcons.paste) { setText( ClipboardUtil.read() .orEmpty() ) } }, errorText = errorText ) } ================================================ FILE: desktop/app/src/main/kotlin/com/abdownloadmanager/desktop/pages/extenallibs/ExternalLibsPage.kt ================================================ package com.abdownloadmanager.desktop.pages.extenallibs import com.abdownloadmanager.shared.util.ui.ProvideTextStyle import com.abdownloadmanager.shared.util.ui.theme.myTextSizes import com.abdownloadmanager.shared.ui.widget.table.customtable.Table import com.abdownloadmanager.shared.ui.widget.table.customtable.TableState import com.abdownloadmanager.shared.util.ui.WithContentAlpha import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.* import androidx.compose.foundation.lazy.rememberLazyListState import com.abdownloadmanager.shared.ui.widget.Text import com.abdownloadmanager.shared.ui.widget.table.customtable.styled.MyStyledTableHeader import androidx.compose.runtime.* import androidx.compose.ui.Modifier import androidx.compose.ui.text.TextStyle import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp import com.mikepenz.aboutlibraries.Libs import com.mikepenz.aboutlibraries.entity.Library import okio.FileSystem import okio.Path.Companion.toPath @Composable internal fun ExternalLibsPage() { val libs = rememberLibs() OpenSourceLibraries( libs = libs, modifier = Modifier.fillMaxSize(), ) } @Composable private fun OpenSourceLibraries( libs: Libs, modifier: Modifier, ) { var currentDialog by remember { mutableStateOf(null as Library?) } Column( modifier ) { val tableState = remember { TableState( cells = LibraryCells.all() ) } val itemHorizontalPadding = 16.dp Table( modifier = Modifier .fillMaxWidth() .padding(8.dp) .weight(1f), list = libs.libraries, listState = rememberLazyListState(), tableState = tableState, wrapHeader = { MyStyledTableHeader( itemHorizontalPadding = itemHorizontalPadding, content = it, ) }, wrapItem = { _, item, rowContent -> Box( Modifier .clickable { currentDialog = item } .widthIn(getTableSize().visibleWidth) .padding(vertical = 6.dp, horizontal = itemHorizontalPadding)) { rowContent() } }, renderCell = { libraryCell, library -> when (libraryCell) { LibraryCells.Name -> { Column { WithContentAlpha(1f) { Row(Modifier) { Text( library.name, fontSize = myTextSizes.base, overflow = TextOverflow.Ellipsis, maxLines = 1 ) Spacer(Modifier.width(2.dp)) library.artifactVersion?.let { version -> Text( text = version, fontSize = myTextSizes.base, overflow = TextOverflow.Ellipsis, maxLines = 1, ) } } } WithContentAlpha(0.75f) { Text( library.artifactId, maxLines = 1, overflow = TextOverflow.Ellipsis, fontSize = myTextSizes.sm, ) } } } LibraryCells.Author -> { val by = library.by() if (by.isNotEmpty()) { Row { WithContentAlpha(0.7f) { ProvideTextStyle( TextStyle(fontSize = myTextSizes.sm) ) { for ((name) in by) { Spacer(Modifier.width(4.dp)) Text( text = name, fontSize = myTextSizes.base, maxLines = 1, overflow = TextOverflow.Ellipsis, ) } } } } } } LibraryCells.License -> { WithContentAlpha(0.75f) { Text( text = library.licenses.joinToString(", ") { it.name }, fontSize = myTextSizes.base, maxLines = 1, overflow = TextOverflow.Ellipsis, ) } } } }, ) } currentDialog.let { library -> if (library != null) { LibraryDialog(library) { currentDialog = null } } } } private fun Library.by(): List> { val d = developers.filter { it.name != null }.map { it.name!! to it.organisationUrl }.takeIf { it.isNotEmpty() } if (d != null) return d return organization?.let { listOf(it.name to it.url) } ?: emptyList() } @Composable private fun rememberLibs(): Libs { return remember { val jsonContent = FileSystem.RESOURCES.read("aboutlibraries.json".toPath()) { readUtf8() } Libs.Builder().withJson(jsonContent).build() } } ================================================ FILE: desktop/app/src/main/kotlin/com/abdownloadmanager/desktop/pages/extenallibs/ExternalLibsWindow.kt ================================================ package com.abdownloadmanager.desktop.pages.extenallibs import com.abdownloadmanager.desktop.AppComponent import com.abdownloadmanager.desktop.window.custom.CustomWindow import com.abdownloadmanager.desktop.window.custom.WindowTitle import androidx.compose.runtime.Composable import androidx.compose.runtime.collectAsState import androidx.compose.ui.unit.DpSize import androidx.compose.ui.unit.dp import androidx.compose.ui.window.rememberWindowState import com.abdownloadmanager.resources.Res import ir.amirab.util.compose.resources.myStringResource @Composable fun ShowOpenSourceLibraries(appComponent: AppComponent){ ShowOpenSourceLibraries( visible = appComponent.showOpenSourceLibraries.collectAsState().value, onRequestClose = { appComponent.closeOpenSourceLibraries() } ) } @Composable fun ShowOpenSourceLibraries( visible: Boolean, onRequestClose:()->Unit, ) { if (!visible) return CustomWindow( onCloseRequest = onRequestClose, state = rememberWindowState( size = DpSize(650.dp, 400.dp) ) ) { WindowTitle(myStringResource(Res.string.open_source_software_used_in_this_app)) ExternalLibsPage() } } ================================================ FILE: desktop/app/src/main/kotlin/com/abdownloadmanager/desktop/pages/extenallibs/LibraryDialog.kt ================================================ package com.abdownloadmanager.desktop.pages.extenallibs import com.abdownloadmanager.shared.ui.widget.MaybeLinkText import com.abdownloadmanager.shared.util.ui.ProvideTextStyle import com.abdownloadmanager.shared.util.ui.myColors import com.abdownloadmanager.shared.util.ui.theme.myTextSizes import com.abdownloadmanager.shared.ui.widget.ActionButton import com.abdownloadmanager.shared.util.ui.WithContentAlpha import com.abdownloadmanager.shared.util.div import androidx.compose.foundation.background import androidx.compose.foundation.border import androidx.compose.foundation.layout.* import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.verticalScroll import com.abdownloadmanager.shared.ui.widget.Text import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip import androidx.compose.ui.graphics.Brush import androidx.compose.ui.text.TextStyle import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.dp import androidx.compose.ui.window.Dialog import androidx.compose.ui.window.DialogProperties import com.abdownloadmanager.resources.Res import com.abdownloadmanager.shared.util.ui.theme.myShapes import com.mikepenz.aboutlibraries.entity.Developer import com.mikepenz.aboutlibraries.entity.Library import com.mikepenz.aboutlibraries.entity.License import com.mikepenz.aboutlibraries.entity.Organization import ir.amirab.util.compose.resources.myStringResource import ir.amirab.util.compose.StringSource import ir.amirab.util.compose.asStringSource import kotlinx.collections.immutable.ImmutableSet @Composable fun LibraryDialog( library: Library, onCloseRequest: () -> Unit, ) { Dialog( onCloseRequest, properties = DialogProperties() ) { ProvideTextStyle( TextStyle(fontSize = myTextSizes.base) ) { val shape = myShapes.defaultRounded Column( Modifier .clip(shape) .border(2.dp, myColors.onBackground / 10, shape) .background( Brush.linearGradient( listOf( myColors.surface, myColors.background, ) ) ) .padding(16.dp) ) { Column( Modifier .weight(1f, false) .verticalScroll(rememberScrollState()) ) { LibraryNameAndVersion(library.name, library.artifactVersion, library.artifactId) Spacer(Modifier.height(16.dp)) library.description?.let { LibraryDescription(it) } Spacer(Modifier.height(16.dp)) library.developers.takeIf { it.isNotEmpty() }?.let { LibraryDevelopers(it) } library.organization?.let { LibraryOrganization(it) } val links = buildList { library.scm?.url?.let { add(Res.string.source_code.asStringSource() to it) } library.website?.let { add(Res.string.website.asStringSource() to it) } } links.takeIf { it.isNotEmpty() }?.let { LibraryLinks(links) } LibraryLicenseInfo(library.licenses) } Spacer(Modifier.height(8.dp)) Row( Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.End, ) { ActionButton(myStringResource(Res.string.close), onClick = { onCloseRequest() }) } } } } } @Composable private fun LibraryLinks(links: List>) { KeyValue(myStringResource(Res.string.links)) { ListOfNamesWithLinks(links) } } @Composable private fun LibraryDescription(description: String) { Text( description, modifier = Modifier.background(myColors.surface).padding(8.dp), color = myColors.onSurface, ) } @Composable private fun LibraryLicenseInfo(licenses: ImmutableSet) { KeyValue(myStringResource(Res.string.license)) { val l = licenses.map { it.name.asStringSource() to it.url } if (l.isEmpty()) { Text(myStringResource(Res.string.no_license_found)) } else { ListOfNamesWithLinks(l) } } } @Composable private fun LibraryDevelopers(devs: List) { KeyValue(myStringResource(Res.string.developers)) { ListOfNamesWithLinks( devs .filter { it.name != null } .map { it.name!!.asStringSource() to it.organisationUrl } ) } } @OptIn(ExperimentalLayoutApi::class) @Composable private fun ListOfNamesWithLinks(map: List>) { FlowRow { for ((i, v) in map.withIndex()) { val (name, link) = v MaybeLinkText(name.rememberString(), link) if (i < map.lastIndex) { Text(", ") } } } } @Composable fun LibraryOrganization(organization: Organization) { KeyValue(myStringResource(Res.string.organization)) { MaybeLinkText(organization.name, organization.url) } } @Composable private fun LibraryNameAndVersion( name: String, version: String?, artifactId: String, ) { val nameWithVersion = name + (version?.let { " $it" }.orEmpty()) Column { Row { Text( "$nameWithVersion", fontWeight = FontWeight.Bold, fontSize = myTextSizes.base, ) } Spacer(Modifier.height(4.dp)) WithContentAlpha(0.75f) { Row { Text( "($artifactId)", fontSize = myTextSizes.sm, ) } } } } @Composable private fun KeyValue( key: String, value: @Composable () -> Unit, ) { Row { WithContentAlpha(0.75f) { Text( "$key:", maxLines = 1, ) } Spacer(Modifier.width(8.dp)) value() } } ================================================ FILE: desktop/app/src/main/kotlin/com/abdownloadmanager/desktop/pages/extenallibs/OpenSourceLibraryTable.kt ================================================ package com.abdownloadmanager.desktop.pages.extenallibs import com.abdownloadmanager.shared.ui.widget.table.customtable.CellSize import com.abdownloadmanager.shared.ui.widget.table.customtable.SortableCell import com.abdownloadmanager.shared.ui.widget.table.customtable.TableCell import androidx.compose.ui.unit.dp import com.abdownloadmanager.resources.Res import com.mikepenz.aboutlibraries.entity.Library import ir.amirab.util.compose.StringSource import ir.amirab.util.compose.asStringSource sealed interface LibraryCells : TableCell { data object Name : LibraryCells, SortableCell { override fun comparator(): Comparator = compareBy { it.name } override val id: String = "Name" override val name: StringSource = Res.string.name.asStringSource() override val size: CellSize = CellSize.Resizeable(100.dp..1000.dp, 250.dp) } data object Author : LibraryCells, SortableCell { override fun comparator(): Comparator = compareBy { item -> item.licenses.firstOrNull()?.name.orEmpty() } override val id: String = "Author" override val name: StringSource = Res.string.author.asStringSource() override val size: CellSize = CellSize.Resizeable(100.dp..200.dp, 150.dp) } data object License : LibraryCells, SortableCell { override fun comparator(): Comparator = compareBy { it.licenses.firstOrNull()?.name.orEmpty() } override val id: String = "License" override val name: StringSource = Res.string.license.asStringSource() override val size: CellSize = CellSize.Resizeable(100.dp..200.dp, 150.dp) } companion object { fun all() = listOf( Name, Author, License, ) } } ================================================ FILE: desktop/app/src/main/kotlin/com/abdownloadmanager/desktop/pages/home/Actions.kt ================================================ package com.abdownloadmanager.desktop.pages.home import com.abdownloadmanager.shared.util.ui.widget.MyIcon import com.abdownloadmanager.shared.util.ui.myColors import com.abdownloadmanager.shared.util.ui.theme.myTextSizes import ir.amirab.util.ifThen import com.abdownloadmanager.shared.ui.widget.menu.custom.MyDropDown import com.abdownloadmanager.shared.ui.widget.menu.custom.SubMenu import com.abdownloadmanager.shared.util.ui.WithContentAlpha import com.abdownloadmanager.shared.util.ui.WithContentColor import ir.amirab.util.compose.action.MenuItem import com.abdownloadmanager.shared.util.div import androidx.compose.foundation.* import androidx.compose.foundation.layout.* import com.abdownloadmanager.shared.ui.widget.Text import androidx.compose.runtime.* import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.alpha import androidx.compose.ui.unit.dp import com.abdownloadmanager.shared.ui.widget.Tooltip import com.abdownloadmanager.shared.util.ui.LocalMultiplatformScrollbarStyle import com.abdownloadmanager.shared.util.ui.MultiplatformHorizontalScrollbar import com.abdownloadmanager.shared.util.ui.needScroll import ir.amirab.util.compose.IconSource import ir.amirab.util.compose.StringSource @Composable fun Actions( list: List, showLabels: Boolean, ) { val scrollState = rememberScrollState() Column { Row( Modifier .height(IntrinsicSize.Max) .horizontalScroll(scrollState) ) { for (a in list) { when (a) { MenuItem.Separator -> { Spacer( Modifier .padding(horizontal = 4.dp) .fillMaxHeight() .padding(vertical = 4.dp) .width(1.dp) .background(myColors.onBackground / 5) ) } is MenuItem.SingleItem -> { ActionButton(Modifier, a, showLabels) } is MenuItem.SubMenu -> { GroupActionButton(Modifier, a, showLabels) } } } } val adapter = rememberScrollbarAdapter(scrollState) if (adapter.needScroll()) { MultiplatformHorizontalScrollbar( adapter = adapter, modifier = Modifier .fillMaxWidth() .padding(bottom = 2.dp), ) } } } @Composable private fun ActionButton( modifier: Modifier = Modifier, action: MenuItem.SingleItem, showLabels: Boolean, ) { val enabled by action.isEnabled.collectAsState() Column(modifier) { ActionIconWithLabel( title = action.title.collectAsState().value, icon = action.icon.collectAsState().value, showLabels = showLabels, onClick = { action() }, enabled = enabled ) } } @Composable private fun GroupActionButton( modifier: Modifier = Modifier, action: MenuItem.SubMenu, showLabels: Boolean, ) { val enabled by action.isEnabled.collectAsState() var showSubMenu by remember { mutableStateOf(false) } Column(modifier) { ActionIconWithLabel( title = action.title.collectAsState().value, icon = action.icon.collectAsState().value, showLabels = showLabels, onClick = { showSubMenu = !showSubMenu }, enabled = enabled ) val close = { showSubMenu = false } if (enabled && showSubMenu) { MyDropDown(onDismissRequest = close) { val items by action.items.collectAsState() SubMenu(subMenu = items, onRequestClose = close) } } } } @Composable private fun ActionIconWithLabel( title: StringSource, icon: IconSource?, showLabels: Boolean, onClick: () -> Unit, enabled: Boolean, ) { OptionalTooltip( title.takeIf { !showLabels }, ) { Column( modifier = Modifier .clickable(enabled = enabled, onClick = onClick) .ifThen(!enabled) { alpha(0.5f) } .padding(if (showLabels) 8.dp else 12.dp), horizontalAlignment = Alignment.CenterHorizontally, ) { WithContentColor(myColors.onBackground) { WithContentAlpha(1f) { icon?.let { val iconSize = if (showLabels) 16.dp else 24.dp MyIcon( icon = it, contentDescription = null, modifier = Modifier.size(iconSize) ) } if (showLabels) { Spacer(Modifier.size(2.dp)) Text(title.rememberString(), maxLines = 1, fontSize = myTextSizes.sm) } } } } } } @Composable private fun OptionalTooltip( tooltip: StringSource?, content: @Composable () -> Unit ) { if (tooltip != null) { Tooltip(tooltip) { content() } } else { content() } } ================================================ FILE: desktop/app/src/main/kotlin/com/abdownloadmanager/desktop/pages/home/DesktopDownloadActions.kt ================================================ package com.abdownloadmanager.desktop.pages.home import com.abdownloadmanager.shared.pagemanager.EditDownloadDialogManager import com.abdownloadmanager.shared.pagemanager.FileChecksumDialogManager import com.abdownloadmanager.resources.Res import com.abdownloadmanager.shared.pagemanager.DownloadDialogManager import com.abdownloadmanager.shared.pages.home.AbstractDownloadActions import com.abdownloadmanager.shared.util.DownloadSystem import com.abdownloadmanager.shared.util.category.CategoryManager import com.abdownloadmanager.shared.util.ui.icon.MyIcons import ir.amirab.downloader.monitor.IDownloadItemState import ir.amirab.downloader.queue.QueueManager import ir.amirab.util.compose.action.MenuItem import ir.amirab.util.compose.action.buildMenu import ir.amirab.util.compose.action.simpleAction import ir.amirab.util.compose.asStringSource import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.launch class DesktopDownloadActions( scope: CoroutineScope, downloadSystem: DownloadSystem, downloadDialogManager: DownloadDialogManager, editDownloadDialogManager: EditDownloadDialogManager, fileChecksumDialogManager: FileChecksumDialogManager, selections: StateFlow>, queueManager: QueueManager, categoryManager: CategoryManager, openFile: (Long) -> Unit, requestDelete: (List) -> Unit, mainItem: StateFlow, private val openFolder: (Long) -> Unit, ) : AbstractDownloadActions( scope = scope, downloadSystem = downloadSystem, downloadDialogManager = downloadDialogManager, editDownloadDialogManager = editDownloadDialogManager, fileChecksumDialogManager = fileChecksumDialogManager, selections = selections, queueManager = queueManager, categoryManager = categoryManager, openFile = openFile, requestDelete = requestDelete, mainItem = mainItem, ) { val openFolderAction = simpleAction( title = Res.string.open_folder.asStringSource(), icon = MyIcons.folderOpen, onActionPerformed = { scope.launch { val d = defaultItem.value ?: return@launch openFolder(d.id) } } ) val menu: List = buildMenu { +openFileAction +openFolderAction +(resumeAction) +pauseAction separator() +(deleteAction) +(reDownloadAction) separator() +moveToQueueItems +moveToCategoryAction separator() subMenu(Res.string.copy.asStringSource(), MyIcons.copy) { +(copyDownloadLinkAction) +(copyDownloadCredentialsAsCurlAction) } +editDownloadAction +fileChecksumAction +(openDownloadDialogAction) } } ================================================ FILE: desktop/app/src/main/kotlin/com/abdownloadmanager/desktop/pages/home/DownloadItemListDataFlavor.kt ================================================ package com.abdownloadmanager.desktop.pages.home import ir.amirab.downloader.monitor.IDownloadItemState import java.awt.datatransfer.DataFlavor import java.awt.datatransfer.Transferable import java.awt.datatransfer.UnsupportedFlavorException import java.io.File val DownloadItemListDataFlavor = DataFlavor( IDownloadItemState::class.java, "Download Item" ) class DownloadItemTransferable( val items: List, ) : Transferable { override fun getTransferDataFlavors(): Array { return arrayOf( DataFlavor.javaFileListFlavor, DownloadItemListDataFlavor, ) } override fun isDataFlavorSupported(flavor: DataFlavor?): Boolean { return (flavor in arrayOf( DataFlavor.javaFileListFlavor, DownloadItemListDataFlavor, )) } override fun getTransferData(flavor: DataFlavor?): Any { return when (flavor) { DataFlavor.javaFileListFlavor -> items.map { File(it.folder, it.name) } DownloadItemListDataFlavor -> items else -> throw UnsupportedFlavorException(flavor) } } } ================================================ FILE: desktop/app/src/main/kotlin/com/abdownloadmanager/desktop/pages/home/HomeComponent.kt ================================================ package com.abdownloadmanager.desktop.pages.home import com.abdownloadmanager.desktop.* import com.abdownloadmanager.desktop.actions.* import com.abdownloadmanager.desktop.pages.home.sections.DownloadListCells import com.abdownloadmanager.desktop.storage.PageStatesStorage import com.abdownloadmanager.shared.util.ui.icon.MyIcons import com.abdownloadmanager.shared.ui.widget.NotificationType import com.abdownloadmanager.shared.ui.widget.sort.Sort import com.abdownloadmanager.shared.ui.widget.table.customtable.TableState import com.abdownloadmanager.desktop.utils.* import ir.amirab.util.compose.action.MenuItem import ir.amirab.util.compose.action.buildMenu import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.DpSize import androidx.compose.ui.unit.dp import com.abdownloadmanager.UpdateManager import com.abdownloadmanager.shared.pages.adddownload.AddDownloadCredentialsInUiProps import com.abdownloadmanager.desktop.pages.category.DesktopCategoryDialogManager import com.abdownloadmanager.desktop.storage.AppSettingsStorage import com.abdownloadmanager.resources.Res import com.abdownloadmanager.shared.action.donate import com.abdownloadmanager.shared.action.supportActionGroup import com.abdownloadmanager.shared.pagemanager.EditDownloadDialogManager import com.abdownloadmanager.shared.pagemanager.EnterNewURLDialogManager import com.abdownloadmanager.shared.pagemanager.FileChecksumDialogManager import com.abdownloadmanager.shared.pagemanager.NotificationSender import com.abdownloadmanager.shared.pagemanager.QueuePageManager import com.abdownloadmanager.shared.pages.home.BaseHomeComponent import com.abdownloadmanager.shared.util.* import com.abdownloadmanager.shared.util.FileIconProvider import com.abdownloadmanager.shared.util.category.CategoryManager import com.abdownloadmanager.shared.util.category.DefaultCategories import com.arkivanov.decompose.ComponentContext import ir.amirab.downloader.monitor.* import ir.amirab.downloader.queue.QueueManager import ir.amirab.util.flow.combineStateFlows import ir.amirab.util.flow.mapTwoWayStateFlow import com.abdownloadmanager.shared.util.extractors.linkextractor.DownloadCredentialFromStringExtractor import ir.amirab.downloader.downloaditem.IDownloadCredentials import ir.amirab.util.AppVersionTracker import ir.amirab.util.compose.asStringSource import ir.amirab.util.compose.asStringSourceWithARgs import ir.amirab.util.platform.Platform import ir.amirab.util.platform.isLinux import ir.amirab.util.platform.isMac import kotlinx.coroutines.delay import kotlinx.coroutines.flow.* import kotlinx.coroutines.launch import org.koin.core.component.KoinComponent import org.koin.core.component.inject import java.awt.event.KeyEvent import java.io.File import kotlin.collections.map import kotlin.getValue class HomeComponent( ctx: ComponentContext, downloadItemOpener: DownloadItemOpener, downloadDialogManager: DesktopDownloadDialogManager, editDownloadDialogManager: EditDownloadDialogManager, override val enterNewURLDialogManager: EnterNewURLDialogManager, desktopAddDownloadDialogManager: DesktopAddDownloadDialogManager, fileChecksumDialogManager: FileChecksumDialogManager, queuePageManager: QueuePageManager, categoryDialogManager: DesktopCategoryDialogManager, notificationSender: NotificationSender, downloadSystem: DownloadSystem, categoryManager: CategoryManager, queueManager: QueueManager, defaultCategories: DefaultCategories, fileIconProvider: FileIconProvider, ) : BaseHomeComponent( componentContext = ctx, downloadItemOpener = downloadItemOpener, downloadDialogManager = downloadDialogManager, editDownloadDialogManager = editDownloadDialogManager, addDownloadDialogManager = desktopAddDownloadDialogManager, fileChecksumDialogManager = fileChecksumDialogManager, queuePageManager = queuePageManager, categoryDialogManager = categoryDialogManager, notificationSender = notificationSender, downloadSystem = downloadSystem, categoryManager = categoryManager, queueManager = queueManager, defaultCategories = defaultCategories, fileIconProvider = fileIconProvider, ), ContainsShortcuts, KoinComponent { private val pageStorage: PageStatesStorage by inject() private val appSettings: AppSettingsStorage by inject() private val updateManager: UpdateManager by inject() private val appVersionTracker: AppVersionTracker by inject() val mergeTopBarWithTitleBar = appSettings.mergeTopBarWithTitleBar val useNativeMenuBar = appSettings.useNativeMenuBar private val homePageStateToPersist = MutableStateFlow(pageStorage.homePageStorage.value) init { HomeComponent.homeComponentCreationCount++ } private fun isFirstVisitInThisSession(): Boolean { return HomeComponent.homeComponentCreationCount == 1 } init { homePageStateToPersist .debounce(500) .onEach { newValue -> pageStorage.homePageStorage.update { newValue } }.launchIn(scope) } private val _windowSize = homePageStateToPersist.mapTwoWayStateFlow( map = { it.windowSize.let { (x, y) -> DpSize(x.dp, y.dp) } }, unMap = { copy( windowSize = it.width.value to it.height.value ) } ) val windowSize = _windowSize.asStateFlow() fun setWindowSize(dpSize: DpSize) { _windowSize.value = dpSize } private val _isMaximized = homePageStateToPersist.mapTwoWayStateFlow( map = { it.isMaximized }, unMap = { copy(isMaximized = it) } ) val isMaximized = _isMaximized.asStateFlow() fun setIsMaximized(value: Boolean) { _isMaximized.value = value } private val _categoriesWidth = homePageStateToPersist.mapTwoWayStateFlow( map = { it.categoriesWidth.dp.coerceIn(CATEGORIES_SIZE_RANGE) }, unMap = { copy(categoriesWidth = it.coerceIn(CATEGORIES_SIZE_RANGE).value) } ) val categoriesWidth = _categoriesWidth.asStateFlow() fun setCategoriesWidth(updater: (Dp) -> Dp) { _categoriesWidth.value = updater(_categoriesWidth.value) } private val mainItem = MutableStateFlow(null) val menu: List = buildMenu { subMenu(Res.string.file.asStringSource()) { +newDownloadAction +newDownloadFromClipboardAction +batchDownloadAction separator() +requestExitAction } subMenu(Res.string.tasks.asStringSource()) { // +toggleQueueAction +startQueueGroupAction +stopQueueGroupAction separator() +stopAllAction separator() subMenu( title = Res.string.delete.asStringSource(), icon = MyIcons.remove ) { item(Res.string.all_missing_files.asStringSource()) { requestDelete(downloadSystem.getListOfDownloadThatMissingFileOrHaveNotProgress().map { it.id }) } item(Res.string.all_finished.asStringSource()) { requestDelete(downloadSystem.getFinishedDownloadIds()) } item(Res.string.all_unfinished.asStringSource()) { requestDelete(downloadSystem.getUnfinishedDownloadIds()) } item(Res.string.entire_list.asStringSource()) { requestDelete(downloadSystem.getAllDownloadIds()) } } } subMenu(Res.string.tools.asStringSource()) { if (AppInfo.isInDebugMode()) { +dummyException +dummyMessage +shutdown separator() } +browserIntegrations if (Platform.isLinux()) { +createDesktopEntryAction } separator() +perHostSettings +gotoSettingsAction } subMenu(Res.string.help.asStringSource()) { +supportActionGroup separator() +openOpenSourceThirdPartyLibraries +openTranslators +donate separator() +checkForUpdateAction +openAboutAction } }.filterIsInstance() private val shouldShowOptions = MutableStateFlow(false) val downloadOptions = combineStateFlows( shouldShowOptions, selectionList, ) { shouldShowOptions, selectionList -> if (!shouldShowOptions) { null } else { MenuItem.SubMenu( icon = null, title = if (selectionList.size == 1) { (downloadActions.defaultItem.value?.name ?: "") .asStringSource() } else { Res.string.n_items_selected .asStringSourceWithARgs( Res.string.n_items_selected_createArgs( count = selectionList.size.toString() ) ) }, items = downloadActions.menu ) } } val tableState = TableState( cells = listOf( DownloadListCells.Check, DownloadListCells.Name, DownloadListCells.Size, DownloadListCells.Status, DownloadListCells.Speed, DownloadListCells.TimeLeft, DownloadListCells.DateAdded, ), forceVisibleCells = listOf( DownloadListCells.Name, ), initialSortBy = Sort(DownloadListCells.DateAdded, Sort.DEFAULT_IS_DESCENDING) ).apply { homePageStateToPersist.value.downloadListState?.let { load(it) } onPropChange.onEach { homePageStateToPersist.update { it.copy(downloadListState = save()) } }.launchIn(scope) } fun onRequestOpenDownloadItemOption( mainItem: IDownloadItemState?, ) { if (mainItem != null && mainItem.id !in selectionList.value) { newSelection(listOf(mainItem.id)) } this.mainItem.value = mainItem?.id shouldShowOptions.update { true } } fun onRequestCloseDownloadItemOption() { shouldShowOptions.update { false } mainItem.value = null } fun importLinks(links: List) { val size = links.size when { size <= 0 -> { return } size > 0 -> { requestAddNewDownload(links) } } } val currentActiveDrops: MutableStateFlow?> = MutableStateFlow(null) private fun parseLinks(v: String): List { return DownloadCredentialFromStringExtractor.extract(v) .distinctBy { it.link } } fun onExternalTextDraggedIn(readText: () -> String) { val v = readText() val parsedLinks = parseLinks(v) currentActiveDrops.update { parsedLinks } } fun onExternalFilesDraggedIn(getFilePaths: () -> List) { val filePaths = kotlin.runCatching { getFilePaths() } .getOrNull()?.filter { it.length() <= 1024 * 1024 } ?: return onExternalTextDraggedIn { filePaths .firstOrNull() ?.readText() .orEmpty() } } fun onDragExit() { currentActiveDrops.update { null } } fun onDropped() { currentActiveDrops.value?.let { importLinks(it.map { AddDownloadCredentialsInUiProps(credentials = it) }) } } fun openFolder(id: Long) { scope.launch { downloadItemOpener.openDownloadItemFolder(id) } } fun bringToFront() { sendEffect(Effects.BringToFront) } init { if (isFirstVisitInThisSession()) { // if the app is updated then clean downloaded files if (appVersionTracker.isUpgraded()) { // clean update files scope.launch { // temporary fix: // at the moment we relly on DownloadMonitor for getting the list of downloads by their folder // so wait for the download list to be updated by the download monitor delay(1000) // then clean up the downloaded files updateManager.cleanDownloadedFiles() } // show user about update scope.launch { // let user focus to the app delay(1000) notificationSender.sendNotification( title = Res.string.update_updater.asStringSource(), description = Res.string.update_app_updated_to_version_n.asStringSourceWithARgs( Res.string.update_app_updated_to_version_n_createArgs( version = appVersionTracker.currentVersion.toString() ) ), type = NotificationType.Success, tag = "Updater" ) } } } } private val downloadActions = DesktopDownloadActions( scope = scope, downloadSystem = downloadSystem, downloadDialogManager = downloadDialogManager, editDownloadDialogManager = editDownloadDialogManager, fileChecksumDialogManager = fileChecksumDialogManager, selections = selectionListItems, mainItem = mainItem, queueManager = queueManager, categoryManager = categoryManager, openFile = this::openFile, openFolder = this::openFolder, requestDelete = this::requestDelete, ) override val shortcutManager = DesktopShortcutManager().apply { val isMac = Platform.isMac() val metaKey = if (isMac) "meta" else "ctrl" if (isMac) { KeyEvent.VK_BACK_SPACE to downloadActions.deleteAction } else { "DELETE" to downloadActions.deleteAction } "$metaKey N" to newDownloadAction "$metaKey V" to newDownloadFromClipboardAction "$metaKey C" to downloadActions.copyDownloadLinkAction "$metaKey alt S" to gotoSettingsAction "$metaKey Q" to requestExitAction "$metaKey O" to downloadActions.openFileAction "$metaKey F" to downloadActions.openFolderAction "$metaKey E" to downloadActions.editDownloadAction "$metaKey P" to downloadActions.pauseAction "$metaKey R" to downloadActions.resumeAction "$metaKey I" to downloadActions.openDownloadDialogAction } val showLabels = appSettings.showIconLabels val headerActions = buildMenu { separator() +downloadActions.resumeAction +downloadActions.pauseAction separator() +startQueueGroupAction +stopQueueGroupAction +openQueuesAction separator() +stopAllAction separator() +downloadActions.deleteAction separator() +gotoSettingsAction } companion object { private var homeComponentCreationCount = 0 val CATEGORIES_SIZE_RANGE = 0.dp..500.dp } sealed interface Effects : BaseHomeComponent.Effects.PlatformEffects { data object BringToFront : Effects } } ================================================ FILE: desktop/app/src/main/kotlin/com/abdownloadmanager/desktop/pages/home/HomePage.kt ================================================ package com.abdownloadmanager.desktop.pages.home import androidx.compose.animation.AnimatedVisibility import androidx.compose.animation.core.animateDpAsState import androidx.compose.animation.core.animateFloatAsState import androidx.compose.animation.fadeIn import androidx.compose.animation.fadeOut import androidx.compose.foundation.* import androidx.compose.foundation.draganddrop.dragAndDropTarget import androidx.compose.foundation.interaction.MutableInteractionSource import androidx.compose.foundation.interaction.collectIsFocusedAsState import androidx.compose.foundation.layout.* import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.runtime.* import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draganddrop.DragAndDropEvent import androidx.compose.ui.draganddrop.DragAndDropTarget import androidx.compose.ui.draganddrop.awtTransferable import androidx.compose.ui.draw.alpha import androidx.compose.ui.draw.clip import androidx.compose.ui.focus.FocusRequester import androidx.compose.ui.focus.focusProperties import androidx.compose.ui.focus.focusRequester import androidx.compose.ui.graphics.Brush import androidx.compose.ui.graphics.SolidColor import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.platform.LocalWindowInfo import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.dp import androidx.compose.ui.window.Dialog import com.abdownloadmanager.desktop.pages.home.sections.DownloadList import com.abdownloadmanager.desktop.pages.home.sections.SearchBox import com.abdownloadmanager.shared.pages.home.category.DefinedStatusCategories import com.abdownloadmanager.shared.pages.home.category.DownloadStatusCategoryFilter import com.abdownloadmanager.desktop.pages.home.sections.category.StatusFilterItem import com.abdownloadmanager.desktop.pages.home.sections.queue.QueuesSection import com.abdownloadmanager.desktop.window.custom.TitlePosition import com.abdownloadmanager.desktop.window.custom.WindowEnd import com.abdownloadmanager.desktop.window.custom.WindowStart import com.abdownloadmanager.desktop.window.custom.WindowTitlePosition import com.abdownloadmanager.resources.Res import com.abdownloadmanager.shared.pages.home.BaseHomeComponent import com.abdownloadmanager.shared.pages.home.CategoryActions import com.abdownloadmanager.shared.pages.home.CategoryDeletePromptState import com.abdownloadmanager.shared.pages.home.ConfirmPromptState import com.abdownloadmanager.shared.pages.home.DeletePromptState import com.abdownloadmanager.shared.ui.widget.* import com.abdownloadmanager.shared.ui.widget.menu.custom.MenuBar import com.abdownloadmanager.shared.ui.widget.menu.custom.ShowOptionsInPopup import com.abdownloadmanager.shared.ui.widget.menu.native.NativeMenuBar import com.abdownloadmanager.shared.util.LocalSpeedUnit import com.abdownloadmanager.shared.util.category.Category import com.abdownloadmanager.shared.util.category.rememberIconPainter import com.abdownloadmanager.shared.util.convertPositiveBytesToSizeUnit import com.abdownloadmanager.shared.util.div import com.abdownloadmanager.shared.util.mvi.HandleEffects import com.abdownloadmanager.shared.util.ui.WithContentAlpha import com.abdownloadmanager.shared.util.ui.WithTitleBarDirection import com.abdownloadmanager.shared.util.ui.icon.MyIcons import com.abdownloadmanager.shared.util.ui.myColors import com.abdownloadmanager.shared.util.ui.theme.myShapes import com.abdownloadmanager.shared.util.ui.theme.myTextSizes import com.abdownloadmanager.shared.util.ui.widget.MyIcon import ir.amirab.util.compose.IconSource import ir.amirab.util.compose.action.MenuItem import ir.amirab.util.compose.asStringSource import ir.amirab.util.compose.asStringSourceWithARgs import ir.amirab.util.compose.localizationmanager.WithLanguageDirection import ir.amirab.util.compose.resources.myStringResource import ir.amirab.util.desktop.LocalFrameWindowScope import ir.amirab.util.platform.Platform import ir.amirab.util.platform.isMac import kotlinx.coroutines.launch import java.awt.datatransfer.DataFlavor import java.io.File @Composable fun HomePage(component: HomeComponent) { val listState by component.downloadList.collectAsState() var isDragging by remember { mutableStateOf(false) } var showDeletePromptState by remember { mutableStateOf(null as DeletePromptState?) } var showDeleteCategoryPromptState by remember { mutableStateOf(null as CategoryDeletePromptState?) } var showConfirmPrompt by remember { mutableStateOf(null as ConfirmPromptState?) } val coroutineScope = rememberCoroutineScope() val lazyListState = rememberLazyListState() val tableState = component.tableState HandleEffects(component) { effect -> when (effect) { is BaseHomeComponent.Effects.Common -> { when (effect) { is BaseHomeComponent.Effects.Common.DeleteItems -> { if (effect.list.isNotEmpty()) { showDeletePromptState = DeletePromptState( downloadList = effect.list, finishedCount = effect.finishedCount, unfinishedCount = effect.unfinishedCount, ) } } is BaseHomeComponent.Effects.Common.DeleteCategory -> { showDeleteCategoryPromptState = CategoryDeletePromptState(effect.category) } is BaseHomeComponent.Effects.Common.AutoCategorize -> { showConfirmPrompt = ConfirmPromptState( title = Res.string.confirm_auto_categorize_downloads_title.asStringSource(), description = Res.string.confirm_auto_categorize_downloads_description.asStringSource(), onConfirm = component::onConfirmAutoCategorize ) } is BaseHomeComponent.Effects.Common.ResetCategoriesToDefault -> { showConfirmPrompt = ConfirmPromptState( title = Res.string.confirm_reset_to_default_categories_title.asStringSource(), description = Res.string.confirm_reset_to_default_categories_description.asStringSource(), onConfirm = component::onConfirmResetCategories ) } is BaseHomeComponent.Effects.Common.ScrollToDownloadItem -> { val id = effect.downloadId val positionOrNull = tableState .getItemPosition(listState) { it.id == id } .takeIf { it != -1 } positionOrNull?.let { index -> if (effect.skipIfVisible) { val isVisible = lazyListState.layoutInfo.visibleItemsInfo .any { it.index == index } if (isVisible) { return@let } } coroutineScope.launch { lazyListState.scrollToItem(index) } } } } } is HomeComponent.Effects -> { when (effect) { HomeComponent.Effects.BringToFront -> { // handled else where } } } else -> {} } } showDeletePromptState?.let { ShowDeletePrompts( deletePromptState = it, onCancel = { showDeletePromptState = null }, onConfirm = { showDeletePromptState = null component.confirmDelete(it) }) } showDeleteCategoryPromptState?.let { ShowDeleteCategoryPrompt( deletePromptState = it, onCancel = { showDeleteCategoryPromptState = null }, onConfirm = { showDeleteCategoryPromptState = null component.onConfirmDeleteCategory(it) }) } showConfirmPrompt?.let { ShowConfirmPrompt( promptState = it, onCancel = { showConfirmPrompt = null }, onConfirm = { showConfirmPrompt?.onConfirm?.invoke() showConfirmPrompt = null } ) } val mergeTopBar = shouldMergeTopBarWithTitleBar(component) if (mergeTopBar) { WindowTitlePosition( TitlePosition( centered = true, afterStart = true, padding = PaddingValues(end = 32.dp) ) ) WindowStart { HomeMenuBar(component, Modifier.fillMaxHeight()) } WindowEnd { HomeSearch( component = component, modifier = Modifier .fillMaxHeight() .padding(vertical = 2.dp) ) } } else { WindowTitlePosition( TitlePosition(centered = false, afterStart = false) ) } Box( Modifier .fillMaxSize() .dragAndDropTarget( shouldStartDragAndDrop = { if (it.awtTransferable.isDataFlavorSupported(DownloadItemListDataFlavor)) { // this item is ours we don't want to use our download item for import list usage return@dragAndDropTarget false } else it.awtTransferable.isDataFlavorSupported(DataFlavor.javaFileListFlavor) || it.awtTransferable.isDataFlavorSupported(DataFlavor.stringFlavor) }, target = remember { object : DragAndDropTarget { private fun onDraggedIn(event: DragAndDropEvent) { if (event.awtTransferable.isDataFlavorSupported(DataFlavor.stringFlavor)) { component.onExternalTextDraggedIn { (event.awtTransferable.getTransferData( DataFlavor.stringFlavor ) as String) } return } if (event.awtTransferable.isDataFlavorSupported(DataFlavor.javaFileListFlavor)) { component.onExternalFilesDraggedIn { (event.awtTransferable.getTransferData(DataFlavor.javaFileListFlavor) as List<*>).filterIsInstance() } return } } override fun onStarted(event: DragAndDropEvent) { isDragging = true onDraggedIn(event) } override fun onEnded(event: DragAndDropEvent) { isDragging = false component.onDragExit() } override fun onDrop(event: DragAndDropEvent): Boolean { isDragging = false if (Platform.isMac()) { onDraggedIn(event) } component.onDropped() return true } } } ) ) { Column( Modifier.alpha( animateFloatAsState(if (isDragging) 0.2f else 1f).value ) ) { if (!mergeTopBar) { WithTitleBarDirection { Spacer(Modifier.height(4.dp)) TopBar(component) Spacer(Modifier.height(6.dp)) } } Spacer( Modifier.fillMaxWidth() .height(1.dp) .background(myColors.surface) ) Row { val categoriesWidth by component.categoriesWidth.collectAsState() Column( Modifier .padding(top = 8.dp).width(categoriesWidth) .verticalScroll(rememberScrollState()) ) { Categories( modifier = Modifier.fillMaxWidth(), component = component, ) Spacer(Modifier.size(8.dp)) QueuesSection( modifier = Modifier.fillMaxWidth(), component = component, ) Spacer(Modifier.size(8.dp)) } Spacer(Modifier.size(8.dp)) //split pane Handle( Modifier.width(5.dp) .fillMaxHeight() ) { delta -> component.setCategoriesWidth { it + delta } } Column(Modifier.weight(1f)) { Row( verticalAlignment = Alignment.CenterVertically, ) { Spacer(Modifier.size(4.dp)) AddUrlButton { component.requestEnterNewURL() } Actions( component.headerActions, component.showLabels.collectAsState().value ) } var lastSelected by remember { mutableStateOf(null as Long?) } DownloadList( modifier = Modifier .padding(horizontal = 4.dp) .fillMaxWidth() .weight(1f), downloadList = listState, downloadOptions = component.downloadOptions.collectAsState().value, onRequestCloseOption = { component.onRequestCloseDownloadItemOption() }, onRequestOpenOption = { itemState -> component.onRequestOpenDownloadItemOption(itemState) }, selectionList = component.selectionList.collectAsState().value, onItemSelectionChange = { id, checked -> lastSelected = id component.onItemSelectionChange(id, checked) }, onRequestOpenDownload = { component.openFileOrShowProperties(it) }, onNewSelection = { component.newSelection(ids = it) }, lastSelectedId = lastSelected, tableState = tableState, fileIconProvider = component.fileIconProvider, categoryManager = component.categoryManager, lazyListState = lazyListState, ) Spacer( Modifier .fillMaxWidth() .height(2.dp) .background( myColors.surface ) ) Footer(component) } } } NotificationArea( Modifier .width(310.dp) .padding(24.dp) .align(Alignment.BottomEnd) ) AnimatedVisibility( visible = isDragging, enter = fadeIn(), exit = fadeOut(), ) { DragWidget( Modifier.fillMaxSize() .wrapContentSize(Alignment.Center), component.currentActiveDrops.value?.size, ) } } } @Composable private fun shouldMergeTopBarWithTitleBar(component: HomeComponent): Boolean { val mergeTopBarWithTitleBarInSettings = component.mergeTopBarWithTitleBar.collectAsState().value if (!mergeTopBarWithTitleBarInSettings) return false val density = LocalDensity.current val widthDp = density.run { LocalWindowInfo.current.containerSize.width.toDp() } return widthDp > 700.dp } @Composable private fun ShowDeletePrompts( deletePromptState: DeletePromptState, onConfirm: () -> Unit, onCancel: () -> Unit, ) { val shape = myShapes.defaultRounded Dialog(onDismissRequest = onCancel) { Column( Modifier .clip(shape) .border(2.dp, myColors.onBackground / 10, shape) .background( Brush.linearGradient( listOf( myColors.surface, myColors.background, ) ) ) .padding(16.dp) .width(IntrinsicSize.Max) .widthIn(max = 260.dp) ) { Text( myStringResource(Res.string.confirm_delete_download_items_title), fontWeight = FontWeight.Bold, fontSize = myTextSizes.xl, color = myColors.onBackground, ) Spacer(Modifier.height(12.dp)) val finishedCount = deletePromptState.finishedCount val unfinishedCount = deletePromptState.unfinishedCount Text( when { deletePromptState.hasBothFinishedAndUnfinished() -> { Res.string.confirm_delete_download_finished_and_unfinished_items_description.asStringSourceWithARgs( Res.string.confirm_delete_download_finished_and_unfinished_items_description_createArgs( finishedCount = finishedCount.toString(), unfinishedCount = unfinishedCount.toString(), ) ) } deletePromptState.hasUnfinishedDownloads -> { Res.string.confirm_delete_download_unfinished_items_description.asStringSourceWithARgs( Res.string.confirm_delete_download_unfinished_items_description_createArgs( count = unfinishedCount.toString(), ) ) } else -> { Res.string.confirm_delete_download_items_description.asStringSourceWithARgs( Res.string.confirm_delete_download_items_description_createArgs( count = finishedCount.toString() ), ) } }.rememberString(), fontSize = myTextSizes.base, color = myColors.onBackground, ) if (deletePromptState.hasFinishedDownloads) { Spacer(Modifier.height(12.dp)) val alsoDeleteFileInteractionSource = remember { MutableInteractionSource() } Row( Modifier .clickable( interactionSource = alsoDeleteFileInteractionSource, indication = null ) { deletePromptState.alsoDeleteFile = !deletePromptState.alsoDeleteFile }, verticalAlignment = Alignment.CenterVertically, ) { CheckBox( value = deletePromptState.alsoDeleteFile, onValueChange = { deletePromptState.alsoDeleteFile = it }, modifier = Modifier // the Row itself is clickable (focusable) so we don't need to focus this checkbox // is there a better way? .focusProperties { canFocus = false }, interactionSource = alsoDeleteFileInteractionSource, ) Spacer(Modifier.width(8.dp)) Text( myStringResource(Res.string.also_delete_file_from_disk), fontSize = myTextSizes.base, color = myColors.onBackground, ) } } Spacer(Modifier.height(12.dp)) Row( modifier = Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically, ) { val confirmFocusRequester = remember { FocusRequester() } LaunchedEffect(Unit) { confirmFocusRequester.requestFocus() } Spacer(Modifier.weight(1f)) ActionButton( text = myStringResource(Res.string.delete), onClick = onConfirm, focusedBorderColor = SolidColor(myColors.error), contentColor = myColors.error, modifier = Modifier.focusRequester(confirmFocusRequester) ) Spacer(Modifier.width(8.dp)) ActionButton(text = myStringResource(Res.string.cancel), onClick = onCancel) } } } } @Composable private fun ShowConfirmPrompt( promptState: ConfirmPromptState, onConfirm: () -> Unit, onCancel: () -> Unit, ) { val shape = myShapes.defaultRounded Dialog(onDismissRequest = onCancel) { Column( Modifier .clip(shape) .border(2.dp, myColors.onBackground / 10, shape) .background( Brush.linearGradient( listOf( myColors.surface, myColors.background, ) ) ) .padding(16.dp) .width(IntrinsicSize.Max) .widthIn(max = 260.dp) ) { Text( text = promptState.title.rememberString(), fontWeight = FontWeight.Bold, fontSize = myTextSizes.xl, color = myColors.onBackground, ) Spacer(Modifier.height(12.dp)) Text( text = promptState.description.rememberString(), fontSize = myTextSizes.base, color = myColors.onBackground, ) Spacer(Modifier.height(12.dp)) Row( modifier = Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically, ) { val confirmFocusRequester = remember { FocusRequester() } LaunchedEffect(Unit) { confirmFocusRequester.requestFocus() } Spacer(Modifier.weight(1f)) ActionButton( text = myStringResource(Res.string.ok), onClick = onConfirm, modifier = Modifier.focusRequester(confirmFocusRequester) ) Spacer(Modifier.width(8.dp)) ActionButton(text = myStringResource(Res.string.cancel), onClick = onCancel) } } } } @Composable private fun ShowDeleteCategoryPrompt( deletePromptState: CategoryDeletePromptState, onConfirm: () -> Unit, onCancel: () -> Unit, ) { val shape = myShapes.defaultRounded Dialog(onDismissRequest = onCancel) { Column( Modifier .clip(shape) .border(2.dp, myColors.onBackground / 10, shape) .background( Brush.linearGradient( listOf( myColors.surface, myColors.background, ) ) ) .padding(16.dp) .width(IntrinsicSize.Max) .widthIn(max = 260.dp) ) { Text( myStringResource( Res.string.confirm_delete_category_item_title, Res.string.confirm_delete_category_item_title_createArgs( name = deletePromptState.category.name ), ), fontWeight = FontWeight.Bold, fontSize = myTextSizes.xl, color = myColors.onBackground, ) Spacer(Modifier.height(12.dp)) Text( myStringResource( Res.string.confirm_delete_category_item_description, Res.string.confirm_delete_category_item_description_createArgs( value = deletePromptState.category.name ) ), fontSize = myTextSizes.base, color = myColors.onBackground, ) Spacer(Modifier.height(12.dp)) Text( myStringResource(Res.string.your_download_will_not_be_deleted), fontSize = myTextSizes.base, color = myColors.onBackground, ) Spacer(Modifier.height(12.dp)) Row( modifier = Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically, ) { val confirmFocusRequester = remember { FocusRequester() } LaunchedEffect(Unit) { confirmFocusRequester.requestFocus() } Spacer(Modifier.weight(1f)) ActionButton( text = myStringResource(Res.string.delete), onClick = onConfirm, focusedBorderColor = SolidColor(myColors.error), modifier = Modifier.focusRequester(confirmFocusRequester), contentColor = myColors.error, ) Spacer(Modifier.width(8.dp)) ActionButton(text = myStringResource(Res.string.cancel), onClick = onCancel) } } } } @Composable fun DragWidget( modifier: Modifier, linkCount: Int?, ) { val shape = RoundedCornerShape(12.dp) val background = myColors.onBackground / 10 Column( modifier .clip(shape) .background(background) .padding(8.dp) .dashedBorder( shape = shape, width = 2.dp, color = myColors.onBackground, on = 1.dp, off = 4.dp ) .padding(32.dp), horizontalAlignment = Alignment.CenterHorizontally, ) { MyIcon( MyIcons.download, null, Modifier.size(36.dp), ) Text( text = myStringResource(Res.string.drop_link_or_file_here), fontSize = myTextSizes.xl ) if (linkCount != null && Platform.isMac().not()) { when { linkCount > 0 -> { Text( myStringResource( Res.string.n_links_will_be_imported, Res.string.n_links_will_be_imported_createArgs( count = linkCount.toString() ) ), fontSize = myTextSizes.base, color = myColors.success, ) } linkCount == 0 -> { Text(myStringResource(Res.string.nothing_will_be_imported)) } } } } } @Composable private fun Categories( modifier: Modifier, component: HomeComponent, ) { val currentTypeFilter = component.filterState.typeCategoryFilter val currentStatusFilter = component.filterState.statusFilter val categories by component.categoryManager.categoriesFlow.collectAsState() val clipShape = myShapes.defaultRounded val showCategoryOption by component.categoryActions.collectAsState() fun showCategoryOption(item: Category?) { component.showCategoryOptions(item) } fun closeCategoryOptions() { component.closeCategoryOptions() } Column( modifier .padding(start = 16.dp) .clip(clipShape) .border(1.dp, myColors.surface, clipShape) .padding(1.dp) ) { var expendedItem: DownloadStatusCategoryFilter? by remember { mutableStateOf( currentStatusFilter ) } for (statusCategoryFilter in DefinedStatusCategories.values()) { StatusFilterItem( isExpanded = expendedItem == statusCategoryFilter, currentTypeCategoryFilter = currentTypeFilter, currentStatusCategoryFilter = currentStatusFilter, statusFilter = statusCategoryFilter, categories = categories, onFilterChange = { component.onCategoryFilterChange(statusCategoryFilter, it) }, onRequestExpand = { expand -> expendedItem = statusCategoryFilter.takeIf { expand } }, onItemsDroppedInCategory = { category, ids -> component.moveItemsToCategory(category, ids) }, onRequestOpenOptionMenu = { showCategoryOption(it) }, onCategoryReorderRequest = {fromIndex, delta -> component.reorderCategory(fromIndex, delta) } ) } } showCategoryOption?.let { CategoryOption( categoryOptionMenuState = it, onDismiss = { closeCategoryOptions() } ) } } @Composable fun CategoryOption( categoryOptionMenuState: CategoryActions, onDismiss: () -> Unit, ) { ShowOptionsInPopup( MenuItem.SubMenu( icon = categoryOptionMenuState.categoryItem?.rememberIconPainter(), title = categoryOptionMenuState.categoryItem?.name?.asStringSource() ?: Res.string.categories.asStringSource(), categoryOptionMenuState.menu, ), onDismiss ) } @Composable private fun HomeMenuBar( component: HomeComponent, modifier: Modifier, ) { val nativeMenuBarWithTitleBarInSettings by component.useNativeMenuBar.collectAsState() val menu = component.menu if (nativeMenuBarWithTitleBarInSettings) { val scope = LocalFrameWindowScope.current NativeMenuBar(scope, menu) } else { MenuBar( modifier, menu ) } } @Composable private fun Footer(component: HomeComponent) { Row( modifier = Modifier.padding(vertical = 8.dp, horizontal = 16.dp), horizontalArrangement = Arrangement.spacedBy(8.dp) ) { Spacer(Modifier.weight(1f)) val activeCount by component.activeDownloadCountFlow.collectAsState() FooterItem(MyIcons.activeCount, activeCount.toString(), "") val size by component.globalSpeedFlow.collectAsState(0) val speed = convertPositiveBytesToSizeUnit(size, LocalSpeedUnit.current) if (speed != null) { val speedText = speed.formatedValue() val unitText = speed.unit.toString() + "/s" FooterItem(MyIcons.speed, speedText, unitText) } } } @Composable private fun FooterItem(icon: IconSource, value: String, unit: String) { Row(verticalAlignment = Alignment.CenterVertically) { WithContentAlpha(0.25f) { MyIcon(icon, null, Modifier.size(16.dp)) } Spacer(Modifier.width(8.dp)) WithContentAlpha(0.75f) { Text(value, maxLines = 1, fontSize = myTextSizes.base) } Spacer(Modifier.width(8.dp)) WithContentAlpha(0.25f) { Text(unit, maxLines = 1, fontSize = myTextSizes.base) } } } @Composable private fun TopBar(component: HomeComponent) { Row( modifier = Modifier.padding(start = 16.dp, end = 16.dp), verticalAlignment = Alignment.CenterVertically ) { HomeMenuBar(component, Modifier) Box(Modifier.weight(1f)) HomeSearch( component = component, modifier = Modifier, textPadding = PaddingValues(8.dp), ) } } @Composable fun HomeSearch( component: HomeComponent, modifier: Modifier, textPadding: PaddingValues = PaddingValues(horizontal = 8.dp), ) { val searchBoxInteractionSource = remember { MutableInteractionSource() } val isFocused by searchBoxInteractionSource.collectIsFocusedAsState() WithLanguageDirection { SearchBox( text = component.filterState.textToSearch, onTextChange = { component.filterState.textToSearch = it }, textPadding = textPadding, interactionSource = searchBoxInteractionSource, modifier = modifier .width( animateDpAsState( if (isFocused) 220.dp else 180.dp ).value ) ) } } ================================================ FILE: desktop/app/src/main/kotlin/com/abdownloadmanager/desktop/pages/home/HomePersistedState.kt ================================================ package com.abdownloadmanager.desktop.pages.home import com.abdownloadmanager.shared.ui.widget.table.customtable.TableState import arrow.optics.Lens import ir.amirab.util.config.floatKeyOf import ir.amirab.util.config.getDecoded import ir.amirab.util.config.keyOfEncoded import ir.amirab.util.config.putEncodedNullable import ir.amirab.util.config.MapConfig import ir.amirab.util.config.booleanKeyOf import kotlinx.serialization.Serializable import kotlinx.serialization.json.Json import org.koin.core.component.KoinComponent import org.koin.core.component.inject @Serializable data class HomePageStateToPersist( val downloadListState: TableState.SerializableTableState? = null, val windowSize: Pair = 1000f to 500f, val isMaximized: Boolean = false, val categoriesWidth: Float = 185f, ) { class ConfigLens(prefix: String) : Lens, KoinComponent { private val json: Json by inject() class Keys(prefix: String) { val windowWidth = floatKeyOf("${prefix}window.width") val windowHeight = floatKeyOf("${prefix}window.height") val isMaximized = booleanKeyOf("${prefix}window.isMaximized") val categoriesWidth = floatKeyOf("${prefix}categories.width") val downloadListTableState = keyOfEncoded("${prefix}downloadListState") } private val keys = Keys(prefix) override fun get(source: MapConfig): HomePageStateToPersist { val default by lazy { HomePageStateToPersist() } return with(json) { HomePageStateToPersist( downloadListState = source.getDecoded(keys.downloadListTableState), categoriesWidth = source.get(keys.categoriesWidth) ?: default.categoriesWidth, windowSize = run { val width = source.get(keys.windowWidth) val height = source.get(keys.windowHeight) if (height != null && width != null) { width to height } else { default.windowSize } }, isMaximized = source.get(keys.isMaximized) ?: default.isMaximized, ) } } override fun set(source: MapConfig, focus: HomePageStateToPersist): MapConfig { with(json) { source.put(keys.windowWidth, focus.windowSize.first) source.put(keys.windowHeight, focus.windowSize.second) source.put(keys.isMaximized, focus.isMaximized) source.put(keys.categoriesWidth, focus.categoriesWidth) source.putEncodedNullable(keys.downloadListTableState, focus.downloadListState) } return source } } } ================================================ FILE: desktop/app/src/main/kotlin/com/abdownloadmanager/desktop/pages/home/HomeWindow.kt ================================================ package com.abdownloadmanager.desktop.pages.home import androidx.compose.foundation.layout.BoxWithConstraints import androidx.compose.runtime.* import androidx.compose.ui.Alignment import androidx.compose.ui.window.WindowPlacement import androidx.compose.ui.window.WindowPosition import androidx.compose.ui.window.rememberWindowState import com.abdownloadmanager.shared.util.LocalShortCutManager import com.abdownloadmanager.desktop.window.custom.CustomWindow import com.abdownloadmanager.desktop.window.custom.rememberWindowController import com.abdownloadmanager.shared.util.ui.icon.MyIcons import com.abdownloadmanager.desktop.utils.AppInfo import com.abdownloadmanager.shared.util.mvi.HandleEffects import java.awt.Dimension @Composable fun HomeWindow( homeComponent: HomeComponent, onCLoseRequest: () -> Unit, ) { val size by homeComponent.windowSize.collectAsState() val isMaximized by homeComponent.isMaximized.collectAsState() val windowState = rememberWindowState( size = size, position = WindowPosition.Aligned(Alignment.Center), placement = if (isMaximized) { WindowPlacement.Maximized } else { WindowPlacement.Floating } ) val onCloseRequest = onCLoseRequest val windowIcon = MyIcons.appIcon val windowController = rememberWindowController( AppInfo.displayName, windowIcon.rememberPainter(), ) CompositionLocalProvider( LocalShortCutManager provides homeComponent.shortcutManager ) { CustomWindow( state = windowState, onCloseRequest = onCloseRequest, windowController = windowController, onKeyEvent = { homeComponent.shortcutManager.handle(it) } ) { LaunchedEffect(windowState.size) { if (!windowState.isMinimized && windowState.placement == WindowPlacement.Floating) { homeComponent.setWindowSize(windowState.size) } } LaunchedEffect(windowState.placement) { homeComponent.setIsMaximized(windowState.placement == WindowPlacement.Maximized) } window.minimumSize = Dimension( 400, 400 ) HandleEffects(homeComponent) { when (it) { HomeComponent.Effects.BringToFront -> { windowState.isMinimized = false window.toFront() } else -> {} } } BoxWithConstraints { HomePage(homeComponent) } } } } ================================================ FILE: desktop/app/src/main/kotlin/com/abdownloadmanager/desktop/pages/home/dropDownloadItemsHere.kt ================================================ package com.abdownloadmanager.desktop.pages.home import androidx.compose.foundation.draganddrop.dragAndDropTarget import androidx.compose.runtime.getValue import androidx.compose.runtime.remember import androidx.compose.runtime.rememberUpdatedState import androidx.compose.ui.Modifier import androidx.compose.ui.composed import androidx.compose.ui.draganddrop.DragAndDropEvent import androidx.compose.ui.draganddrop.DragAndDropTarget import androidx.compose.ui.draganddrop.awtTransferable import ir.amirab.downloader.monitor.IDownloadItemState internal fun Modifier.dropDownloadItemsHere( onDragIn: () -> Unit, onDragDone: () -> Unit, onItemsDropped: (ids: List) -> Unit, ): Modifier { return composed { val onDragIn by rememberUpdatedState(onDragIn) val onDragDone by rememberUpdatedState(onDragDone) val onItemsDropped by rememberUpdatedState(onItemsDropped) dragAndDropTarget( shouldStartDragAndDrop = { it.awtTransferable.isDataFlavorSupported(DownloadItemListDataFlavor) }, target = remember { object : DragAndDropTarget { override fun onEntered(event: DragAndDropEvent) { onDragIn() } override fun onExited(event: DragAndDropEvent) { onDragDone() } override fun onDrop(event: DragAndDropEvent): Boolean { onDragDone() val items = (event.awtTransferable.getTransferData(DownloadItemListDataFlavor) as List<*>) .filterIsInstance() onItemsDropped(items.map { it.id }) return true } } } ) } } ================================================ FILE: desktop/app/src/main/kotlin/com/abdownloadmanager/desktop/pages/home/sections/DownloadList.kt ================================================ package com.abdownloadmanager.desktop.pages.home.sections import com.abdownloadmanager.shared.util.DOUBLE_CLICK_DELAY import com.abdownloadmanager.shared.util.ui.WithContentAlpha import com.abdownloadmanager.shared.util.ui.myColors import com.abdownloadmanager.shared.ui.widget.CheckBox import com.abdownloadmanager.shared.ui.widget.Text import com.abdownloadmanager.shared.ui.widget.table.customtable.Table import com.abdownloadmanager.shared.ui.widget.table.customtable.styled.MyStyledTableHeader import com.abdownloadmanager.shared.ui.widget.menu.custom.LocalMenuDisabledItemBehavior import com.abdownloadmanager.shared.ui.widget.menu.custom.MenuDisabledItemBehavior import com.abdownloadmanager.shared.ui.widget.menu.custom.ShowOptionsInPopup import ir.amirab.util.compose.action.MenuItem import androidx.compose.foundation.* import androidx.compose.foundation.draganddrop.dragAndDropSource import androidx.compose.foundation.interaction.MutableInteractionSource import androidx.compose.foundation.layout.* import androidx.compose.foundation.lazy.LazyListState import androidx.compose.runtime.* import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draganddrop.DragAndDropTransferAction import androidx.compose.ui.draganddrop.DragAndDropTransferData import androidx.compose.ui.draganddrop.DragAndDropTransferable import androidx.compose.ui.draw.clip import androidx.compose.ui.graphics.Color import androidx.compose.ui.input.key.* import androidx.compose.ui.input.pointer.* import androidx.compose.ui.platform.LocalWindowInfo import androidx.compose.ui.unit.dp import com.abdownloadmanager.desktop.pages.home.DownloadItemTransferable import com.abdownloadmanager.resources.Res import com.abdownloadmanager.shared.ui.widget.table.customtable.CellSize import com.abdownloadmanager.shared.ui.widget.table.customtable.CustomCellRenderer import com.abdownloadmanager.shared.ui.widget.table.customtable.SortableCell import com.abdownloadmanager.shared.ui.widget.table.customtable.TableCell import com.abdownloadmanager.shared.ui.widget.table.customtable.TableState import com.abdownloadmanager.shared.util.FileIconProvider import com.abdownloadmanager.shared.util.category.CategoryManager import com.abdownloadmanager.shared.util.category.rememberCategoryOf import com.abdownloadmanager.shared.util.ui.theme.myShapes import ir.amirab.downloader.monitor.* import ir.amirab.util.compose.resources.myStringResource import ir.amirab.util.compose.StringSource import ir.amirab.util.compose.asStringSource import ir.amirab.util.desktop.isCtrlPressed import ir.amirab.util.desktop.isShiftPressed import ir.amirab.util.ifThen import kotlinx.coroutines.delay class DownloadListContext( val onNewSelection: (List) -> Unit, val downloadList: List, val isAllSelected: Boolean, ) { fun newSelection(ids: List, isSelected: Boolean) { onNewSelection(ids.filter { isSelected }) } fun changeAllSelection(isSelected: Boolean) { newSelection(downloadList.map { it.id }, isSelected) } } private val LocalDownloadListContext = compositionLocalOf { error("DownloadListContext not provided") } @Composable fun DownloadList( modifier: Modifier, downloadList: List, downloadOptions: MenuItem.SubMenu?, onRequestOpenOption: (IDownloadItemState) -> Unit, tableState: TableState, onRequestCloseOption: () -> Unit, selectionList: List, onItemSelectionChange: (Long, Boolean) -> Unit, onRequestOpenDownload: (Long) -> Unit, onNewSelection: (List) -> Unit, lastSelectedId: Long?, fileIconProvider: FileIconProvider, categoryManager: CategoryManager, lazyListState: LazyListState ) { ShowDownloadOptions( downloadOptions, onRequestCloseOption ) val isALlSelected by derivedStateOf { val list = downloadList if (list.isEmpty()) { false } else { list.map { it.id }.all { it in selectionList } } } val listToBeDragged by rememberUpdatedState( downloadList.filter { it.id in selectionList } ) val tableInteractionSource = remember { MutableInteractionSource() } fun newSelection(ids: List, isSelected: Boolean) { onNewSelection(ids.filter { isSelected }) } fun changeAllSelection(isSelected: Boolean) { newSelection(downloadList.map { it.id }, isSelected) } val windowInfo = LocalWindowInfo.current CompositionLocalProvider( LocalDownloadListContext provides DownloadListContext( onNewSelection, downloadList, isALlSelected, ) ) { val itemHorizontalPadding = 16.dp Table( tableState = tableState, listState = lazyListState, key = { it.id }, list = downloadList, modifier = modifier .onKeyEvent { if (it.key == Key.A && isCtrlPressed(windowInfo)) { changeAllSelection(true) true } else { false } } .onKeyEvent { if (it.key == Key.Escape) { changeAllSelection(false) true } else { false } } .clickable( indication = null, interactionSource = tableInteractionSource, onClick = { //deselect all on click empty area changeAllSelection(false) }, ), drawOnEmpty = { WithContentAlpha(0.75f) { Text(myStringResource(Res.string.list_is_empty), Modifier.align(Alignment.Center)) } }, wrapHeader = { MyStyledTableHeader(itemHorizontalPadding = itemHorizontalPadding, content = it) }, wrapItem = { _, item, rowContent -> val isSelected = selectionList.contains(item.id) var shouldWaitForSecondClick by remember { mutableStateOf(false) } LaunchedEffect(shouldWaitForSecondClick) { delay(DOUBLE_CLICK_DELAY) if (shouldWaitForSecondClick) { shouldWaitForSecondClick = false } } val itemInteractionSource = remember { MutableInteractionSource() } CompositionLocalProvider( LocalDownloadItemProperties provides DownloadItemProperties( isSelected, item, ) ) { val windowInfo = LocalWindowInfo.current WithContentAlpha(1f) { val shape = myShapes.defaultRounded Box( Modifier .widthIn(min = getTableSize().visibleWidth) .ifThen(isSelected) { dragAndDropSource( drawDragDecoration = {}, transferData = { val selectedDownloads = listToBeDragged if (selectedDownloads.isEmpty() || !isSelected) { return@dragAndDropSource null } val shiftPressed = isShiftPressed(windowInfo) val supportedActions = listOf( if (shiftPressed) { DragAndDropTransferAction.Move } else { DragAndDropTransferAction.Copy } ) DragAndDropTransferData( transferable = DragAndDropTransferable( DownloadItemTransferable(selectedDownloads) ), supportedActions = supportedActions, ) } ) } .onClick( interactionSource = itemInteractionSource ) { if (shouldWaitForSecondClick) { onRequestOpenDownload(item.id) shouldWaitForSecondClick = false } else { if (isCtrlPressed(windowInfo)) { onItemSelectionChange(item.id, !isSelected) } else { changeAllSelection(false) onItemSelectionChange(item.id, true) shouldWaitForSecondClick = true } } } .onClick( matcher = PointerMatcher.mouse(PointerButton.Secondary), ) { onRequestOpenOption(item) } .onClick( enabled = lastSelectedId != null, keyboardModifiers = { this.isShiftPressed } ) { val lastSelected = lastSelectedId ?: return@onClick val currentId = item.id val ids = tableState.getARangeOfItems( list = downloadList, id = { it.id }, fromItem = lastSelected, toItem = currentId, ) newSelection(ids, true) } .padding(vertical = 1.dp) .clip(shape) .indication( interactionSource = itemInteractionSource, indication = LocalIndication.current ) .hoverable(itemInteractionSource) .focusable( interactionSource = itemInteractionSource ) .let { if (isSelected) { val selectionColor = myColors.onBackground it .border( 1.dp, myColors.selectionGradient(0.10f, 0.05f, selectionColor), shape ) .background(myColors.selectionGradient(0.15f, 0.03f, selectionColor)) } else { it.border(1.dp, Color.Transparent) } } .padding(vertical = 6.dp, horizontal = itemHorizontalPadding) ) { rowContent() } } } } ) { cell, item -> when (cell) { DownloadListCells.Check -> { CheckCell( onCheckedChange = { downloadId, isChecked -> val currentSelection = selectionList.find { downloadId == it }?.let { true } ?: false onItemSelectionChange(downloadId, !currentSelection) }, dItemState = item ) } DownloadListCells.Name -> { NameCell( itemState = item, category = categoryManager.rememberCategoryOf(item.id), fileIconProvider = fileIconProvider, ) } DownloadListCells.DateAdded -> { DateAddedCell(item) } DownloadListCells.Size -> { SizeCell(item) } DownloadListCells.Speed -> { SpeedCell(item) } DownloadListCells.Status -> { StatusCell(item) } DownloadListCells.TimeLeft -> { TimeLeftCell(item) } } } } } sealed interface DownloadListCells : TableCell { data object Check : DownloadListCells, CustomCellRenderer { override val id: String = "#" override val name: StringSource = "#".asStringSource() override val size: CellSize = CellSize.Fixed(26.dp) @Composable override fun drawHeader() { val c = LocalDownloadListContext.current CheckBox( c.isAllSelected, { c.changeAllSelection(it) }, modifier = Modifier.size(12.dp) ) } } data object Name : DownloadListCells, SortableCell { override fun comparator(): Comparator = compareBy { it.name } override val id: String = "Name" override val name: StringSource = Res.string.name.asStringSource() override val size: CellSize = CellSize.Resizeable(50.dp..1000.dp, 200.dp) } data object Status : DownloadListCells, SortableCell { override fun comparator(): Comparator = compareBy( { it.statusOrFinished().order }, { when (it) { is CompletedDownloadItemState -> 100 is ProcessingDownloadItemState -> it.percent ?: 0 } } ) override val id: String = "Status" override val name: StringSource = Res.string.status.asStringSource() override val size: CellSize = CellSize.Resizeable(100.dp..140.dp, 120.dp) } data object Size : DownloadListCells, SortableCell { override fun comparator(): Comparator = compareBy { it.contentLength } override val id: String = "Size" override val name: StringSource = Res.string.size.asStringSource() override val size: CellSize = CellSize.Resizeable(70.dp..110.dp, 70.dp) } data object Speed : DownloadListCells, SortableCell { override fun comparator(): Comparator = compareBy { it.speedOrNull() ?: 0L } override val id: String = "Speed" override val name: StringSource = Res.string.speed.asStringSource() override val size: CellSize = CellSize.Resizeable(70.dp..110.dp, 80.dp) } data object TimeLeft : DownloadListCells, SortableCell { override fun comparator(): Comparator = compareBy { it.remainingOrNull() ?: Long.MAX_VALUE } override val id: String = "Time Left" override val name: StringSource = Res.string.time_left.asStringSource() override val size: CellSize = CellSize.Resizeable(70.dp..150.dp, 100.dp) } data object DateAdded : DownloadListCells, SortableCell { override fun comparator(): Comparator = compareBy { it.dateAdded } override val id: String = "Date Added" override val name: StringSource = Res.string.date_added.asStringSource() override val size: CellSize = CellSize.Resizeable(90.dp..150.dp, 100.dp) } } @Composable fun ShowDownloadOptions( options: MenuItem.SubMenu?, onDismiss: () -> Unit, ) { if (options != null) { CompositionLocalProvider( LocalMenuDisabledItemBehavior provides MenuDisabledItemBehavior.LowerOpacity ) { ShowOptionsInPopup(options, onDismiss) } } } ================================================ FILE: desktop/app/src/main/kotlin/com/abdownloadmanager/desktop/pages/home/sections/Filters.kt ================================================ package com.abdownloadmanager.desktop.pages.home.sections import com.abdownloadmanager.shared.util.ui.widget.MyIcon import com.abdownloadmanager.shared.util.ui.icon.MyIcons import com.abdownloadmanager.shared.util.ui.myColors import com.abdownloadmanager.shared.util.ui.theme.myTextSizes import com.abdownloadmanager.shared.ui.widget.MyTextField import com.abdownloadmanager.shared.util.ui.WithContentAlpha import androidx.compose.animation.* import androidx.compose.animation.core.animateFloatAsState import androidx.compose.foundation.clickable import androidx.compose.foundation.interaction.MutableInteractionSource import androidx.compose.foundation.layout.* import androidx.compose.foundation.shape.CircleShape import androidx.compose.runtime.Composable import androidx.compose.runtime.remember import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip import androidx.compose.ui.input.pointer.PointerIcon import androidx.compose.ui.input.pointer.pointerHoverIcon import androidx.compose.ui.unit.dp import com.abdownloadmanager.resources.Res import com.abdownloadmanager.shared.ui.widget.MyTextFieldIcon import com.abdownloadmanager.shared.ui.widget.MyTextFieldWithIcons import com.abdownloadmanager.shared.util.ui.theme.myShapes import com.abdownloadmanager.shared.util.ui.theme.mySpacings import ir.amirab.util.compose.resources.myStringResource @Composable fun SearchBox( text: String, onTextChange: (String) -> Unit, textPadding: PaddingValues = PaddingValues(horizontal = 8.dp), interactionSource: MutableInteractionSource = remember { MutableInteractionSource() }, placeholder: String = myStringResource(Res.string.search_in_the_list), modifier: Modifier, ) { val shape = myShapes.defaultRounded val textSize = myTextSizes.base MyTextField( text = text, fontSize = textSize, onTextChange = onTextChange, shape = shape, textPadding = textPadding, interactionSource = interactionSource, start = { WithContentAlpha( animateFloatAsState(if (text.isBlank()) 0.9f else 1f).value ) { MyIcon( MyIcons.search, myStringResource(Res.string.search), Modifier .padding(start = 8.dp) .size(mySpacings.iconSize) ) } }, end = { AnimatedVisibility(text.isNotBlank()) { MyTextFieldIcon( icon = MyIcons.clear, enabled = true, contentDescription = myStringResource(Res.string.clear), onClick = { onTextChange("") } ) } }, modifier = modifier, placeholder = placeholder ) } ================================================ FILE: desktop/app/src/main/kotlin/com/abdownloadmanager/desktop/pages/home/sections/TableDownloadItem.kt ================================================ @file:OptIn(ExperimentalTime::class) package com.abdownloadmanager.desktop.pages.home.sections import com.abdownloadmanager.shared.util.ui.LocalContentColor import com.abdownloadmanager.shared.util.ui.widget.MyIcon import com.abdownloadmanager.shared.util.ui.myColors import com.abdownloadmanager.shared.util.ui.theme.myTextSizes import androidx.compose.animation.core.animateFloatAsState import androidx.compose.animation.core.tween import androidx.compose.foundation.background import androidx.compose.foundation.border import androidx.compose.foundation.layout.* import androidx.compose.foundation.shape.CircleShape import androidx.compose.runtime.* import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip import androidx.compose.ui.focus.focusProperties import androidx.compose.ui.graphics.Brush import androidx.compose.ui.graphics.Color import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp import com.abdownloadmanager.shared.ui.widget.CheckBox import com.abdownloadmanager.shared.ui.widget.Text import com.abdownloadmanager.resources.Res import com.abdownloadmanager.shared.util.* import com.abdownloadmanager.shared.util.FileIconProvider import com.abdownloadmanager.shared.util.category.Category import ir.amirab.util.compose.resources.myStringResource import ir.amirab.downloader.downloaditem.DownloadJobStatus import ir.amirab.downloader.monitor.CompletedDownloadItemState import ir.amirab.downloader.monitor.IDownloadItemState import ir.amirab.downloader.monitor.ProcessingDownloadItemState import ir.amirab.downloader.utils.ExceptionUtils import ir.amirab.util.compose.resources.MyStringResource import kotlinx.coroutines.delay import kotlinx.coroutines.isActive import kotlinx.datetime.* import kotlin.time.Clock import kotlin.time.ExperimentalTime import kotlin.time.Instant val LocalDownloadItemProperties = compositionLocalOf { error("not provided download properties") } data class DownloadItemProperties( val isSelected: Boolean, val iDownloadItemState: IDownloadItemState, ) @Composable private fun isSelected(): Boolean { return LocalDownloadItemProperties.current.isSelected } @Composable fun CheckCell( onCheckedChange: (Long, Boolean) -> Unit, dItemState: IDownloadItemState, ) { val isChecked = isSelected() CheckBox( value = isChecked, onValueChange = { onCheckedChange(dItemState.id, it) }, modifier = Modifier.focusProperties { canFocus = false }, size = 12.dp, ) } @Composable fun NameCell( itemState: IDownloadItemState, category: Category?, fileIconProvider: FileIconProvider, ) { val fileIcon = fileIconProvider.rememberIcon(itemState.name) Row( verticalAlignment = Alignment.CenterVertically ) { MyIcon( icon = fileIcon, modifier = Modifier.size(16.dp), contentDescription = null, // tint = LocalContentColor.current / 75 ) Spacer(Modifier.width(6.dp)) Column { Text( text = itemState.name, maxLines = 1, fontSize = myTextSizes.base, overflow = TextOverflow.Ellipsis, ) Text( category?.name ?: myStringResource(Res.string.general), maxLines = 1, fontSize = myTextSizes.xs, color = LocalContentColor.current / 50 ) } } } @Composable fun TimeLeftCell( itemState: IDownloadItemState, ) { (itemState as? ProcessingDownloadItemState)?.remainingTime?.let { remaining -> Text( text = convertTimeRemainingToHumanReadable(remaining, TimeNames.ShortNames), maxLines = 1, fontSize = myTextSizes.base, overflow = TextOverflow.Ellipsis, ) } } @Composable fun DateAddedCell( itemState: IDownloadItemState, ) { var dateAddedString by remember { mutableStateOf("") } val useRelativeDateTime = LocalUseRelativeDateTime.current LaunchedEffect( itemState.dateAdded, useRelativeDateTime, ) { val instant = Instant.fromEpochMilliseconds(itemState.dateAdded) if (useRelativeDateTime) { while (isActive) { val now = Clock.System.now() val period = now.periodUntil(instant, TimeZone.UTC) val relativeTime = prettifyRelativeTime(period) dateAddedString = relativeTime delay(1000) } } else { val dateTime = instant.toLocalDateTime(TimeZone.currentSystemDefault()) dateAddedString = dateTime.format(MyDateAndTimeFormats.fullDateTime) } } Text( text = dateAddedString, maxLines = 1, fontSize = myTextSizes.base, overflow = TextOverflow.Ellipsis, ) } @Composable fun SpeedCell( itemState: IDownloadItemState, ) { (itemState as? ProcessingDownloadItemState)?.speed?.let { remaining -> if (itemState.status == DownloadJobStatus.Downloading) { Text( text = convertPositiveSpeedToHumanReadable( remaining, LocalSpeedUnit.current, ), maxLines = 1, fontSize = myTextSizes.base, overflow = TextOverflow.Ellipsis, ) } } } @Composable fun SizeCell( item: IDownloadItemState, ) { item.contentLength.let { Text( convertPositiveSizeToHumanReadable( it, LocalSizeUnit.current ).rememberString(), maxLines = 1, fontSize = myTextSizes.base, overflow = TextOverflow.Ellipsis, ) } } @Composable fun StatusCell( itemState: IDownloadItemState, ) { when (itemState) { is ProcessingDownloadItemState -> { when (val status = itemState.status) { is DownloadJobStatus.Canceled -> { ProgressAndPercent( itemState.percent, if (ExceptionUtils.isNormalCancellation(status.e)) { if (!itemState.gotAnyProgress) { DownloadProgressStatus.Added } else { DownloadProgressStatus.Paused } } else { DownloadProgressStatus.Error }, itemState.gotAnyProgress, itemState.isWaiting, ) } DownloadJobStatus.IDLE -> { ProgressAndPercent( itemState.percent, if (!itemState.gotAnyProgress) { DownloadProgressStatus.Added } else { DownloadProgressStatus.Paused }, itemState.gotAnyProgress, itemState.isWaiting, ) } DownloadJobStatus.Downloading -> { ProgressAndPercent( itemState.percent, DownloadProgressStatus.Downloading, itemState.gotAnyProgress, itemState.isWaiting, ) } is DownloadJobStatus.PreparingFile -> { ProgressAndPercent( status.percent, DownloadProgressStatus.CreatingFile, itemState.gotAnyProgress, itemState.isWaiting, ) } is DownloadJobStatus.Resuming -> { ProgressAndPercent( itemState.percent, DownloadProgressStatus.Resuming, itemState.gotAnyProgress, itemState.isWaiting, ) } is DownloadJobStatus.Retrying -> { ProgressAndPercent( itemState.percent, DownloadProgressStatus.Retrying, itemState.gotAnyProgress, itemState.isWaiting, ) } DownloadJobStatus.Finished, -> SimpleStatus( myStringResource(itemState.status.toStringResource()), myColors.success, ) } } is CompletedDownloadItemState -> { SimpleStatus( myStringResource(Res.string.finished), myColors.success, ) } } } @Composable private fun DownloadJobStatus.toStringResource(): MyStringResource { return when (this) { is DownloadJobStatus.Canceled -> { Res.string.canceled } DownloadJobStatus.Downloading -> { Res.string.downloading } DownloadJobStatus.Finished -> { Res.string.finished } DownloadJobStatus.IDLE -> { Res.string.idle } is DownloadJobStatus.PreparingFile -> { Res.string.preparing_file } DownloadJobStatus.Resuming -> { Res.string.resuming } is DownloadJobStatus.Retrying -> { Res.string.retrying } } } private fun DownloadProgressStatus.toStringResource(): MyStringResource { return when (this) { DownloadProgressStatus.Added -> { Res.string.added } DownloadProgressStatus.Error -> { Res.string.error } DownloadProgressStatus.Paused -> { Res.string.paused } DownloadProgressStatus.CreatingFile -> { Res.string.creating_file } DownloadProgressStatus.Resuming -> { Res.string.resuming } DownloadProgressStatus.Downloading -> { Res.string.downloading } DownloadProgressStatus.Retrying -> { Res.string.retrying } } } @Composable private fun SimpleStatus( string: String, color: Color = LocalContentColor.current ) { Text( text = string, maxLines = 1, fontSize = myTextSizes.base, overflow = TextOverflow.Ellipsis, color = color, ) } private enum class DownloadProgressStatus { Added, Error, Paused, CreatingFile, Resuming, Downloading, Retrying } @Composable private fun ProgressAndPercent( percent: Int?, status: DownloadProgressStatus, gotAnyProgress: Boolean, isWaiting: Boolean, ) { val background = when (status) { DownloadProgressStatus.Error -> myColors.errorGradient DownloadProgressStatus.Paused, DownloadProgressStatus.Added -> myColors.warningGradient DownloadProgressStatus.CreatingFile -> myColors.infoGradient DownloadProgressStatus.Resuming -> myColors.infoGradient DownloadProgressStatus.Downloading -> myColors.primaryGradient DownloadProgressStatus.Retrying -> myColors.errorGradient } val statusString = myStringResource( if (isWaiting) { Res.string.waiting } else { status.toStringResource() } ) Column { val statusText = if (gotAnyProgress) { "${percent ?: "."}% $statusString" } else { statusString } SimpleStatus(statusText, LocalContentColor.current) if (status != DownloadProgressStatus.Added) { Spacer(Modifier.height(2.5.dp)) ProgressStatus( percent, background ) } } } @Composable private fun ProgressStatus( percent: Int?, background: Brush = myColors.primaryGradient, ) { Box( Modifier .fillMaxWidth() .clip(CircleShape) .border(Dp.Hairline, myColors.onSurface / 0.1f, CircleShape) .background(myColors.surface) ) { if (percent != null) { val w = (percent / 100f).coerceIn(0f..1f) Spacer( Modifier .height(5.dp) .fillMaxWidth( animateFloatAsState( w, tween(100) ).value ) .background(background) ) } } } ================================================ FILE: desktop/app/src/main/kotlin/com/abdownloadmanager/desktop/pages/home/sections/category/Categories.kt ================================================ package com.abdownloadmanager.desktop.pages.home.sections.category import androidx.compose.animation.* import androidx.compose.animation.core.LinearEasing import androidx.compose.animation.core.RepeatMode import androidx.compose.animation.core.rememberInfiniteTransition import androidx.compose.animation.core.infiniteRepeatable import androidx.compose.animation.core.tween import androidx.compose.foundation.PointerMatcher import androidx.compose.foundation.background import androidx.compose.foundation.border import com.abdownloadmanager.shared.util.ui.widget.MyIcon import com.abdownloadmanager.shared.util.ui.icon.MyIcons import com.abdownloadmanager.shared.util.ui.theme.myTextSizes import com.abdownloadmanager.shared.ui.widget.ExpandableItem import com.abdownloadmanager.shared.util.ui.WithContentAlpha import androidx.compose.foundation.clickable import androidx.compose.foundation.hoverable import androidx.compose.foundation.interaction.MutableInteractionSource import androidx.compose.foundation.interaction.collectIsHoveredAsState import androidx.compose.foundation.layout.* import androidx.compose.foundation.onClick import androidx.compose.foundation.selection.selectable import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.RoundedCornerShape import com.abdownloadmanager.shared.ui.widget.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue import androidx.compose.runtime.key import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.alpha import androidx.compose.ui.draw.clip import androidx.compose.ui.draw.rotate import androidx.compose.ui.graphics.Color import androidx.compose.ui.input.pointer.PointerButton import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp import com.abdownloadmanager.desktop.pages.home.dropDownloadItemsHere import com.abdownloadmanager.shared.util.ui.myColors import com.abdownloadmanager.shared.util.div import com.abdownloadmanager.resources.Res import com.abdownloadmanager.shared.pages.home.category.DownloadStatusCategoryFilter import com.abdownloadmanager.shared.ui.widget.DelayedTooltipPopup import com.abdownloadmanager.shared.util.category.Category import com.abdownloadmanager.shared.util.category.rememberIconPainter import com.abdownloadmanager.shared.util.ui.theme.mySpacings import ir.amirab.util.compose.resources.myStringResource import ir.amirab.util.ifThen import sh.calvin.reorderable.ReorderableColumn import sh.calvin.reorderable.ReorderableListItemScope @Composable private fun ReorderableListItemScope.CategoryFilterItem( modifier: Modifier, category: Category, isSelected: Boolean, onItemsDropped: (ids: List) -> Unit, onClick: () -> Unit, isDragging: Boolean, ) { var isDraggingOnMe by remember { mutableStateOf(false) } val interactionSource = remember { MutableInteractionSource() } val isHovered by interactionSource.collectIsHoveredAsState() val shouldShowDragIcon = isHovered && !isDraggingOnMe || isDragging Box( modifier .dropDownloadItemsHere( onDragIn = { isDraggingOnMe = true }, onDragDone = { isDraggingOnMe = false }, onItemsDropped = onItemsDropped, ) .hoverable(interactionSource) .background( if (isSelected) { myColors.onBackground / 0.05f } else Color.Transparent ) .ifThen(isDraggingOnMe) { val infiniteTransition = rememberInfiniteTransition() val color by infiniteTransition.animateColor( initialValue = myColors.primary, targetValue = myColors.secondary, animationSpec = infiniteRepeatable( animation = tween(1000, easing = LinearEasing), repeatMode = RepeatMode.Reverse ) ) border(1.dp, color) } .selectable( selected = isSelected, onClick = onClick ), ) { if (isDraggingOnMe) { DelayedTooltipPopup( {}, myStringResource(Res.string.move_to_this_category), ) } Row( modifier = Modifier .padding(start = 24.dp) .padding(horizontal = 4.dp, vertical = 6.dp), verticalAlignment = Alignment.CenterVertically, ) { WithContentAlpha(if (isSelected) 1f else 0.75f) { val iconPainter = category.rememberIconPainter() MyIcon( iconPainter ?: MyIcons.folder, null, Modifier.size(16.dp), ) Spacer(Modifier.width(4.dp)) Text( category.name, Modifier.weight(1f), maxLines = 1, overflow = TextOverflow.Ellipsis, fontWeight = if (isSelected) FontWeight.Bold else FontWeight.Normal, fontSize = myTextSizes.base ) AnimatedVisibility( visible = shouldShowDragIcon, ) { MyIcon( MyIcons.grip, null, Modifier .draggableHandle() .size(16.dp) .alpha( if (isDragging) { 1f } else { 0.5f } ) ) } } } AnimatedVisibility( isSelected, modifier = Modifier.align(Alignment.CenterStart), enter = scaleIn(), exit = scaleOut(), ) { Spacer( Modifier .height(16.dp) .width(3.dp) .clip( RoundedCornerShape( topStart = 0.dp, bottomStart = 0.dp, bottomEnd = 12.dp, topEnd = 12.dp, ) ) .background(myColors.primary) ) } } } @Composable fun StatusFilterItem( isExpanded: Boolean, onRequestExpand: (Boolean) -> Unit, currentTypeCategoryFilter: Category?, currentStatusCategoryFilter: DownloadStatusCategoryFilter?, statusFilter: DownloadStatusCategoryFilter, categories: List, onCategoryReorderRequest: (index: Int, delta: Int) -> Unit, onItemsDroppedInCategory: (category: Category, downloadIds: List) -> Unit, onFilterChange: ( typeFilter: Category?, ) -> Unit, onRequestOpenOptionMenu: (Category?) -> Unit, ) { val isStatusSelected = currentStatusCategoryFilter == statusFilter val isSelected = isStatusSelected && currentTypeCategoryFilter == null ExpandableItem( modifier = Modifier .onClick( matcher = PointerMatcher.mouse(PointerButton.Secondary), ) { onRequestOpenOptionMenu(null) }, isExpanded = isExpanded, header = { Box( Modifier .height(IntrinsicSize.Max) .background( if (isSelected) { myColors.onBackground / 0.05f } else Color.Transparent ) .selectable( selected = isSelected, onClick = { if (!isExpanded) { onRequestExpand(true) } onFilterChange(null) } ) ) { Row( Modifier.padding(vertical = 4.dp) .padding(start = 16.dp) .padding(end = 2.dp), verticalAlignment = Alignment.CenterVertically, ) { WithContentAlpha(if (isSelected) 1f else 0.75f) { MyIcon( statusFilter.icon, null, Modifier.size(mySpacings.iconSize) ) Spacer(Modifier.width(4.dp)) Text( statusFilter.name.rememberString(), Modifier.weight(1f), fontWeight = if (isSelected) FontWeight.Bold else FontWeight.Normal, fontSize = myTextSizes.lg, overflow = TextOverflow.Ellipsis, maxLines = 1, ) MyIcon( MyIcons.up, null, Modifier .fillMaxHeight().wrapContentHeight() .clip(CircleShape) .size(24.dp) .clickable { onRequestExpand(!isExpanded) } .padding(6.dp) .width(16.dp) .rotate(if (isExpanded) 0f else 180f)) } } AnimatedVisibility( isSelected, modifier = Modifier.align(Alignment.CenterStart), enter = scaleIn(), exit = scaleOut(), ) { Spacer( Modifier .height(16.dp) .width(3.dp) .clip( RoundedCornerShape( topStart = 0.dp, bottomStart = 0.dp, bottomEnd = 12.dp, topEnd = 12.dp, ) ) .background(myColors.primary) ) } } }, body = { ReorderableColumn( list = categories, onSettle = { from, to -> onCategoryReorderRequest(from, to - from) }, ) { index, category, isDragging -> key(category.id) { ReorderableItem { CategoryFilterItem( modifier = Modifier .onClick( matcher = PointerMatcher.mouse(PointerButton.Secondary), ) { onRequestOpenOptionMenu(category) }, category = category, isSelected = isStatusSelected && currentTypeCategoryFilter == category, onItemsDropped = { onItemsDroppedInCategory(category, it) }, onClick = { onFilterChange(category) }, isDragging = isDragging, ) } Spacer(Modifier.height(2.dp)) } } } ) } ================================================ FILE: desktop/app/src/main/kotlin/com/abdownloadmanager/desktop/pages/home/sections/queue/Queues.kt ================================================ package com.abdownloadmanager.desktop.pages.home.sections.queue import androidx.compose.animation.AnimatedVisibility import androidx.compose.animation.animateColor import androidx.compose.animation.animateColorAsState import androidx.compose.animation.core.LinearEasing import androidx.compose.animation.core.RepeatMode import androidx.compose.animation.core.infiniteRepeatable import androidx.compose.animation.core.rememberInfiniteTransition import androidx.compose.animation.core.tween import androidx.compose.animation.scaleIn import androidx.compose.animation.scaleOut import androidx.compose.foundation.PointerMatcher import androidx.compose.foundation.background import androidx.compose.foundation.border import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.IntrinsicSize import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxHeight import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.width import androidx.compose.foundation.layout.wrapContentHeight import androidx.compose.foundation.onClick import androidx.compose.foundation.selection.selectable import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.runtime.Composable import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue import androidx.compose.runtime.key import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip import androidx.compose.ui.draw.rotate import androidx.compose.ui.graphics.Color import androidx.compose.ui.input.pointer.PointerButton import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp import com.abdownloadmanager.desktop.pages.home.HomeComponent import com.abdownloadmanager.shared.pages.home.queue.QueueActions import com.abdownloadmanager.desktop.pages.home.dropDownloadItemsHere import com.abdownloadmanager.resources.Res import com.abdownloadmanager.shared.ui.widget.DelayedTooltipPopup import com.abdownloadmanager.shared.ui.widget.ExpandableItem import com.abdownloadmanager.shared.ui.widget.Text import com.abdownloadmanager.shared.ui.widget.menu.custom.ShowOptionsInPopup import com.abdownloadmanager.shared.util.div import com.abdownloadmanager.shared.util.ui.LocalContentAlpha import com.abdownloadmanager.shared.util.ui.LocalContentColor import com.abdownloadmanager.shared.util.ui.WithContentAlpha import com.abdownloadmanager.shared.util.ui.icon.MyIcons import com.abdownloadmanager.shared.util.ui.myColors import com.abdownloadmanager.shared.util.ui.theme.myShapes import com.abdownloadmanager.shared.util.ui.theme.mySpacings import com.abdownloadmanager.shared.util.ui.theme.myTextSizes import com.abdownloadmanager.shared.util.ui.widget.MyIcon import ir.amirab.downloader.db.QueueModel import ir.amirab.downloader.queue.DownloadQueue import ir.amirab.util.compose.action.MenuItem import ir.amirab.util.compose.asStringSource import ir.amirab.util.compose.resources.myStringResource import ir.amirab.util.ifThen @Composable internal fun QueuesSection( modifier: Modifier, component: HomeComponent, ) { val currentSelectedQueue = component.filterState.queueFilter val queues by component.queueManager.queues.collectAsState() val clipShape = myShapes.defaultRounded val showQueueOption by component.queueActions.collectAsState() fun showQueueOption(downloadQueue: DownloadQueue?) { component.showCategoryOptions(downloadQueue) } fun closeQueueOptions() { component.closeQueueOptions() } val (isExpanded, setExpanded) = remember { mutableStateOf(true) } Column( modifier .padding(start = 16.dp) .clip(clipShape) .border(1.dp, myColors.surface, clipShape) .padding(1.dp), ) { ExpandableItem( isExpanded = isExpanded, modifier = Modifier .onClick( matcher = PointerMatcher.mouse(PointerButton.Secondary), ) { showQueueOption(null) }, header = { Box( Modifier .height(IntrinsicSize.Max) .clickable( onClick = { setExpanded(!isExpanded) } ) ) { Row( Modifier.padding(vertical = 4.dp) .padding(start = 16.dp) .padding(end = 2.dp), verticalAlignment = Alignment.CenterVertically, ) { WithContentAlpha(0.75f) { MyIcon( MyIcons.queue, null, Modifier.size(mySpacings.iconSize) ) Spacer(Modifier.width(4.dp)) Text( myStringResource(Res.string.queues), Modifier.weight(1f), fontWeight = FontWeight.Normal, fontSize = myTextSizes.lg, overflow = TextOverflow.Ellipsis, maxLines = 1, ) MyIcon( MyIcons.up, null, Modifier .fillMaxHeight().wrapContentHeight() .clip(CircleShape) .size(24.dp) .clickable { setExpanded(!isExpanded) } .padding(6.dp) .width(16.dp) .rotate(if (isExpanded) 0f else 180f)) } } } }, body = { Column { queues.forEachIndexed { index, queue -> key(queue.id) { QueueFilterItem( modifier = Modifier .onClick( matcher = PointerMatcher.mouse(PointerButton.Secondary), ) { showQueueOption(queue) }, isSelected = currentSelectedQueue?.id == queue.id, onSelect = { component.onQueueFilterChange(queue.queueModel.value) }, onItemsDroppedInQueue = { downloadIds -> component.moveItemsToQueue(queue, downloadIds) }, queueModel = queue.queueModel.collectAsState().value, isActive = queue.activeFlow.collectAsState().value, parentShape = clipShape, isLast = queues.lastIndex == index ) } } } }, ) } showQueueOption?.let { QueueOption( queueOptionMenuState = it, onDismiss = { closeQueueOptions() } ) } } @Composable private fun QueueFilterItem( isSelected: Boolean, onSelect: () -> Unit, onItemsDroppedInQueue: (List) -> Unit, queueModel: QueueModel, isActive: Boolean, modifier: Modifier = Modifier, // I add this to properly create border on drag when the item is in the last position isLast: Boolean, parentShape: RoundedCornerShape, ) { var isDraggingOnMe by remember { mutableStateOf(false) } Box( modifier .dropDownloadItemsHere( onDragIn = { isDraggingOnMe = true }, onDragDone = { isDraggingOnMe = false }, onItemsDropped = onItemsDroppedInQueue, ) .background( if (isSelected) { myColors.onBackground / 0.05f } else Color.Transparent ) .ifThen(isDraggingOnMe) { val infiniteTransition = rememberInfiniteTransition() val color by infiniteTransition.animateColor( initialValue = myColors.primary, targetValue = myColors.secondary, animationSpec = infiniteRepeatable( animation = tween(1000, easing = LinearEasing), repeatMode = RepeatMode.Reverse ) ) val shape = RoundedCornerShape(0.dp).let { when { isLast -> it.copy( bottomStart = parentShape.bottomStart, bottomEnd = parentShape.bottomEnd, ) else -> it } } border(1.dp, color, shape) } .selectable( selected = isSelected, onClick = { onSelect() } ) ) { if (isDraggingOnMe) { DelayedTooltipPopup( {}, myStringResource(Res.string.move_to_this_queue), ) } Row( Modifier .padding(start = 24.dp) .padding(horizontal = 4.dp, vertical = 6.dp), verticalAlignment = Alignment.CenterVertically, ) { WithContentAlpha(if (isSelected) 1f else 0.75f) { MyIcon( MyIcons.folder, null, Modifier.size(mySpacings.iconSize) ) Spacer(Modifier.width(4.dp)) Text( queueModel.name, Modifier.weight(1f), fontWeight = if (isSelected) FontWeight.Bold else FontWeight.Normal, fontSize = myTextSizes.lg, overflow = TextOverflow.Ellipsis, maxLines = 1, ) val counterColor = animateColorAsState( if (isActive) { myColors.success } else { LocalContentColor.current / LocalContentAlpha.current } ).value Text( text = "${queueModel.queueItems.size}", modifier = Modifier.padding(horizontal = 6.dp), color = counterColor ) } } AnimatedVisibility( isSelected, modifier = Modifier.align(Alignment.CenterStart), enter = scaleIn(), exit = scaleOut(), ) { Spacer( Modifier .height(16.dp) .width(3.dp) .clip( RoundedCornerShape( topStart = 0.dp, bottomStart = 0.dp, bottomEnd = 12.dp, topEnd = 12.dp, ) ) .background(myColors.primary) ) } } } @Composable private fun QueueOption( queueOptionMenuState: QueueActions, onDismiss: () -> Unit, ) { ShowOptionsInPopup( MenuItem.SubMenu( icon = MyIcons.queue, title = queueOptionMenuState.mainQueueModel?.name?.asStringSource() ?: Res.string.queues.asStringSource(), items = queueOptionMenuState.menu, ), onDismiss ) } ================================================ FILE: desktop/app/src/main/kotlin/com/abdownloadmanager/desktop/pages/newQueue/NewQueueDialog.kt ================================================ package com.abdownloadmanager.desktop.pages.newQueue import com.abdownloadmanager.desktop.AppComponent import com.abdownloadmanager.desktop.window.custom.CustomWindow import androidx.compose.runtime.Composable import androidx.compose.runtime.collectAsState import androidx.compose.ui.Alignment import androidx.compose.ui.unit.DpSize import androidx.compose.ui.unit.dp import androidx.compose.ui.window.WindowPosition import androidx.compose.ui.window.rememberWindowState import com.abdownloadmanager.shared.util.ui.theme.LocalUiScale import ir.amirab.util.desktop.screen.applyUiScale @Composable fun NewQueueDialog( appComponent: AppComponent, ) { if (appComponent.showCreateQueueDialog.collectAsState().value){ CustomWindow( state = rememberWindowState( size = DpSize(width = 300.dp, height = 130.dp) .applyUiScale(LocalUiScale.current), position = WindowPosition.Aligned(Alignment.Center), ), resizable = false, onRequestToggleMaximize = null, onRequestMinimize = null, alwaysOnTop = true, onCloseRequest = { appComponent.closeNewQueueDialog() } ) { NewQueue( onQueueCreate = { appComponent.closeNewQueueDialog() appComponent.createNewQueue(it) }, onCloseRequest = { appComponent.closeNewQueueDialog() } ) } } } ================================================ FILE: desktop/app/src/main/kotlin/com/abdownloadmanager/desktop/pages/newQueue/NewQueuePage.kt ================================================ package com.abdownloadmanager.desktop.pages.newQueue import com.abdownloadmanager.desktop.window.custom.WindowTitle import com.abdownloadmanager.shared.ui.widget.ActionButton import com.abdownloadmanager.shared.ui.widget.MyTextField import androidx.compose.foundation.layout.* import androidx.compose.runtime.* import androidx.compose.ui.Modifier import androidx.compose.ui.focus.FocusRequester import androidx.compose.ui.focus.focusRequester import androidx.compose.ui.unit.dp import com.abdownloadmanager.resources.Res import ir.amirab.util.compose.resources.myStringResource @Composable fun NewQueue( onQueueCreate: (String) -> Unit, onCloseRequest: () -> Unit, ) { WindowTitle(myStringResource(Res.string.add_new_queue)) var name by remember { mutableStateOf("") } val focusRequester= remember { FocusRequester() } LaunchedEffect(Unit){ focusRequester.requestFocus() } Column(Modifier) { Spacer(Modifier.height(8.dp)) MyTextField( text = name, onTextChange = { name = it }, modifier = Modifier .focusRequester(focusRequester) .padding(horizontal = 8.dp) .widthIn(max = 400.dp), placeholder = myStringResource(Res.string.queue_name), ) Spacer(Modifier.height(8.dp)) Spacer(Modifier.weight(1f)) Row( modifier = Modifier .fillMaxWidth() .padding(horizontal = 8.dp) .padding(bottom = 8.dp), horizontalArrangement = Arrangement.End, ) { ActionButton( text = myStringResource(Res.string.add), onClick = { onQueueCreate(name) } ) Spacer(Modifier.width(4.dp)) ActionButton( text = myStringResource(Res.string.cancel), onClick = { onCloseRequest() } ) } } } ================================================ FILE: desktop/app/src/main/kotlin/com/abdownloadmanager/desktop/pages/perhostsettings/DesktopPerHostSettingsComponent.kt ================================================ package com.abdownloadmanager.desktop.pages.perhostsettings import com.abdownloadmanager.shared.pages.perhostsettings.BasePerHostSettingsComponent import com.abdownloadmanager.shared.repository.BaseAppRepository import com.abdownloadmanager.shared.util.perhostsettings.PerHostSettingsManager import com.arkivanov.decompose.ComponentContext import kotlinx.coroutines.CoroutineScope class DesktopPerHostSettingsComponent( ctx: ComponentContext, perHostSettingsManager: PerHostSettingsManager, appRepository: BaseAppRepository, appScope: CoroutineScope, closeRequested: () -> Unit, ) : BasePerHostSettingsComponent( ctx = ctx, perHostSettingsManager = perHostSettingsManager, appRepository = appRepository, appScope = appScope, closeRequested = closeRequested, ) { data class Config( override val openedHost: String? ) : BasePerHostSettingsComponent.Config fun bringToFront() { sendEffect(Effects.BringToFront) } sealed interface Effects : BasePerHostSettingsComponent.Effects.Platform { data object BringToFront : Effects } } ================================================ FILE: desktop/app/src/main/kotlin/com/abdownloadmanager/desktop/pages/perhostsettings/PerHostSettingsPage.kt ================================================ package com.abdownloadmanager.desktop.pages.perhostsettings import androidx.compose.animation.AnimatedVisibility import androidx.compose.animation.scaleIn import androidx.compose.animation.scaleOut import com.abdownloadmanager.shared.ui.configurable.ConfigurableGroup import com.abdownloadmanager.shared.ui.configurable.RenderConfigurableGroup import com.abdownloadmanager.desktop.window.custom.WindowTitle import com.abdownloadmanager.shared.util.ui.icon.MyIcons import com.abdownloadmanager.shared.util.ui.myColors import androidx.compose.foundation.background import androidx.compose.foundation.border import androidx.compose.foundation.layout.* import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.items import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.selection.selectable import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.verticalScroll import androidx.compose.runtime.* import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip import androidx.compose.ui.graphics.Brush import androidx.compose.ui.graphics.Color import androidx.compose.ui.platform.LocalFocusManager import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp import com.abdownloadmanager.desktop.pages.home.sections.SearchBox import com.abdownloadmanager.shared.ui.widget.* import com.abdownloadmanager.resources.Res import com.abdownloadmanager.shared.pages.perhostsettings.PerHostSettingsItemWithId import com.abdownloadmanager.shared.util.div import com.abdownloadmanager.shared.util.ui.WithContentAlpha import com.abdownloadmanager.shared.util.ui.theme.myShapes import com.abdownloadmanager.shared.util.ui.theme.myTextSizes import ir.amirab.util.compose.asStringSource import ir.amirab.util.compose.resources.myStringResource import ir.amirab.util.ifThen import kotlinx.coroutines.* @Composable fun PerHostSettingsPage(component: DesktopPerHostSettingsComponent) { val perHostSettings by component.editedPerHostSettings.collectAsState() val selectedItemId by component.selectedId.collectAsState() WindowTitle(myStringResource(Res.string.settings_per_host_settings)) Column { Row( Modifier.weight(1f) ) { HostList( modifier = Modifier .padding(8.dp) .width(220.dp) .fillMaxHeight(), hosts = perHostSettings, selectedId = selectedItemId, setSelected = { id -> component.onIdSelected(id) }, component = component ) val configurableList = component.selectedItemConfigurableList.collectAsState().value if (configurableList != null) { RenderPerHostSettingsItem( modifier = Modifier .padding(8.dp) .weight(1f), itemId = configurableList.id, configurableList = configurableList.configurableGroups, ) } else { Text( myStringResource(Res.string.settings_per_host_settings_not_selected), Modifier.fillMaxSize().wrapContentSize() ) } } Actions(component) } } @Composable private fun Actions( component: DesktopPerHostSettingsComponent, ) { val canSave by component.canSave.collectAsState() val scope = rememberCoroutineScope() Row( Modifier.fillMaxWidth() .wrapContentWidth(Alignment.End) .padding(horizontal = 16.dp) .padding(vertical = 16.dp), ) { val space = @Composable { Spacer(Modifier.width(4.dp)) } ActionButton( text = myStringResource( Res.string.update ), modifier = Modifier, enabled = canSave, onClick = { scope.launch { component.saveAndClose() } } ) space() ActionButton( text = myStringResource(Res.string.cancel), modifier = Modifier, onClick = { component.close() } ) } } @Composable private fun RenderPerHostSettingsItem( modifier: Modifier, itemId: String, configurableList: List, ) { val fm = LocalFocusManager.current //remove focus to prevent accidentally change config in different queue LaunchedEffect(itemId) { fm.clearFocus() } Column(modifier) { val pageModifier = Modifier .fillMaxSize() RenderPerHostSettingsConfigurableGroup(pageModifier, configurableList) } } @Composable private fun RenderPerHostSettingsConfigurableGroup( modifier: Modifier, configurableGroups: List, ) { Column( modifier .verticalScroll(rememberScrollState()) ) { for ((index, cfgGroup) in configurableGroups.withIndex()) { RenderConfigurableGroup( cfgGroup, Modifier ) if (index != configurableGroups.lastIndex) { Spacer(Modifier.height(8.dp)) } } } } @Composable private fun HostList( modifier: Modifier, hosts: List, selectedId: String?, setSelected: (String) -> Unit, component: DesktopPerHostSettingsComponent, ) { val shape = myShapes.defaultRounded val borderColor = myColors.surface / 0.5f var search by remember { mutableStateOf("") } val defaultEmptyName = myStringResource(Res.string.settings_per_host_settings_new_host) val filteredHosts = remember(hosts, search) { hosts.ifThen(search.isNotEmpty()) { filter { it.perHostSettingsItem.host.contains(search, true) } } } Column( modifier .border(1.dp, borderColor, shape) .clip(shape) ) { Box( Modifier .weight(1f) .fillMaxWidth() ) { LazyColumn { items(filteredHosts, key = { it.id }) { s -> val isSelected = selectedId == s.id SideBarItem( isSelected = isSelected, onClick = { setSelected(s.id) }, name = s.perHostSettingsItem.host.takeIf { it.isNotBlank() } ?: defaultEmptyName, modifier = Modifier.animateItem(), ) } } if (filteredHosts.isEmpty()) { WithContentAlpha(0.75f) { Text( myStringResource(Res.string.list_is_empty), modifier = Modifier.align(Alignment.Center) ) } } } val spacer = @Composable { Spacer(Modifier.width(4.dp)) } Spacer( Modifier .background(borderColor) .fillMaxWidth() .height(1.dp) ) Row( modifier = Modifier .padding(vertical = 4.dp) .padding(horizontal = 8.dp) .height(IntrinsicSize.Max) .fillMaxWidth(), verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.End ) { SearchBox( search, onTextChange = { search = it }, placeholder = myStringResource(Res.string.search), modifier = Modifier.weight(1f).fillMaxHeight(), ) spacer() IconActionButton( icon = MyIcons.add, contentDescription = Res.string.add.asStringSource(), onClick = { component.onRequestAddNewHostSettingsItem() } ) spacer() IconActionButton( icon = MyIcons.remove, contentDescription = Res.string.remove.asStringSource(), enabled = selectedId != null, onClick = { selectedId?.let { component.onRequestDeleteConfig(it) } } ) } } } @Composable private fun SideBarItem( name: String, isSelected: Boolean, onClick: () -> Unit, modifier: Modifier = Modifier, ) { Box( modifier .height(IntrinsicSize.Max) .ifThen(isSelected) { background(myColors.onBackground / 0.05f) } .selectable( selected = isSelected, onClick = onClick ) ) { Row( Modifier .padding(vertical = 8.dp) .padding(start = 16.dp) .padding(end = 2.dp), verticalAlignment = Alignment.CenterVertically, ) { WithContentAlpha(if (isSelected) 1f else 0.75f) { Text( name, Modifier.weight(1f), fontWeight = if (isSelected) FontWeight.Bold else FontWeight.Normal, fontSize = myTextSizes.lg, overflow = TextOverflow.Ellipsis, maxLines = 1, ) } } AnimatedVisibility( isSelected, modifier = Modifier .align(Alignment.CenterStart), enter = scaleIn(), exit = scaleOut(), ) { Spacer( Modifier .height(16.dp) .width(3.dp) .clip( RoundedCornerShape( topStart = 0.dp, bottomStart = 0.dp, bottomEnd = 12.dp, topEnd = 12.dp, ) ) .background(myColors.primary) ) } if (isSelected) { listOf( Alignment.TopCenter, Alignment.BottomCenter, ).forEach { Spacer( Modifier .align(it) .fillMaxWidth() .height(1.dp) .background( Brush.horizontalGradient( listOf( Color.Transparent, myColors.onBackground / 0.1f, myColors.onBackground / 0.1f, Color.Transparent, ) ) ) ) } } } } ================================================ FILE: desktop/app/src/main/kotlin/com/abdownloadmanager/desktop/pages/perhostsettings/PerHostSettingsWindow.kt ================================================ package com.abdownloadmanager.desktop.pages.perhostsettings import androidx.compose.runtime.Composable import androidx.compose.ui.Alignment import androidx.compose.ui.unit.DpSize import androidx.compose.ui.unit.dp import androidx.compose.ui.window.WindowPosition import androidx.compose.ui.window.rememberWindowState import com.abdownloadmanager.desktop.AppComponent import com.abdownloadmanager.desktop.window.custom.CustomWindow import com.abdownloadmanager.shared.pages.perhostsettings.BasePerHostSettingsComponent import com.abdownloadmanager.shared.util.mvi.HandleEffects import com.abdownloadmanager.shared.util.rememberChild @Composable fun PerHostSettingsWindow( appComponent: AppComponent ) { val component = appComponent.perHostSettingsSlot.rememberChild() if (component != null) { val windowState = rememberWindowState( size = DpSize( 600.dp, 400.dp, ), position = WindowPosition.Aligned(Alignment.Center) ) CustomWindow( state = windowState, onCloseRequest = appComponent::closePerHostSettings, ) { HandleEffects(component) { when (it) { is BasePerHostSettingsComponent.Effects.Platform -> { when (it as DesktopPerHostSettingsComponent.Effects) { DesktopPerHostSettingsComponent.Effects.BringToFront -> { windowState.isMinimized = false window.toFront() } } } } } PerHostSettingsPage(component) } } } ================================================ FILE: desktop/app/src/main/kotlin/com/abdownloadmanager/desktop/pages/poweractionalert/PowerActionAlertWindow.kt ================================================ package com.abdownloadmanager.desktop.pages.poweractionalert import androidx.compose.animation.core.animateFloatAsState import androidx.compose.foundation.background import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.aspectRatio import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.width import androidx.compose.foundation.layout.wrapContentHeight import androidx.compose.runtime.Composable import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.DpSize import androidx.compose.ui.unit.dp import androidx.compose.ui.window.WindowPosition import androidx.compose.ui.window.rememberWindowState import com.abdownloadmanager.desktop.AppComponent import com.abdownloadmanager.desktop.window.custom.CustomWindow import com.abdownloadmanager.desktop.window.custom.WindowTitle import com.abdownloadmanager.resources.Res import com.abdownloadmanager.shared.ui.widget.ActionButton import com.abdownloadmanager.shared.ui.widget.LoadingIndicatorWithBrush import com.abdownloadmanager.shared.ui.widget.Text import com.abdownloadmanager.shared.util.div import com.abdownloadmanager.shared.util.rememberChild import com.abdownloadmanager.shared.util.ui.myColors import com.abdownloadmanager.shared.util.ui.theme.LocalUiScale import com.abdownloadmanager.shared.util.ui.theme.myTextSizes import ir.amirab.util.compose.asStringSource import ir.amirab.util.compose.resources.myStringResource import ir.amirab.util.desktop.screen.applyUiScale @Composable fun PowerActionAlert(appComponent: AppComponent) { appComponent.openedPowerAction.rememberChild()?.let { PowerActionAlertWindow(it) } } @Composable private fun PowerActionAlertWindow( component: PowerActionComponent ) { val uiScale = LocalUiScale.current val windowState = rememberWindowState( position = WindowPosition.Aligned(Alignment.Center), size = DpSize( width = 450.dp, height = 200.dp, ).applyUiScale(uiScale), ) CustomWindow( onCloseRequest = component::performCancel, state = windowState, alwaysOnTop = true, resizable = false, onRequestMinimize = null, onRequestToggleMaximize = null, ) { PowerActionAlertPage(component) } } @Composable private fun PowerActionAlertPage(component: PowerActionComponent) { val totalTime = component.totalDelay val remainingTime by component.remainingDelay.collectAsState() val powerActionError = component.powerActionError.collectAsState().value val cancel = component::performCancel val performPowerActionNow = component::performPowerAction val remainingSeconds = remainingTime / 1000 WindowTitle(myStringResource(Res.string.shutdown_alert)) Column { Row( Modifier .weight(1f) .padding( horizontal = 16.dp, vertical = 8.dp ) .wrapContentHeight(), verticalAlignment = Alignment.CenterVertically, ) { Box { val progress = animateFloatAsState( (remainingTime.toFloat() / totalTime) ).value val strokeWidth = 4.dp Row( Modifier .align(Alignment.Center) .padding(strokeWidth * 4), verticalAlignment = Alignment.Bottom, ) { if (powerActionError == null) { Text( text = remainingSeconds.toString().padStart(2, '0'), fontSize = myTextSizes.x3l, modifier = Modifier, ) Text( "s", fontSize = myTextSizes.xl, ) } else { Text( text = myStringResource(Res.string.error), fontSize = myTextSizes.x3l, modifier = Modifier, ) } } LoadingIndicatorWithBrush( Modifier.matchParentSize() .aspectRatio(1f), brush = if (powerActionError == null) { myColors.primaryGradient } else { myColors.errorGradient }, progress = if (powerActionError == null) { progress } else { 1f }, ) } Spacer(Modifier.width(16.dp)) Column { Text( if (powerActionError == null) { myStringResource(Res.string.system_shutdown_soon) } else { myStringResource(Res.string.system_shutdown_failed) }, fontSize = myTextSizes.x3l, fontWeight = FontWeight.Bold, color = if (powerActionError == null) { myColors.warning } else { myColors.error }, ) Column( Modifier.padding(end = 8.dp) ) { Spacer(Modifier.height(4.dp)) val description = if (powerActionError == null) { myStringResource(Res.string.system_shutdown_soon_description) } else { (powerActionError .localizedMessage .takeIf { it.isNotBlank() } ?.asStringSource() ?: Res.string.unknown_error.asStringSource() ) .rememberString() } Text( description ) component .powerActionReason?.let { Spacer(Modifier.height(4.dp)) Text( it.message.rememberString(), color = when (it.type) { PowerActionComponent.PowerActionReason.Type.Success -> myColors.success PowerActionComponent.PowerActionReason.Type.Warning -> myColors.warning PowerActionComponent.PowerActionReason.Type.Error -> myColors.error } ) } } } } Actions( modifier = Modifier, isShuttingDown = component.isShuttingDown.collectAsState().value, cancel = cancel, performPowerActionNow = performPowerActionNow, ) } } @Composable private fun Actions( modifier: Modifier, isShuttingDown: Boolean, cancel: () -> Unit, performPowerActionNow: () -> Unit, ) { Column(modifier) { Spacer( Modifier .fillMaxWidth() .height(1.dp) .background(myColors.onBackground / 0.15f) ) Row( Modifier .fillMaxWidth() .background(myColors.surface / 0.5f) .padding(horizontal = 16.dp) .padding(vertical = 8.dp), verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.End, ) { ActionButton( Res.string.shutdown_now.asStringSource().rememberString(), enabled = !isShuttingDown, modifier = Modifier, onClick = performPowerActionNow, ) Spacer(Modifier.width(8.dp)) ActionButton( myStringResource(Res.string.cancel), modifier = Modifier, onClick = cancel, ) } } } ================================================ FILE: desktop/app/src/main/kotlin/com/abdownloadmanager/desktop/pages/poweractionalert/PowerActionComponent.kt ================================================ package com.abdownloadmanager.desktop.pages.poweractionalert import com.abdownloadmanager.resources.Res import com.abdownloadmanager.shared.util.BaseComponent import com.arkivanov.decompose.ComponentContext import ir.amirab.util.compose.StringSource import ir.amirab.util.compose.asStringSource import ir.amirab.util.desktop.DesktopUtils import ir.amirab.util.desktop.poweraction.PowerActionConfig import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.delay import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.launch import kotlinx.coroutines.withContext import org.koin.core.component.KoinComponent import org.koin.core.component.inject class PowerActionComponent( val ctx: ComponentContext, val powerActionConfig: PowerActionConfig, val powerActionReason: PowerActionReason?, private val powerActionDelay: Long, private val close: () -> Unit, private val onBeforePowerAction: suspend () -> Unit, ) : BaseComponent( ctx, ), KoinComponent { val applicationScope by inject() val totalDelay = this@PowerActionComponent.powerActionDelay private val _remainingDelay = MutableStateFlow(totalDelay) val remainingDelay = _remainingDelay.asStateFlow() private val _isPerformingPowerAction = MutableStateFlow(false) val isShuttingDown = _isPerformingPowerAction.asStateFlow() private val _powerActionError = MutableStateFlow(null as Throwable?) val powerActionError = _powerActionError.asStateFlow() init { start() } fun start() { scope.launch { var remaining = this@PowerActionComponent.powerActionDelay val eachStep = 1000 / 33L while (remaining >= 0) { delay(eachStep) remaining = (remaining - eachStep) _remainingDelay.value = remaining.coerceAtLeast(0) } performPowerAction() } } fun performCancel() { close() } fun performPowerAction() { applicationScope.launch { _isPerformingPowerAction.value = true val success = try { doPowerAction() } catch (e: Exception) { _powerActionError.value = e e.printStackTrace() _isPerformingPowerAction.value = false false } if (success) { withContext(Dispatchers.Main) { close() } } } } private suspend fun doPowerAction(): Boolean { onBeforePowerAction() delay(1000) return DesktopUtils.powerAction().initiate(powerActionConfig) } data class Config( val powerActionConfig: PowerActionConfig, val powerActionDelay: Long = 30_000, val powerActionReason: PowerActionReason? = null, ) enum class PowerActionReason( val message: StringSource, val type: Type, ) { QueueWorkFinished(Res.string.system_shutdown_reason_queue_completed.asStringSource(), Type.Success), QueueEndTimeReached(Res.string.system_shutdown_reason_queue_end_time_reached.asStringSource(), Type.Success), DownloadFinished(Res.string.system_shutdown_download_finished.asStringSource(), Type.Success), Unknown(Res.string.unknown.asStringSource(), Type.Error); enum class Type { Success, Warning, Error, } } } ================================================ FILE: desktop/app/src/main/kotlin/com/abdownloadmanager/desktop/pages/queue/QueueInfoComponent.kt ================================================ package com.abdownloadmanager.desktop.pages.queue import com.abdownloadmanager.shared.ui.configurable.ConfigurableGroup import com.abdownloadmanager.shared.util.BaseComponent import ir.amirab.util.flow.createMutableStateFlowFromStateFlow import ir.amirab.util.flow.mapStateFlow import com.abdownloadmanager.shared.util.newScopeBasedOn import androidx.compose.runtime.toMutableStateList import com.abdownloadmanager.desktop.storage.DesktopExtraQueueSettings import com.abdownloadmanager.shared.storage.ExtraQueueSettingsStorage import com.abdownloadmanager.resources.Res import com.abdownloadmanager.shared.ui.configurable.item.BooleanConfigurable import com.abdownloadmanager.shared.ui.configurable.item.DayOfWeekConfigurable import com.abdownloadmanager.shared.ui.configurable.item.IntConfigurable import com.abdownloadmanager.shared.ui.configurable.item.StringConfigurable import com.abdownloadmanager.shared.ui.configurable.item.TimeConfigurable import com.arkivanov.decompose.ComponentContext import ir.amirab.downloader.monitor.IDownloadItemState import ir.amirab.downloader.monitor.IDownloadMonitor import ir.amirab.downloader.queue.DownloadQueue import ir.amirab.downloader.queue.QueueManager import ir.amirab.util.compose.asStringSource import ir.amirab.util.compose.asStringSourceWithARgs import ir.amirab.util.desktop.poweraction.PowerActionConfig import ir.amirab.util.flow.combineStateFlows import ir.amirab.util.flow.createMutableStateFlowFromFlow import ir.amirab.util.flow.mapTwoWayStateFlow import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.flow.* import kotlinx.coroutines.launch import org.koin.core.component.KoinComponent import org.koin.core.component.inject import kotlin.collections.map class QueueInfoComponent( ctx: ComponentContext, id: Long, ) : BaseComponent(ctx), KoinComponent { private val downloadMonitor: IDownloadMonitor by inject() private val queueManager: QueueManager by inject() val downloadQueue = queueManager.queues.value.find { it.id == id }!! val selectedListItems = MutableStateFlow(emptyList()) private val lastSelectedItem = MutableStateFlow(null as Long?) val extraQueueSettingsStorage by inject>() val extraDownloadItemSettingsFlow = createMutableStateFlowFromFlow( flow = extraQueueSettingsStorage.getExternalQueueSettingsAsFlow( id = id, initialEmit = false, ), initialValue = extraQueueSettingsStorage.getExtraQueueSettings(id), updater = { scope.launch { extraQueueSettingsStorage.setExtraQueueSettings(it) } }, scope = scope, ) init { downloadQueue.queueModel.map { it.queueItems }.onEach { l -> selectedListItems.value = selectedListItems.value.filter { it in l } }.launchIn(scope) } fun selectAll() { val all = downloadQueueItems.value.map { it.id } selectedListItems.value = all lastSelectedItem.value = all.last() } fun clearSelection() { selectedListItems.value = emptyList() lastSelectedItem.value = null } fun setSelectedItem( id: Long, selected: Boolean, ctrlPressed: Boolean, shiftPressed: Boolean, ) { val selectedIds = selectedListItems.value val availableItems = downloadQueueItems.value selectedListItems.value = selectedIds.let { selectedIds -> if (ctrlPressed) { lastSelectedItem.value = id selectedIds.toMutableStateList().also { mutableList -> val contains = mutableList.contains(id) if (contains && !selected) { mutableList.remove(id) } else if (!contains && selected) { mutableList.add(id) } }.toList() } else if (shiftPressed) { val lastSelected = lastSelectedItem.value val fromIndex = lastSelected?.let { lastSelectedId -> availableItems.indexOfFirst { itemState -> itemState.id == lastSelectedId }.takeIf { it != -1 } } val toIndex = availableItems.indexOfFirst { itemState -> itemState.id == id }.takeIf { it != -1 } if (fromIndex != null && toIndex != null) { availableItems.map { it.id }.subList( minOf(fromIndex, toIndex), maxOf(fromIndex, toIndex) + 1, ) } else { lastSelectedItem.value = id listOf(id) } } else { if (selected) { lastSelectedItem.value = id listOf(id) } else { lastSelectedItem.value = null emptyList() } } } } val configurations: List = createConfigurableList(downloadQueue, scope) private fun createConfigurableList( downloadQueue: DownloadQueue, parentScope: CoroutineScope, ): List { val scope = newScopeBasedOn(parentScope) val enabledStartTimeFlow = downloadQueue.queueModel.mapStateFlow() { it.scheduledTimes.enabledStartTime } val enabledEndTimeFlow = downloadQueue.queueModel.mapStateFlow() { it.scheduledTimes.enabledEndTime } val enabledSchedulerFlow = combineStateFlows(enabledStartTimeFlow, enabledEndTimeFlow) { start, end -> start || end } return listOf( ConfigurableGroup( groupTitle = MutableStateFlow(Res.string.general.asStringSource()), nestedConfigurable = listOf( StringConfigurable( Res.string.name.asStringSource(), Res.string.queue_name_help.asStringSource(), backedBy = createMutableStateFlowFromStateFlow( scope = scope, flow = downloadQueue.queueModel.mapStateFlow() { it.name }, updater = { newValue -> downloadQueue.setName(newValue) }, ), validate = { it.length in 1..32 }, describe = { Res.string.queue_name_describe .asStringSourceWithARgs( Res.string.queue_name_describe_createArgs( value = it ) ) }, ), IntConfigurable( Res.string.queue_max_concurrent_download.asStringSource(), Res.string.queue_max_concurrent_download_description.asStringSource(), backedBy = createMutableStateFlowFromStateFlow( scope = scope, flow = downloadQueue.queueModel.mapStateFlow() { it.maxConcurrent }, updater = { newValue -> downloadQueue.setMaxConcurrent(newValue) }, ), describe = { "$it".asStringSource() }, range = 1..32, renderMode = IntConfigurable.RenderMode.TextField, ), ), ), ConfigurableGroup( groupTitle = MutableStateFlow(Res.string.on_completion.asStringSource()), nestedConfigurable = listOf( BooleanConfigurable( Res.string.queue_automatic_stop.asStringSource(), Res.string.queue_automatic_stop_description.asStringSource(), backedBy = createMutableStateFlowFromStateFlow( scope = scope, flow = downloadQueue.queueModel.mapStateFlow() { it.stopQueueOnEmpty }, updater = { newValue -> downloadQueue.setStopQueueOnEmpty(newValue) }, ), describe = { if (it) Res.string.enabled.asStringSource() else Res.string.disabled.asStringSource() }, ), BooleanConfigurable( title = Res.string.queue_shutdown_on_completion.asStringSource(), description = Res.string.queue_shutdown_on_completion_description.asStringSource(), backedBy = extraDownloadItemSettingsFlow.mapTwoWayStateFlow( map = { it.powerActionTypeOnFinish != null }, unMap = { copy( powerActionTypeOnFinish = when (it) { true -> PowerActionConfig.Type.Shutdown false -> null }, ) }, ), describe = { if (it) Res.string.enabled.asStringSource() else Res.string.disabled.asStringSource() }, ) ), ), ConfigurableGroup( groupTitle = MutableStateFlow(Res.string.queue_scheduler.asStringSource()), nestedVisible = enabledSchedulerFlow, mainConfigurable = BooleanConfigurable( Res.string.queue_enable_scheduler.asStringSource(), description = "".asStringSource(), describe = { "".asStringSource() }, backedBy = createMutableStateFlowFromStateFlow( flow = enabledSchedulerFlow, scope = scope, updater = { newValue -> downloadQueue.setScheduledTimes { copy( enabledStartTime = newValue, enabledEndTime = newValue, ) } } ), ), nestedConfigurable = listOf( DayOfWeekConfigurable( Res.string.queue_active_days.asStringSource(), Res.string.queue_active_days_description.asStringSource(), backedBy = createMutableStateFlowFromStateFlow( scope = scope, flow = downloadQueue.queueModel.mapStateFlow() { it.scheduledTimes.daysOfWeek }, updater = { newValue -> downloadQueue.setScheduledTimes { copy(daysOfWeek = newValue) } }, ), validate = { it.isNotEmpty() }, describe = { "".asStringSource() }, ), BooleanConfigurable( Res.string.queue_scheduler_enable_auto_start_time.asStringSource(), description = "".asStringSource(), describe = { "".asStringSource() }, backedBy = createMutableStateFlowFromStateFlow( scope = scope, flow = enabledStartTimeFlow, updater = { newValue -> downloadQueue.setScheduledTimes { copy(enabledStartTime = newValue) } }, ), ), TimeConfigurable( Res.string.queue_scheduler_auto_start_time.asStringSource(), "".asStringSource(), backedBy = createMutableStateFlowFromStateFlow( scope = scope, flow = downloadQueue.queueModel.mapStateFlow() { it.scheduledTimes.startTime }, updater = { downloadQueue.setScheduledTimes { copy(startTime = it) } }, ), describe = { "".asStringSource() }, visible = enabledStartTimeFlow, ), BooleanConfigurable( Res.string.queue_scheduler_enable_auto_stop_time.asStringSource(), description = "".asStringSource(), describe = { "".asStringSource() }, backedBy = createMutableStateFlowFromStateFlow( scope = scope, flow = enabledEndTimeFlow, updater = { newValue -> downloadQueue.setScheduledTimes { copy(enabledEndTime = newValue) } }, ), ), TimeConfigurable( Res.string.queue_scheduler_auto_stop_time.asStringSource(), "".asStringSource(), backedBy = createMutableStateFlowFromStateFlow( scope = scope, flow = downloadQueue.queueModel.mapStateFlow() { it.scheduledTimes.endTime }, updater = { newValue -> downloadQueue.setScheduledTimes { copy(endTime = newValue) } }, ), describe = { "".asStringSource() }, visible = enabledEndTimeFlow, ), ) ), ) } private val dls = downloadMonitor.downloadListFlow .stateIn(scope, SharingStarted.Eagerly, emptyList()) val downloadQueueItems = merge( downloadQueue.queueModel .map { it.queueItems } .distinctUntilChanged(), dls, ).map { getQueueItemsAsDownloadItem() }.stateIn(scope, SharingStarted.Eagerly, emptyList()) private fun getQueueItemsAsDownloadItem( ): List { return downloadQueue.queueModel.value.queueItems.mapNotNull { dlId -> dls.value.find { it.id == dlId } } } fun deleteItems() { downloadQueue.removeFromQueue(selectedListItems.value) } fun moveDownItems() { downloadQueue.moveDown(selectedListItems.value) } fun moveUpItems() { downloadQueue.moveUp(selectedListItems.value) } fun swapItem(fromIndex: Int, toIndex: Int) { //maybe removed by queue itself during download completion val currentDraggingItem = runCatching { downloadQueue.getQueueItemFromOrder(fromIndex) }.getOrNull() val listOfIds = selectedListItems.value .let { if (currentDraggingItem != null && !it.contains(currentDraggingItem)) { it.plus(currentDraggingItem) } else { it } } downloadQueue.move( listOfIds, toIndex - fromIndex ) } } ================================================ FILE: desktop/app/src/main/kotlin/com/abdownloadmanager/desktop/pages/queue/QueueWindow.kt ================================================ package com.abdownloadmanager.desktop.pages.queue import com.abdownloadmanager.desktop.window.custom.CustomWindow import com.abdownloadmanager.shared.util.mvi.HandleEffects import androidx.compose.runtime.Composable import androidx.compose.ui.window.rememberWindowState @Composable fun QueuesWindow(queuesComponent: QueuesComponent) { val state = rememberWindowState() CustomWindow( state = state, onCloseRequest = queuesComponent.close ) { HandleEffects(queuesComponent) { if (it == QueuesComponentEffects.ToFront) { state.isMinimized = false window.toFront() } } QueuePage(queuesComponent) } } ================================================ FILE: desktop/app/src/main/kotlin/com/abdownloadmanager/desktop/pages/queue/QueuesComponent.kt ================================================ package com.abdownloadmanager.desktop.pages.queue import com.abdownloadmanager.desktop.actions.newQueueAction import com.abdownloadmanager.shared.util.mvi.ContainsEffects import com.abdownloadmanager.shared.util.mvi.supportEffects import androidx.compose.runtime.* import com.abdownloadmanager.shared.util.BaseComponent import com.abdownloadmanager.shared.util.DownloadSystem import com.abdownloadmanager.shared.util.asState import com.abdownloadmanager.shared.util.subscribeAsStateFlow import com.arkivanov.decompose.ComponentContext import com.arkivanov.decompose.router.slot.SlotNavigation import com.arkivanov.decompose.router.slot.childSlot import com.arkivanov.decompose.router.slot.navigate import ir.amirab.downloader.queue.QueueManager import kotlinx.coroutines.flow.* import kotlinx.coroutines.launch import org.koin.core.component.KoinComponent import org.koin.core.component.inject sealed interface QueuesComponentEffects{ data object ToFront:QueuesComponentEffects } class QueuesComponent( ctx: ComponentContext, val close: () -> Unit, ) : BaseComponent(ctx), ContainsEffects by supportEffects(), KoinComponent { val downloadSystem: DownloadSystem by inject() val queueManager: QueueManager by inject() private val queues = queueManager.queues val queuesState by queues.asState(scope) var selectedItemIndex by mutableStateOf(0) fun getNearest(lastIndex: Int): Int { return lastIndex.coerceIn(queuesState.indices) } val selectedItem by derivedStateOf { queuesState.get(getNearest(selectedItemIndex)) } fun onQueueSelected(queueId: Long) { val foundIndex = queuesState.indexOfFirst { it.id == queueId } if (foundIndex == -1) { return } selectedItemIndex = foundIndex } fun addQueue() { newQueueAction() // scope.launch { // queueManager.addQueue("New Queue") // } } fun canDeleteThisQueue(queueId: Long): Boolean { return queueManager.canDelete(queueId) } fun requestDeleteQueue(id: Long) { scope.launch { downloadSystem.deleteQueue(id) } } fun bringToFront() { sendEffect(QueuesComponentEffects.ToFront) } init { queues.map { it.size } .distinctUntilChanged() .onEach { selectedItemIndex = getNearest(selectedItemIndex) }.launchIn(scope) } data class QueueInfoNavigationConfig( val queueId:Long, ) val queueInfoNavigation = SlotNavigation() val queueInfoComponent = childSlot( queueInfoNavigation, serializer = null, initialConfiguration = { QueueInfoNavigationConfig(selectedItem.id) }, childFactory = {config,ctx-> QueueInfoComponent(ctx,config.queueId) } ).subscribeAsStateFlow() init { snapshotFlow { selectedItem }.onEach {q-> queueInfoNavigation.navigate { if (it?.queueId==q.id){ it }else{ QueueInfoNavigationConfig( q.id ) } } }.launchIn(scope) } } ================================================ FILE: desktop/app/src/main/kotlin/com/abdownloadmanager/desktop/pages/queue/QueuesPage.kt ================================================ package com.abdownloadmanager.desktop.pages.queue import com.abdownloadmanager.shared.ui.configurable.ConfigurableGroup import com.abdownloadmanager.shared.ui.configurable.RenderConfigurableGroup import com.abdownloadmanager.shared.util.ui.LocalContentAlpha import com.abdownloadmanager.shared.util.ui.LocalContentColor import com.abdownloadmanager.desktop.window.custom.WindowTitle import ir.amirab.util.compose.IconSource import com.abdownloadmanager.shared.util.ui.widget.MyIcon import com.abdownloadmanager.shared.util.ui.icon.MyIcons import com.abdownloadmanager.shared.util.ui.myColors import com.abdownloadmanager.shared.util.ui.theme.myTextSizes import androidx.compose.foundation.background import androidx.compose.foundation.border import androidx.compose.foundation.clickable import androidx.compose.foundation.interaction.MutableInteractionSource import androidx.compose.foundation.layout.* import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.LazyItemScope import androidx.compose.foundation.lazy.itemsIndexed import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.verticalScroll import androidx.compose.runtime.* import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip import androidx.compose.ui.graphics.Color import androidx.compose.ui.input.key.Key import androidx.compose.ui.input.key.KeyEventType import androidx.compose.ui.input.key.isCtrlPressed import androidx.compose.ui.input.key.key import androidx.compose.ui.input.key.onKeyEvent import androidx.compose.ui.input.key.type import androidx.compose.ui.platform.LocalFocusManager import androidx.compose.ui.platform.LocalWindowInfo import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp import com.abdownloadmanager.shared.ui.widget.* import com.abdownloadmanager.resources.Res import com.abdownloadmanager.shared.util.div import com.abdownloadmanager.shared.util.ui.theme.myShapes import ir.amirab.util.compose.resources.myStringResource import ir.amirab.downloader.downloaditem.DownloadJobStatus import ir.amirab.downloader.monitor.IDownloadItemState import ir.amirab.downloader.monitor.statusOrFinished import ir.amirab.downloader.queue.DownloadQueue import ir.amirab.util.compose.StringSource import ir.amirab.util.compose.asStringSource import ir.amirab.util.desktop.isCtrlPressed import ir.amirab.util.desktop.isShiftPressed import kotlinx.coroutines.* import sh.calvin.reorderable.ReorderableItem import sh.calvin.reorderable.ReorderableLazyListState import sh.calvin.reorderable.rememberReorderableLazyListState @Composable fun QueuePage(component: QueuesComponent) { val queues = component.queuesState val activeItem: DownloadQueue = component.selectedItem WindowTitle(myStringResource(Res.string.queues)) val borderShape = myShapes.defaultRounded val borderColor = myColors.surface Column { Row( Modifier.weight(1f) ) { QueueListSection( modifier = Modifier .padding(horizontal = 8.dp) .width(200.dp) .padding(2.dp) .border(1.dp, borderColor, borderShape) .clip(borderShape) .padding(1.dp) .fillMaxHeight(), queues = queues, selectedItem = component.selectedItem.id, setSelected = { id -> component.onQueueSelected(id) }, component = component ) QueueInfo( modifier = Modifier .weight(1f) .padding(2.dp) .border(1.dp, borderColor, borderShape) .padding(1.dp) .clip(borderShape), item = activeItem, component = component.queueInfoComponent.collectAsState().value.child!!.instance, borderColor = borderColor, ) } Actions(component, activeItem) } } @Composable private fun Actions( component: QueuesComponent, selectedItem: DownloadQueue, ) { val isActive by selectedItem.activeFlow.collectAsState() val scope = rememberCoroutineScope() Row( Modifier.fillMaxWidth() .wrapContentWidth(Alignment.End) .padding(horizontal = 16.dp) .padding(vertical = 16.dp), ) { val space = @Composable { Spacer(Modifier.width(4.dp)) } ActionButton( text = myStringResource( if (isActive) { Res.string.stop_queue } else { Res.string.start_queue } ), modifier = Modifier, onClick = { scope.launch { if (isActive) { selectedItem.stop() } else { selectedItem.start() } } } ) space() ActionButton( text = myStringResource(Res.string.close), modifier = Modifier, onClick = { component.close() } ) } } enum class QueueInfoPages(val title: StringSource, val icon: IconSource) { Config(Res.string.config.asStringSource(), MyIcons.settings), Items(Res.string.items.asStringSource(), MyIcons.queue), } @Composable private fun QueueInfo( modifier: Modifier, item: DownloadQueue, component: QueueInfoComponent, borderColor: Color, ) { val fm = LocalFocusManager.current //remove focus to prevent accidentally change config in different queue LaunchedEffect(item) { fm.clearFocus() } var currentPage by remember { mutableStateOf(QueueInfoPages.Config) } Column(modifier) { Column( Modifier ) { MyTabRow { QueueInfoPages.entries.forEach { MyTab( selected = it == currentPage, onClick = { currentPage = it }, icon = it.icon, title = it.title, ) } } Spacer(Modifier.fillMaxWidth().height(1.dp).background(borderColor)) val pageModifier = Modifier .fillMaxSize() .padding(4.dp) when (currentPage) { QueueInfoPages.Config -> RenderQueueConfig(pageModifier, component) QueueInfoPages.Items -> RenderQueueItems(pageModifier, component) } } } } @Composable fun RenderQueueItems( modifier: Modifier, component: QueueInfoComponent, ) { val windowInfo = LocalWindowInfo.current val downloadItems by component.downloadQueueItems.collectAsState() val selectedIds by component.selectedListItems.collectAsState() val lazyListState = rememberLazyListState() val state = rememberReorderableLazyListState( lazyListState, onMove = { from, to -> component.swapItem(from.index, to.index) } ) val listInteractionSource = remember { MutableInteractionSource() } Column(modifier) { LazyColumn( state = lazyListState, verticalArrangement = Arrangement.spacedBy(4.dp), modifier = Modifier .weight(1f) .clickable( indication = null, interactionSource = listInteractionSource ) { component.clearSelection() } .onKeyEvent { if (it.type != KeyEventType.KeyDown) { return@onKeyEvent false } when (it.key) { Key.A if it.isCtrlPressed -> { component.selectAll() true } Key.Escape -> { component.clearSelection() true } Key.Delete -> { component.deleteItems() true } Key.DirectionUp -> { component.moveUpItems() true } Key.DirectionDown -> { component.moveDownItems() true } else -> { false } } } ) { itemsIndexed(downloadItems, key = { _, item -> item.id } ) { index, downloadItem -> RenderQueueItem( state = state, value = downloadItem, isSelected = selectedIds.contains(downloadItem.id), setSelected = { selected -> component.setSelectedItem( id = downloadItem.id, selected = selected, ctrlPressed = isCtrlPressed(windowInfo), shiftPressed = isShiftPressed(windowInfo), ) }, index = index ) } } Spacer( Modifier .fillMaxWidth() .height(1.dp) .background(myColors.onBackground / 5) ) Row(Modifier.padding(8.dp)) { val hasSelections = selectedIds.isNotEmpty() val space = 4.dp IconActionButton( icon = MyIcons.remove, contentDescription = Res.string.remove.asStringSource(), onClick = { component.deleteItems() }, enabled = hasSelections, ) Spacer(Modifier.weight(1f)) IconActionButton( icon = MyIcons.down, contentDescription = Res.string.move_down.asStringSource(), onClick = { component.moveDownItems() }, enabled = hasSelections, ) Spacer(Modifier.width(space)) IconActionButton( icon = MyIcons.up, contentDescription = Res.string.move_up.asStringSource(), onClick = { component.moveUpItems() }, enabled = hasSelections, ) } } } @Composable private fun LazyItemScope.RenderQueueItem( state: ReorderableLazyListState, value: IDownloadItemState, isSelected: Boolean, setSelected: (Boolean) -> Unit, index: Int ) { ReorderableItem( state, key = value.id ) { dragging -> Box( modifier = Modifier .draggableHandle() ) { NavigateableItem( isSelected = isSelected, onClick = { setSelected(!isSelected) }, content = { val isActive = if (value.statusOrFinished() is DownloadJobStatus.IsActive) { true } else { false } Row { Text( "${index + 1}. ", fontSize = myTextSizes.base, maxLines = 1, fontWeight = FontWeight.Bold, color = (if (isActive) { myColors.success } else { LocalContentColor.current }) / LocalContentAlpha.current, modifier = Modifier .border(1.dp, myColors.onBackground / 5) .padding(1.dp) ) Text( value.name, fontSize = myTextSizes.base, maxLines = 1, ) } } ) } } } @Composable private fun RenderQueueConfig( modifier: Modifier, component: QueueInfoComponent, ) { val configurables: List = component.configurations Column( modifier .verticalScroll(rememberScrollState()) ) { for ((index, cfgGroup) in configurables.withIndex()) { RenderConfigurableGroup( cfgGroup, Modifier ) if (index != configurables.lastIndex) { Spacer(Modifier.height(4.dp)) } } } } @Composable private fun QueueListSection( modifier: Modifier, queues: List, selectedItem: Long, setSelected: (Long) -> Unit, component: QueuesComponent, ) { Column(modifier) { Column( Modifier .padding(top = 12.dp) .padding(horizontal = 8.dp) .verticalScroll(rememberScrollState()) .weight(1f) ) { for (s in queues) { val queueModel by s.queueModel.collectAsState() val isQueueActive by s.activeFlow.collectAsState() val isSelected = selectedItem == s.id NavigateableItem( isSelected = isSelected, onClick = { setSelected(s.id) } ) { MyIcon( MyIcons.folder, null, Modifier.size(16.dp) ) Spacer(Modifier.width(8.dp)) Text( queueModel.name, maxLines = 1, overflow = TextOverflow.Ellipsis, fontWeight = if (isSelected) FontWeight.Bold else FontWeight.Normal, modifier = Modifier.weight(1f) ) Spacer(Modifier.width(8.dp)) Spacer(Modifier .size(8.dp) .clip(CircleShape) .background( if (isQueueActive) { myColors.success } else { myColors.onSurface / 50 } ) ) } } } val spacer = @Composable { Spacer(Modifier.width(4.dp)) } Spacer( Modifier .background(myColors.onBackground / 5) .fillMaxWidth() .height(1.dp) ) Row( modifier = Modifier .padding(vertical = 4.dp) .padding(horizontal = 8.dp) .fillMaxWidth(), verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.End ) { IconActionButton( icon = MyIcons.add, contentDescription = Res.string.add_new_queue.asStringSource(), onClick = { component.addQueue() } ) spacer() IconActionButton( icon = MyIcons.remove, contentDescription = Res.string.remove_queue.asStringSource(), enabled = component.canDeleteThisQueue(selectedItem), onClick = { component.requestDeleteQueue(selectedItem) } ) } } } ================================================ FILE: desktop/app/src/main/kotlin/com/abdownloadmanager/desktop/pages/settings/DesktopSettings.kt ================================================ package com.abdownloadmanager.desktop.pages.settings import com.abdownloadmanager.desktop.repository.AppRepository import com.abdownloadmanager.desktop.storage.AppSettingsStorage import com.abdownloadmanager.desktop.ui.configurable.platform.item.FontConfigurable import com.abdownloadmanager.desktop.utils.renderapi.CustomRenderApi import com.abdownloadmanager.desktop.utils.renderapi.RenderApi import com.abdownloadmanager.resources.Res import com.abdownloadmanager.shared.ui.configurable.item.BooleanConfigurable import com.abdownloadmanager.shared.ui.configurable.item.EnumConfigurable import com.abdownloadmanager.shared.ui.configurable.item.ProxyConfigurable import com.abdownloadmanager.shared.util.proxy.ProxyManager import com.abdownloadmanager.shared.util.proxy.ProxyMode import ir.amirab.util.compose.asStringSource import ir.amirab.util.compose.asStringSourceWithARgs import ir.amirab.util.flow.createMutableStateFlowFromStateFlow import ir.amirab.util.platform.Platform import ir.amirab.util.platform.isMac import kotlinx.coroutines.CoroutineScope object DesktopSettings { fun mergeTopBarWithTitleBarConfig(appSettings: AppSettingsStorage): BooleanConfigurable { return BooleanConfigurable( title = Res.string.settings_compact_top_bar.asStringSource(), description = Res.string.settings_compact_top_bar_description.asStringSource(), backedBy = appSettings.mergeTopBarWithTitleBar, describe = { if (it) { Res.string.enabled.asStringSource() } else { Res.string.disabled.asStringSource() } }, ) } fun useNativeMenuBarConfig(appSettings: AppSettingsStorage): BooleanConfigurable? { if (Platform.Companion.isMac().not()) return null return BooleanConfigurable( title = Res.string.settings_use_native_menu_bar.asStringSource(), description = Res.string.settings_use_native_menu_bar_description.asStringSource(), backedBy = appSettings.useNativeMenuBar, describe = { if (it) { Res.string.enabled.asStringSource() } else { Res.string.disabled.asStringSource() } }, ) } fun useSystemTray(appSettings: AppSettingsStorage): BooleanConfigurable { return BooleanConfigurable( title = Res.string.settings_use_system_tray.asStringSource(), description = Res.string.settings_use_system_tray_description.asStringSource(), backedBy = appSettings.useSystemTray, describe = { if (it) { Res.string.enabled.asStringSource() } else { Res.string.disabled.asStringSource() } }, ) } fun fontConfig( fontManager: FontManager, scope: CoroutineScope, ): FontConfigurable { return FontConfigurable( title = Res.string.settings_font.asStringSource(), description = Res.string.settings_font_description.asStringSource(), backedBy = createMutableStateFlowFromStateFlow( flow = fontManager.currentFontInfo, updater = { font -> fontManager.setFont(font.id) }, scope = scope, ), possibleValues = fontManager.selectableFonts.value, describe = { it.name } ) } fun renderApi( customRenderApi: CustomRenderApi, ): EnumConfigurable { return EnumConfigurable( title = "Render API".asStringSource(), description = "Configures the Render API backend used by the application. A restart is required for the change to take effect.".asStringSource(), backedBy = customRenderApi.data, possibleValues = buildList { add(null) addAll(customRenderApi.getSupportedRenderApiForThisPlatform()) }, describe = { it?.prettyName?.asStringSource()?: Res.string.default.asStringSource() } ) } } ================================================ FILE: desktop/app/src/main/kotlin/com/abdownloadmanager/desktop/pages/settings/DesktopSettingsComponent.kt ================================================ package com.abdownloadmanager.desktop.pages.settings import com.abdownloadmanager.desktop.pages.settings.SettingSection.* import com.abdownloadmanager.desktop.repository.AppRepository import com.abdownloadmanager.desktop.storage.AppSettingsStorage import com.abdownloadmanager.shared.util.ui.icon.MyIcons import androidx.compose.ui.unit.DpSize import androidx.compose.ui.unit.dp import com.abdownloadmanager.shared.pagemanager.PerHostSettingsPageManager import com.abdownloadmanager.desktop.storage.PageStatesStorage import com.abdownloadmanager.desktop.utils.renderapi.CustomRenderApi import com.abdownloadmanager.shared.ui.configurable.ConfigurableGroup import com.abdownloadmanager.resources.Res import com.abdownloadmanager.shared.settings.BaseSettingsComponent import com.abdownloadmanager.shared.settings.CommonSettings import com.abdownloadmanager.shared.ui.theme.ThemeManager import com.abdownloadmanager.shared.util.proxy.ProxyManager import com.arkivanov.decompose.ComponentContext import ir.amirab.util.compose.* import ir.amirab.util.compose.localizationmanager.LanguageManager import ir.amirab.util.flow.mapStateFlow import ir.amirab.util.flow.mapTwoWayStateFlow import kotlinx.coroutines.flow.* import org.koin.core.component.KoinComponent import org.koin.core.component.inject sealed class SettingSection( val icon: IconSource, val name: StringSource, ) { data object Appearance : SettingSection(MyIcons.appearance, Res.string.appearance.asStringSource()) // TODO ADD Network section (proxy , etc..) // data object Network : SettingSections(MyIcons.network, "Network") data object DownloadEngine : SettingSection(MyIcons.downloadEngine, Res.string.download_engine.asStringSource()) data object BrowserIntegration : SettingSection(MyIcons.network, Res.string.browser_integration.asStringSource()) } interface SettingSectionGetter { operator fun get(key: SettingSection): List } class DesktopSettingsComponent( ctx: ComponentContext, val perHostSettingsPageManager: PerHostSettingsPageManager, ) : BaseSettingsComponent(ctx), KoinComponent { private val appSettings by inject() private val pageStorage by inject() private val appRepository by inject() private val proxyManager by inject() private val themeManager by inject() private val languageManager by inject() private val fontManager by inject() private val customRenderApi by inject() private val allConfigs = object : SettingSectionGetter { override operator fun get(key: SettingSection): List { return when (key) { Appearance -> listOf( ConfigurableGroup( mainConfigurable = CommonSettings.themeConfig(themeManager, scope), nestedVisible = themeManager.currentThemeInfo.mapStateFlow { it.id == ThemeManager.systemThemeInfo.id }, nestedConfigurable = listOfNotNull( CommonSettings.defaultDarkThemeConfig(themeManager, scope), CommonSettings.defaultLightThemeConfig(themeManager, scope), ) ), ConfigurableGroup( nestedConfigurable = listOf( CommonSettings.languageConfig(languageManager, scope), DesktopSettings.fontConfig(fontManager, scope), CommonSettings.uiScaleConfig(appSettings), ) ), ConfigurableGroup( nestedConfigurable = listOfNotNull( DesktopSettings.useNativeMenuBarConfig(appSettings), DesktopSettings.mergeTopBarWithTitleBarConfig(appSettings), CommonSettings.showIconLabels(appSettings), CommonSettings.useRelativeDateTime(appSettings), CommonSettings.playSoundNotification(appSettings), ) ), ConfigurableGroup( nestedConfigurable = listOf( CommonSettings.autoStartConfig(appSettings), DesktopSettings.useSystemTray(appSettings), ) ), ConfigurableGroup( nestedConfigurable = listOf( CommonSettings.sizeUnit(appRepository, scope), CommonSettings.speedUnit(appRepository, scope), CommonSettings.useAverageSpeedConfig(appRepository), ) ), ConfigurableGroup( nestedConfigurable = listOf( CommonSettings.autoShowDownloadProgressWindow(appSettings), CommonSettings.showDownloadFinishWindow(appSettings), ) ), ConfigurableGroup( nestedConfigurable = listOf( DesktopSettings.renderApi(customRenderApi), ) ) ) // Network -> listOf() BrowserIntegration -> listOf( ConfigurableGroup( nestedConfigurable = listOf( CommonSettings.browserIntegrationEnabled(appRepository), CommonSettings.browserIntegrationPort(appRepository) ) ) ) DownloadEngine -> listOf( ConfigurableGroup( nestedConfigurable = listOf( CommonSettings.defaultDownloadFolderConfig(appSettings), CommonSettings.useCategoryByDefault(appSettings), ) ), ConfigurableGroup( nestedConfigurable = listOf( CommonSettings.speedLimitConfig(appRepository), CommonSettings.threadCountConfig(appRepository), CommonSettings.maxConcurrentDownloads(appRepository), CommonSettings.maxDownloadRetryCount(appRepository), CommonSettings.dynamicPartDownloadConfig(appRepository), ) ), ConfigurableGroup( nestedConfigurable = listOf( CommonSettings.perHostSettings(perHostSettingsPageManager), ) ), ConfigurableGroup( nestedConfigurable = listOf( CommonSettings.proxyConfig(proxyManager), CommonSettings.userAgent(appSettings), CommonSettings.ignoreSSLCertificates(appSettings), CommonSettings.useServerLastModified(appRepository), ) ), ConfigurableGroup( nestedConfigurable = listOf( CommonSettings.trackDeletedFilesOnDisk(appRepository), CommonSettings.appendExtensionToIncompleteDownloads(appRepository), CommonSettings.deletePartialFileOnDownloadCancellation(appSettings), CommonSettings.useSparseFileAllocation(appRepository), ) ), ) } } } fun toFront() { sendEffect(Effects.BringToFront) } val settingsPageStateToPersist = MutableStateFlow(pageStorage.settingsPageStorage.value) private val _windowSize = settingsPageStateToPersist.mapTwoWayStateFlow( map = { it.windowSize.let { (x, y) -> DpSize(x.dp, y.dp) } }, unMap = { copy( windowSize = it.width.value to it.height.value ) } ) val windowSize = _windowSize.asStateFlow() fun setWindowSize(dpSize: DpSize) { _windowSize.value = dpSize } init { settingsPageStateToPersist .debounce(500) .onEach { newValue -> pageStorage.settingsPageStorage.update { newValue } }.launchIn(scope) } var pages = listOf( Appearance, // Network, DownloadEngine, BrowserIntegration, ) private val _currentPage: MutableStateFlow = MutableStateFlow(Appearance) val currentPage: StateFlow = _currentPage.asStateFlow() fun setCurrentPage(section: SettingSection) { _currentPage.value = section _configurables.value = allConfigs[section] } private val _configurables = MutableStateFlow( allConfigs[currentPage.value] ) override val configurables = _configurables.asStateFlow() sealed interface Effects : BaseSettingsComponent.Effects.Platform { data object BringToFront : Effects } } ================================================ FILE: desktop/app/src/main/kotlin/com/abdownloadmanager/desktop/pages/settings/FontManager.kt ================================================ package com.abdownloadmanager.desktop.pages.settings import androidx.compose.runtime.Immutable import androidx.compose.ui.text.ExperimentalTextApi import androidx.compose.ui.text.font.FontFamily import androidx.compose.ui.text.platform.FileFont import androidx.compose.ui.text.platform.ResourceFont import com.abdownloadmanager.desktop.storage.AppSettingsStorage import com.abdownloadmanager.resources.Res import ir.amirab.util.compose.StringSource import ir.amirab.util.compose.asStringSource import ir.amirab.util.compose.contants.FILE_PROTOCOL import ir.amirab.util.compose.contants.RESOURCE_PROTOCOL import ir.amirab.util.compose.contants.SYSTEM_PROTOCOL import ir.amirab.util.flow.combineStateFlows import ir.amirab.util.flow.mapStateFlow import ir.amirab.util.guardedEntry import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.update import java.awt.GraphicsEnvironment import java.net.URI import kotlin.io.path.toPath class FontManager( private val appSettings: AppSettingsStorage, ) { companion object { private const val DEFAULT_FONT_ID = "default" val defaultFontInfo = FontInfo( id = DEFAULT_FONT_ID, uri = "", name = Res.string.default.asStringSource(), fontFamily = FontFamily.Default, ) fun getUsableFontFamilyNamesOfSystem(): List { return GraphicsEnvironment.getLocalGraphicsEnvironment().availableFontFamilyNames .toList() } } private val _availableFonts = MutableStateFlow(emptyList()) val availableFonts = _availableFonts.asStateFlow() private fun getFontByUri(uri: String): FontFamily? { return runCatching { FontFamilyUtil.fromUri(URI.create(uri)) } .onFailure { it.printStackTrace() } .getOrNull() } val selectableFonts = availableFonts.mapStateFlow { buildList { add(defaultFontInfo) addAll(it) } } val currentFontInfo = combineStateFlows( appSettings.font, selectableFonts, ) { fontId, possibleFonts -> val fontId = fontId ?: DEFAULT_FONT_ID possibleFonts.find { it.id == fontId } ?: possibleFonts.find { it.id == DEFAULT_FONT_ID }!! } val currentFontFamily = currentFontInfo.mapStateFlow { it.fontFamily } fun setFont(fontId: String?) { synchronized(this) { val fontId = fontId ?: DEFAULT_FONT_ID val font = availableFonts.value.find { it.id == fontId } ?: defaultFontInfo appSettings.font.value = font.takeIf { it != defaultFontInfo }?.uri } } private val booted = guardedEntry() fun boot() { booted.action { val systemFontFamilies = getUsableFontFamilyNamesOfSystem() .mapNotNull { fontFamilyName -> val uri = runCatching { URI(SYSTEM_PROTOCOL, fontFamilyName, null).toString() }.onFailure { throwable -> // it seems that some fonts has empty name, which causes URI creation to fail // in order to not break the app, we will just ignore those fonts println("system font family with name:\"$fontFamilyName\" can't be used: $throwable") }.getOrNull() if (uri == null) { return@mapNotNull null } val fontFamily = getFontByUri(uri) if (fontFamily == null) { return@mapNotNull null } FontInfo( id = uri, uri = uri, name = fontFamilyName.asStringSource(), fontFamily = fontFamily, ) } _availableFonts.update { it.plus(systemFontFamilies) } setFont(appSettings.font.value) } } } /** * This is for demonstration purposes of a font */ @Immutable data class FontInfo( val id: String, val uri: String, val name: StringSource, val fontFamily: FontFamily, ) private object FontFamilyUtil { @OptIn(ExperimentalTextApi::class) fun fromUri(uri: URI): FontFamily { return when (uri.scheme) { FILE_PROTOCOL -> { return FontFamily( FileFont(uri.toPath().toFile()) ) } RESOURCE_PROTOCOL -> { val path = uri.schemeSpecificPart require(path.isNotEmpty()) FontFamily( ResourceFont(uri.schemeSpecificPart) ) } SYSTEM_PROTOCOL -> { // This is a system font, we can use it directly val name = uri.schemeSpecificPart require(name.isNotEmpty()) FontFamily(name) } else -> throw IllegalArgumentException("Unsupported font URI scheme: ${uri.scheme}") } } } ================================================ FILE: desktop/app/src/main/kotlin/com/abdownloadmanager/desktop/pages/settings/SettingPageStateToPersist.kt ================================================ package com.abdownloadmanager.desktop.pages.settings import arrow.optics.Lens import com.abdownloadmanager.desktop.pages.home.HomePageStateToPersist import ir.amirab.util.config.* import kotlinx.serialization.Serializable import org.koin.core.component.KoinComponent @Serializable data class SettingPageStateToPersist( val windowSize: Pair = 800f to 400f ) { class ConfigLens(prefix: String) : Lens, KoinComponent { class Keys(prefix: String) { val windowWidth = floatKeyOf("${prefix}window.width") val windowHeight = floatKeyOf("${prefix}window.height") } private val keys = Keys(prefix) override fun get(source: MapConfig): SettingPageStateToPersist { val default by lazy { HomePageStateToPersist() } return SettingPageStateToPersist( windowSize = run { val width = source.get(keys.windowWidth) val height = source.get(keys.windowHeight) if (height != null && width != null) { width to height } else { default.windowSize } } ) } override fun set(source: MapConfig, focus: SettingPageStateToPersist): MapConfig { source.put(keys.windowWidth, focus.windowSize.first) source.put(keys.windowHeight, focus.windowSize.second) return source } } } ================================================ FILE: desktop/app/src/main/kotlin/com/abdownloadmanager/desktop/pages/settings/SettingWindow.kt ================================================ package com.abdownloadmanager.desktop.pages.settings import com.abdownloadmanager.desktop.window.custom.CustomWindow import com.abdownloadmanager.shared.util.mvi.HandleEffects import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.ui.Alignment import androidx.compose.ui.window.WindowPlacement import androidx.compose.ui.window.WindowPosition import androidx.compose.ui.window.rememberWindowState import com.abdownloadmanager.shared.settings.BaseSettingsComponent @Composable fun SettingWindow( settingsComponent: DesktopSettingsComponent, onRequestCloseWindow: () -> Unit, ) { val windowState = rememberWindowState( size = settingsComponent.windowSize.value, position = WindowPosition.Aligned(Alignment.Center), ) LaunchedEffect(windowState.size) { if (!windowState.isMinimized && windowState.placement == WindowPlacement.Floating) { settingsComponent.setWindowSize(windowState.size) } } CustomWindow(windowState, { onRequestCloseWindow() }) { HandleEffects(settingsComponent) { when (it) { is BaseSettingsComponent.Effects.Platform -> { when (it as DesktopSettingsComponent.Effects) { DesktopSettingsComponent.Effects.BringToFront -> { windowState.isMinimized = false window.toFront() } } } } } // Spacer(Modifier.fillMaxWidth().height(1.dp).background(myColors.surface)) SettingsPage(settingsComponent, onRequestCloseWindow) } } ================================================ FILE: desktop/app/src/main/kotlin/com/abdownloadmanager/desktop/pages/settings/SettingsPage.kt ================================================ package com.abdownloadmanager.desktop.pages.settings import com.abdownloadmanager.shared.util.ui.WithContentAlpha import com.abdownloadmanager.desktop.window.custom.WindowIcon import com.abdownloadmanager.desktop.window.custom.WindowTitle import ir.amirab.util.compose.IconSource import com.abdownloadmanager.shared.util.ui.widget.MyIcon import com.abdownloadmanager.shared.util.ui.icon.MyIcons import com.abdownloadmanager.shared.util.ui.myColors import com.abdownloadmanager.shared.ui.widget.Handle import com.abdownloadmanager.shared.ui.widget.Text import androidx.compose.animation.AnimatedContent import androidx.compose.animation.AnimatedVisibility import androidx.compose.animation.scaleIn import androidx.compose.animation.scaleOut import androidx.compose.foundation.* import androidx.compose.foundation.gestures.Orientation import androidx.compose.foundation.layout.* import androidx.compose.foundation.selection.selectable import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.runtime.* import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip import androidx.compose.ui.graphics.Brush import androidx.compose.ui.graphics.Color import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp import com.abdownloadmanager.shared.ui.configurable.RenderConfigurableGroup import com.abdownloadmanager.resources.Res import com.abdownloadmanager.shared.util.div import com.abdownloadmanager.shared.util.ui.MultiplatformVerticalScrollbar import com.abdownloadmanager.shared.util.ui.needScroll import com.abdownloadmanager.shared.util.ui.theme.myShapes import com.abdownloadmanager.shared.util.ui.theme.myTextSizes import io.github.oikvpqya.compose.fastscroller.rememberScrollbarAdapter import ir.amirab.util.compose.resources.myStringResource import ir.amirab.util.ifThen @Composable private fun SideBar( settingsComponent: DesktopSettingsComponent, modifier: Modifier = Modifier, ) { val shape = myShapes.defaultRounded Column( modifier .fillMaxHeight() .border(1.dp, myColors.surface / 0.5f, shape) .clip(shape) ) { // var searchText by remember { mutableStateOf("") } // SearchBox( // searchText, // onTextChange = { searchText = it }, // modifier = Modifier.height(38.dp), // ) val collectAsState by settingsComponent.currentPage.collectAsState() for (i in settingsComponent.pages) { SideBarItem( icon = i.icon, name = i.name.rememberString(), isSelected = collectAsState == i, onClick = { settingsComponent.setCurrentPage(i) } ) } } } @Composable private fun SideBarItem(icon: IconSource, name: String, isSelected: Boolean, onClick: () -> Unit) { Box( Modifier .height(IntrinsicSize.Max) .ifThen(isSelected) { background(myColors.onBackground / 0.05f) } .selectable( selected = isSelected, onClick = onClick ) ) { Row( Modifier .padding(vertical = 8.dp) .padding(start = 16.dp) .padding(end = 2.dp), verticalAlignment = Alignment.CenterVertically, ) { WithContentAlpha(if (isSelected) 1f else 0.75f) { MyIcon( icon, null, Modifier.size(16.dp) ) Spacer(Modifier.width(4.dp)) Text( name, Modifier.weight(1f), fontWeight = if (isSelected) FontWeight.Bold else FontWeight.Normal, fontSize = myTextSizes.lg, overflow = TextOverflow.Ellipsis, maxLines = 1, ) } } AnimatedVisibility( isSelected, modifier = Modifier .align(Alignment.CenterStart), enter = scaleIn(), exit = scaleOut(), ) { Spacer( Modifier .height(16.dp) .width(3.dp) .clip( RoundedCornerShape( topStart = 0.dp, bottomStart = 0.dp, bottomEnd = 12.dp, topEnd = 12.dp, ) ) .background(myColors.primary) ) } if (isSelected) { listOf( Alignment.TopCenter, Alignment.BottomCenter, ).forEach { Spacer( Modifier .align(it) .fillMaxWidth() .height(1.dp) .background( Brush.horizontalGradient( listOf( Color.Transparent, myColors.onBackground / 0.1f, myColors.onBackground / 0.1f, Color.Transparent, ) ) ) ) } } } } @Composable fun SettingsPage( settingsComponent: DesktopSettingsComponent, onDismissRequest: () -> Unit, ) { WindowTitle(myStringResource(Res.string.settings)) // WindowIcon(MyIcons.settings) WindowIcon(MyIcons.appIcon) Column { Spacer( Modifier .fillMaxWidth() .height(1.dp) .background(myColors.surface) ) Row { var sideBarWidth by remember { mutableStateOf(250.dp) } SideBar( settingsComponent, Modifier .fillMaxHeight() .width(sideBarWidth) .padding(8.dp) ) val currentConfigurables by settingsComponent.configurables.collectAsState() Handle( Modifier.width(5.dp).fillMaxHeight(), orientation = Orientation.Horizontal ) { sideBarWidth = (sideBarWidth + it).coerceIn(150.dp..300.dp) } AnimatedContent(currentConfigurables) { configurableGroups -> val scrollState = rememberScrollState() val scrollbarAdapter = rememberScrollbarAdapter(scrollState) Row { Column( Modifier .weight(1f) .verticalScroll(scrollState) .padding( horizontal = 8.dp, vertical = 8.dp ), verticalArrangement = Arrangement.spacedBy(16.dp) ) { for (cfgGroup in configurableGroups) { RenderConfigurableGroup( cfgGroup, Modifier, itemPadding = PaddingValues( vertical = 8.dp, horizontal = 16.dp ) ) } } if (scrollbarAdapter.needScroll()) { MultiplatformVerticalScrollbar( adapter = scrollbarAdapter, modifier = Modifier .padding(vertical = 8.dp) .padding(end = 2.dp), ) } } } } } } ================================================ FILE: desktop/app/src/main/kotlin/com/abdownloadmanager/desktop/pages/singleDownloadPage/CompletedDownloadPage.kt ================================================ package com.abdownloadmanager.desktop.pages.singleDownloadPage import androidx.compose.foundation.background import androidx.compose.foundation.basicMarquee import androidx.compose.foundation.draganddrop.dragAndDropSource import androidx.compose.foundation.layout.* import androidx.compose.runtime.Composable import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draganddrop.DragAndDropTransferAction import androidx.compose.ui.draganddrop.DragAndDropTransferData import androidx.compose.ui.draganddrop.DragAndDropTransferable import androidx.compose.ui.platform.LocalWindowInfo import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.dp import com.abdownloadmanager.desktop.pages.home.DownloadItemTransferable import com.abdownloadmanager.shared.util.ui.icon.MyIcons import com.abdownloadmanager.shared.util.ui.myColors import com.abdownloadmanager.shared.util.ui.theme.myTextSizes import com.abdownloadmanager.shared.ui.widget.ActionButton import com.abdownloadmanager.shared.ui.widget.Text import com.abdownloadmanager.shared.util.LocalSizeUnit import com.abdownloadmanager.shared.util.convertPositiveSizeToHumanReadable import com.abdownloadmanager.shared.util.div import com.abdownloadmanager.resources.Res import com.abdownloadmanager.shared.ui.widget.IconActionButton import com.abdownloadmanager.shared.ui.widget.Tooltip import com.abdownloadmanager.shared.util.ui.WithContentColor import com.abdownloadmanager.shared.util.ui.widget.MyIcon import ir.amirab.downloader.monitor.CompletedDownloadItemState import ir.amirab.util.compose.asStringSource import ir.amirab.util.compose.resources.myStringResource import ir.amirab.util.desktop.isShiftPressed @Composable fun CompletedDownloadPage( component: DesktopSingleDownloadComponent, completedDownloadItemState: CompletedDownloadItemState, ) { Column { Row( Modifier .padding( horizontal = 16.dp, vertical = 8.dp ) ) { RenderFileIconAndSize( modifier = Modifier.align(Alignment.CenterVertically), component = component, itemState = completedDownloadItemState, ) Spacer(Modifier.width(16.dp)) RenderName( Modifier.weight(1f), completedDownloadItemState.name, ) } Spacer(Modifier.weight(1f)) Actions(Modifier, component) } } @Composable private fun Actions( modifier: Modifier, component: DesktopSingleDownloadComponent, ) { val iDownloadItemState by component.itemStateFlow.collectAsState() Column(modifier) { Spacer( Modifier .fillMaxWidth() .height(1.dp) .background(myColors.onBackground / 0.15f) ) Row( Modifier .fillMaxWidth() .background(myColors.surface / 0.5f) .padding(horizontal = 16.dp) .padding(vertical = 8.dp), verticalAlignment = Alignment.CenterVertically, ) { ActionButton( myStringResource(Res.string.open), modifier = Modifier, onClick = { component.openFile() }, ) Spacer(Modifier.width(8.dp)) ActionButton( myStringResource(Res.string.open_folder), modifier = Modifier, onClick = { component.openFolder() }, ) Spacer(Modifier.width(8.dp)) val dragTheFileDescription = Res.string.drag_the_file_to_another_app.asStringSource() val windowInfo = LocalWindowInfo.current Tooltip(dragTheFileDescription) { IconActionButton( icon = MyIcons.dragAndDrop, contentDescription = dragTheFileDescription, modifier = Modifier .dragAndDropSource( drawDragDecoration = {}, transferData = { val completedDownloadItemState = iDownloadItemState as? CompletedDownloadItemState ?: return@dragAndDropSource null val shiftPressed = isShiftPressed(windowInfo) val supportedActions = listOf( if (shiftPressed) { DragAndDropTransferAction.Move } else { DragAndDropTransferAction.Copy } ) DragAndDropTransferData( transferable = DragAndDropTransferable( DownloadItemTransferable( listOf(completedDownloadItemState) ) ), supportedActions = supportedActions, ) } ), onClick = {}, ) } Spacer(Modifier.weight(1f)) ActionButton( myStringResource(Res.string.close), modifier = Modifier, onClick = component::close, ) } } } @Composable private fun RenderName( modifier: Modifier, name: String, ) { Column( modifier = modifier ) { WithContentColor( myColors.success ) { Row( verticalAlignment = Alignment.CenterVertically, ) { MyIcon( MyIcons.check, null, Modifier.size(24.dp) ) Spacer(Modifier.width(4.dp)) Text( myStringResource(Res.string.download_page_download_completed), fontWeight = FontWeight.Bold, fontSize = myTextSizes.lg, ) } } Spacer(Modifier.height(8.dp)) Text( text = name, maxLines = 1, modifier = Modifier.basicMarquee( iterations = Int.MAX_VALUE ) ) } } @Composable private fun RenderFileIconAndSize( modifier: Modifier, component: DesktopSingleDownloadComponent, itemState: CompletedDownloadItemState, ) { Column( modifier = modifier, horizontalAlignment = Alignment.CenterHorizontally, ) { MyIcon( icon = component.fileIconProvider.rememberIcon(itemState.name), contentDescription = null, modifier = Modifier.size(24.dp), ) Spacer(Modifier.height(4.dp)) Text( text = convertPositiveSizeToHumanReadable( itemState.contentLength, LocalSizeUnit.current, ).rememberString(), ) } } ================================================ FILE: desktop/app/src/main/kotlin/com/abdownloadmanager/desktop/pages/singleDownloadPage/DesktopSingleDownloadPageComponent.kt ================================================ package com.abdownloadmanager.desktop.pages.singleDownloadPage import arrow.optics.copy import com.abdownloadmanager.desktop.storage.DesktopExtraDownloadItemSettings import com.abdownloadmanager.desktop.storage.PageStatesStorage import com.abdownloadmanager.resources.Res import com.abdownloadmanager.shared.repository.BaseAppRepository import com.abdownloadmanager.shared.singledownloadpage.BaseSingleDownloadComponent import com.abdownloadmanager.shared.storage.BaseAppSettingsStorage import com.abdownloadmanager.shared.storage.ExtraDownloadSettingsStorage import com.abdownloadmanager.shared.ui.configurable.item.BooleanConfigurable import com.abdownloadmanager.shared.util.* import com.arkivanov.decompose.ComponentContext import ir.amirab.util.compose.asStringSource import ir.amirab.util.desktop.poweraction.PowerActionConfig import ir.amirab.util.flow.mapTwoWayStateFlow import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.flow.update import org.koin.core.component.get import kotlin.getValue class DesktopSingleDownloadComponent( ctx: ComponentContext, downloadItemOpener: DownloadItemOpener, onDismiss: () -> Unit, downloadId: Long, extraDownloadSettingsStorage: ExtraDownloadSettingsStorage, downloadSystem: DownloadSystem, appSettings: BaseAppSettingsStorage, appRepository: BaseAppRepository, applicationScope: CoroutineScope, fileIconProvider: FileIconProvider, ) : BaseSingleDownloadComponent( ctx = ctx, downloadItemOpener = downloadItemOpener, onDismiss = onDismiss, downloadId = downloadId, extraDownloadSettingsStorage = extraDownloadSettingsStorage, downloadSystem = downloadSystem, appSettings = appSettings, appRepository = appRepository, applicationScope = applicationScope, fileIconProvider = fileIconProvider, ) { private val singleDownloadPageStateToPersist by lazy { get().singleDownloadPageState } override val defaultShowPartInfo: Boolean = singleDownloadPageStateToPersist.value.showPartInfo override fun setShowPartInfo(value: Boolean) { super.setShowPartInfo(value) singleDownloadPageStateToPersist.update { it.copy { SingleDownloadPageStateToPersist.showPartInfo.set(value) } } } sealed interface Effects : BaseSingleDownloadComponent.Effects.Platform { data object BringToFront : Effects } fun bringToFront() { sendEffect(Effects.BringToFront) } val onCompletion by lazy { listOf( BooleanConfigurable( title = Res.string.download_item_settings_shutdown_on_completion.asStringSource(), description = Res.string.download_item_settings_shutdown_on_completion_description.asStringSource(), backedBy = extraDownloadItemSettingsFlow.mapTwoWayStateFlow( map = { it.powerActionTypeOnFinish != null }, unMap = { copy( powerActionTypeOnFinish = when (it) { true -> PowerActionConfig.Type.Shutdown false -> null }, ) } ), describe = { when (it) { true -> Res.string.enabled false -> Res.string.disabled }.asStringSource() }, ), BooleanConfigurable( title = Res.string.download_item_settings_show_download_completion_dialog.asStringSource(), description = Res.string.download_item_settings_show_download_completion_dialog_description.asStringSource(), backedBy = itemShouldShowCompletionDialog.mapTwoWayStateFlow( map = { it ?: globalShowCompletionDialog.value }, unMap = { it } ), describe = { when (it) { true -> Res.string.enabled false -> Res.string.disabled }.asStringSource() }, ), ) } data class Config( override val id: Long ) : BaseSingleDownloadComponent.Config } ================================================ FILE: desktop/app/src/main/kotlin/com/abdownloadmanager/desktop/pages/singleDownloadPage/ProgressDownloadPage.kt ================================================ package com.abdownloadmanager.desktop.pages.singleDownloadPage import com.abdownloadmanager.shared.ui.configurable.RenderConfigurable import com.abdownloadmanager.desktop.pages.singleDownloadPage.SingleDownloadPageSections.* import com.abdownloadmanager.shared.util.ui.LocalContentColor import com.abdownloadmanager.shared.util.ui.WithContentAlpha import ir.amirab.util.compose.IconSource import com.abdownloadmanager.shared.util.ui.widget.MyIcon import com.abdownloadmanager.shared.util.ui.icon.MyIcons import com.abdownloadmanager.shared.util.ui.myColors import com.abdownloadmanager.shared.util.ui.theme.myTextSizes import androidx.compose.animation.core.* import androidx.compose.foundation.* import androidx.compose.foundation.gestures.Orientation import androidx.compose.foundation.interaction.MutableInteractionSource import androidx.compose.foundation.interaction.collectIsDraggedAsState import androidx.compose.foundation.interaction.collectIsHoveredAsState import androidx.compose.foundation.layout.* import androidx.compose.foundation.shape.CircleShape import androidx.compose.runtime.* import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip import androidx.compose.ui.graphics.Brush import androidx.compose.ui.graphics.Color import androidx.compose.ui.text.SpanStyle import androidx.compose.ui.text.buildAnnotatedString import androidx.compose.ui.text.withStyle import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.DpOffset import androidx.compose.ui.unit.dp import androidx.compose.ui.window.Popup import com.abdownloadmanager.shared.ui.widget.rememberMyComponentRectPositionProvider import com.abdownloadmanager.shared.ui.widget.* import com.abdownloadmanager.shared.ui.widget.table.customtable.CellSize import com.abdownloadmanager.shared.ui.widget.table.customtable.Table import com.abdownloadmanager.shared.ui.widget.table.customtable.TableCell import com.abdownloadmanager.shared.ui.widget.table.customtable.TableState import com.abdownloadmanager.resources.Res import com.abdownloadmanager.shared.singledownloadpage.SingleDownloadPagePropertyItem import com.abdownloadmanager.shared.ui.configurable.ConfigurableUiProps import com.abdownloadmanager.shared.util.LocalSizeUnit import com.abdownloadmanager.shared.util.convertPositiveSizeToHumanReadable import com.abdownloadmanager.shared.util.ui.useIsInDebugMode import com.abdownloadmanager.shared.util.div import com.abdownloadmanager.shared.util.ui.MultiplatformVerticalScrollbar import com.abdownloadmanager.shared.util.ui.theme.myShapes import ir.amirab.downloader.downloaditem.DownloadJobStatus import ir.amirab.downloader.monitor.* import ir.amirab.downloader.part.PartDownloadStatus import ir.amirab.downloader.utils.ExceptionUtils import ir.amirab.util.compose.StringSource import ir.amirab.util.compose.asStringSource import ir.amirab.util.compose.resources.myStringResource enum class SingleDownloadPageSections( val title: StringSource, val icon: IconSource, ) { Info( Res.string.info.asStringSource(), MyIcons.info ), Settings( Res.string.speed.asStringSource(), MyIcons.fast, ), OnCompletion( Res.string.on_completion.asStringSource(), MyIcons.flag ), } private val tabs = entries.toList() @Composable fun ProgressDownloadPage( singleDownloadComponent: DesktopSingleDownloadComponent, itemState: ProcessingDownloadItemState ) { var selectedTab by remember { mutableStateOf(Info) } val showPartInfo by singleDownloadComponent.showPartInfo.collectAsState() val setShowPartInfo = singleDownloadComponent::setShowPartInfo val resizingState = LocalSingleDownloadPageSizing.current val horizontalPadding = 16.dp LaunchedEffect(resizingState.resizingPartInfo) { if (resizingState.partInfoHeight <= 0.dp) { setShowPartInfo(false) } } Column( Modifier.fillMaxSize() ) { Column( Modifier .clip(myShapes.defaultRounded) .padding(1.dp), ) { //tabs MyTabRow { for (tab in tabs) { MyTab( selected = tab == selectedTab, onClick = { selectedTab = tab }, icon = tab.icon, title = tab.title, ) } } val scrollState = rememberScrollState() //info / settings ... val tabContentModifier = Modifier Spacer(Modifier.fillMaxWidth().height(1.dp).background(myColors.surface)) Box( Modifier .height(150.dp) .background(myColors.background) .verticalScroll(scrollState) ) { when (selectedTab) { Info -> RenderInfo( tabContentModifier, horizontalPadding, singleDownloadComponent, ) Settings -> RenderSettings( modifier = tabContentModifier.padding(end = 12.dp), horizontalPadding = horizontalPadding, singleDownloadComponent = singleDownloadComponent, ) OnCompletion -> RenderOnCompletion( modifier = tabContentModifier.padding(end = 12.dp), horizontalPadding = horizontalPadding, singleDownloadComponent = singleDownloadComponent, ) } MultiplatformVerticalScrollbar( adapter = rememberScrollbarAdapter(scrollState), modifier = Modifier.matchParentSize().wrapContentWidth(Alignment.End), ) } } Spacer( Modifier .fillMaxWidth() .height(1.dp) .background(myColors.onBackground / 0.15f) ) Column( Modifier .weight(1f) .background(myColors.surface / 0.5f) ) { Column( Modifier .padding(horizontal = horizontalPadding) ) { Spacer(Modifier.size(8.dp)) RenderProgressBar(itemState) Spacer(Modifier.size(8.dp)) RenderActions(itemState, singleDownloadComponent, showPartInfo, setShowPartInfo) Spacer(Modifier.size(8.dp)) } if (showPartInfo) { RenderPartInfo( modifier = Modifier.weight(1f), itemState = itemState, horizontalPadding = horizontalPadding, ) } } } } @Composable private fun RenderSettings( modifier: Modifier, horizontalPadding: Dp, singleDownloadComponent: DesktopSingleDownloadComponent, ) { Column(modifier) { for (configurable in singleDownloadComponent.settings) { RenderConfigurable( configurable, ConfigurableUiProps( modifier = Modifier // I'm using Configurable object which their renderer by default uses 8.dp, we want 16.dp, so I only add 8.dp here 16-8 == 8 // I may improve this later .padding(horizontal = (horizontalPadding - 8.dp).coerceAtLeast(0.dp)) ) ) } } } @Composable private fun RenderOnCompletion( modifier: Modifier, horizontalPadding: Dp, singleDownloadComponent: DesktopSingleDownloadComponent, ) { Column(modifier) { for (configurable in singleDownloadComponent.onCompletion) { RenderConfigurable( configurable, ConfigurableUiProps( modifier = Modifier // I'm using Configurable object which their renderer by default uses 8.dp, we want 16.dp, so I only add 8.dp here 16-8 == 8 // I may improve this later .padding(horizontal = (horizontalPadding - 8.dp).coerceAtLeast(0.dp)) ) ) } } } @Composable private fun RenderProgressBar(itemState: IDownloadItemState) { val progress = when (itemState) { is CompletedDownloadItemState -> 100 is ProcessingDownloadItemState -> when (val status = itemState.status) { is DownloadJobStatus.PreparingFile -> status.percent else -> itemState.percent } }?.let { it / 100f } val status = itemState.statusOrFinished() val background = when (status) { is DownloadJobStatus.Finished -> myColors.successGradient is DownloadJobStatus.Canceled -> if (ExceptionUtils.isNormalCancellation(status.e)) { myColors.warningGradient } else { myColors.errorGradient } DownloadJobStatus.IDLE -> myColors.warningGradient is DownloadJobStatus.Retrying -> myColors.errorGradient DownloadJobStatus.Finished -> myColors.successGradient is DownloadJobStatus.PreparingFile -> myColors.infoGradient DownloadJobStatus.Resuming, DownloadJobStatus.Downloading, -> myColors.primaryGradient } Box( Modifier .fillMaxWidth() .clip(myShapes.defaultRounded) .height(14.dp) .background(myColors.onBackground / 15) ) { progress?.let { progress -> Box( Modifier .background(background) .fillMaxHeight() .fillMaxWidth( animateFloatAsState( progress, tween(100, easing = LinearEasing) ).value ) ) { if (progress == 1f) { MyIcon( MyIcons.check, null, Modifier .padding(1.dp) .clip(CircleShape) .background(myColors.onBackground) .padding(1.dp) .fillMaxHeight() .align(Alignment.CenterEnd), tint = myColors.background, ) } } } if (progress == null && status is DownloadJobStatus.IsActive) { val anim = rememberInfiniteTransition() val l = 2000 val endPos by anim.animateFloat( 0f, 1f, infiniteRepeatable(tween(l), RepeatMode.Restart) ) val width by anim.animateFloat( 6f, 16f, infiniteRepeatable( keyframes { durationMillis = l 0f atFraction 0f 0.75f atFraction 0.25f 0f atFraction 1f }, repeatMode = RepeatMode.Restart ) ) Box( Modifier .fillMaxHeight() .fillMaxWidth(endPos) ) { Box( Modifier .background(background) .fillMaxHeight() .align(Alignment.CenterEnd) .fillMaxWidth(width) ) } } } } @Composable private fun RenderPartInfo( modifier: Modifier, itemState: ProcessingDownloadItemState, horizontalPadding: Dp, ) { Column(modifier) { val singleDownloadPageSizing = LocalSingleDownloadPageSizing.current val mutableInteractionSource = remember { MutableInteractionSource() } val isDraggingHandle by mutableInteractionSource.collectIsDraggedAsState() LaunchedEffect(isDraggingHandle) { singleDownloadPageSizing.resizingPartInfo = isDraggingHandle } Column( Modifier.weight(1f) ) { RenderParts( itemState.parts, Modifier .padding(horizontal = horizontalPadding) .height(4.dp) .clip(myShapes.defaultRounded) .background(myColors.onBackground / 15) ) Box( Modifier.weight(1f) ) { val (onlyActiveParts, setOnlyActiveParts) = rememberSaveable { mutableStateOf(true) } val listToShow = remember(itemState, onlyActiveParts) { itemState.parts .let { parts -> if (onlyActiveParts) { parts.filter { when (it.status) { is PartDownloadStatus.Canceled -> true PartDownloadStatus.Completed -> false PartDownloadStatus.IDLE -> false PartDownloadStatus.ReceivingData -> true PartDownloadStatus.Connecting -> true } } } else { parts } } .withIndex() .toList() } Table( list = listToShow, key = { it.value.id }, modifier = Modifier .fillMaxSize(), wrapHeader = { WithContentAlpha(0.75f) { Box( Modifier .fillMaxWidth() .padding(horizontal = horizontalPadding) .padding(vertical = 4.dp) ) { it() } } }, tableState = remember { TableState( cells = PartInfoCells.all() ) }, wrapItem = { _, _, content -> WithContentAlpha(1f) { val interactionSource = remember { MutableInteractionSource() } val isHovered by interactionSource.collectIsHoveredAsState() Box( Modifier .padding(horizontal = horizontalPadding) .hoverable(interactionSource) .background( if (isHovered) myColors.onSurface / 10 else Color.Transparent ) ) { content() } } } ) { cell, it -> when (cell) { PartInfoCells.Number -> { SimpleCellText("${it.index + 1}") } PartInfoCells.Status -> { SimpleCellText(prettifyStatus(it.value.status).rememberString()) } PartInfoCells.Downloaded -> { SimpleCellText( convertPositiveSizeToHumanReadable( it.value.howMuchProceed, LocalSizeUnit.current ).rememberString() ) } PartInfoCells.Total -> { SimpleCellText( it.value.length?.let { length -> convertPositiveSizeToHumanReadable(length, LocalSizeUnit.current).rememberString() } ?: myStringResource(Res.string.unknown), ) } } } if (useIsInDebugMode()) { Row( modifier = Modifier .align(Alignment.BottomEnd) .padding(bottom = 8.dp, end = 8.dp) .onClick { setOnlyActiveParts(!onlyActiveParts) }, verticalAlignment = Alignment.CenterVertically ) { Text("Only Actives") Spacer(Modifier.width(4.dp)) CheckBox(onlyActiveParts, { setOnlyActiveParts(it) }) } } } } Handle( Modifier.fillMaxWidth().height(8.dp), orientation = Orientation.Vertical, interactionSource = mutableInteractionSource ) { singleDownloadPageSizing.partInfoHeight += it } } } private fun prettifyStatus(status: PartDownloadStatus): StringSource { return when (status) { is PartDownloadStatus.Canceled -> Res.string.disconnected PartDownloadStatus.IDLE -> Res.string.idle PartDownloadStatus.Completed -> Res.string.finished PartDownloadStatus.ReceivingData -> Res.string.receiving_data PartDownloadStatus.Connecting -> Res.string.connecting }.asStringSource() } @Composable private fun SimpleCellText(text: String) { Text(text, fontSize = myTextSizes.base, maxLines = 1) } sealed class PartInfoCells : TableCell> { data object Number : PartInfoCells() { override val id: String = "#" override val name: StringSource = "#".asStringSource() override val size: CellSize = CellSize.Fixed(32.dp) } data object Status : PartInfoCells() { override val id: String = "Status" override val name: StringSource = Res.string.status.asStringSource() override val size: CellSize = CellSize.Resizeable(100.dp..200.dp) } data object Downloaded : PartInfoCells() { override val id: String = "Downloaded" override val name: StringSource = Res.string.parts_info_downloaded_size.asStringSource() override val size: CellSize = CellSize.Resizeable(90.dp..200.dp) } data object Total : PartInfoCells() { override val id: String = "Total" override val name: StringSource = Res.string.parts_info_total_size.asStringSource() override val size: CellSize = CellSize.Resizeable(90.dp..200.dp) } companion object { fun all(): List { return listOf( Number, Status, Downloaded, Total, ) } } } @Composable private fun RenderPropertyItem(propertyItem: SingleDownloadPagePropertyItem) { val title = propertyItem.name val value = propertyItem.value Row( verticalAlignment = Alignment.CenterVertically, modifier = Modifier.fillMaxWidth() ) { WithContentAlpha(0.75f) { Text( text = "${title.rememberString()}:", modifier = Modifier.weight(0.3f), maxLines = 1, fontSize = myTextSizes.base ) } WithContentAlpha(1f) { Text( text = value.rememberString(), modifier = Modifier .basicMarquee( iterations = Int.MAX_VALUE ) .weight(0.7f), maxLines = 1, fontSize = myTextSizes.base, color = when (propertyItem.valueState) { SingleDownloadPagePropertyItem.ValueType.Normal -> LocalContentColor.current SingleDownloadPagePropertyItem.ValueType.Error -> myColors.error SingleDownloadPagePropertyItem.ValueType.Success -> myColors.success } ) } } } @Composable private fun RenderInfo( modifier: Modifier, horizontalPadding: Dp, singleDownloadComponent: DesktopSingleDownloadComponent, ) { Column( modifier .padding(horizontal = horizontalPadding) .padding(top = 8.dp) ) { for (propertyItem in singleDownloadComponent.extraDownloadProgressInfo.collectAsState().value) { Spacer(Modifier.height(2.dp)) RenderPropertyItem(propertyItem) } } } @Composable private fun RenderActions( itemState: ProcessingDownloadItemState, singleDownloadComponent: DesktopSingleDownloadComponent, showingPartInfo: Boolean, onRequestShowPartInfo: (show: Boolean) -> Unit, ) { Row { PartInfoButton(showingPartInfo, onRequestShowPartInfo) Spacer(Modifier.weight(1f)) ToggleButton( itemState = itemState, toggle = singleDownloadComponent::toggle, pause = singleDownloadComponent::pause, ) Spacer(Modifier.width(8.dp)) CancelButton( cancel = singleDownloadComponent::cancel, icon = if (singleDownloadComponent.deletePartialFileOnDownloadCancellation.collectAsState().value) { MyIcons.stop } else { null }, ) } } @Composable private fun PartInfoButton( showing: Boolean, onClick: (Boolean) -> Unit, ) { val partsInfoTitle = Res.string.parts_info.asStringSource() Tooltip(partsInfoTitle) { IconActionButton( onClick = { onClick(!showing) }, contentDescription = partsInfoTitle, icon = if (showing) { MyIcons.up } else { MyIcons.down } ) } } @Composable private fun SingleDownloadPageButton( onClick: () -> Unit, text: String, color: Color = LocalContentColor.current, icon: IconSource? = null, ) { ActionButton( text = text, start = { icon?.let { Row { MyIcon(it, null, Modifier.size(16.dp)) Spacer(Modifier.width(4.dp)) } } }, contentPadding = PaddingValues(vertical = 6.dp, horizontal = 12.dp), contentColor = color, onClick = onClick, ) } @Composable private fun CancelButton( cancel: () -> Unit, icon: IconSource?, ) { SingleDownloadPageButton( { cancel() }, icon = icon, text = myStringResource(Res.string.cancel) ) } @Composable private fun ToggleButton( itemState: ProcessingDownloadItemState, toggle: () -> Unit, pause: () -> Unit, ) { var showPromptOnNonePresumablePause by remember(itemState.status is DownloadJobStatus.IsActive) { mutableStateOf(false) } val isResumeSupported = itemState.supportResume == true val (icon, text) = when { itemState.canBeResumed() -> { MyIcons.resume to Res.string.resume } itemState.canBePaused() -> { MyIcons.pause to Res.string.pause } else -> return } Box { SingleDownloadPageButton( { if (isResumeSupported) { toggle() } else { if (itemState.status is DownloadJobStatus.IsActive) { showPromptOnNonePresumablePause = true } else { toggle() } } }, icon = icon, text = myStringResource(text), color = if (isResumeSupported) { LocalContentColor.current } else { if (itemState.status is DownloadJobStatus.IsActive) { myColors.error } else { LocalContentColor.current } }, ) if (showPromptOnNonePresumablePause) { val shape = myShapes.defaultRounded val closePopup = { showPromptOnNonePresumablePause = false } Popup( popupPositionProvider = rememberMyComponentRectPositionProvider( offset = DpOffset.Zero, anchor = Alignment.TopEnd, alignment = Alignment.TopStart, ), onDismissRequest = closePopup ) { Column( Modifier .clip(shape) .border(2.dp, myColors.onBackground / 10, shape) .background( Brush.linearGradient( listOf( myColors.surface, myColors.background, ) ) ) .padding(16.dp) .widthIn(max = 140.dp) ) { Text(buildAnnotatedString { withStyle(SpanStyle(color = myColors.warning)) { append("${myStringResource(Res.string.warning)}:\n") } append(myStringResource(Res.string.unsupported_resume_warning)) }) Spacer(Modifier.height(8.dp)) ActionButton( myStringResource(Res.string.stop_anyway), onClick = { closePopup() pause() }, contentColor = myColors.error ) } } } } } @Composable private fun RenderParts(parts: List, modifier: Modifier) { Row( modifier .fillMaxWidth() ) { if (parts.isNotEmpty()) { val sortedParts = remember(parts) { parts.sortedBy { it.id } } for (p in sortedParts) { val partSpace = p.partSpace if (partSpace <= 0f) continue key(p.id) { RenderPart( p, Modifier .fillMaxHeight() .weight(partSpace) ) } } } } } @Composable private fun RenderPart(part: UiPart, modifier: Modifier) { val partProgress = part.percent?.let { it / 100f } ?: 0f val foregroundColor = when (part.status) { is PartDownloadStatus.Canceled -> myColors.error PartDownloadStatus.Completed -> myColors.info PartDownloadStatus.IDLE -> myColors.info / 25 PartDownloadStatus.ReceivingData -> myColors.success PartDownloadStatus.Connecting -> myColors.warning } Row(modifier) { Box( Modifier .fillMaxSize() ) { Box( Modifier .align(Alignment.CenterStart) .fillMaxWidth(partProgress) .fillMaxHeight() .background(foregroundColor) ) } } } ================================================ FILE: desktop/app/src/main/kotlin/com/abdownloadmanager/desktop/pages/singleDownloadPage/ShowDownloadDialogs.kt ================================================ package com.abdownloadmanager.desktop.pages.singleDownloadPage import com.abdownloadmanager.desktop.DesktopDownloadDialogManager import com.abdownloadmanager.desktop.window.custom.CustomWindow import com.abdownloadmanager.desktop.window.custom.WindowIcon import com.abdownloadmanager.desktop.window.custom.WindowTitle import com.abdownloadmanager.shared.util.ui.icon.MyIcons import com.abdownloadmanager.shared.util.mvi.HandleEffects import androidx.compose.runtime.* import androidx.compose.ui.Alignment import androidx.compose.ui.unit.DpSize import androidx.compose.ui.unit.dp import androidx.compose.ui.window.FrameWindowScope import androidx.compose.ui.window.WindowPosition import androidx.compose.ui.window.WindowState import androidx.compose.ui.window.rememberWindowState import com.abdownloadmanager.shared.singledownloadpage.BaseSingleDownloadComponent import com.abdownloadmanager.shared.util.ui.theme.LocalUiScale import ir.amirab.downloader.downloaditem.DownloadJobStatus import ir.amirab.downloader.monitor.CompletedDownloadItemState import ir.amirab.downloader.monitor.IDownloadItemState import ir.amirab.downloader.monitor.ProcessingDownloadItemState import ir.amirab.downloader.monitor.statusOrFinished import ir.amirab.downloader.utils.ExceptionUtils import ir.amirab.util.desktop.screen.applyUiScale import java.awt.Dimension import java.awt.Taskbar import java.awt.Window @Composable private fun getDownloadTitle(itemState: IDownloadItemState): String { return buildString { if (itemState is ProcessingDownloadItemState && itemState.percent != null) { append("${itemState.percent}%") append(" ") } append(itemState.name) } } val LocalSingleDownloadPageSizing = compositionLocalOf { error("LocalSingleBoxSizing not provided") } @Stable class SingleProgressDownloadPageSizing { var resizingPartInfo by mutableStateOf(false) var partInfoHeight by mutableStateOf(150.dp) } @Composable fun ShowDownloadDialogs(component: DesktopDownloadDialogManager) { val openedDownloadDialogs = component.openedDownloadDialogs.collectAsState().value for (singleDownloadComponent in openedDownloadDialogs) { key(singleDownloadComponent.downloadId) { ShowDownloadDialog(singleDownloadComponent) } } } @Composable private fun ShowDownloadDialog(singleDownloadComponent: DesktopSingleDownloadComponent) { val itemState by singleDownloadComponent.itemStateFlow.collectAsState() itemState?.let { when (it) { is CompletedDownloadItemState -> { CompletedWindow( singleDownloadComponent, it, ) } is ProcessingDownloadItemState -> { ProgressWindow( singleDownloadComponent = singleDownloadComponent, itemState = it, ) } } } } @Composable private fun FrameWindowScope.CommonContent( singleDownloadComponent: DesktopSingleDownloadComponent, state: WindowState, itemState: IDownloadItemState, ) { HandleEffects(singleDownloadComponent) { when (it) { is BaseSingleDownloadComponent.Effects.Platform -> { it as DesktopSingleDownloadComponent.Effects when (it) { DesktopSingleDownloadComponent.Effects.BringToFront -> { state.isMinimized = false window.toFront() } } } } } WindowTitle(getDownloadTitle(itemState)) WindowIcon(MyIcons.appIcon) UpdateTaskBar(window, itemState) } @Composable private fun CompletedWindow( singleDownloadComponent: DesktopSingleDownloadComponent, itemState: CompletedDownloadItemState, ) { val onRequestClose = { singleDownloadComponent.close() } val defaultHeight = 160f val defaultWidth = 450f val uiScale = LocalUiScale.current val state = rememberWindowState( size = DpSize( height = defaultHeight.dp, width = defaultWidth.dp ).applyUiScale(uiScale), position = WindowPosition(Alignment.Center) ) CustomWindow( state = state, onRequestToggleMaximize = null, resizable = false, alwaysOnTop = true, onCloseRequest = onRequestClose, ) { CommonContent( singleDownloadComponent = singleDownloadComponent, state = state, itemState = itemState, ) LaunchedEffect(Unit) { window.minimumSize = Dimension(defaultWidth.toInt(), defaultHeight.toInt()) } var h = defaultHeight var w = defaultWidth LaunchedEffect(w, h) { state.size = DpSize( width = w.dp, height = h.dp ).applyUiScale(uiScale) } CompletedDownloadPage( singleDownloadComponent, itemState, ) } } @Composable private fun ProgressWindow( singleDownloadComponent: DesktopSingleDownloadComponent, itemState: ProcessingDownloadItemState, ) { val onRequestClose = { singleDownloadComponent.close() } val uiScale = LocalUiScale.current val defaultHeight = 290f.applyUiScale(uiScale) val defaultWidth = 450f.applyUiScale(uiScale) val showPartInfo by singleDownloadComponent.showPartInfo.collectAsState() val singleDownloadPageSizing = remember(showPartInfo) { SingleProgressDownloadPageSizing() } var h = defaultHeight var w = defaultWidth if (showPartInfo) { h += singleDownloadPageSizing.partInfoHeight.value .applyUiScale(uiScale) } val state = rememberWindowState( height = h.dp, width = w.dp, position = WindowPosition(Alignment.Center) ) CustomWindow( state = state, onRequestToggleMaximize = null, resizable = false, onCloseRequest = onRequestClose, ) { CommonContent( singleDownloadComponent = singleDownloadComponent, state = state, itemState = itemState, ) LaunchedEffect(Unit) { window.minimumSize = Dimension(defaultWidth.toInt(), defaultHeight.toInt()) } LaunchedEffect(w, h) { state.size = DpSize( width = w.dp, height = h.dp ) } CompositionLocalProvider( LocalSingleDownloadPageSizing provides singleDownloadPageSizing ) { ProgressDownloadPage( singleDownloadComponent, itemState, ) } } } @Composable private fun UpdateTaskBar( window: Window, state: IDownloadItemState, ) { val percent = state.getPercent() val status = state.statusOrFinished() LaunchedEffect(percent, status, window) { if (!Taskbar.isTaskbarSupported()) return@LaunchedEffect runCatching { val taskbar = Taskbar.getTaskbar() percent?.let { taskbar.setWindowProgressValue( window, percent ) } taskbar.setWindowProgressState( window, when (status) { is DownloadJobStatus.Canceled -> { if (ExceptionUtils.isNormalCancellation(status.e)) { Taskbar.State.PAUSED } else { Taskbar.State.ERROR } } DownloadJobStatus.Downloading, is DownloadJobStatus.Retrying -> { if (percent != null) { Taskbar.State.NORMAL } else { Taskbar.State.INDETERMINATE } } DownloadJobStatus.Resuming -> { Taskbar.State.INDETERMINATE } DownloadJobStatus.Finished -> { Taskbar.State.OFF } DownloadJobStatus.IDLE -> { Taskbar.State.OFF } is DownloadJobStatus.PreparingFile -> { Taskbar.State.INDETERMINATE } } ) } } } private fun IDownloadItemState.getPercent(): Int? { return when (this) { is CompletedDownloadItemState -> 100 is ProcessingDownloadItemState -> percent } } private fun IDownloadItemState.isActive(): Boolean { return when (this) { is CompletedDownloadItemState -> false is ProcessingDownloadItemState -> status is DownloadJobStatus.IsActive } } ================================================ FILE: desktop/app/src/main/kotlin/com/abdownloadmanager/desktop/pages/singleDownloadPage/SingleDownloadPageStateToPersist.kt ================================================ package com.abdownloadmanager.desktop.pages.singleDownloadPage import arrow.optics.Lens import arrow.optics.optics import ir.amirab.util.config.MapConfig import ir.amirab.util.config.booleanKeyOf import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.serialization.Serializable import org.koin.core.component.KoinComponent interface SingleDownloadPageStateStorage { val singleDownloadPageState: MutableStateFlow } @optics @Serializable data class SingleDownloadPageStateToPersist( val showPartInfo: Boolean = false, ) { class ConfigLens(prefix: String) : Lens, KoinComponent { class Keys(prefix: String) { val showPartInfo = booleanKeyOf("${prefix}showPartInfo") } private val keys = Keys(prefix) override fun get(source: MapConfig): SingleDownloadPageStateToPersist { val default by lazy { SingleDownloadPageStateToPersist() } return SingleDownloadPageStateToPersist( showPartInfo = source.get(keys.showPartInfo) ?: default.showPartInfo, ) } override fun set(source: MapConfig, focus: SingleDownloadPageStateToPersist): MapConfig { source.put(keys.showPartInfo, focus.showPartInfo) return source } } companion object } ================================================ FILE: desktop/app/src/main/kotlin/com/abdownloadmanager/desktop/pages/updater/NewUpdatePage.kt ================================================ package com.abdownloadmanager.desktop.pages.updater import androidx.compose.animation.animateColor import androidx.compose.animation.core.* import androidx.compose.foundation.* import com.abdownloadmanager.desktop.window.custom.WindowIcon import com.abdownloadmanager.desktop.window.custom.WindowTitle import com.abdownloadmanager.shared.util.ui.icon.MyIcons import com.abdownloadmanager.shared.util.ui.myColors import com.abdownloadmanager.shared.util.ui.theme.myTextSizes import com.abdownloadmanager.shared.ui.widget.ActionButton import com.abdownloadmanager.shared.util.ui.WithContentAlpha import com.abdownloadmanager.shared.util.div import androidx.compose.foundation.layout.* import androidx.compose.foundation.shape.CircleShape import com.abdownloadmanager.shared.ui.widget.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.remember import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.BlurredEdgeTreatment import androidx.compose.ui.draw.blur import androidx.compose.ui.draw.clip import androidx.compose.ui.graphics.Brush import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp import com.abdownloadmanager.shared.ui.theme.myMarkdownColors import com.abdownloadmanager.shared.ui.theme.myMarkdownTypography import com.abdownloadmanager.resources.Res import com.abdownloadmanager.shared.util.ui.LocalMultiplatformScrollbarStyle import com.abdownloadmanager.shared.util.ui.MultiplatformVerticalScrollbar import io.github.z4kn4fein.semver.Version import com.abdownloadmanager.updatechecker.UpdateInfo import com.abdownloadmanager.shared.util.ui.needScroll import com.mikepenz.markdown.compose.Markdown import ir.amirab.util.compose.resources.myStringResource @Composable fun NewUpdatePage( newVersionInfo: UpdateInfo, currentVersion: Version, update: () -> Unit, cancel: () -> Unit, ) { WindowTitle(myStringResource(Res.string.update_updater)) WindowIcon(MyIcons.refresh) val contentHorizontalPadding = 16.dp Box { BackgroundEffects() Column( Modifier .fillMaxSize() ) { Column( Modifier .padding( top = 8.dp ) .weight(1f) ) { Column( Modifier .padding(horizontal = contentHorizontalPadding) ) { Row(verticalAlignment = Alignment.CenterVertically) { Text( text = myStringResource(Res.string.update_available), fontSize = myTextSizes.xl, fontWeight = FontWeight.Bold ) Spacer(Modifier.width(8.dp)) Text( text = myStringResource( Res.string.version_n, Res.string.version_n_createArgs( newVersionInfo.version.toString() ) ), fontSize = myTextSizes.xl, fontWeight = FontWeight.Bold, color = myColors.success, ) } Spacer(Modifier.height(8.dp)) Text( text = myStringResource(Res.string.update_available_suggest_to_to_update), fontSize = myTextSizes.base, ) Spacer(Modifier.height(8.dp)) } RenderChangeLog( Modifier .fillMaxWidth() .weight(1f), newVersionInfo.changeLog, horizontalPadding = contentHorizontalPadding, ) } Actions( Modifier.fillMaxWidth(), update, cancel ) } } } @Composable private fun BoxScope.BackgroundEffects() { Box( Modifier .align(Alignment.TopCenter) .offset(y = (-148).dp) .fillMaxWidth(0.5f) .height(200.dp) .blur( 56.dp, edgeTreatment = BlurredEdgeTreatment.Unbounded ) .clip(CircleShape) .background( myColors.primary / 0.15f ) ) Box( Modifier .align(Alignment.BottomEnd) .size(180.dp) .offset(x = 32.dp, y = (-32).dp) .blur( 56.dp, edgeTreatment = BlurredEdgeTreatment.Unbounded ) .clip(CircleShape) .background( myColors.secondary / 0.15f ) ) } @Composable fun Actions(modifier: Modifier, update: () -> Unit, cancel: () -> Unit) { Column(modifier) { Spacer( Modifier .fillMaxWidth() .height(1.dp) .background(myColors.onBackground / 0.15f) ) Row( Modifier .fillMaxWidth() .padding(horizontal = 16.dp) .padding(vertical = 16.dp), horizontalArrangement = Arrangement.End ) { UpdateButton(Modifier, update) Spacer(Modifier.width(8.dp)) CancelButton(Modifier, cancel) } } } @Composable fun UpdateButton( modifier: Modifier, update: () -> Unit, ) { val backgroundColor = Brush.horizontalGradient( myColors.primaryGradientColors.map { it / 30 } ) val borderColor = Brush.horizontalGradient( myColors.primaryGradientColors ) val disabledBorderColor = Brush.horizontalGradient( myColors.primaryGradientColors.map { it / 50 } ) ActionButton( text = myStringResource(Res.string.update), modifier = modifier, onClick = update, backgroundColor = backgroundColor, disabledBackgroundColor = backgroundColor, borderColor = borderColor, disabledBorderColor = disabledBorderColor, ) } @Composable fun CancelButton( modifier: Modifier, cancel: () -> Unit, ) { ActionButton( text = myStringResource(Res.string.cancel), modifier = modifier, onClick = cancel, ) } @Composable private fun RenderChangeLog( modifier: Modifier, changeLog: String, horizontalPadding: Dp, ) { val trimmedChangelog = remember(changeLog) { changeLog .lines() .filterNot { it.isBlank() } .joinToString("\n") } Column(modifier) { Text( text = myStringResource(Res.string.update_release_notes), modifier = Modifier.padding(horizontal = horizontalPadding), fontWeight = FontWeight.Bold, fontSize = myTextSizes.lg, ) Spacer(Modifier.height(8.dp)) Column( Modifier.background(myColors.surface / 75) ) { val transition = rememberInfiniteTransition() val topBorderColors = listOf( myColors.primary to myColors.secondaryVariant, myColors.secondary to myColors.primaryVariant, myColors.primaryVariant to myColors.secondary, myColors.secondaryVariant to myColors.primary, ) val animatedTopBorderColors = topBorderColors.map { transition.animateColor( it.first, it.second, infiniteRepeatable( animation = tween(durationMillis = 3000, easing = LinearEasing), repeatMode = RepeatMode.Reverse ) ) } Spacer( Modifier.fillMaxWidth() .height(2.dp) .background( Brush.horizontalGradient( animatedTopBorderColors.map { it.value } ) ) ) val scrollState = rememberScrollState() val scrollbarAdapter = rememberScrollbarAdapter(scrollState) Row( Modifier .fillMaxSize() ) { Markdown( modifier = Modifier .weight(1f) .verticalScroll(scrollState) .padding( horizontal = horizontalPadding, vertical = 8.dp ), content = trimmedChangelog, colors = myMarkdownColors(), typography = myMarkdownTypography() ) if (scrollbarAdapter.needScroll()) { MultiplatformVerticalScrollbar( modifier = Modifier .fillMaxHeight() .padding( vertical = 4.dp, horizontal = 4.dp ), adapter = scrollbarAdapter ) } } } } } @Composable private fun RenderKeyValue( key: String, value: String, ) { Row(verticalAlignment = Alignment.CenterVertically) { WithContentAlpha(0.50f) { Text( key, fontSize = myTextSizes.base, maxLines = 1, ) } Spacer(Modifier.width(8.dp)) Text( value, fontSize = myTextSizes.base, maxLines = 1, ) } } ================================================ FILE: desktop/app/src/main/kotlin/com/abdownloadmanager/desktop/pages/updater/UpdaterDialog.kt ================================================ package com.abdownloadmanager.desktop.pages.updater import com.abdownloadmanager.desktop.window.custom.CustomWindow import androidx.compose.runtime.* import androidx.compose.ui.Alignment import androidx.compose.ui.unit.DpSize import androidx.compose.ui.unit.dp import androidx.compose.ui.window.WindowPosition import androidx.compose.ui.window.rememberWindowState import com.abdownloadmanager.shared.util.ui.theme.LocalUiScale import com.abdownloadmanager.shared.pages.updater.RenderUpdateNotifications import com.abdownloadmanager.shared.pages.updater.UpdateComponent import ir.amirab.util.desktop.screen.applyUiScale @Composable fun ShowUpdaterDialog(updaterComponent: UpdateComponent) { val showUpdate = updaterComponent.showNewUpdate.collectAsState().value val newVersion = updaterComponent.newVersionData.collectAsState().value val closeUpdatePage = { updaterComponent.requestClose() } RenderUpdateNotifications(updaterComponent) if (showUpdate && newVersion != null) { val uiScale = LocalUiScale.current CustomWindow( state = rememberWindowState( size = DpSize(500.dp, 400.dp).applyUiScale(uiScale), position = WindowPosition.Aligned(Alignment.Center) ), onCloseRequest = closeUpdatePage, ) { NewUpdatePage( newVersionInfo = newVersion, currentVersion = updaterComponent.currentVersion, cancel = closeUpdatePage, update = { updaterComponent.performUpdate() closeUpdatePage() } ) } } } ================================================ FILE: desktop/app/src/main/kotlin/com/abdownloadmanager/desktop/repository/AppRepository.kt ================================================ package com.abdownloadmanager.desktop.repository import com.abdownloadmanager.shared.util.DownloadSystem import ir.amirab.downloader.DownloadSettings import com.abdownloadmanager.integration.Integration import com.abdownloadmanager.integration.IntegrationResult import com.abdownloadmanager.shared.repository.BaseAppRepository import com.abdownloadmanager.shared.storage.BaseAppSettingsStorage import com.abdownloadmanager.shared.util.autoremove.RemovedDownloadsFromDiskTracker import com.abdownloadmanager.shared.util.category.CategoryManager import com.abdownloadmanager.shared.util.proxy.ProxyManager import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.flow.* class AppRepository( scope: CoroutineScope, appSettings: BaseAppSettingsStorage, proxyManager: ProxyManager, downloadSystem: DownloadSystem, downloadSettings: DownloadSettings, removedDownloadsFromDiskTracker: RemovedDownloadsFromDiskTracker, categoryManager: CategoryManager, private val integration: Integration, ) : BaseAppRepository( scope = scope, appSettings = appSettings, proxyManager = proxyManager, downloadSystem = downloadSystem, downloadSettings = downloadSettings, removedDownloadsFromDiskTracker = removedDownloadsFromDiskTracker, categoryManager = categoryManager, ) { init { integrationPort .debounce(500) .onEach { if (integrationEnabled.value) { integration.enable(it) } }.launchIn(scope) integrationEnabled .debounce(500) .onEach { isEnabled -> if (isEnabled) { integration.enable(integrationPort.value) } else { integration.disable() } }.launchIn(scope) integration.integrationStatus.onEach { result -> //if there is an error in connection disable integration if (result is IntegrationResult.Fail) { integrationEnabled.update { false } } }.launchIn(scope) } } ================================================ FILE: desktop/app/src/main/kotlin/com/abdownloadmanager/desktop/storage/AppSettingsStorage.kt ================================================ package com.abdownloadmanager.desktop.storage import androidx.datastore.core.DataStore import arrow.optics.Lens import arrow.optics.optics import com.abdownloadmanager.shared.storage.BaseAppSettingsStorage import com.abdownloadmanager.shared.storage.IAppSettingsModel import com.abdownloadmanager.shared.storage.SupportedSizeUnits import com.abdownloadmanager.shared.ui.theme.ThemeSettingsStorage import com.abdownloadmanager.shared.util.downloadlocation.PlatformDownloadLocationProvider import com.abdownloadmanager.shared.util.ConfigBaseSettingsByMapConfig import com.abdownloadmanager.shared.util.SystemDownloadLocationProvider import com.abdownloadmanager.shared.util.ui.theme.DEFAULT_UI_SCALE import ir.amirab.util.compose.localizationmanager.LanguageStorage import ir.amirab.util.config.* import ir.amirab.util.enumValueOrNull import kotlinx.serialization.Serializable import org.koin.core.component.KoinComponent @optics([arrow.optics.OpticsTarget.LENS]) @Serializable data class AppSettingsModel( override val theme: String = "dark", override val defaultDarkTheme: String = "dark", override val defaultLightTheme: String = "light", override val language: String? = null, override val font: String? = null, override val uiScale: Float? = null, val mergeTopBarWithTitleBar: Boolean = true, val useNativeMenuBar: Boolean = false, override val showIconLabels: Boolean = true, override val useRelativeDateTime: Boolean = true, val useSystemTray: Boolean = true, override val threadCount: Int = 8, override val maxConcurrentDownloads: Int = 0, override val maxDownloadRetryCount: Int = 3, override val dynamicPartCreation: Boolean = true, override val useServerLastModifiedTime: Boolean = false, override val appendExtensionToIncompleteDownloads: Boolean = false, override val useSparseFileAllocation: Boolean = true, override val useAverageSpeed: Boolean = true, override val showDownloadProgressDialog: Boolean = true, override val showDownloadCompletionDialog: Boolean = true, override val speedLimit: Long = 0, override val autoStartOnBoot: Boolean = true, override val notificationSound: Boolean = true, override val defaultDownloadFolder: String = PlatformDownloadLocationProvider .instance.getDownloadLocation() .resolve("ABDM") .canonicalFile.absolutePath, override val browserIntegrationEnabled: Boolean = true, override val browserIntegrationPort: Int = 15151, override val trackDeletedFilesOnDisk: Boolean = false, override val deletePartialFileOnDownloadCancellation: Boolean = false, override val sizeUnit: SupportedSizeUnits = SupportedSizeUnits.BinaryBytes, override val speedUnit: SupportedSizeUnits = SupportedSizeUnits.BinaryBytes, override val ignoreSSLCertificates: Boolean = false, override val useCategoryByDefault: Boolean = true, override val userAgent: String = "", ) : IAppSettingsModel { companion object { val default: AppSettingsModel get() = AppSettingsModel() } object ConfigLens : Lens, KoinComponent { object Keys { val theme = stringKeyOf("theme") val defaultDarkTheme = stringKeyOf("defaultDarkTheme") val defaultLightTheme = stringKeyOf("defaultLightTheme") val language = stringKeyOf("language") val font = stringKeyOf("font") val uiScale = floatKeyOf("uiScale") val mergeTopBarWithTitleBar = booleanKeyOf("mergeTopBarWithTitleBar") val useNativeMenuBar = booleanKeyOf("useNativeMenuBar") val showIconLabels = booleanKeyOf("showIconLabels") val useRelativeDateTime = booleanKeyOf("useRelativeDateTime") val useSystemTray = booleanKeyOf("useSystemTray") val threadCount = intKeyOf("threadCount") val maxConcurrentDownloads = intKeyOf("maxConcurrentDownloads") val maxDownloadRetryCount = intKeyOf("maxDownloadRetryCount") val dynamicPartCreation = booleanKeyOf("dynamicPartCreation") val useServerLastModifiedTime = booleanKeyOf("useServerLastModifiedTime") val appendExtensionToIncompleteDownloads = booleanKeyOf("appendExtensionToIncompleteDownloads") val useSparseFileAllocation = booleanKeyOf("useSparseFileAllocation") val useAverageSpeed = booleanKeyOf("useAverageSpeed") val showDownloadProgressDialog = booleanKeyOf("showDownloadProgressDialog") val showDownloadCompletionDialog = booleanKeyOf("showDownloadCompletionDialog") val speedLimit = longKeyOf("speedLimit") val autoStartOnBoot = booleanKeyOf("autoStartOnBoot") val notificationSound = booleanKeyOf("notificationSound") val defaultDownloadFolder = stringKeyOf("defaultDownloadFolder") val browserIntegrationEnabled = booleanKeyOf("browserIntegrationEnabled") val browserIntegrationPort = intKeyOf("browserIntegrationPort") val trackDeletedFilesOnDisk = booleanKeyOf("trackDeletedFilesOnDisk") val deletePartialFileOnDownloadCancellation = booleanKeyOf("deletePartialFileOnDownloadCancellation") val sizeUnit = stringKeyOf("sizeUnit") val speedUnit = stringKeyOf("speedUnit") val ignoreSSLCertificates = booleanKeyOf("ignoreSSLCertificates") val useCategoryByDefault = booleanKeyOf("useCategoryByDefault") val userAgent = stringKeyOf("userAgent") } override fun get(source: MapConfig): AppSettingsModel { val default by lazy { AppSettingsModel.default } // for nullable types we don't get default value return AppSettingsModel( theme = source.get(Keys.theme) ?: default.theme, defaultDarkTheme = source.get(Keys.defaultDarkTheme) ?: default.defaultDarkTheme, defaultLightTheme = source.get(Keys.defaultLightTheme) ?: default.defaultLightTheme, language = source.get(Keys.language), font = source.get(Keys.font), uiScale = source.get(Keys.uiScale), mergeTopBarWithTitleBar = source.get(Keys.mergeTopBarWithTitleBar) ?: default.mergeTopBarWithTitleBar, useNativeMenuBar = source.get(Keys.useNativeMenuBar) ?: default.useNativeMenuBar, showIconLabels = source.get(Keys.showIconLabels) ?: default.showIconLabels, useRelativeDateTime = source.get(Keys.useRelativeDateTime) ?: default.useRelativeDateTime, useSystemTray = source.get(Keys.useSystemTray) ?: default.useSystemTray, threadCount = source.get(Keys.threadCount) ?: default.threadCount, maxConcurrentDownloads = source.get(Keys.maxConcurrentDownloads) ?: default.maxConcurrentDownloads, maxDownloadRetryCount = source.get(Keys.maxDownloadRetryCount) ?: default.maxDownloadRetryCount, dynamicPartCreation = source.get(Keys.dynamicPartCreation) ?: default.dynamicPartCreation, useServerLastModifiedTime = source.get(Keys.useServerLastModifiedTime) ?: default.useServerLastModifiedTime, appendExtensionToIncompleteDownloads = source.get(Keys.appendExtensionToIncompleteDownloads) ?: default.appendExtensionToIncompleteDownloads, useSparseFileAllocation = source.get(Keys.useSparseFileAllocation) ?: default.useSparseFileAllocation, useAverageSpeed = source.get(Keys.useAverageSpeed) ?: default.useAverageSpeed, showDownloadProgressDialog = source.get(Keys.showDownloadProgressDialog) ?: default.showDownloadProgressDialog, showDownloadCompletionDialog = source.get(Keys.showDownloadCompletionDialog) ?: default.showDownloadCompletionDialog, speedLimit = source.get(Keys.speedLimit) ?: default.speedLimit, autoStartOnBoot = source.get(Keys.autoStartOnBoot) ?: default.autoStartOnBoot, notificationSound = source.get(Keys.notificationSound) ?: default.notificationSound, defaultDownloadFolder = source.get(Keys.defaultDownloadFolder) ?: default.defaultDownloadFolder, browserIntegrationEnabled = source.get(Keys.browserIntegrationEnabled) ?: default.browserIntegrationEnabled, browserIntegrationPort = source.get(Keys.browserIntegrationPort) ?: default.browserIntegrationPort, trackDeletedFilesOnDisk = source.get(Keys.trackDeletedFilesOnDisk) ?: default.trackDeletedFilesOnDisk, deletePartialFileOnDownloadCancellation = source.get(Keys.deletePartialFileOnDownloadCancellation) ?: default.deletePartialFileOnDownloadCancellation, sizeUnit = source.get(Keys.sizeUnit)?.enumValueOrNull() ?: default.sizeUnit, speedUnit = source.get(Keys.speedUnit)?.enumValueOrNull() ?: default.speedUnit, ignoreSSLCertificates = source.get(Keys.ignoreSSLCertificates) ?: default.ignoreSSLCertificates, useCategoryByDefault = source.get(Keys.useCategoryByDefault) ?: default.useCategoryByDefault, userAgent = source.get(Keys.userAgent) ?: default.userAgent, ) } override fun set(source: MapConfig, focus: AppSettingsModel): MapConfig { return source.apply { put(Keys.theme, focus.theme) put(Keys.defaultDarkTheme, focus.defaultDarkTheme) put(Keys.defaultLightTheme, focus.defaultLightTheme) putNullable(Keys.language, focus.language) putNullable(Keys.font, focus.font) putNullable(Keys.uiScale, focus.uiScale) put(Keys.mergeTopBarWithTitleBar, focus.mergeTopBarWithTitleBar) put(Keys.useNativeMenuBar, focus.useNativeMenuBar) put(Keys.showIconLabels, focus.showIconLabels) put(Keys.useRelativeDateTime, focus.useRelativeDateTime) put(Keys.useSystemTray, focus.useSystemTray) put(Keys.threadCount, focus.threadCount) put(Keys.maxConcurrentDownloads, focus.maxConcurrentDownloads) put(Keys.maxDownloadRetryCount, focus.maxDownloadRetryCount) put(Keys.dynamicPartCreation, focus.dynamicPartCreation) put(Keys.useServerLastModifiedTime, focus.useServerLastModifiedTime) put(Keys.appendExtensionToIncompleteDownloads, focus.appendExtensionToIncompleteDownloads) put(Keys.useSparseFileAllocation, focus.useSparseFileAllocation) put(Keys.useAverageSpeed, focus.useAverageSpeed) put(Keys.showDownloadProgressDialog, focus.showDownloadProgressDialog) put(Keys.showDownloadCompletionDialog, focus.showDownloadCompletionDialog) put(Keys.speedLimit, focus.speedLimit) put(Keys.autoStartOnBoot, focus.autoStartOnBoot) put(Keys.notificationSound, focus.notificationSound) put(Keys.defaultDownloadFolder, focus.defaultDownloadFolder) put(Keys.browserIntegrationEnabled, focus.browserIntegrationEnabled) put(Keys.browserIntegrationPort, focus.browserIntegrationPort) put(Keys.trackDeletedFilesOnDisk, focus.trackDeletedFilesOnDisk) put(Keys.deletePartialFileOnDownloadCancellation, focus.deletePartialFileOnDownloadCancellation) put(Keys.sizeUnit, focus.sizeUnit.name) put(Keys.speedUnit, focus.speedUnit.name) put(Keys.ignoreSSLCertificates, focus.ignoreSSLCertificates) put(Keys.useCategoryByDefault, focus.useCategoryByDefault) put(Keys.userAgent, focus.userAgent) } } } } private val fontLens: Lens get() = Lens( get = { it.font }, set = { s, f -> s.copy(font = f) } ) // use null for default scale! private val uiScaleLens: Lens get() = Lens( get = { it.uiScale ?: DEFAULT_UI_SCALE }, set = { s, f -> s.copy(uiScale = f.takeIf { it != DEFAULT_UI_SCALE }) } ) private val languageLens: Lens get() = Lens( get = { it.language }, set = { s, f -> s.copy(language = f) } ) class AppSettingsStorage( settings: DataStore, ) : BaseAppSettingsStorage, ConfigBaseSettingsByMapConfig(settings, AppSettingsModel.ConfigLens) { override val theme = from(AppSettingsModel.theme) override val defaultDarkTheme = from(AppSettingsModel.defaultDarkTheme) override val defaultLightTheme = from(AppSettingsModel.defaultLightTheme) override val selectedLanguage = from(languageLens) override val font = from(fontLens) override val uiScale = from(uiScaleLens) val mergeTopBarWithTitleBar = from(AppSettingsModel.mergeTopBarWithTitleBar) val useNativeMenuBar = from(AppSettingsModel.useNativeMenuBar) override val showIconLabels = from(AppSettingsModel.showIconLabels) override val useRelativeDateTime = from(AppSettingsModel.useRelativeDateTime) val useSystemTray = from(AppSettingsModel.useSystemTray) override val threadCount = from(AppSettingsModel.threadCount) override val maxConcurrentDownloads = from(AppSettingsModel.maxConcurrentDownloads) override val dynamicPartCreation = from(AppSettingsModel.dynamicPartCreation) override val useServerLastModifiedTime = from(AppSettingsModel.useServerLastModifiedTime) override val appendExtensionToIncompleteDownloads = from(AppSettingsModel.appendExtensionToIncompleteDownloads) override val useSparseFileAllocation = from(AppSettingsModel.useSparseFileAllocation) override val useAverageSpeed = from(AppSettingsModel.useAverageSpeed) override val maxDownloadRetryCount = from(AppSettingsModel.maxDownloadRetryCount) override val showDownloadProgressDialog = from(AppSettingsModel.showDownloadProgressDialog) override val showDownloadCompletionDialog = from(AppSettingsModel.showDownloadCompletionDialog) override val speedLimit = from(AppSettingsModel.speedLimit) override val autoStartOnBoot = from(AppSettingsModel.autoStartOnBoot) override val notificationSound = from(AppSettingsModel.notificationSound) override val defaultDownloadFolder = from(AppSettingsModel.defaultDownloadFolder) override val browserIntegrationEnabled = from(AppSettingsModel.browserIntegrationEnabled) override val browserIntegrationPort = from(AppSettingsModel.browserIntegrationPort) override val trackDeletedFilesOnDisk = from(AppSettingsModel.trackDeletedFilesOnDisk) override val deletePartialFileOnDownloadCancellation = from(AppSettingsModel.deletePartialFileOnDownloadCancellation) override val sizeUnit = from(AppSettingsModel.sizeUnit) override val speedUnit = from(AppSettingsModel.speedUnit) override val ignoreSSLCertificates = from(AppSettingsModel.ignoreSSLCertificates) override val useCategoryByDefault = from(AppSettingsModel.useCategoryByDefault) override val userAgent = from(AppSettingsModel.userAgent) } ================================================ FILE: desktop/app/src/main/kotlin/com/abdownloadmanager/desktop/storage/DesktopDefinedPaths.kt ================================================ package com.abdownloadmanager.desktop.storage import com.abdownloadmanager.shared.util.DefinedPaths import okio.Path import java.io.File class DesktopDefinedPaths( dataDir: Path ) : DefinedPaths( dataDir ) { val pageStatesStorageFile: Path = configDir.resolve("pageStatesStorage.json") val renderApiFile: Path = optionsDir.resolve("renderApi.txt") } ================================================ FILE: desktop/app/src/main/kotlin/com/abdownloadmanager/desktop/storage/DesktopExtraDownloadItemSettings.kt ================================================ package com.abdownloadmanager.desktop.storage import com.abdownloadmanager.shared.storage.IExtraDownloadItemSettings import ir.amirab.util.desktop.poweraction.ContainsPowerActionConfigOnFinish import ir.amirab.util.desktop.poweraction.PowerActionConfig import kotlinx.serialization.KSerializer import kotlinx.serialization.Serializable @Serializable data class DesktopExtraDownloadItemSettings( override val id: Long, val powerActionTypeOnFinish: PowerActionConfig.Type? = null, val powerActionUseForceOnFinish: Boolean = false, ) : IExtraDownloadItemSettings, ContainsPowerActionConfigOnFinish { override fun getPowerActionConfigOnFinish() = powerActionTypeOnFinish?.let { PowerActionConfig( powerActionTypeOnFinish, powerActionUseForceOnFinish, ) } companion object : IExtraDownloadItemSettings.DataClassDefinitions { override fun createDefault(id: Long) = DesktopExtraDownloadItemSettings(id = id) override val serializer: KSerializer = DesktopExtraDownloadItemSettings.serializer() } } ================================================ FILE: desktop/app/src/main/kotlin/com/abdownloadmanager/desktop/storage/DesktopExtraQueueSettings.kt ================================================ package com.abdownloadmanager.desktop.storage import com.abdownloadmanager.shared.storage.IExtraQueueSettings import ir.amirab.util.desktop.poweraction.ContainsPowerActionConfigOnFinish import ir.amirab.util.desktop.poweraction.PowerActionConfig import kotlinx.serialization.KSerializer import kotlinx.serialization.Serializable @Serializable data class DesktopExtraQueueSettings( override val id: Long, val powerActionTypeOnFinish: PowerActionConfig.Type? = null, val powerActionUseForceOnFinish: Boolean = false, ) : IExtraQueueSettings, ContainsPowerActionConfigOnFinish { override fun getPowerActionConfigOnFinish() = powerActionTypeOnFinish?.let { PowerActionConfig( powerActionTypeOnFinish, powerActionUseForceOnFinish, ) } companion object : IExtraQueueSettings.DataClassDefinitions { override fun createDefault(id: Long) = DesktopExtraQueueSettings(id) override val serializer: KSerializer = DesktopExtraQueueSettings.serializer() } } ================================================ FILE: desktop/app/src/main/kotlin/com/abdownloadmanager/desktop/storage/PageStatesStorage.kt ================================================ package com.abdownloadmanager.desktop.storage import com.abdownloadmanager.desktop.pages.home.HomePageStateToPersist import androidx.datastore.core.DataStore import arrow.optics.Lens import arrow.optics.optics import com.abdownloadmanager.desktop.pages.settings.SettingPageStateToPersist import com.abdownloadmanager.desktop.pages.singleDownloadPage.SingleDownloadPageStateStorage import com.abdownloadmanager.desktop.pages.singleDownloadPage.SingleDownloadPageStateToPersist import com.abdownloadmanager.shared.storage.ILastSavedLocationsStorage import com.abdownloadmanager.shared.util.ConfigBaseSettingsByMapConfig import ir.amirab.util.config.getDecoded import ir.amirab.util.config.keyOfEncoded import ir.amirab.util.config.putEncoded import ir.amirab.util.config.MapConfig import kotlinx.serialization.Serializable import kotlinx.serialization.json.Json import org.koin.core.component.KoinComponent import org.koin.core.component.inject @optics @Serializable data class CommonData( val lastSavedLocations: List = emptyList(), ) { companion object class ConfigLens(prefix: String) : Lens, KoinComponent { class Keys(prefix: String) { val lastSavedLocations = keyOfEncoded>("${prefix}lastSavedLocations") } private val json: Json by inject() private val keys = Keys(prefix) override fun get(source: MapConfig): CommonData { return with(json) { CommonData( lastSavedLocations = source.getDecoded(keys.lastSavedLocations) ?: emptyList() ) } } override fun set(source: MapConfig, focus: CommonData): MapConfig { return with(json) { source.putEncoded(keys.lastSavedLocations, focus.lastSavedLocations) source } } } } @optics @Serializable data class PageStatesModel( val home: HomePageStateToPersist = HomePageStateToPersist(), val settings: SettingPageStateToPersist = SettingPageStateToPersist(), val downloadPage: SingleDownloadPageStateToPersist = SingleDownloadPageStateToPersist(), val global: CommonData = CommonData(), ) { companion object { val default get() = PageStatesModel() } object ConfigLens : Lens, KoinComponent { private val json: Json by inject() object Child { val common = CommonData.ConfigLens("global.") val downloadPage = SingleDownloadPageStateToPersist.ConfigLens("downloadPage.") val home = HomePageStateToPersist.ConfigLens("home.") val settings = SettingPageStateToPersist.ConfigLens("settings.") } override fun get(source: MapConfig): PageStatesModel { return PageStatesModel( home = Child.home.get(source), settings = Child.settings.get(source), downloadPage = Child.downloadPage.get(source), global = Child.common.get(source) ) } override fun set(source: MapConfig, focus: PageStatesModel): MapConfig { Child.home.set(source, focus.home) Child.settings.set(source, focus.settings) Child.downloadPage.set(source, focus.downloadPage) Child.common.set(source, focus.global) return source } } } class PageStatesStorage( settings: DataStore, ) : ConfigBaseSettingsByMapConfig(settings, PageStatesModel.ConfigLens), ILastSavedLocationsStorage, SingleDownloadPageStateStorage { override val lastUsedSaveLocations = from(PageStatesModel.global.lastSavedLocations) override val singleDownloadPageState = from(PageStatesModel.downloadPage) val homePageStorage = from(PageStatesModel.home) val settingsPageStorage = from(PageStatesModel.settings) } ================================================ FILE: desktop/app/src/main/kotlin/com/abdownloadmanager/desktop/ui/Ui.kt ================================================ package com.abdownloadmanager.desktop.ui import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.ui.window.ApplicationScope import androidx.compose.ui.window.application import com.abdownloadmanager.desktop.AppArguments import com.abdownloadmanager.desktop.AppComponent import com.abdownloadmanager.desktop.AppEffects import com.abdownloadmanager.desktop.actions.gotoSettingsAction import com.abdownloadmanager.desktop.actions.requestExitAction import com.abdownloadmanager.desktop.actions.showDownloadList import com.abdownloadmanager.desktop.pages.about.ShowAboutDialog import com.abdownloadmanager.desktop.pages.addDownload.ShowAddDownloadDialogs import com.abdownloadmanager.desktop.pages.batchdownload.BatchDownloadWindow import com.abdownloadmanager.desktop.pages.category.ShowCategoryDialogs import com.abdownloadmanager.desktop.pages.confirmexit.ConfirmExit import com.abdownloadmanager.desktop.pages.credits.translators.ShowTranslators import com.abdownloadmanager.desktop.pages.editdownload.EditDownloadWindow import com.abdownloadmanager.desktop.pages.enterurl.EnterNewDownloadWindow import com.abdownloadmanager.desktop.pages.extenallibs.ShowOpenSourceLibraries import com.abdownloadmanager.desktop.pages.checksum.FileChecksumWindow import com.abdownloadmanager.desktop.pages.home.HomeWindow import com.abdownloadmanager.desktop.pages.newQueue.NewQueueDialog import com.abdownloadmanager.desktop.pages.perhostsettings.PerHostSettingsWindow import com.abdownloadmanager.desktop.pages.queue.QueuesWindow import com.abdownloadmanager.desktop.pages.settings.FontManager import com.abdownloadmanager.desktop.pages.settings.SettingWindow import com.abdownloadmanager.shared.ui.theme.ThemeManager import com.abdownloadmanager.desktop.pages.poweractionalert.PowerActionAlert import com.abdownloadmanager.desktop.pages.singleDownloadPage.ShowDownloadDialogs import com.abdownloadmanager.desktop.pages.updater.ShowUpdaterDialog import com.abdownloadmanager.desktop.ui.configurable.comon.CommonConfigurableRenderersForDesktop import com.abdownloadmanager.desktop.ui.configurable.platform.PlatformConfigurableRenderersForDesktop import com.abdownloadmanager.desktop.ui.widget.Tray import com.abdownloadmanager.desktop.ui.widget.ShowMessageDialogs import com.abdownloadmanager.desktop.utils.AppInfo import com.abdownloadmanager.desktop.utils.GlobalAppExceptionHandler import com.abdownloadmanager.desktop.utils.ProvideGlobalExceptionHandler import com.abdownloadmanager.desktop.utils.isInDebugMode import com.abdownloadmanager.shared.ui.ProvideCommonSettings import com.abdownloadmanager.shared.ui.ProvideSizeUnits import com.abdownloadmanager.shared.ui.configurable.ConfigurableRendererRegistry import com.abdownloadmanager.shared.ui.theme.ABDownloaderTheme import com.abdownloadmanager.shared.ui.widget.NotificationManager import com.abdownloadmanager.shared.ui.widget.ProvideLanguageManager import com.abdownloadmanager.shared.ui.widget.ProvideNotificationManager import com.abdownloadmanager.shared.ui.widget.useNotification import com.abdownloadmanager.shared.util.mvi.HandleEffects import com.abdownloadmanager.shared.util.ui.ProvideDebugInfo import com.abdownloadmanager.shared.util.ui.icon.MyIcons import ir.amirab.util.compose.action.buildMenu import ir.amirab.util.compose.localizationmanager.LanguageManager import ir.amirab.util.desktop.PlatformDockToggler import ir.amirab.util.desktop.mac.event.MacEventHandler import ir.amirab.util.platform.Platform import ir.amirab.util.platform.isMac import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.launch import kotlinx.coroutines.withTimeout import org.koin.core.component.KoinComponent import org.koin.core.component.get import org.koin.core.component.inject object Ui : KoinComponent { val scope: CoroutineScope by inject() fun boot( appArguments: AppArguments, globalAppExceptionHandler: GlobalAppExceptionHandler, ) { val appComponent: AppComponent = get() val themeManager: ThemeManager = get() val fontManager: FontManager = get() val languageManager: LanguageManager = get() val notificationManager: NotificationManager = get() themeManager.boot() fontManager.boot() languageManager.boot() if (!appArguments.startSilent) { appComponent.openHome() } if (Platform.isMac()) { MacEventHandler.configure( onClickIcon = appComponent::activateHomeIfNotOpen, onAboutClick = { appComponent.showAboutPage.value = true }, onSettingsClick = appComponent::openSettings, onQuit = { scope.launch { appComponent.requestExitApp() } } ) } application { ProvideLocalProviders( languageManager = languageManager, appComponent = appComponent, themeManager = themeManager, fontManager = fontManager, globalAppExceptionHandler = globalAppExceptionHandler, notificationManager = notificationManager, ) { HandleEffectsForApp(appComponent) SystemTray(appComponent) val showHomeSlot = appComponent.showHomeSlot.collectAsState().value showHomeSlot.child?.instance?.let { HomeWindow(it, appComponent::closeHome) } val showSettingSlot = appComponent.showSettingSlot.collectAsState().value showSettingSlot.child?.instance?.let { SettingWindow(it, appComponent::closeSettings) } val showQueuesSlot = appComponent.showQueuesSlot.collectAsState().value showQueuesSlot.child?.instance?.let { QueuesWindow(it) } val batchDownloadSlot = appComponent.batchDownloadSlot.collectAsState().value batchDownloadSlot.child?.instance?.let { BatchDownloadWindow(it) } val editDownloadSlot = appComponent.editDownloadSlot.collectAsState().value editDownloadSlot.child?.instance?.let { EditDownloadWindow(it) } EnterNewDownloadWindow(appComponent) ShowAddDownloadDialogs(appComponent) ShowDownloadDialogs(appComponent) ShowCategoryDialogs(appComponent) FileChecksumWindow(appComponent) ShowUpdaterDialog(appComponent.updater) ShowAboutDialog(appComponent) NewQueueDialog(appComponent) ShowMessageDialogs(appComponent) ShowOpenSourceLibraries(appComponent) ShowTranslators(appComponent) ConfirmExit(appComponent) PowerActionAlert(appComponent) PerHostSettingsWindow(appComponent) } } } } @Composable private fun ProvideLocalProviders( languageManager: LanguageManager, themeManager: ThemeManager, fontManager: FontManager, appComponent: AppComponent, notificationManager: NotificationManager, globalAppExceptionHandler: GlobalAppExceptionHandler, content: @Composable () -> Unit ) { val theme by themeManager.currentThemeColor.collectAsState() val fontFamily by fontManager.currentFontFamily.collectAsState() val configurableRendererRegistry = remember { ConfigurableRendererRegistry { listOf( PlatformConfigurableRenderersForDesktop, CommonConfigurableRenderersForDesktop, ).forEach { it.getAllRenderers().forEach { (key, renderer) -> this.register(key, renderer) } } } } ProvideDebugInfo(AppInfo.isInDebugMode()) { ProvideLanguageManager(languageManager) { ProvideCommonSettings( appSettings = appComponent.appSettings, configurableRendererRegistry = configurableRendererRegistry, iconProvider = appComponent.iconFromUriResolver ) { ProvideNotificationManager(notificationManager) { ABDownloaderTheme( myColors = theme, fontFamily = fontFamily, uiScale = appComponent.uiScale.collectAsState().value ) { ProvideGlobalExceptionHandler(globalAppExceptionHandler) { ProvideSizeUnits(appComponent.appRepository) { content() } } } } } } } } @Composable private fun HandleEffectsForApp(appComponent: AppComponent) { val notificationManager = useNotification() val scope = rememberCoroutineScope() HandleEffects(appComponent) { when (it) { is AppEffects.SimpleNotificationNotification -> { scope.launch { withTimeout(5000) { notificationManager.showNotification(it.notificationModel) } } } } } } @Composable private fun ApplicationScope.SystemTray( component: AppComponent, ) { val useSystemTray by component.useSystemTray.collectAsState() if (useSystemTray) { LaunchedEffect(Unit) { PlatformDockToggler.hide() } val menu = remember { buildMenu { +showDownloadList +gotoSettingsAction +requestExitAction } } Tray( icon = MyIcons.appIcon, tooltip = AppInfo.displayName, primaryAction = { showDownloadList.onClick() }, menu = menu, ) } else { LaunchedEffect(Unit) { PlatformDockToggler.show() } } } ================================================ FILE: desktop/app/src/main/kotlin/com/abdownloadmanager/desktop/ui/configurable/DesktopConfigurableRendererUtils.kt ================================================ package com.abdownloadmanager.desktop.ui.configurable import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.ColumnScope import androidx.compose.foundation.layout.IntrinsicSize import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxHeight import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.runtime.Composable import androidx.compose.runtime.collectAsState import androidx.compose.runtime.remember import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.alpha import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.dp import com.abdownloadmanager.shared.ui.configurable.Configurable import com.abdownloadmanager.shared.ui.configurable.Help import com.abdownloadmanager.shared.ui.configurable.isConfigEnabled import com.abdownloadmanager.shared.ui.widget.Text import com.abdownloadmanager.shared.util.ui.WithContentAlpha import com.abdownloadmanager.shared.util.ui.theme.myTextSizes import ir.amirab.util.ifThen @Composable fun TitleAndDescription( cfg: Configurable, describe: Boolean = true, modifier: Modifier = Modifier.padding(8.dp), ) { val enabled = isConfigEnabled() Column( modifier.ifThen(!enabled) { alpha(0.5f) } ) { Row(verticalAlignment = Alignment.CenterVertically) { Text( cfg.title.rememberString(), fontSize = myTextSizes.base, fontWeight = FontWeight.Bold, modifier = Modifier.weight(1f, false) ) if (cfg.description.rememberString().isNotBlank()) { Spacer(Modifier.size(4.dp)) Help( Modifier.align(Alignment.Top), cfg ) } } if (describe) { val value = cfg.backedBy.collectAsState().value val describedStringSource = remember(value) { cfg.describe(value) } val describeContent = describedStringSource.rememberString() if (describeContent.isNotBlank()) { WithContentAlpha(0.75f) { Text( describeContent, fontSize = myTextSizes.base, ) } } } } } @Composable fun ConfigTemplate( modifier: Modifier, title: @Composable ColumnScope.() -> Unit, value: @Composable ColumnScope.() -> Unit, nestedContent: @Composable ColumnScope.() -> Unit = {}, ) { Column( modifier ) { Row( Modifier .height(IntrinsicSize.Max), horizontalArrangement = Arrangement.Center, ) { Column( Modifier.weight(2f, true), verticalArrangement = Arrangement.Center, horizontalAlignment = Alignment.Start, ) { title() } Column( Modifier.fillMaxHeight().weight(1f, true), verticalArrangement = Arrangement.Center, horizontalAlignment = Alignment.End, ) { value() } } Column( Modifier.fillMaxWidth() ) { nestedContent() } } } ================================================ FILE: desktop/app/src/main/kotlin/com/abdownloadmanager/desktop/ui/configurable/comon/CommonConfigurableRenderersForDesktop.kt ================================================ package com.abdownloadmanager.desktop.ui.configurable.comon import com.abdownloadmanager.desktop.ui.configurable.comon.renderer.BooleanConfigurableRenderer import com.abdownloadmanager.desktop.ui.configurable.comon.renderer.DayOfWeekConfigurableRenderer import com.abdownloadmanager.desktop.ui.configurable.comon.renderer.EnumConfigurableRenderer import com.abdownloadmanager.desktop.ui.configurable.comon.renderer.FileChecksumConfigurableRenderer import com.abdownloadmanager.desktop.ui.configurable.comon.renderer.FloatConfigurableRenderer import com.abdownloadmanager.desktop.ui.configurable.comon.renderer.FolderConfigurableRenderer import com.abdownloadmanager.desktop.ui.configurable.comon.renderer.IntConfigurableRenderer import com.abdownloadmanager.desktop.ui.configurable.comon.renderer.LongConfigurableRenderer import com.abdownloadmanager.desktop.ui.configurable.comon.renderer.PerHostSettingsConfigurableRenderer import com.abdownloadmanager.desktop.ui.configurable.comon.renderer.SpeedLimitConfigurableRenderer import com.abdownloadmanager.desktop.ui.configurable.comon.renderer.StringConfigurableRenderer import com.abdownloadmanager.desktop.ui.configurable.comon.renderer.ThemeConfigurableRenderer import com.abdownloadmanager.desktop.ui.configurable.comon.renderer.TimeConfigurableRenderer import com.abdownloadmanager.desktop.ui.configurable.comon.renderer.ProxyConfigurableRenderer import com.abdownloadmanager.shared.ui.configurable.CommonConfigurableRenderers val CommonConfigurableRenderersForDesktop = CommonConfigurableRenderers( booleanConfigurableRenderer = BooleanConfigurableRenderer, dayOfWeekConfigurableRenderer = DayOfWeekConfigurableRenderer, fileChecksumConfigurableRenderer = FileChecksumConfigurableRenderer, floatConfigurableRenderer = FloatConfigurableRenderer, folderConfigurableRenderer = FolderConfigurableRenderer, intConfigurableRenderer = IntConfigurableRenderer, longConfigurableRenderer = LongConfigurableRenderer, perHostSettingsConfigurableRenderer = PerHostSettingsConfigurableRenderer, enumConfigurableRenderer = EnumConfigurableRenderer, speedConfigurableRenderer = SpeedLimitConfigurableRenderer, stringConfigurableRenderer = StringConfigurableRenderer, themeConfigurableRenderer = ThemeConfigurableRenderer, timeConfigurableRenderer = TimeConfigurableRenderer, proxyConfigurableRenderer = ProxyConfigurableRenderer, ) ================================================ FILE: desktop/app/src/main/kotlin/com/abdownloadmanager/desktop/ui/configurable/comon/renderer/BooleanConfigurableRenderer.kt ================================================ package com.abdownloadmanager.desktop.ui.configurable.comon.renderer import androidx.compose.foundation.layout.padding import androidx.compose.runtime.Composable import androidx.compose.runtime.collectAsState import com.abdownloadmanager.desktop.ui.configurable.ConfigTemplate import com.abdownloadmanager.shared.ui.configurable.ConfigurableRenderer import com.abdownloadmanager.desktop.ui.configurable.TitleAndDescription import com.abdownloadmanager.shared.ui.configurable.ConfigurableUiProps import com.abdownloadmanager.shared.ui.configurable.isConfigEnabled import com.abdownloadmanager.shared.ui.configurable.item.BooleanConfigurable import com.abdownloadmanager.shared.ui.widget.CheckBox import com.abdownloadmanager.shared.ui.widget.Switch object BooleanConfigurableRenderer : ConfigurableRenderer { @Composable override fun RenderConfigurable(configurable: BooleanConfigurable, configurableUiProps: ConfigurableUiProps) { RenderBooleanConfig(configurable, configurableUiProps) } @Composable private fun RenderBooleanConfig( cfg: BooleanConfigurable, configurableUiProps: ConfigurableUiProps, ) { val checked = cfg.stateFlow.collectAsState().value val setValue = cfg::set val enabled = isConfigEnabled() ConfigTemplate( modifier = configurableUiProps.modifier.padding(configurableUiProps.itemPaddingValues), title = { TitleAndDescription(cfg, true) }, value = { when (cfg.renderMode) { BooleanConfigurable.RenderMode.Checkbox -> { CheckBox( value = checked, enabled = enabled, onValueChange = { setValue(it) } ) } BooleanConfigurable.RenderMode.Switch -> { Switch( checked = checked, enabled = enabled, onCheckedChange = { setValue(it) } ) } } }) } } ================================================ FILE: desktop/app/src/main/kotlin/com/abdownloadmanager/desktop/ui/configurable/comon/renderer/DayOfWeekConfigurableRenderer.kt ================================================ package com.abdownloadmanager.desktop.ui.configurable.comon.renderer import androidx.compose.foundation.background import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.FlowRow import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.width import androidx.compose.foundation.shape.CircleShape import androidx.compose.runtime.Composable import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.alpha import androidx.compose.ui.draw.clip import androidx.compose.ui.unit.dp import com.abdownloadmanager.resources.Res import com.abdownloadmanager.desktop.ui.configurable.ConfigTemplate import com.abdownloadmanager.shared.ui.configurable.ConfigurableRenderer import com.abdownloadmanager.desktop.ui.configurable.TitleAndDescription import com.abdownloadmanager.shared.ui.configurable.ConfigurableUiProps import com.abdownloadmanager.shared.ui.configurable.isConfigEnabled import com.abdownloadmanager.shared.ui.configurable.item.DayOfWeekConfigurable import com.abdownloadmanager.shared.ui.widget.Text import com.abdownloadmanager.shared.util.div import com.abdownloadmanager.shared.util.ui.icon.MyIcons import com.abdownloadmanager.shared.util.ui.myColors import com.abdownloadmanager.shared.util.ui.theme.myTextSizes import com.abdownloadmanager.shared.util.ui.widget.MyIcon import ir.amirab.util.compose.asStringSource import ir.amirab.util.ifThen import kotlinx.datetime.DayOfWeek object DayOfWeekConfigurableRenderer : ConfigurableRenderer { @Composable override fun RenderConfigurable(configurable: DayOfWeekConfigurable, configurableUiProps: ConfigurableUiProps) { RenderDayOfWeekConfigurable(configurable, configurableUiProps) } @Composable private fun RenderDayOfWeekConfigurable(cfg: DayOfWeekConfigurable, configurableUiProps: ConfigurableUiProps) { val value by cfg.stateFlow.collectAsState() val setValue = cfg::set val allDays = DayOfWeek.entries.toSet() val enabled = isConfigEnabled() fun isSelected(dayOfWeek: DayOfWeek): Boolean { return dayOfWeek in value } fun selectDay(dayOfWeek: DayOfWeek, select: Boolean) { if (!enabled) return if (select) { setValue( value.plus(dayOfWeek).sorted().toSet() ) } else { setValue( value.minus(dayOfWeek).sorted().toSet() ) } } ConfigTemplate( modifier = configurableUiProps.modifier.padding(configurableUiProps.itemPaddingValues), title = { TitleAndDescription(cfg, true) }, value = { Row( verticalAlignment = Alignment.CenterVertically ) { Row( Modifier.ifThen(!enabled) { alpha(0.5f) } ) { FlowRow(Modifier.fillMaxWidth()) { allDays.forEach { dayOfWeek -> RenderDayOfWeek( modifier = Modifier, enabled = enabled, dayOfWeek = dayOfWeek, selected = isSelected(dayOfWeek), onSelect = { s, isSelected -> selectDay(dayOfWeek, isSelected) } ) } } } } } ) } @Composable fun RenderDayOfWeek( modifier: Modifier, dayOfWeek: DayOfWeek, selected: Boolean, onSelect: (DayOfWeek, Boolean) -> Unit, enabled: Boolean = true, ) { Row( verticalAlignment = Alignment.CenterVertically, modifier = modifier .padding(2.dp) .clip(CircleShape) .ifThen(selected) { background(myColors.onBackground / 10) } .clickable(enabled = enabled) { onSelect(dayOfWeek, !selected) } .padding(vertical = 4.dp) .padding(horizontal = 8.dp) ) { MyIcon( MyIcons.check, null, Modifier.size(10.dp) .alpha(if (selected) 1f else 0f), ) Spacer(Modifier.width(2.dp)) Text( text = dayOfWeek.asStringSource().rememberString(), modifier = Modifier.alpha( if (selected) 1f else 0.5f ), softWrap = false, fontSize = myTextSizes.base, ) } } private fun DayOfWeek.asStringSource() = when (this) { DayOfWeek.MONDAY -> Res.string.monday DayOfWeek.TUESDAY -> Res.string.tuesday DayOfWeek.WEDNESDAY -> Res.string.wednesday DayOfWeek.THURSDAY -> Res.string.thursday DayOfWeek.FRIDAY -> Res.string.friday DayOfWeek.SATURDAY -> Res.string.saturday DayOfWeek.SUNDAY -> Res.string.sunday }.asStringSource() } ================================================ FILE: desktop/app/src/main/kotlin/com/abdownloadmanager/desktop/ui/configurable/comon/renderer/EnumConfigurableRenderer.kt ================================================ package com.abdownloadmanager.desktop.ui.configurable.comon.renderer import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.widthIn import androidx.compose.runtime.Composable import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue import androidx.compose.runtime.remember import androidx.compose.ui.Modifier import androidx.compose.ui.unit.dp import com.abdownloadmanager.desktop.ui.configurable.ConfigTemplate import com.abdownloadmanager.shared.ui.configurable.ConfigurableRenderer import com.abdownloadmanager.shared.ui.configurable.RenderSpinner import com.abdownloadmanager.desktop.ui.configurable.TitleAndDescription import com.abdownloadmanager.shared.ui.configurable.ConfigurableUiProps import com.abdownloadmanager.shared.ui.configurable.isConfigEnabled import com.abdownloadmanager.shared.ui.configurable.item.EnumConfigurable import com.abdownloadmanager.shared.ui.widget.Text object EnumConfigurableRenderer : ConfigurableRenderer> { @Composable override fun RenderConfigurable(configurable: EnumConfigurable, configurableUiProps: ConfigurableUiProps) { RenderEnumConfig(configurable, configurableUiProps) } @Composable private fun RenderEnumConfig(cfg: EnumConfigurable, configurableUiProps: ConfigurableUiProps) { val value by cfg.stateFlow.collectAsState() val setValue = cfg::set val index = remember(cfg.possibleValues, value) { cfg.possibleValues.indexOf(value) } val enabled = isConfigEnabled() ConfigTemplate( modifier = configurableUiProps.modifier.padding(configurableUiProps.itemPaddingValues), title = { TitleAndDescription(cfg, false) }, value = { when (cfg.renderMode) { EnumConfigurable.RenderMode.Spinner -> RenderSpinner( possibleValues = cfg.possibleValues, value = value, onSelect = { setValue(it) }, valueToString = cfg.valueToString, modifier = Modifier.widthIn(min = 160.dp), enabled = enabled, render = { Text(cfg.describe(it).rememberString()) }) } } ) } } ================================================ FILE: desktop/app/src/main/kotlin/com/abdownloadmanager/desktop/ui/configurable/comon/renderer/FileChecksumConfigurableRenderer.kt ================================================ package com.abdownloadmanager.desktop.ui.configurable.comon.renderer import androidx.compose.animation.AnimatedVisibility import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.padding import androidx.compose.runtime.Composable import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.RectangleShape import androidx.compose.ui.unit.dp import com.abdownloadmanager.resources.Res import com.abdownloadmanager.desktop.ui.configurable.ConfigTemplate import com.abdownloadmanager.shared.ui.configurable.ConfigurableRenderer import com.abdownloadmanager.shared.ui.configurable.RenderSpinner import com.abdownloadmanager.desktop.ui.configurable.TitleAndDescription import com.abdownloadmanager.shared.ui.configurable.ConfigurableUiProps import com.abdownloadmanager.shared.ui.configurable.isConfigEnabled import com.abdownloadmanager.shared.ui.configurable.item.FileChecksumConfigurable import com.abdownloadmanager.shared.ui.widget.CheckBox import com.abdownloadmanager.shared.ui.widget.MyTextField import com.abdownloadmanager.shared.ui.widget.Text import com.abdownloadmanager.shared.util.FileChecksum import com.abdownloadmanager.shared.util.FileChecksumAlgorithm import ir.amirab.util.compose.resources.myStringResource object FileChecksumConfigurableRenderer : ConfigurableRenderer { @Composable override fun RenderConfigurable(configurable: FileChecksumConfigurable, configurableUiProps: ConfigurableUiProps) { RenderFileChecksumConfig(configurable, configurableUiProps) } @Composable private fun RenderFileChecksumConfig(cfg: FileChecksumConfigurable, configurableUiProps: ConfigurableUiProps) { val value by cfg.stateFlow.collectAsState() val setValue = cfg::set val enabled = isConfigEnabled() val hasFileChecksum = value != null ConfigTemplate( configurableUiProps.modifier.padding(configurableUiProps.itemPaddingValues), title = { Row(verticalAlignment = Alignment.CenterVertically) { TitleAndDescription(cfg, true) } }, nestedContent = { Column(Modifier.align(Alignment.End)) { AnimatedVisibility( hasFileChecksum, ) { value?.let { value -> Row( Modifier .padding(vertical = 8.dp), verticalAlignment = Alignment.CenterVertically, ) { RenderSpinner( possibleValues = FileChecksumAlgorithm .all() .map { it.algorithm }, value = value.algorithm, modifier = Modifier.Companion, enabled = enabled, onSelect = { setValue(value.copy(algorithm = it)) } ) { Text(it) } Text(":", Modifier.padding(horizontal = 4.dp)) MyTextField( text = value.value, onTextChange = { setValue(value.copy(value = it)) }, shape = RectangleShape, textPadding = PaddingValues(4.dp), enabled = enabled, modifier = Modifier.weight(1f), placeholder = myStringResource(Res.string.file_checksum), ) } } } } }, value = { CheckBox( value = hasFileChecksum, enabled = enabled, onValueChange = { if (it) { setValue( FileChecksum( FileChecksumAlgorithm.default().algorithm, "", ) ) } else { setValue(null) } }) } ) } } ================================================ FILE: desktop/app/src/main/kotlin/com/abdownloadmanager/desktop/ui/configurable/comon/renderer/FloatConfigurableRenderer.kt ================================================ package com.abdownloadmanager.desktop.ui.configurable.comon.renderer import androidx.compose.foundation.interaction.MutableInteractionSource import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.width import androidx.compose.foundation.text.KeyboardOptions import androidx.compose.runtime.Composable import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue import androidx.compose.runtime.remember import androidx.compose.ui.Modifier import androidx.compose.ui.text.input.KeyboardType import androidx.compose.ui.unit.dp import com.abdownloadmanager.desktop.ui.configurable.ConfigTemplate import com.abdownloadmanager.shared.ui.configurable.ConfigurableRenderer import com.abdownloadmanager.desktop.ui.configurable.TitleAndDescription import com.abdownloadmanager.shared.ui.configurable.ConfigurableUiProps import com.abdownloadmanager.shared.ui.configurable.isConfigEnabled import com.abdownloadmanager.shared.ui.configurable.item.FloatConfigurable import com.abdownloadmanager.shared.ui.widget.FloatTextField object FloatConfigurableRenderer : ConfigurableRenderer { @Composable override fun RenderConfigurable(configurable: FloatConfigurable, configurableUiProps: ConfigurableUiProps) { RenderFloatConfig(configurable, configurableUiProps) } @Composable private fun RenderFloatConfig(cfg: FloatConfigurable, configurableUiProps: ConfigurableUiProps) { val value by cfg.stateFlow.collectAsState() val setValue = cfg::set val enabled = isConfigEnabled() ConfigTemplate( modifier = configurableUiProps.modifier.padding(configurableUiProps.itemPaddingValues), title = { TitleAndDescription(cfg, true) }, value = { when (cfg.renderMode) { FloatConfigurable.RenderMode.TextField -> { val interactionSource: MutableInteractionSource = remember { MutableInteractionSource() } val modifier = Modifier.Companion.width(100.dp) FloatTextField( value = value, onValueChange = { v -> setValue(v) }, interactionSource = interactionSource, range = cfg.range, modifier = modifier, enabled = enabled, keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Companion.Decimal), placeholder = "", ) } } } ) } } ================================================ FILE: desktop/app/src/main/kotlin/com/abdownloadmanager/desktop/ui/configurable/comon/renderer/FolderConfigurableRenderer.kt ================================================ package com.abdownloadmanager.desktop.ui.configurable.comon.renderer import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.fillMaxHeight import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.wrapContentHeight import androidx.compose.runtime.Composable import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue import androidx.compose.runtime.remember import androidx.compose.ui.Modifier import androidx.compose.ui.input.pointer.PointerIcon import androidx.compose.ui.input.pointer.pointerHoverIcon import androidx.compose.ui.unit.dp import com.abdownloadmanager.desktop.ui.configurable.ConfigTemplate import com.abdownloadmanager.shared.ui.configurable.ConfigurableRenderer import com.abdownloadmanager.desktop.ui.configurable.TitleAndDescription import com.abdownloadmanager.shared.ui.configurable.ConfigurableUiProps import com.abdownloadmanager.shared.ui.configurable.item.FolderConfigurable import com.abdownloadmanager.desktop.ui.util.rememberMyDirectoryPickerLauncher import com.abdownloadmanager.shared.ui.widget.MyTextField import com.abdownloadmanager.shared.util.ui.icon.MyIcons import com.abdownloadmanager.shared.util.ui.theme.myShapes import com.abdownloadmanager.shared.util.ui.widget.MyIcon import java.io.File object FolderConfigurableRenderer : ConfigurableRenderer { @Composable override fun RenderConfigurable(configurable: FolderConfigurable, configurableUiProps: ConfigurableUiProps) { RenderFolderConfig(configurable, configurableUiProps) } @Composable private fun RenderFolderConfig(cfg: FolderConfigurable, configurableUiProps: ConfigurableUiProps) { val value by cfg.stateFlow.collectAsState() val setValue = cfg::set val pickFolderLauncher = rememberMyDirectoryPickerLauncher( title = cfg.title.rememberString(), initialDirectory = remember(value) { runCatching { File(value).canonicalPath }.getOrNull() }, ) { directory -> directory?.let(setValue) } ConfigTemplate( modifier = configurableUiProps.modifier.padding(configurableUiProps.itemPaddingValues), title = { TitleAndDescription(cfg, true) }, value = { MyTextField( modifier = Modifier.fillMaxWidth(), text = value, onTextChange = { setValue(it) }, shape = myShapes.defaultRounded, textPadding = PaddingValues(4.dp), placeholder = cfg.title.rememberString(), end = { MyIcon( icon = MyIcons.folder, contentDescription = null, modifier = Modifier .pointerHoverIcon(PointerIcon.Default) .fillMaxHeight() .clickable { pickFolderLauncher.launch() } .wrapContentHeight() .padding(horizontal = 8.dp) .size(16.dp)) } ) } ) } } ================================================ FILE: desktop/app/src/main/kotlin/com/abdownloadmanager/desktop/ui/configurable/comon/renderer/IntConfigurableRenderer.kt ================================================ package com.abdownloadmanager.desktop.ui.configurable.comon.renderer import androidx.compose.foundation.interaction.MutableInteractionSource import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.width import androidx.compose.foundation.text.KeyboardOptions import androidx.compose.runtime.Composable import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue import androidx.compose.runtime.remember import androidx.compose.ui.Modifier import androidx.compose.ui.text.input.KeyboardType import androidx.compose.ui.unit.dp import com.abdownloadmanager.desktop.ui.configurable.ConfigTemplate import com.abdownloadmanager.shared.ui.configurable.ConfigurableRenderer import com.abdownloadmanager.desktop.ui.configurable.TitleAndDescription import com.abdownloadmanager.shared.ui.configurable.ConfigurableUiProps import com.abdownloadmanager.shared.ui.configurable.isConfigEnabled import com.abdownloadmanager.shared.ui.configurable.item.IntConfigurable import com.abdownloadmanager.shared.ui.widget.IntTextField object IntConfigurableRenderer : ConfigurableRenderer { @Composable override fun RenderConfigurable(configurable: IntConfigurable, configurableUiProps: ConfigurableUiProps) { RenderIntegerConfig(configurable, configurableUiProps) } private operator fun IntRange.get(index: Int): Int { return (start + index).also { if (it > last) { throw IndexOutOfBoundsException("$it bigger that $last") } } } @Composable private fun RenderIntegerConfig(cfg: IntConfigurable, configurableUiProps: ConfigurableUiProps) { val value by cfg.stateFlow.collectAsState() val setValue = cfg::set val enabled = isConfigEnabled() ConfigTemplate( modifier = configurableUiProps.modifier.padding(configurableUiProps.itemPaddingValues), title = { TitleAndDescription(cfg, true) }, value = { when (cfg.renderMode) { IntConfigurable.RenderMode.TextField -> { val interactionSource: MutableInteractionSource = remember { MutableInteractionSource() } IntTextField( value = value, onValueChange = { v -> setValue(v) }, // colors = TextFieldDefaults.outlinedTextFieldColors( // backgroundColor = Color.Transparent // ), interactionSource = interactionSource, range = cfg.range, modifier = Modifier.width(100.dp), enabled = enabled, keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Decimal), placeholder = "", ) } } }) } } ================================================ FILE: desktop/app/src/main/kotlin/com/abdownloadmanager/desktop/ui/configurable/comon/renderer/LongConfigurableRenderer.kt ================================================ package com.abdownloadmanager.desktop.ui.configurable.comon.renderer import androidx.compose.foundation.interaction.MutableInteractionSource import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.width import androidx.compose.foundation.text.KeyboardOptions import androidx.compose.runtime.Composable import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue import androidx.compose.runtime.remember import androidx.compose.ui.Modifier import androidx.compose.ui.text.input.KeyboardType import androidx.compose.ui.unit.dp import com.abdownloadmanager.desktop.ui.configurable.ConfigTemplate import com.abdownloadmanager.shared.ui.configurable.ConfigurableRenderer import com.abdownloadmanager.desktop.ui.configurable.TitleAndDescription import com.abdownloadmanager.shared.ui.configurable.ConfigurableUiProps import com.abdownloadmanager.shared.ui.configurable.isConfigEnabled import com.abdownloadmanager.shared.ui.configurable.item.LongConfigurable import com.abdownloadmanager.shared.ui.widget.LongTextField object LongConfigurableRenderer : ConfigurableRenderer { @Composable override fun RenderConfigurable(configurable: LongConfigurable, configurableUiProps: ConfigurableUiProps) { RenderLongConfig(configurable, configurableUiProps) } private operator fun LongRange.get(index: Int): Long { return (start + index).also { if (it > last) { throw IndexOutOfBoundsException("$it bigger that $last") } } } @Composable private fun RenderLongConfig(cfg: LongConfigurable, configurableUiProps: ConfigurableUiProps) { val value by cfg.stateFlow.collectAsState() val setValue = cfg::set val enabled = isConfigEnabled() ConfigTemplate( modifier = configurableUiProps.modifier.padding(configurableUiProps.itemPaddingValues), title = { TitleAndDescription(cfg, true) }, value = { when (cfg.renderMode) { LongConfigurable.RenderMode.TextField -> { val interactionSource = remember { MutableInteractionSource() } LongTextField( value = value, onValueChange = { v -> setValue(v) }, // colors = TextFieldDefaults.textFieldColors( // backgroundColor = Color.Transparent // ), modifier = Modifier.width(200.dp), keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Decimal), interactionSource = interactionSource, range = cfg.range, enabled = enabled, ) } } }) } } ================================================ FILE: desktop/app/src/main/kotlin/com/abdownloadmanager/desktop/ui/configurable/comon/renderer/PerHostSettingsConfigurableRenderer.kt ================================================ package com.abdownloadmanager.desktop.ui.configurable.comon.renderer import androidx.compose.foundation.layout.padding import androidx.compose.runtime.Composable import com.abdownloadmanager.resources.Res import com.abdownloadmanager.desktop.ui.configurable.ConfigTemplate import com.abdownloadmanager.shared.ui.configurable.ConfigurableRenderer import com.abdownloadmanager.desktop.ui.configurable.TitleAndDescription import com.abdownloadmanager.shared.ui.configurable.ConfigurableUiProps import com.abdownloadmanager.shared.ui.configurable.item.NavigatableConfigurable import com.abdownloadmanager.shared.ui.widget.ActionButton import ir.amirab.util.compose.resources.myStringResource object PerHostSettingsConfigurableRenderer : ConfigurableRenderer { @Composable override fun RenderConfigurable( configurable: NavigatableConfigurable, configurableUiProps: ConfigurableUiProps ) { RenderPerHostSettingsConfigurable( cfg = configurable, configurableUiProps = configurableUiProps, onRequestOpenConfigWindow = configurable.onRequestNavigate, ) } @Composable private fun RenderPerHostSettingsConfigurable( cfg: NavigatableConfigurable, configurableUiProps: ConfigurableUiProps, onRequestOpenConfigWindow: () -> Unit ) { // val value by cfg.stateFlow.collectAsState() // val setValue = cfg::set // val enabled = isConfigEnabled() ConfigTemplate( modifier = configurableUiProps.modifier.padding(configurableUiProps.itemPaddingValues), title = { TitleAndDescription(cfg, true) }, value = { ActionButton( myStringResource(Res.string.change), onClick = onRequestOpenConfigWindow, ) }, ) } } ================================================ FILE: desktop/app/src/main/kotlin/com/abdownloadmanager/desktop/ui/configurable/comon/renderer/ProxyConfigurableRenderer.kt ================================================ package com.abdownloadmanager.desktop.ui.configurable.comon.renderer import androidx.compose.animation.core.animateDpAsState import androidx.compose.foundation.background import androidx.compose.foundation.border import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.ColumnScope import androidx.compose.foundation.layout.IntrinsicSize import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.RowScope import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.width import androidx.compose.foundation.onClick import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.text.KeyboardOptions import androidx.compose.foundation.verticalScroll import androidx.compose.runtime.Composable import androidx.compose.runtime.Stable import androidx.compose.runtime.collectAsState import androidx.compose.runtime.derivedStateOf import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip import androidx.compose.ui.graphics.Brush import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.dp import androidx.compose.ui.window.Dialog import com.abdownloadmanager.desktop.ui.configurable.ConfigTemplate import com.abdownloadmanager.desktop.ui.configurable.TitleAndDescription import com.abdownloadmanager.resources.Res import com.abdownloadmanager.shared.ui.configurable.ConfigurableRenderer import com.abdownloadmanager.shared.ui.configurable.ConfigurableUiProps import com.abdownloadmanager.shared.ui.configurable.isConfigEnabled import com.abdownloadmanager.shared.ui.configurable.item.ProxyConfigurable import com.abdownloadmanager.shared.ui.widget.ActionButton import com.abdownloadmanager.shared.ui.widget.CheckBox import com.abdownloadmanager.shared.ui.widget.ExpandableItem import com.abdownloadmanager.shared.ui.widget.Help import com.abdownloadmanager.shared.ui.widget.IntTextField import com.abdownloadmanager.shared.ui.widget.Multiselect import com.abdownloadmanager.shared.ui.widget.MyTextField import com.abdownloadmanager.shared.ui.widget.RadioButton import com.abdownloadmanager.shared.ui.widget.Text import com.abdownloadmanager.shared.util.div import com.abdownloadmanager.shared.util.proxy.ProxyData import com.abdownloadmanager.shared.util.proxy.ProxyMode import com.abdownloadmanager.shared.util.proxy.ProxyRules import com.abdownloadmanager.shared.util.ui.LocalContentColor import com.abdownloadmanager.shared.util.ui.icon.MyIcons import com.abdownloadmanager.shared.util.ui.myColors import com.abdownloadmanager.shared.util.ui.theme.myShapes import com.abdownloadmanager.shared.util.ui.theme.myTextSizes import com.abdownloadmanager.shared.util.ui.widget.MyIcon import ir.amirab.downloader.connection.proxy.Proxy import ir.amirab.downloader.connection.proxy.ProxyType import ir.amirab.util.HttpUrlUtils import ir.amirab.util.compose.StringSource import ir.amirab.util.compose.asStringSource import ir.amirab.util.compose.resources.myStringResource import ir.amirab.util.desktop.DesktopUtils import ir.amirab.util.ifThen object ProxyConfigurableRenderer : ConfigurableRenderer { @Composable override fun RenderConfigurable(configurable: ProxyConfigurable, configurableUiProps: ConfigurableUiProps) { RenderProxyConfig(configurable, configurableUiProps) } @Composable fun RenderProxyConfig(cfg: ProxyConfigurable, configurableUiProps: ConfigurableUiProps) { val value by cfg.stateFlow.collectAsState() val setValue = cfg::set val enabled = isConfigEnabled() ConfigTemplate( modifier = configurableUiProps.modifier.padding(configurableUiProps.itemPaddingValues), title = { TitleAndDescription(cfg, true) }, value = { RenderChangeProxyConfig( proxyWithRules = value, setProxyWithRules = { setValue(it) } ) }, ) } @Stable private class ProxyEditState( private val proxyData: ProxyData, private val setProxyData: (ProxyData) -> Unit, ) { var proxyMode = mutableStateOf(proxyData.proxyMode) //pac var pacURL = mutableStateOf(proxyData.pac.uri) //manual var proxyType = mutableStateOf(proxyData.proxyWithRules.proxy.type) var proxyHost = mutableStateOf(proxyData.proxyWithRules.proxy.host) var proxyPort = mutableStateOf(proxyData.proxyWithRules.proxy.port) var useAuth = mutableStateOf(proxyData.proxyWithRules.proxy.username != null) var proxyUsername = mutableStateOf(proxyData.proxyWithRules.proxy.username.orEmpty()) var proxyPassword = mutableStateOf(proxyData.proxyWithRules.proxy.password.orEmpty()) var excludeURLPatterns = mutableStateOf(proxyData.proxyWithRules.rules.excludeURLPatterns.joinToString(" ")) val canSave: Boolean by derivedStateOf { when (proxyMode.value) { ProxyMode.Direct -> true ProxyMode.UseSystem -> true ProxyMode.Manual -> { val hostValid = proxyHost.value.isNotBlank() hostValid } ProxyMode.Pac -> { HttpUrlUtils.isValidUrl(pacURL.value) } } } fun save() { val useAuth = useAuth.value if (!canSave) { return } setProxyData( proxyData.copy( proxyMode = proxyMode.value, pac = proxyData.pac.copy(pacURL.value), proxyWithRules = proxyData.proxyWithRules.copy( proxy = Proxy( type = proxyType.value, host = proxyHost.value.trim(), port = proxyPort.value, username = proxyUsername.value.takeIf { it.isNotEmpty() && useAuth }, password = proxyPassword.value.takeIf { it.isNotEmpty() && useAuth }, ), rules = ProxyRules( excludeURLPatterns = excludeURLPatterns.value .split(" ") .map { it.trim() } .filterNot { it.isEmpty() }, ) ) ) ) } } @Composable fun RenderChangeProxyConfig( proxyWithRules: ProxyData, setProxyWithRules: (ProxyData) -> Unit, ) { var showProxyConfig by remember { mutableStateOf(false) } ActionButton( myStringResource(Res.string.change_proxy), onClick = { showProxyConfig = true }, ) if (showProxyConfig) { val dismiss = { showProxyConfig = false } val state = remember(setProxyWithRules) { ProxyEditState( proxyData = proxyWithRules, setProxyData = { setProxyWithRules(it) dismiss() } ) } ProxyEditDialog(state, onDismiss = dismiss) } } @Composable private fun ProxyEditDialog( state: ProxyEditState, onDismiss: () -> Unit, ) { Dialog( onDismissRequest = (onDismiss), content = { val (mode, setMode) = state.proxyMode SettingsDialog( headerTitle = myStringResource(Res.string.proxy_change_title), onDismiss = onDismiss, content = { val shape = myShapes.defaultRounded Column( Modifier.Companion .verticalScroll(rememberScrollState()) ) { Accordion( wrapItem = { item, content -> val selected = item == mode Box( Modifier.Companion.ifThen(selected) { Modifier.Companion .clip(shape) .border(1.dp, myColors.onBackground / 0.15f, shape) .background(myColors.background / 25) } ) { content() } }, possibleValues = ProxyMode.Companion.usableValues(), selectedItem = mode, renderHeader = { val selected = it == mode Row( Modifier.Companion .fillMaxWidth() .clip(shape) .clickable { setMode(it) } .padding(8.dp) .padding( animateDpAsState( if (selected) 4.dp else 0.dp ).value ) ) { RadioButton( value = selected, onValueChange = {}, ) Spacer(Modifier.Companion.width(8.dp)) Text( text = it.asStringSource().rememberString(), fontSize = if (selected) { myTextSizes.lg } else { myTextSizes.base }, fontWeight = if (selected) { FontWeight.Companion.Bold } else { null } ) } }, renderContent = { val cm = Modifier.Companion .fillMaxWidth() .padding( vertical = 12.dp, horizontal = 16.dp ) when (it) { ProxyMode.Direct -> { } ProxyMode.UseSystem -> { Column(cm) { ActionButton( myStringResource(Res.string.proxy_open_system_proxy_settings), onClick = { DesktopUtils.Companion.openSystemProxySettings() }, ) } } ProxyMode.Manual -> { Column(cm) { RenderManualConfig(state) } } ProxyMode.Pac -> { Column(cm) { RenderPACConfig(state) } } } } ) ProxyConfigSpacer() } }, actions = { ActionButton( myStringResource(Res.string.change), enabled = state.canSave, onClick = { state.save() }) Spacer(Modifier.Companion.width(8.dp)) ActionButton(myStringResource(Res.string.cancel), onClick = { onDismiss() }) } ) } ) } @Composable private fun RenderPACConfig( state: ProxyEditState, ) { Column { val (url, setPacUrl) = state.pacURL DialogConfigItem( modifier = Modifier.Companion, title = { Text(myStringResource(Res.string.proxy_pac_url)) }, value = { Row( verticalAlignment = Alignment.Companion.CenterVertically, ) { MyTextField( text = url, onTextChange = setPacUrl, placeholder = "http://path/to/file.pac", modifier = Modifier.Companion.weight(1f), ) } } ) } } @Composable private fun RenderManualConfig( state: ProxyEditState, ) { val (type, setType) = state.proxyType val (host, setHost) = state.proxyHost val (port, setPort) = state.proxyPort val (useAuth, setUseAuth) = state.useAuth val (username, setUsername) = state.proxyUsername val (password, setPassword) = state.proxyPassword val (excludeURLPatterns, setExcludeURLPatterns) = state.excludeURLPatterns DialogConfigItem( modifier = Modifier.Companion, title = { Text(myStringResource(Res.string.proxy_type)) }, value = { Multiselect( selections = ProxyType.entries.toList(), selectedItem = type, onSelectionChange = setType, modifier = Modifier.Companion, render = { Text( it.name, modifier = Modifier.Companion.padding(vertical = 4.dp, horizontal = 8.dp), ) }, selectedColor = LocalContentColor.current / 15, unselectedAlpha = 0.8f, ) } ) ProxyConfigSpacer() DialogConfigItem( modifier = Modifier.Companion, title = { Text(myStringResource(Res.string.address_and_port)) }, value = { Row( verticalAlignment = Alignment.Companion.CenterVertically, ) { MyTextField( text = host, onTextChange = setHost, placeholder = "127.0.0.1", modifier = Modifier.Companion.weight(1f), ) Text(":", Modifier.Companion.padding(horizontal = 8.dp)) IntTextField( value = port, onValueChange = setPort, placeholder = myStringResource(Res.string.port), range = 1..65535, modifier = Modifier.Companion.width(96.dp), keyboardOptions = KeyboardOptions(), textPadding = PaddingValues(8.dp), shape = RoundedCornerShape(12.dp), ) } } ) ProxyConfigSpacer() DialogConfigItem( modifier = Modifier.Companion, title = { Row( modifier = Modifier.Companion.onClick { setUseAuth(!useAuth) } ) { CheckBox( value = useAuth, onValueChange = setUseAuth, size = 16.dp ) Spacer(Modifier.Companion.width(8.dp)) Text(myStringResource(Res.string.use_authentication)) } }, value = { Row( verticalAlignment = Alignment.Companion.CenterVertically, ) { MyTextField( text = username, onTextChange = setUsername, placeholder = myStringResource(Res.string.username), modifier = Modifier.Companion.weight(1f), enabled = useAuth, ) Spacer(Modifier.Companion.width(8.dp)) MyTextField( text = password, onTextChange = setPassword, placeholder = myStringResource(Res.string.password), modifier = Modifier.Companion.weight(1f), enabled = useAuth, ) } } ) ProxyConfigSpacer() DialogConfigItem( modifier = Modifier.Companion, title = { Row { Text(myStringResource(Res.string.proxy_do_not_use_proxy_for)) Spacer(Modifier.Companion.width(8.dp)) Help( myStringResource(Res.string.proxy_do_not_use_proxy_for_description) ) } }, value = { Row( verticalAlignment = Alignment.Companion.CenterVertically, ) { MyTextField( text = excludeURLPatterns, onTextChange = setExcludeURLPatterns, placeholder = "example.com 192.168.1.*", modifier = Modifier.Companion, ) } } ) } @Composable private fun SettingsDialog( headerTitle: String, onDismiss: () -> Unit, content: @Composable () -> Unit, actions: (@Composable RowScope.() -> Unit)? = null, ) { val shape = myShapes.defaultRounded Column( modifier = Modifier.Companion .clip(shape) .border(2.dp, myColors.onBackground / 10, shape) .background( Brush.Companion.linearGradient( listOf( myColors.surface, myColors.background, ) ) ) .padding(16.dp) .width(450.dp), ) { Row( verticalAlignment = Alignment.Companion.CenterVertically, modifier = Modifier.Companion.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween, ) { Text( headerTitle, fontSize = myTextSizes.lg, fontWeight = FontWeight.Companion.Bold, ) MyIcon( MyIcons.windowClose, myStringResource(Res.string.close), Modifier.Companion .clip(CircleShape) .clickable { onDismiss() } .padding(12.dp) .size(12.dp), ) } Spacer(Modifier.Companion.height(8.dp)) Box(Modifier.Companion.weight(1f, false)) { content() } actions?.let { Spacer(Modifier.Companion.height(8.dp)) Row( Modifier.Companion.align(Alignment.Companion.End), verticalAlignment = Alignment.Companion.CenterVertically, ) { actions() } } } } @Composable private fun ProxyConfigSpacer() { Spacer(Modifier.Companion.height(8.dp)) } @Composable private fun DialogConfigItem( modifier: Modifier, title: @Composable ColumnScope.() -> Unit, value: @Composable ColumnScope.() -> Unit, ) { Column( modifier, ) { Column( Modifier.Companion .height(IntrinsicSize.Max), ) { Column( verticalArrangement = Arrangement.Center, horizontalAlignment = Alignment.Companion.Start, ) { title() } Spacer(Modifier.Companion.height(8.dp)) Column( verticalArrangement = Arrangement.Center, horizontalAlignment = Alignment.Companion.End, ) { value() } } } } private fun ProxyMode.asStringSource(): StringSource { return when (this) { ProxyMode.Direct -> Res.string.proxy_no ProxyMode.UseSystem -> Res.string.proxy_system ProxyMode.Manual -> Res.string.proxy_manual ProxyMode.Pac -> Res.string.proxy_pac }.asStringSource() } @Composable private fun Accordion( possibleValues: List, selectedItem: T, wrapItem: @Composable (T, @Composable () -> Unit) -> Unit = { _, content -> content() }, renderHeader: @Composable (T) -> Unit, renderContent: @Composable (T) -> Unit, ) { Column { possibleValues.forEach { wrapItem(it) { ExpandableItem( isExpanded = selectedItem == it, header = { renderHeader(it) }, body = { renderContent(it) }, ) } } } } } ================================================ FILE: desktop/app/src/main/kotlin/com/abdownloadmanager/desktop/ui/configurable/comon/renderer/SpeedLimitConfigurableRenderer.kt ================================================ package com.abdownloadmanager.desktop.ui.configurable.comon.renderer import androidx.compose.animation.AnimatedVisibility import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.width import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.unit.dp import com.abdownloadmanager.desktop.ui.configurable.ConfigTemplate import com.abdownloadmanager.shared.ui.configurable.ConfigurableRenderer import com.abdownloadmanager.shared.ui.configurable.RenderSpinner import com.abdownloadmanager.desktop.ui.configurable.TitleAndDescription import com.abdownloadmanager.shared.ui.configurable.ConfigurableUiProps import com.abdownloadmanager.shared.ui.configurable.isConfigEnabled import com.abdownloadmanager.shared.ui.configurable.item.SpeedLimitConfigurable import com.abdownloadmanager.shared.ui.widget.CheckBox import com.abdownloadmanager.shared.ui.widget.DoubleTextField import com.abdownloadmanager.shared.ui.widget.Text import com.abdownloadmanager.shared.util.LocalSpeedUnit import ir.amirab.util.datasize.SizeConverter import ir.amirab.util.datasize.SizeFactors import ir.amirab.util.datasize.SizeUnit import ir.amirab.util.datasize.SizeWithUnit import ir.amirab.util.datasize.asConverterConfig object SpeedLimitConfigurableRenderer : ConfigurableRenderer { @Composable override fun RenderConfigurable( configurable: SpeedLimitConfigurable, configurableUiProps: ConfigurableUiProps ) { RenderSpeedConfig(configurable, configurableUiProps) } @Composable private fun RenderSpeedConfig(cfg: SpeedLimitConfigurable, configurableUiProps: ConfigurableUiProps) { val value by cfg.stateFlow.collectAsState() val setValue = cfg::set val speedUnit = LocalSpeedUnit.current val allowedFactors = listOf( SizeFactors.FactorValue.Kilo, SizeFactors.FactorValue.Mega, ) val units = allowedFactors.map { SizeUnit( factorValue = it, baseSize = speedUnit.baseSize, factors = speedUnit.factors ) } val enabled = isConfigEnabled() val hasLimitSpeed = value > 0L var currentUnit by remember(hasLimitSpeed) { mutableStateOf( SizeConverter.bytesToSize( value, speedUnit.copy(acceptedFactors = allowedFactors) ).unit ) } var currentValue by remember(value) { val v = SizeConverter.bytesToSize( value, currentUnit.asConverterConfig() ).formatedValue().toDouble() mutableStateOf(v) } LaunchedEffect(currentValue, currentUnit) { setValue( SizeConverter.sizeToBytes( SizeWithUnit(currentValue, currentUnit), ) ) } ConfigTemplate( configurableUiProps.modifier.padding(configurableUiProps.itemPaddingValues), title = { Row(verticalAlignment = Alignment.CenterVertically) { TitleAndDescription(cfg, true) } }, nestedContent = { Column(Modifier.align(Alignment.End)) { AnimatedVisibility(hasLimitSpeed) { Row( Modifier .padding(vertical = 8.dp) .width(200.dp) ) { DoubleTextField( value = currentValue, onValueChange = { currentValue = it }, enabled = enabled && hasLimitSpeed, range = 0.0..1_000.0, unit = 1.0, modifier = Modifier.weight(1f), ) Spacer(Modifier.width(2.dp)) RenderSpinner( possibleValues = units, value = currentUnit, modifier = Modifier.Companion, enabled = enabled && hasLimitSpeed, onSelect = { currentUnit = it } ) { val prettified = remember(it) { "$it/s" } Text(prettified) } } } } }, value = { CheckBox( value = hasLimitSpeed, enabled = enabled, onValueChange = { if (it) { setValue( SizeConverter.sizeToBytes( SizeWithUnit( 256.0, currentUnit ) ) ) } else { setValue(0) } }) } ) } } ================================================ FILE: desktop/app/src/main/kotlin/com/abdownloadmanager/desktop/ui/configurable/comon/renderer/StringConfigurableRenderer.kt ================================================ package com.abdownloadmanager.desktop.ui.configurable.comon.renderer import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding import androidx.compose.runtime.Composable import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue import androidx.compose.ui.Modifier import androidx.compose.ui.unit.dp import com.abdownloadmanager.desktop.ui.configurable.ConfigTemplate import com.abdownloadmanager.shared.ui.configurable.ConfigurableRenderer import com.abdownloadmanager.desktop.ui.configurable.TitleAndDescription import com.abdownloadmanager.shared.ui.configurable.ConfigurableUiProps import com.abdownloadmanager.shared.ui.configurable.item.StringConfigurable import com.abdownloadmanager.shared.ui.widget.MyTextField import com.abdownloadmanager.shared.util.ui.theme.myShapes object StringConfigurableRenderer : ConfigurableRenderer { @Composable override fun RenderConfigurable(configurable: StringConfigurable, configurableUiProps: ConfigurableUiProps) { RenderStringConfig(configurable, configurableUiProps) } @Composable fun RenderStringConfig(cfg: StringConfigurable, configurableUiProps: ConfigurableUiProps) { val value by cfg.stateFlow.collectAsState() val setValue = cfg::set ConfigTemplate( modifier = configurableUiProps.modifier.padding(configurableUiProps.itemPaddingValues), title = { TitleAndDescription(cfg, true) }, value = { MyTextField( modifier = Modifier.fillMaxWidth(), text = value, onTextChange = { setValue(it) }, shape = myShapes.defaultRounded, textPadding = PaddingValues(4.dp), placeholder = "", ) } ) } } ================================================ FILE: desktop/app/src/main/kotlin/com/abdownloadmanager/desktop/ui/configurable/comon/renderer/ThemeConfigurableRenderer.kt ================================================ package com.abdownloadmanager.desktop.ui.configurable.comon.renderer import androidx.compose.foundation.background import androidx.compose.foundation.border import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.width import androidx.compose.foundation.layout.widthIn import androidx.compose.foundation.shape.CircleShape import androidx.compose.runtime.Composable import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.alpha import androidx.compose.ui.draw.clip import androidx.compose.ui.graphics.Brush import androidx.compose.ui.unit.dp import com.abdownloadmanager.desktop.ui.configurable.ConfigTemplate import com.abdownloadmanager.shared.ui.configurable.ConfigurableRenderer import com.abdownloadmanager.shared.ui.configurable.RenderSpinner import com.abdownloadmanager.desktop.ui.configurable.TitleAndDescription import com.abdownloadmanager.shared.ui.configurable.ConfigurableUiProps import com.abdownloadmanager.shared.ui.configurable.isConfigEnabled import com.abdownloadmanager.shared.ui.configurable.item.ThemeConfigurable import com.abdownloadmanager.shared.ui.widget.Text import com.abdownloadmanager.shared.util.ui.myColors import com.abdownloadmanager.shared.util.ui.theme.myTextSizes import ir.amirab.util.ifThen object ThemeConfigurableRenderer : ConfigurableRenderer { @Composable override fun RenderConfigurable(configurable: ThemeConfigurable, configurableUiProps: ConfigurableUiProps) { RenderThemeConfig(configurable, configurableUiProps) } @Composable private fun RenderThemeConfig(cfg: ThemeConfigurable, configurableUiProps: ConfigurableUiProps) { val value by cfg.stateFlow.collectAsState() val setValue = cfg::set val enabled = isConfigEnabled() ConfigTemplate( modifier = configurableUiProps.modifier.padding(configurableUiProps.itemPaddingValues), title = { TitleAndDescription(cfg, true) }, value = { RenderSpinner( possibleValues = cfg.possibleValues, value = value, onSelect = { setValue(it) }, modifier = Modifier.widthIn(min = 160.dp), enabled = enabled, valueToString = cfg.valueToString, render = { Row( verticalAlignment = Alignment.CenterVertically, modifier = Modifier.ifThen(!enabled) { alpha(0.5f) } ) { Spacer( Modifier .clip(CircleShape) .border( 1.dp, Brush.verticalGradient(myColors.primaryGradientColors), CircleShape ) .padding(1.dp) .background( it.color, ) .size(16.dp) ) Spacer(Modifier.width(16.dp)) Text(cfg.describe(it).rememberString(), fontSize = myTextSizes.lg) } }) } ) } } ================================================ FILE: desktop/app/src/main/kotlin/com/abdownloadmanager/desktop/ui/configurable/comon/renderer/TimeConfigurableRenderer.kt ================================================ package com.abdownloadmanager.desktop.ui.configurable.comon.renderer import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.width import androidx.compose.foundation.text.KeyboardOptions import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.text.input.KeyboardType import androidx.compose.ui.unit.dp import com.abdownloadmanager.desktop.ui.configurable.ConfigTemplate import com.abdownloadmanager.shared.ui.configurable.ConfigurableRenderer import com.abdownloadmanager.desktop.ui.configurable.TitleAndDescription import com.abdownloadmanager.shared.ui.configurable.ConfigurableUiProps import com.abdownloadmanager.shared.ui.configurable.isConfigEnabled import com.abdownloadmanager.shared.ui.configurable.item.TimeConfigurable import com.abdownloadmanager.shared.ui.widget.IntTextField import com.abdownloadmanager.shared.ui.widget.Text import kotlinx.datetime.LocalTime object TimeConfigurableRenderer : ConfigurableRenderer { @Composable override fun RenderConfigurable(configurable: TimeConfigurable, configurableUiProps: ConfigurableUiProps) { RenderTimeConfig(configurable, configurableUiProps) } @Composable fun RenderTimeConfig(cfg: TimeConfigurable, configurableUiProps: ConfigurableUiProps) { val value by cfg.stateFlow.collectAsState() val setValue = cfg::set val enabled = isConfigEnabled() var hour by remember(value) { mutableStateOf(value.hour) } var minute by remember(value) { mutableStateOf(value.minute) } LaunchedEffect(hour, minute) { setValue( LocalTime( hour = hour, minute = minute, ) ) } val textFieldModifier = Modifier.width(64.dp) ConfigTemplate( modifier = configurableUiProps.modifier.padding(configurableUiProps.itemPaddingValues), title = { TitleAndDescription(cfg, true) }, value = { Row( verticalAlignment = Alignment.CenterVertically ) { IntTextField( value = hour, onValueChange = { hour = it }, range = 0..23, modifier = textFieldModifier, enabled = enabled, keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Decimal), placeholder = "hour", prettify = { it.toString().padStart(2, '0') }, ) Text(":", Modifier.padding(horizontal = 4.dp)) IntTextField( value = minute, onValueChange = { minute = it }, range = 0..59, modifier = textFieldModifier, enabled = enabled, keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Decimal), placeholder = "minute", prettify = { it.toString().padStart(2, '0') }, ) } } ) } } ================================================ FILE: desktop/app/src/main/kotlin/com/abdownloadmanager/desktop/ui/configurable/platform/DesktopConfigurableRenderers.kt ================================================ package com.abdownloadmanager.desktop.ui.configurable.platform import com.abdownloadmanager.desktop.ui.configurable.platform.item.FontConfigurable import com.abdownloadmanager.shared.ui.configurable.item.ProxyConfigurable import com.abdownloadmanager.desktop.ui.configurable.platform.renderer.FontConfigurableRenderer import com.abdownloadmanager.desktop.ui.configurable.comon.renderer.ProxyConfigurableRenderer import com.abdownloadmanager.shared.ui.configurable.Configurable import com.abdownloadmanager.shared.ui.configurable.ConfigurableRenderer import com.abdownloadmanager.shared.ui.configurable.ContainsConfigurableRenderers data class DesktopConfigurableRenderers( val fontConfigurableRenderer: ConfigurableRenderer, ) : ContainsConfigurableRenderers { override fun getAllRenderers(): Map> { return mapOf( FontConfigurable.Key to fontConfigurableRenderer, ) } } val PlatformConfigurableRenderersForDesktop = DesktopConfigurableRenderers( fontConfigurableRenderer = FontConfigurableRenderer, ) ================================================ FILE: desktop/app/src/main/kotlin/com/abdownloadmanager/desktop/ui/configurable/platform/item/FontConfigurable.kt ================================================ package com.abdownloadmanager.desktop.ui.configurable.platform.item import com.abdownloadmanager.desktop.pages.settings.FontInfo import com.abdownloadmanager.shared.ui.configurable.BaseEnumConfigurable import com.abdownloadmanager.shared.ui.configurable.Configurable import ir.amirab.util.compose.StringSource import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow class FontConfigurable( title: StringSource, description: StringSource, backedBy: MutableStateFlow, describe: (FontInfo) -> StringSource, possibleValues: List, valueToString: (FontInfo) -> List = { listOf(it.name.getString()) }, enabled: StateFlow = DefaultEnabledValue, visible: StateFlow = DefaultVisibleValue, ) : BaseEnumConfigurable( title = title, description = description, backedBy = backedBy, describe = describe, possibleValues = possibleValues, valueToString = valueToString, enabled = enabled, visible = visible, ) { object Key : Configurable.Key override fun getKey() = Key } ================================================ FILE: desktop/app/src/main/kotlin/com/abdownloadmanager/desktop/ui/configurable/platform/renderer/FontConfigurableRenderer.kt ================================================ package com.abdownloadmanager.desktop.ui.configurable.platform.renderer import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.widthIn import androidx.compose.runtime.Composable import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.alpha import androidx.compose.ui.unit.dp import com.abdownloadmanager.desktop.ui.configurable.platform.item.FontConfigurable import com.abdownloadmanager.desktop.ui.configurable.ConfigTemplate import com.abdownloadmanager.shared.ui.configurable.ConfigurableRenderer import com.abdownloadmanager.shared.ui.configurable.RenderSpinner import com.abdownloadmanager.desktop.ui.configurable.TitleAndDescription import com.abdownloadmanager.shared.ui.configurable.ConfigurableUiProps import com.abdownloadmanager.shared.ui.configurable.isConfigEnabled import com.abdownloadmanager.shared.ui.widget.Text import com.abdownloadmanager.shared.util.ui.theme.myTextSizes import ir.amirab.util.ifThen object FontConfigurableRenderer : ConfigurableRenderer { @Composable override fun RenderConfigurable(configurable: FontConfigurable, configurableUiProps: ConfigurableUiProps) { RenderFontConfig(configurable, configurableUiProps) } @Composable private fun RenderFontConfig(cfg: FontConfigurable, configurableUiProps: ConfigurableUiProps) { val value by cfg.stateFlow.collectAsState() val setValue = cfg::set val enabled = isConfigEnabled() ConfigTemplate( modifier = configurableUiProps.modifier.padding(configurableUiProps.itemPaddingValues), title = { TitleAndDescription(cfg, true) }, value = { RenderSpinner( possibleValues = cfg.possibleValues, value = value, onSelect = { setValue(it) }, valueToString = cfg.valueToString, modifier = Modifier.widthIn(min = 160.dp), enabled = enabled, render = { Row( verticalAlignment = Alignment.CenterVertically, modifier = Modifier.ifThen(!enabled) { alpha(0.5f) } ) { Text( cfg.describe(it).rememberString(), fontFamily = it.fontFamily, fontSize = myTextSizes.lg, ) } }) } ) } } ================================================ FILE: desktop/app/src/main/kotlin/com/abdownloadmanager/desktop/ui/error/ErrorUi.kt ================================================ package com.abdownloadmanager.desktop.ui.error import com.abdownloadmanager.desktop.window.custom.CustomWindow import com.abdownloadmanager.desktop.window.custom.WindowTitle import com.abdownloadmanager.shared.util.ui.myColors import com.abdownloadmanager.shared.util.ui.theme.myTextSizes import com.abdownloadmanager.shared.util.ui.widget.ScreenSurface import com.abdownloadmanager.shared.ui.widget.ActionButton import com.abdownloadmanager.desktop.utils.AppInfo import com.abdownloadmanager.shared.util.ClipboardUtil import androidx.compose.foundation.background import androidx.compose.foundation.layout.* import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.text.selection.SelectionContainer import androidx.compose.foundation.verticalScroll import com.abdownloadmanager.shared.ui.widget.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.remember import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.unit.DpSize import androidx.compose.ui.unit.dp import androidx.compose.ui.window.WindowPosition import androidx.compose.ui.window.rememberWindowState @Composable fun ErrorWindow( throwable: Throwable, close: () -> Unit, ){ CustomWindow( onCloseRequest = close, resizable = true, state = rememberWindowState( size = DpSize(500.dp,400.dp), position = WindowPosition.Aligned(Alignment.Center) ), alwaysOnTop = true, ) { ErrorUi(throwable, close) } } @Composable private fun ErrorUi( e: Throwable, close: () -> Unit, ) { WindowTitle("Error") ScreenSurface( modifier = Modifier.fillMaxSize(), contentColor = myColors.onBackground, background = myColors.background, ) { Column( modifier = Modifier.fillMaxSize() .padding(horizontal = 8.dp) ) { Header( modifier = Modifier .fillMaxWidth(), e ) Spacer(Modifier.height(8.dp)) RenderException( modifier = Modifier .fillMaxWidth() .weight(1f), e = e ) Spacer(Modifier.height(8.dp)) Actions( modifier = Modifier .fillMaxWidth(), close = close, copyInformation = { ClipboardUtil.copy(createInformation(e)) } ) Spacer(Modifier.height(8.dp)) } } } fun createInformation( e: Throwable, ): String { val exceptionString = e.stackTraceToString().replace("\t", " ") val version = AppInfo.version val platform = AppInfo.platform.name return """ ### Application Runtime Error ###### App Info ``` appVersion = $version platform = $platform ``` ###### Exception ``` $exceptionString ``` """.trimIndent() } @Composable private fun Header(modifier: Modifier = Modifier, e: Throwable) { Text( text = "There is an error happen in application (\"${e.localizedMessage}\")", modifier = modifier, fontSize = myTextSizes.xl ) } @Composable private fun RenderException(modifier: Modifier, e: Throwable) { val errorText = remember(e) { e.stackTraceToString() //replace tab with space for compose to render it correctly .replace("\t", " ") } Box( modifier = modifier .background(myColors.surface) .verticalScroll(rememberScrollState()) .padding(8.dp) ) { SelectionContainer { Text( text = errorText, color = myColors.error, fontSize = myTextSizes.xl, ) } } } @Composable private fun Actions( modifier: Modifier = Modifier, close: () -> Unit, copyInformation: () -> Unit, ) { Row( modifier = modifier, horizontalArrangement = Arrangement.End, ) { ActionButton( text = "Copy Information", onClick = copyInformation ) Spacer(Modifier.width(8.dp)) ActionButton( text = "Close", onClick = close ) } } ================================================ FILE: desktop/app/src/main/kotlin/com/abdownloadmanager/desktop/ui/util/FilePickerUtils.kt ================================================ package com.abdownloadmanager.desktop.ui.util import androidx.compose.runtime.Composable import com.abdownloadmanager.shared.ui.util.LocalWindow import io.github.vinceglb.filekit.compose.PickerResultLauncher import io.github.vinceglb.filekit.compose.rememberDirectoryPickerLauncher import io.github.vinceglb.filekit.core.FileKitPlatformSettings @Composable fun rememberMyDirectoryPickerLauncher( title: String? = null, initialDirectory: String? = null, attachToWindow: Boolean = true, onResult: (String?) -> Unit, ): PickerResultLauncher { return rememberDirectoryPickerLauncher( title = title, initialDirectory = initialDirectory, platformSettings = createPlatformSettings( attachToWindow = attachToWindow ), onResult = { onResult(it?.path) }, ) } @Composable fun createPlatformSettings(attachToWindow: Boolean): FileKitPlatformSettings { return FileKitPlatformSettings( parentWindow = LocalWindow.current.takeIf { attachToWindow } ) } ================================================ FILE: desktop/app/src/main/kotlin/com/abdownloadmanager/desktop/ui/widget/ConfirmDialog.kt ================================================ package com.abdownloadmanager.desktop.ui.widget import com.abdownloadmanager.desktop.window.custom.CustomWindow import com.abdownloadmanager.desktop.window.custom.WindowTitle import com.abdownloadmanager.shared.util.ui.widget.MyIcon import com.abdownloadmanager.shared.util.ui.icon.MyIcons import com.abdownloadmanager.shared.util.ui.myColors import com.abdownloadmanager.shared.util.ui.theme.myTextSizes import androidx.compose.foundation.layout.* import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.verticalScroll import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.remember import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.focus.FocusRequester import androidx.compose.ui.focus.focusRequester import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.DpSize import androidx.compose.ui.unit.dp import androidx.compose.ui.window.WindowPosition import androidx.compose.ui.window.rememberWindowState import com.abdownloadmanager.shared.ui.widget.ActionButton import com.abdownloadmanager.shared.ui.widget.ActionContainer import com.abdownloadmanager.shared.ui.widget.Text import com.abdownloadmanager.shared.util.ui.theme.LocalUiScale import com.abdownloadmanager.resources.Res import ir.amirab.util.compose.StringSource import ir.amirab.util.compose.resources.myStringResource import ir.amirab.util.desktop.screen.applyUiScale import java.awt.Dimension @Suppress("unused") sealed class ConfirmDialogType { data object Success : ConfirmDialogType() data object Info : ConfirmDialogType() data object Error : ConfirmDialogType() data object Warning : ConfirmDialogType() } @Composable fun ConfirmDialog( title: StringSource, message: StringSource, type: ConfirmDialogType, onConfirm: () -> Unit, onCancel: () -> Unit, ) { val uiScale = LocalUiScale.current val h = 180.applyUiScale(uiScale) val w = 400.applyUiScale(uiScale) val state = rememberWindowState( size = DpSize(w.dp, h.dp), position = WindowPosition.Aligned(Alignment.Center) ) CustomWindow( state, onRequestMinimize = null, onRequestToggleMaximize = null, onCloseRequest = onCancel, alwaysOnTop = true, ) { LaunchedEffect(Unit) { window.minimumSize = Dimension(w, h) } val typeName = type.toString() WindowTitle(typeName) Column { Row( Modifier .weight(1f) .padding(8.dp), ) { val color = when (type) { ConfirmDialogType.Error -> myColors.info ConfirmDialogType.Info -> myColors.warning ConfirmDialogType.Success -> myColors.success ConfirmDialogType.Warning -> myColors.warning } MyIcon( icon = MyIcons.info, tint = color, modifier = Modifier .padding(16.dp) .requiredSize(36.dp), contentDescription = null, ) Column { Text( title.rememberString(), fontSize = myTextSizes.xl, fontWeight = FontWeight.Bold, ) Spacer(Modifier.height(8.dp)) Text( message.rememberString(), fontSize = myTextSizes.base, modifier = Modifier .weight(1f) .fillMaxWidth() .verticalScroll(rememberScrollState()) ) Spacer(Modifier.height(8.dp)) } } ActionContainer( Modifier.fillMaxWidth() ) { Row( Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.End, ) { val confirmFocusRequester = remember { FocusRequester() } LaunchedEffect(Unit) { confirmFocusRequester.requestFocus() } ActionButton( myStringResource(Res.string.ok), onClick = onConfirm, modifier = Modifier.focusRequester(confirmFocusRequester) ) Spacer(Modifier.width(8.dp)) ActionButton( myStringResource(Res.string.cancel), onClick = onCancel ) } } } } } ================================================ FILE: desktop/app/src/main/kotlin/com/abdownloadmanager/desktop/ui/widget/MessageDialogModel.kt ================================================ package com.abdownloadmanager.desktop.ui.widget import com.abdownloadmanager.desktop.AppComponent import com.abdownloadmanager.desktop.window.custom.CustomWindow import com.abdownloadmanager.desktop.window.custom.WindowTitle import com.abdownloadmanager.shared.util.ui.widget.MyIcon import com.abdownloadmanager.shared.util.ui.icon.MyIcons import com.abdownloadmanager.shared.util.ui.myColors import com.abdownloadmanager.shared.util.ui.theme.myTextSizes import androidx.compose.foundation.layout.* import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.verticalScroll import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue import androidx.compose.ui.Modifier import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.DpSize import androidx.compose.ui.unit.dp import androidx.compose.ui.window.rememberWindowState import com.abdownloadmanager.shared.ui.widget.ActionButton import com.abdownloadmanager.shared.ui.widget.Text import com.abdownloadmanager.shared.util.ui.theme.LocalUiScale import com.abdownloadmanager.resources.Res import com.abdownloadmanager.shared.ui.widget.MessageDialogType import ir.amirab.util.compose.StringSource import ir.amirab.util.compose.resources.myStringResource import ir.amirab.util.desktop.screen.applyUiScale import java.awt.Dimension import java.util.UUID data class MessageDialogModel( val id: String = UUID.randomUUID().toString(), val title: StringSource, val description: StringSource, val type: MessageDialogType = MessageDialogType.Info, ) @Composable fun ShowMessageDialogs( appComponent: AppComponent, ) { val list by appComponent.dialogMessages.collectAsState() for (msg in list) { MessageDialog( msgContent = msg, onConfirm = { appComponent.onDismissDialogMessage(msg) } ) } } @Composable fun MessageDialog( msgContent: MessageDialogModel, onConfirm: () -> Unit, ) { val uiScale = LocalUiScale.current val h = 200.applyUiScale(uiScale) val w = 400.applyUiScale(uiScale) val state = rememberWindowState( size = DpSize(w.dp, h.dp) ) CustomWindow( state, onRequestMinimize = null, onRequestToggleMaximize = null, onCloseRequest = onConfirm, alwaysOnTop = true, ) { LaunchedEffect(Unit) { window.minimumSize = Dimension(w, h) } val typeName = msgContent.type.toString() WindowTitle(typeName) Row( Modifier.padding(8.dp), ) { val color = when (msgContent.type) { MessageDialogType.Error -> myColors.info MessageDialogType.Info -> myColors.warning MessageDialogType.Success -> myColors.success MessageDialogType.Warning -> myColors.warning } MyIcon( icon = MyIcons.info, tint = color, modifier = Modifier .padding(16.dp) .requiredSize(36.dp), contentDescription = null, ) Column { Text( msgContent.title.rememberString(), fontSize = myTextSizes.xl, fontWeight = FontWeight.Bold, ) Spacer(Modifier.height(8.dp)) Text( msgContent.description.rememberString(), fontSize = myTextSizes.base, modifier = Modifier .weight(1f) .fillMaxWidth() .verticalScroll(rememberScrollState()) ) Spacer(Modifier.height(8.dp)) Row( Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.End, ) { ActionButton( myStringResource(Res.string.ok), onClick = onConfirm ) } } } } } ================================================ FILE: desktop/app/src/main/kotlin/com/abdownloadmanager/desktop/ui/widget/Tray.kt ================================================ package com.abdownloadmanager.desktop.ui.widget import androidx.compose.runtime.Composable import androidx.compose.runtime.Immutable import androidx.compose.runtime.collectAsState import androidx.compose.ui.window.ApplicationScope import com.kdroid.composetray.menu.api.TrayMenuBuilder import com.kdroid.composetray.tray.api.Tray import ir.amirab.util.compose.IconSource import ir.amirab.util.compose.action.MenuItem import ir.amirab.util.platform.Platform import ir.amirab.util.platform.asDesktop @Composable fun ApplicationScope.Tray( icon: IconSource, tooltip: String, primaryAction: () -> Unit, menu: List ) { // use this composable to properly update menu item properties (State,StateFlow properties) val immutableMenu = menu.toImmutableMenuItem() val menuContent: TrayMenuBuilder.() -> Unit = { for (item in immutableMenu) { renderTrayItem(item) } } val shouldBeMonochrome = when (Platform.asDesktop()) { Platform.Desktop.MacOS -> true Platform.Desktop.Linux -> false Platform.Desktop.Windows -> false } if (shouldBeMonochrome && icon is IconSource.VectorIconSource) { // for tray icon the library automatically converts the ImageVector to monochrome // we want this behavior only for macOS Tray( icon = icon.value, tooltip = tooltip, primaryAction = primaryAction, menuContent = menuContent, ) } else { Tray( icon = icon.rememberPainter(), tooltip = tooltip, primaryAction = primaryAction, menuContent = menuContent ) } } private fun TrayMenuBuilder.renderTrayItem(item: ImmutableMenuItem) { when (item) { is ImmutableMenuItem.SingleItem -> { renderTraySingleItem(item) } is ImmutableMenuItem.SubMenu -> { renderTraySubMenu(item) } is ImmutableMenuItem.Separator -> Divider() } } private fun TrayMenuBuilder.renderTraySingleItem(item: ImmutableMenuItem.SingleItem) { val title = item.title val isEnabled = item.enabled val onClick = item.onAction when (val iconSource = item.icon) { is IconSource.VectorIconSource -> Item( label = title, isEnabled = isEnabled, onClick = onClick, icon = iconSource.value, ) is IconSource.PainterIconSource -> Item( label = title, isEnabled = isEnabled, onClick = onClick, icon = iconSource.value, ) null -> Item( label = title, isEnabled = isEnabled, onClick = onClick, ) } } private fun TrayMenuBuilder.renderTraySubMenu(submenu: ImmutableMenuItem.SubMenu) { val title = submenu.title val isEnabled = submenu.enabled val submenuContent: TrayMenuBuilder.() -> Unit = { for (item in submenu.items) { renderTrayItem(item) } } when (val iconSource = submenu.icon) { is IconSource.PainterIconSource -> { SubMenu( label = title, isEnabled = isEnabled, submenuContent = submenuContent, icon = iconSource.value, ) } is IconSource.VectorIconSource -> { SubMenu( label = title, isEnabled = isEnabled, submenuContent = submenuContent, icon = iconSource.value, ) } null -> { SubMenu( label = title, isEnabled = isEnabled, submenuContent = submenuContent, ) } } } @Composable private fun List.toImmutableMenuItem(): List { return map { when (it) { MenuItem.Separator -> ImmutableMenuItem.Separator is MenuItem.SingleItem -> { ImmutableMenuItem.SingleItem( title = it.title.collectAsState().value.rememberString(), icon = it.icon.collectAsState().value, enabled = it.isEnabled.value, onAction = it::onClick ) } is MenuItem.SubMenu -> ImmutableMenuItem.SubMenu( title = it.title.collectAsState().value.rememberString(), icon = it.icon.value, enabled = it.isEnabled.value, items = it.items.collectAsState().value.toImmutableMenuItem() ) } } } @Immutable private sealed class ImmutableMenuItem { data object Separator : ImmutableMenuItem() data class SingleItem( val title: String, val icon: IconSource?, val enabled: Boolean, val onAction: () -> Unit, ) : ImmutableMenuItem() data class SubMenu( val title: String, val icon: IconSource?, val enabled: Boolean, val items: List ) : ImmutableMenuItem() } ================================================ FILE: desktop/app/src/main/kotlin/com/abdownloadmanager/desktop/utils/AppInfo.kt ================================================ package com.abdownloadmanager.desktop.utils import com.abdownloadmanager.desktop.AppArguments import com.abdownloadmanager.shared.util.SharedConstants import com.abdownloadmanager.desktop.storage.DesktopDefinedPaths import com.abdownloadmanager.shared.util.AppVersion import ir.amirab.util.platform.Platform import okio.Path.Companion.toOkioPath import java.io.File object AppInfo { val name = SharedConstants.appName val displayName = SharedConstants.appDisplayName val packageName = SharedConstants.packageName val website = SharedConstants.projectWebsite val sourceCode = SharedConstants.projectSourceCode val translationsUrl = SharedConstants.projectTranslations val version = AppVersion.get() val platform = Platform.getCurrentPlatform() val exeFile: String? = run { // if (!AppProperties.isAppInstalled()){ // return@run null // } System.getProperty("jpackage.app-path") } private fun File.findAppFolder() = generateSequence(this) { it.parentFile } .firstOrNull { it.name.endsWith(".app") } val installationFolder: String? = run { exeFile?.let(::File) ?.parentFile // executable path ?.let { when (Platform.getCurrentPlatform()) { Platform.Desktop.Linux -> it.parentFile // /bin/ABDownloadManager Platform.Desktop.MacOS -> it.findAppFolder() // /Applications/ABDownloadManager.app Platform.Desktop.Windows -> it // /ABDownloadManager.exe else -> null }?.path } } private fun getUserDataDir(): File { val dataDirName = SharedConstants.dataDirName return File(System.getProperty("user.home"), dataDirName) } val dataDir by lazy { PortableUtil.getPortableDataDir(installationFolder) ?: getUserDataDir() } val definedPaths = DesktopDefinedPaths(dataDir.toOkioPath()) } fun AppInfo.isAppInstalled(): Boolean { return AppInfo.exeFile != null } fun AppInfo.isInIDE(): Boolean { return !isAppInstalled() } fun AppInfo.isInDebugMode(): Boolean { return AppArguments.get().debug || AppProperties.isDebugMode() || isInIDE() } ================================================ FILE: desktop/app/src/main/kotlin/com/abdownloadmanager/desktop/utils/AppProperties.kt ================================================ package com.abdownloadmanager.desktop.utils import okio.FileSystem import okio.Path.Companion.toOkioPath import okio.Path.Companion.toPath import okio.Source import okio.buffer import okio.use import java.util.Properties import kotlin.io.path.Path object AppProperties { private val defaultProps = Properties() private val appProps = Properties(defaultProps) private object Paths { const val DEFAULT_APP_PROPS_PATH = "configs/app_default.properties" const val INSTALLED_APP_PROPS_NAME = "app.properties" } private object Keys { const val DEBUG: String = "app.debug" } private fun loadFromSource(props: Properties, source: Source) { source.buffer() .inputStream() .use { props.load(it) } } private fun loadDefaultProps() { FileSystem.RESOURCES .source(Paths.DEFAULT_APP_PROPS_PATH.toPath()) .use { loadFromSource(defaultProps, it) } } private var foundAppProperties = false private fun loadAppProps() { val resourceDir:String?=System.getProperty("compose.application.resources.dir") if (resourceDir.isNullOrBlank()){ foundAppProperties = false return } val file = Path(resourceDir,Paths.INSTALLED_APP_PROPS_NAME).toOkioPath() if (!FileSystem.SYSTEM.exists(file)) { // app is in development and don't have app.properties, // so we use only default foundAppProperties = false return } else { foundAppProperties = true } FileSystem.SYSTEM .source(file) .use { loadFromSource(appProps, it) } } private fun ensureAndGet(key: String): Any { return requireNotNull( appProps.getProperty(key) ) { "key: '$key' not found in properties file" } .hydrateVariables() .trim('"') .trim('\'') } fun isDebugMode(): Boolean { return ensureAndGet(Keys.DEBUG) .toString() .toBoolean() } //app.properties in installation directory fun isAppPropertiesFound(): Boolean { return foundAppProperties } val userDir: String get() { return System.getProperty("user.home") } fun boot(){ loadDefaultProps() loadAppProps() } fun getAll() = appProps } private val variableRegex = "\\$\\{(.*?)\\}".toRegex() private fun String.hydrateVariables(): String { return variableRegex.replace(this) { val variableName = it.groupValues[1] System.getProperty(variableName) } } ================================================ FILE: desktop/app/src/main/kotlin/com/abdownloadmanager/desktop/utils/DebugboardUtils.kt ================================================ package com.abdownloadmanager.desktop.utils // //import ir.amirab.debugboard.core.plugin.watcher.RemoveWatch //import kotlinx.coroutines.CoroutineScope //import kotlinx.coroutines.job // //fun RemoveWatch.inScope(scope: CoroutineScope) { // val removeWatch = this // scope.coroutineContext.job.invokeOnCompletion { // removeWatch() // } //} ================================================ FILE: desktop/app/src/main/kotlin/com/abdownloadmanager/desktop/utils/DesktopEntryCreator.kt ================================================ package com.abdownloadmanager.desktop.utils import ir.amirab.util.platform.Platform import ir.amirab.util.platform.isLinux import java.io.File object DesktopEntryCreator { fun createLinuxDesktopEntry() { if (!Platform.isLinux()) { return } runCatching { LinuxDesktopEntryCreator.createDesktopEntry( name = AppInfo.displayName, comment = "Manage and organize your download files better than before", desktopEntryFilename = AppInfo.packageName, execFile = requireNotNull(AppInfo.exeFile) { "Exe file not known" }, startupWMClass = "com-abdownloadmanager-desktop-AppKt" ) }.onFailure { it.printStackTrace() } } } private object LinuxDesktopEntryCreator { fun createDesktopEntry( name: String, execFile: String, comment: String, desktopEntryFilename: String, startupWMClass: String, ) { val iconFilePath = requireNotNull(getIconFilePath(execFile)) { "Icon path is null! for this exe file: $execFile" } val desktopEntryContent = buildString { appendLine("[Desktop Entry]") appendLine("Name=$name") appendLine("Comment=$comment") appendLine("GenericName=Downloader") appendLine("Categories=Utility;Network;") appendLine("Exec=\"$execFile\"") appendLine("Icon=${iconFilePath}") appendLine("Terminal=false") appendLine("Type=Application") appendLine("StartupWMClass=${startupWMClass}") } val homePath = System.getProperty("user.home") val desktopEntryFile = File(homePath, ".local/share/applications/${desktopEntryFilename}.desktop") desktopEntryFile.writeText(desktopEntryContent) } private fun getIconFilePath(execFile: String): String? { return runCatching { val file = File(execFile) val name = file.name file .parentFile.parentFile .resolve("lib/$name.png") .takeIf { it.exists() }?.path }.getOrNull() } } ================================================ FILE: desktop/app/src/main/kotlin/com/abdownloadmanager/desktop/utils/DesktopShortcutManager.kt ================================================ package com.abdownloadmanager.desktop.utils import androidx.compose.ui.awt.awtEventOrNull import androidx.compose.ui.input.key.KeyEvent import com.abdownloadmanager.shared.util.PlatformKeyStroke import com.abdownloadmanager.shared.util.ShortcutManager import java.awt.Toolkit import java.awt.event.InputEvent import javax.swing.KeyStroke class DesktopShortcutManager : ShortcutManager() { override fun stringToKeyStroke(keyStrokeString: String): PlatformKeyStroke { return KeyStroke .getKeyStroke(keyStrokeString) .asPlatformKeyStroke() } override fun getKeyStrokeFromEvent(s: KeyEvent): PlatformKeyStroke? { val awtEvent = s.awtEventOrNull ?: return null return runCatching { KeyStroke.getKeyStrokeForEvent(awtEvent) }.getOrNull()?.asPlatformKeyStroke() } override fun getKeyStrokeFromKeyCode(keyCode: Int): PlatformKeyStroke? { return runCatching { KeyStroke.getKeyStroke(keyCode, 0) }.getOrNull()?.asPlatformKeyStroke() } } data class DesktopKeyStroke( val awtKeyStroke: KeyStroke, ) : PlatformKeyStroke { override val keyCode: Int get() = awtKeyStroke.keyCode override fun getModifiers(): List { return KeyUtil.getModifiers(awtKeyStroke.modifiers) } override fun getKeyText(): String { return KeyUtil.getKeyText(awtKeyStroke.keyCode) } } fun KeyStroke.asPlatformKeyStroke() = DesktopKeyStroke(this) object KeyUtil { fun getKeyText(keyCode: Int): String { return java.awt.event.KeyEvent.getKeyText(keyCode) } fun getModifiers(modifiers: Int): List { return buildList { if (modifiers and InputEvent.META_DOWN_MASK != 0) { add(Toolkit.getProperty("AWT.meta", "Meta")) } if (modifiers and InputEvent.CTRL_DOWN_MASK != 0) { add(Toolkit.getProperty("AWT.control", "Ctrl")) } if (modifiers and InputEvent.ALT_DOWN_MASK != 0) { add(Toolkit.getProperty("AWT.alt", "Alt")) } if (modifiers and InputEvent.SHIFT_DOWN_MASK != 0) { add(Toolkit.getProperty("AWT.shift", "Shift")) } if (modifiers and InputEvent.ALT_GRAPH_DOWN_MASK != 0) { add(Toolkit.getProperty("AWT.altGraph", "Alt Graph")) } } } } ================================================ FILE: desktop/app/src/main/kotlin/com/abdownloadmanager/desktop/utils/GlobalAppExceptionHandler.kt ================================================ package com.abdownloadmanager.desktop.utils import com.abdownloadmanager.desktop.ui.error.ErrorWindow import com.abdownloadmanager.shared.ui.theme.ABDownloaderTheme import androidx.compose.runtime.Composable import androidx.compose.runtime.CompositionLocalProvider import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.collectAsState import androidx.compose.ui.window.* import com.abdownloadmanager.shared.ui.theme.ThemeManager import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.update import java.awt.Window import java.awt.event.WindowEvent import java.lang.Thread.UncaughtExceptionHandler import java.util.concurrent.atomic.AtomicBoolean import kotlin.concurrent.thread import kotlin.system.exitProcess interface GlobalAppExceptionHandler : UncaughtExceptionHandler, WindowExceptionHandlerFactory { @Composable fun content() /** * tell handler to exit app after crash, this app is useless */ fun onProcessIsUseless() } private class GlobalExceptionHandlerImpl : GlobalAppExceptionHandler { private val isRenderedInAppScope = MutableStateFlow(false) private var exitProcessOnClose = AtomicBoolean(false) override fun onProcessIsUseless() { exitProcessOnClose.set(true) } private fun shouldExitAppInsteadOfClose(): Boolean { return exitProcessOnClose.get() } private fun showErrorInUi(throwable: Throwable) { if (isRenderedInAppScope.value) { showErrorInCurrentApplicationScope(throwable) } else { showErrorInNewApplicationScope(throwable) } } val activeThrowableList = MutableStateFlow(emptyList()) private fun showErrorInCurrentApplicationScope(throwable: Throwable) { activeThrowableList.update { it + throwable } } private fun showErrorInNewApplicationScope(throwable: Throwable) { kotlin.runCatching { application(exitProcessOnExit = false) { val close = { if (shouldExitAppInsteadOfClose()) { exitProcess(0) } else { exitApplication() } } ABDownloaderTheme( ThemeManager.DefaultTheme, ) { ErrorWindow(throwable, close) } } }.onFailure { println("We have error in Global Exception Handler") it.printStackTrace() } } @Composable override fun content() { DisposableEffect(Unit) { isRenderedInAppScope.update { true } onDispose { isRenderedInAppScope.update { false } } } val list = activeThrowableList.collectAsState().value for (throwable in list) { ErrorWindow( throwable = throwable, close = { activeThrowableList.update { it - throwable } if (activeThrowableList.value.isEmpty()) { if (shouldExitAppInsteadOfClose()) { exitProcess(0) } } } ) } } private fun showErrorInConsole(thread: Thread, e: Throwable) { val output = System.err output.println("""Exception in thread "${thread.name}" ${e::class.qualifiedName}""") e.printStackTrace(output) } private fun showErrorInConsole(window: Window, throwable: Throwable) { val output = System.err output.println("""Exception in windows $window ,${throwable::class.qualifiedName}""") throwable.printStackTrace(output) } override fun uncaughtException(t: Thread, e: Throwable) { showErrorInConsole(t, e) showErrorInUi(e) } override fun exceptionHandler(window: Window): WindowExceptionHandler { return WindowExceptionHandler { thread { showErrorInConsole(window, it) showErrorInUi(it) //await exit window.dispatchEvent(WindowEvent(window, WindowEvent.WINDOW_CLOSING)) } } } } @Composable fun ProvideGlobalExceptionHandler( eh: GlobalAppExceptionHandler, content: @Composable () -> Unit, ) { CompositionLocalProvider(LocalWindowExceptionHandlerFactory provides eh) { content() eh.content() } } fun createAndSetGlobalExceptionHandler(): GlobalAppExceptionHandler { val handler = GlobalExceptionHandlerImpl() Thread.setDefaultUncaughtExceptionHandler(handler) return handler } ================================================ FILE: desktop/app/src/main/kotlin/com/abdownloadmanager/desktop/utils/IntegrationUtil.kt ================================================ package com.abdownloadmanager.desktop.utils import java.io.File import kotlin.concurrent.thread object IntegrationPortBroadcaster { const val INTEGRATION_DISABLED=-1 const val INTEGRATION_UNKNOWN=-2 private var requestedToCleanOnClose = false /*fun cleanOnClose() { if (requestedToCleanOnClose) return Runtime.getRuntime().addShutdownHook( thread( start = false ) { setIntegrationPortInFile(null) } ) }*/ // private val portFIle get() = File(AppProperties.getConfigDirectory(), "integration.port") private var port:Int=INTEGRATION_UNKNOWN fun isInitialized(): Boolean { return port!=INTEGRATION_UNKNOWN } fun setIntegrationPortInFile(portNumber: Int?) { port = portNumber ?: INTEGRATION_DISABLED } fun getIntegrationPort(): Int { return port } } ================================================ FILE: desktop/app/src/main/kotlin/com/abdownloadmanager/desktop/utils/KeepAwakeManager.kt ================================================ package com.abdownloadmanager.desktop.utils import com.abdownloadmanager.shared.util.DownloadSystem import ir.amirab.util.desktop.keepawake.KeepAwake import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Job import kotlinx.coroutines.flow.distinctUntilChanged import kotlinx.coroutines.flow.launchIn import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.onEach class KeepAwakeManager( private val keepAwake: KeepAwake, private val downloadSystem: DownloadSystem, private val scope: CoroutineScope, ) { var job: Job? = null fun boot() { enable() } @Synchronized fun enable() { job?.cancel() job = downloadSystem.downloadMonitor .activeDownloadCount .map { it > 0 } .distinctUntilChanged() .onEach { isDownloadsActive -> if (isDownloadsActive) { keepAwake.keepAwake() } else { keepAwake.allowSleep() } }.launchIn(scope) } @Synchronized fun disable() { job?.cancel() } } ================================================ FILE: desktop/app/src/main/kotlin/com/abdownloadmanager/desktop/utils/OSFileIconProvider.kt ================================================ package com.abdownloadmanager.desktop.utils import androidx.compose.runtime.Composable import androidx.compose.ui.graphics.ImageBitmap import androidx.compose.ui.graphics.painter.BitmapPainter import androidx.compose.ui.graphics.toComposeImageBitmap import com.abdownloadmanager.shared.util.FileIconProvider import com.abdownloadmanager.shared.util.ui.IMyIcons import ir.amirab.util.compose.IconSource import java.awt.image.BufferedImage import java.io.File import javax.swing.filechooser.FileSystemView class OSFileIconProvider( private val icons: IMyIcons ) : FileIconProvider { private val registeredIcons = mutableMapOf() private val lock = Any() private fun getIconOfFileExtension( extension: String, ): ImageBitmap? { val imageBitmap = registeredIcons[extension] if (imageBitmap != null) { return imageBitmap } else { synchronized(lock) { val bitmapFoundInSync = registeredIcons[extension] if (bitmapFoundInSync != null) { return bitmapFoundInSync } val w = 24 val h = 24 return runCatching { val fileSystemView = FileSystemView.getFileSystemView() val file = File.createTempFile("file", "file.$extension") val icon = fileSystemView.getSystemIcon(file, w, h) bufferedImageToImageBitmap(iconToImage(icon)) }.onSuccess { registeredIcons[extension] = it }.getOrNull() } } } private fun iconToImage(icon: javax.swing.Icon): BufferedImage { val width = icon.iconWidth val height = icon.iconHeight val image = BufferedImage(width, height, BufferedImage.TYPE_INT_ARGB) val graphics = image.createGraphics() icon.paintIcon(null, graphics, 0, 0) graphics.dispose() return image } private fun bufferedImageToImageBitmap(bufferedImage: BufferedImage): ImageBitmap { return bufferedImage.toComposeImageBitmap() } override fun getIcon(fileName: String): IconSource { val extension = fileName.substringAfterLast('.', "") val imageBitmap = getIconOfFileExtension(extension) ?: return icons.file return IconSource.PainterIconSource( BitmapPainter(imageBitmap), false ) } @Composable override fun rememberIcon(fileName: String): IconSource { return getIcon(fileName) } } ================================================ FILE: desktop/app/src/main/kotlin/com/abdownloadmanager/desktop/utils/PortableUtil.kt ================================================ package com.abdownloadmanager.desktop.utils import com.abdownloadmanager.shared.util.SharedConstants import java.io.File object PortableUtil { fun getPortableDataDir(installationFolder: String?): File? { if (installationFolder != null) { getDefaultPortableFolder(installationFolder) ?.let { return it } getCustomPortableFolder(installationFolder) ?.let { return it } } return null } private const val PORTABLE_FILE_NAME = ".portable" private fun getDefaultPortableFolder( installationFolder: String ): File? { val dataDirName = SharedConstants.dataDirName val portableDataDir = File(installationFolder, dataDirName) return portableDataDir.takeIf { it.exists() && it.canWrite() } } private fun getCustomPortableFolder( installationFolder: String, ): File? { val portableFile = File(installationFolder, PORTABLE_FILE_NAME) .takeIf { it.exists() && it.canRead() } ?: return null try { val customPortableDirText = portableFile .readText() .trim() .takeIf { it.isNotEmpty() } ?: error("$PORTABLE_FILE_NAME file is empty") val absoluteFile = getAbsoluteFile( baseFile = installationFolder, maybeRelative = customPortableDirText ) // make sure it can be used return absoluteFile.canonicalFile } catch (e: Exception) { System.err.println("getting custom portable path failed") e.printStackTrace() return null } } private fun getAbsoluteFile( baseFile: String, maybeRelative: String ): File { val file = File(maybeRelative) return if (file.isAbsolute) { file } else { File(baseFile, maybeRelative) } } } ================================================ FILE: desktop/app/src/main/kotlin/com/abdownloadmanager/desktop/utils/native_messaging/NativeMessaging.kt ================================================ package com.abdownloadmanager.desktop.utils.native_messaging import com.abdownloadmanager.desktop.utils.AppInfo import com.abdownloadmanager.desktop.utils.isAppInstalled import kotlinx.serialization.SerialName import kotlinx.serialization.Serializable data class NativeMessagingManifests( val firefoxNativeMessagingManifest: FirefoxNativeMessagingManifest, val chromeNativeMessagingManifest: ChromeNativeMessagingManifest, ) @Serializable data class FirefoxNativeMessagingManifest( @SerialName("name") val name: String, @SerialName("description") val description: String, @SerialName("path") val path: String, @SerialName("type") val type: String, @SerialName("allowed_extensions") val allowedExtensions: List, ) @Serializable data class ChromeNativeMessagingManifest( @SerialName("name") val name: String, @SerialName("description") val description: String, @SerialName("path") val path: String, @SerialName("type") val type: String, @SerialName("allowed_origins") val allowedOrigins: List, ) class NativeMessaging( private val nativeMessagingManifestApplier: NativeMessagingManifestApplier, ) { fun boot(){ installManifests() } fun installManifests() { val firefox = createFirefoxManifest() val chrome = createChromeManifest() if (chrome!=null && firefox!=null){ nativeMessagingManifestApplier.updateManifests( NativeMessagingManifests( firefoxNativeMessagingManifest = firefox, chromeNativeMessagingManifest = chrome, ) ) } } private fun createFirefoxManifest(): FirefoxNativeMessagingManifest? { if (!AppInfo.isAppInstalled()) return null val execFile = AppInfo.exeFile!! return FirefoxNativeMessagingManifest( name = AppInfo.displayName, description = AppInfo.displayName, path = execFile, type = "stdio", allowedExtensions = listOf( "" ) ) } private fun createChromeManifest(): ChromeNativeMessagingManifest? { if (!AppInfo.isAppInstalled()) return null val execFile = AppInfo.exeFile!! return ChromeNativeMessagingManifest( name = AppInfo.displayName, description = AppInfo.displayName, path = execFile, type = "stdio", allowedOrigins = listOf( "" ) ) } } ================================================ FILE: desktop/app/src/main/kotlin/com/abdownloadmanager/desktop/utils/native_messaging/NativeMessagingManifestApplier.kt ================================================ package com.abdownloadmanager.desktop.utils.native_messaging import com.abdownloadmanager.desktop.utils.AppInfo import com.abdownloadmanager.desktop.utils.AppProperties import com.abdownloadmanager.desktop.utils.isAppInstalled import ir.amirab.util.createParentDirectories import ir.amirab.util.deleteIfExists import ir.amirab.util.platform.Platform import ir.amirab.util.desktop.WindowsRegistry import ir.amirab.util.writeText import kotlinx.serialization.json.Json import org.koin.core.component.KoinComponent import org.koin.core.component.inject import kotlin.io.path.* abstract class NativeMessagingManifestApplier : KoinComponent { protected val json by inject() protected inline fun serialize(data: T): String { return json.encodeToString(data) } protected inline fun deserialize(string: String): T { return json.decodeFromString(string) } abstract fun updateManifests(manifests: NativeMessagingManifests) abstract fun removeManifests() companion object { fun getForCurrentPlatform(): NativeMessagingManifestApplier { if (!AppInfo.isAppInstalled()){ return NoOpNativeMessagingApplier() } return when(AppInfo.platform){ Platform.Desktop.Linux -> LinuxNativeMessagingManifestApplier() Platform.Desktop.MacOS -> MacosNativeMessagingManifestApplier() Platform.Desktop.Windows -> WindowsNativeMessagingManifestApplier() Platform.Android -> error("there is no native messaging for android so this code should never used in android") } } } } class WindowsNativeMessagingManifestApplier : NativeMessagingManifestApplier() { private val baseNativeMessagingDir get() = AppInfo.definedPaths.configDir / "native-messaging" private val firefoxManifestFile get() = baseNativeMessagingDir / "firefox-native-messaging-manifest.json" private val chromeManifestFile get() = baseNativeMessagingDir / "chrome-native-messaging-manifest.json" private val firefoxRegistryPath get() = "HKCU\\SOFTWARE\\Mozilla\\NativeMessagingHosts\\${AppInfo.packageName}" private val chromeRegistryPath get() = "HKCU\\SOFTWARE\\Google\\Chrome\\NativeMessagingHosts\\${AppInfo.packageName}" override fun updateManifests( manifests: NativeMessagingManifests ) { listOf( firefoxManifestFile, chromeManifestFile, ).forEach { it.createParentDirectories() } firefoxManifestFile.writeText(serialize(manifests.firefoxNativeMessagingManifest)) WindowsRegistry.setValueInRegistry( path = firefoxRegistryPath, key = null, value = firefoxManifestFile.toString() ) chromeManifestFile.writeText(serialize(manifests.chromeNativeMessagingManifest)) WindowsRegistry.setValueInRegistry( path = chromeRegistryPath, key = null, value = chromeManifestFile.toString() ) } override fun removeManifests() { firefoxManifestFile.deleteIfExists() WindowsRegistry.removePathInRegistry( path = firefoxRegistryPath, ) chromeManifestFile.deleteIfExists() WindowsRegistry.removePathInRegistry( path = chromeRegistryPath, ) } } class MacosNativeMessagingManifestApplier : NativeMessagingManifestApplier() { private val firefoxNativeMessagingPath get() = Path(AppProperties.userDir, "Library/Application Support/Mozilla/NativeMessagingHosts", "${AppInfo.packageName}.json" ) private val chromeNativeMessagingPath get() = Path(AppProperties.userDir, "Library/Application Support/Google/Chrome/NativeMessagingHosts", "${AppInfo.packageName}.json" ) private val chromiumNativeMessagingPath get() = Path(AppProperties.userDir, "Library/Application Support/Chromium/NativeMessagingHosts", "${AppInfo.packageName}.json" ) override fun updateManifests(manifests: NativeMessagingManifests) { listOf( firefoxNativeMessagingPath, chromeNativeMessagingPath, chromiumNativeMessagingPath ).forEach { it.createParentDirectories() } firefoxNativeMessagingPath.writeText(serialize(manifests.firefoxNativeMessagingManifest)) val chromeManifestString=serialize(manifests.chromeNativeMessagingManifest) chromeNativeMessagingPath.writeText(chromeManifestString) chromiumNativeMessagingPath.writeText(chromeManifestString) } override fun removeManifests() { firefoxNativeMessagingPath.deleteIfExists() chromeNativeMessagingPath.deleteIfExists() chromiumNativeMessagingPath.deleteIfExists() } } class LinuxNativeMessagingManifestApplier : NativeMessagingManifestApplier() { private val firefoxNativeMessagingPath get() = Path(AppProperties.userDir, ".mozilla/native-messaging-hosts", "${AppInfo.packageName}.json") private val chromeNativeMessagingPath get() = Path(AppProperties.userDir, ".config/google-chrome/NativeMessagingHosts", "${AppInfo.packageName}.json") private val chromiumNativeMessagingPath get() = Path(AppProperties.userDir, ".config/chromium/NativeMessagingHosts", "${AppInfo.packageName}.json") override fun updateManifests(manifests: NativeMessagingManifests) { listOf( firefoxNativeMessagingPath, chromeNativeMessagingPath, chromiumNativeMessagingPath ).forEach { it.createParentDirectories() } firefoxNativeMessagingPath.writeText(serialize(manifests.firefoxNativeMessagingManifest)) val chromeManifestString = serialize(manifests.chromeNativeMessagingManifest) chromeNativeMessagingPath.writeText(chromeManifestString) chromiumNativeMessagingPath.writeText(chromeManifestString) } override fun removeManifests() { firefoxNativeMessagingPath.deleteIfExists() chromeNativeMessagingPath.deleteIfExists() chromiumNativeMessagingPath.deleteIfExists() } } class NoOpNativeMessagingApplier : NativeMessagingManifestApplier() { override fun updateManifests(manifests: NativeMessagingManifests) { //no-op } override fun removeManifests() { //no-op } } ================================================ FILE: desktop/app/src/main/kotlin/com/abdownloadmanager/desktop/utils/proxy/AutoConfigurableProxyProviderForDesktop.kt ================================================ package com.abdownloadmanager.desktop.utils.proxy import com.github.markusbernhardt.proxy.selector.misc.BufferedProxySelector import com.github.markusbernhardt.proxy.selector.misc.ProxyListFallbackSelector import com.github.markusbernhardt.proxy.selector.pac.PacProxySelector import com.github.markusbernhardt.proxy.selector.pac.UrlPacScriptSource import ir.amirab.downloader.connection.proxy.AutoConfigurableProxyProvider import java.net.ProxySelector class AutoConfigurableProxyProviderForDesktop( private val proxyCachingConfig: ProxyCachingConfig ) : AutoConfigurableProxyProvider { @Volatile private var packProxySelector: ProxySelector? = null @Volatile private var lastUsedUri: String? = null override fun getAutoConfigurableProxy(uri: String): ProxySelector? { if (lastUsedUri == uri) { val o = packProxySelector return o ?: createAndInitializePacProxySelector(uri) } else { return createAndInitializePacProxySelector(uri) } } private fun createAndInitializePacProxySelector(uri: String): ProxySelector { synchronized(this) { val s = installBufferingAndFallbackBehaviour(PacProxySelector(UrlPacScriptSource(uri))) lastUsedUri = uri packProxySelector = s return s } } private fun installBufferingAndFallbackBehaviour(selector: ProxySelector): ProxySelector { var selector = selector if (selector is PacProxySelector) { if (proxyCachingConfig.pacCacheSize > 0) { selector = BufferedProxySelector( proxyCachingConfig.pacCacheSize, proxyCachingConfig.pacCacheTTL, selector, proxyCachingConfig.pacCacheScope ) } selector = ProxyListFallbackSelector(selector) } return selector } } ================================================ FILE: desktop/app/src/main/kotlin/com/abdownloadmanager/desktop/utils/proxy/DesktopSystemProxySelectorProvider.kt ================================================ package com.abdownloadmanager.desktop.utils.proxy import com.github.markusbernhardt.proxy.ProxySearch import ir.amirab.downloader.connection.proxy.SystemProxySelectorProvider import java.net.ProxySelector class DesktopSystemProxySelectorProvider( private val proxyCachingConfig: ProxyCachingConfig ) : SystemProxySelectorProvider { private val proxySearch by lazy(LazyThreadSafetyMode.SYNCHRONIZED) { createProxySearch() } private fun createProxySearch(): ProxySearch { return ProxySearch.getDefaultProxySearch().apply { setPacCacheSettings( proxyCachingConfig.pacCacheSize, proxyCachingConfig.pacCacheTTL, proxyCachingConfig.pacCacheScope ) } } override fun getSystemProxySelector(): ProxySelector? { return proxySearch.proxySelector } } ================================================ FILE: desktop/app/src/main/kotlin/com/abdownloadmanager/desktop/utils/proxy/ProxyCachingConfig.kt ================================================ package com.abdownloadmanager.desktop.utils.proxy import com.github.markusbernhardt.proxy.selector.misc.BufferedProxySelector class ProxyCachingConfig( val pacCacheSize: Int, val pacCacheTTL: Long, val pacCacheScope: BufferedProxySelector.CacheScope ) { companion object { fun default() = ProxyCachingConfig( pacCacheSize = 10, pacCacheTTL = 60 * 60 * 1000, pacCacheScope = BufferedProxySelector.CacheScope.CACHE_SCOPE_URL ) } } ================================================ FILE: desktop/app/src/main/kotlin/com/abdownloadmanager/desktop/utils/renderapi/RenderApi.kt ================================================ package com.abdownloadmanager.desktop.utils.renderapi import com.abdownloadmanager.shared.util.BaseStorage import ir.amirab.util.createParentDirectories import ir.amirab.util.deleteIfExists import ir.amirab.util.platform.Arch import ir.amirab.util.platform.Platform import ir.amirab.util.readText import ir.amirab.util.writeText import kotlinx.coroutines.flow.MutableStateFlow import okio.Path /** * this class provides these features * - override the default render api on some platforms * - save/restore user selected render api * ### Note * [boot] must be called before any compose UI logic. * @param file the file path the to save the renderApi backend */ class CustomRenderApi( private val file: Path ) : BaseStorage() { fun boot() { startPersistData() val customRenderApiRequested = System.getenv("SKIKO_RENDER_API") != null || System.getProperty("skiko.renderApi") != null if (!customRenderApiRequested) { val renderApi = getSavedValueIfSupported() ?: getOurDefaultRenderApi() if (renderApi != null) { System.setProperty("skiko.renderApi", renderApi.name) } } } private fun getSavedValueIfSupported(): RenderApi? { return get()?.takeIf { isRenderApiSupportedInThisPlatform(it) } } private fun get(): RenderApi? { return this.data.value } private fun getOurDefaultRenderApi(): RenderApi? { return when (Platform.getCurrentPlatform()) { Platform.Desktop.Linux -> RenderApi.SOFTWARE Platform.Desktop.Windows -> { val arch = Arch.getCurrentArch() when (arch) { Arch.X64 -> RenderApi.OPENGL Arch.Arm64 -> RenderApi.ANGLE else -> null } } else -> null } } private fun isRenderApiSupportedInThisPlatform(renderApi: RenderApi): Boolean { return getSupportedRenderApiForThisPlatform().contains(renderApi) } fun getSupportedRenderApiForThisPlatform(): List { return supportedRenderApisPerPlatform.getOrDefault(Platform.getCurrentPlatform(), emptyList()) } private val supportedRenderApisPerPlatform = mapOf( Platform.Desktop.Windows to listOf( RenderApi.DIRECT3D, RenderApi.OPENGL, RenderApi.ANGLE, RenderApi.SOFTWARE, ), Platform.Desktop.Linux to listOf( RenderApi.OPENGL, RenderApi.SOFTWARE, ), Platform.Desktop.MacOS to listOf( RenderApi.METAL, RenderApi.OPENGL, RenderApi.SOFTWARE, ), ) override val inMemoryState: MutableStateFlow = MutableStateFlow( runCatching { RenderApi.valueOf(file.readText()) }.getOrNull() ) override suspend fun saveData(data: RenderApi?) { runCatching { if (data != null) { file.createParentDirectories() file.writeText(data.name) } else { file.deleteIfExists() } } } } enum class RenderApi(val prettyName: String) { SOFTWARE("Software"), DIRECT3D("DirectX"), METAL("Metal"), OPENGL("Open GL"), ANGLE("ANGLE"), } ================================================ FILE: desktop/app/src/main/kotlin/com/abdownloadmanager/desktop/utils/singleInstance/Comunication.kt ================================================ package com.abdownloadmanager.desktop.utils.singleInstance import kotlinx.serialization.KSerializer import kotlinx.serialization.json.Json import kotlinx.serialization.serializer import org.http4k.client.OkHttp import org.http4k.core.* import org.http4k.lens.BiDiBodyLensSpec import org.http4k.lens.string import org.http4k.routing.RoutingHttpHandler import org.http4k.routing.bind import kotlin.reflect.KType import kotlin.reflect.typeOf inline fun Command(name: String):Command{ return Command(name, typeOf()) } class Command(val name: String, val type: KType) sealed class CommandResult { override fun toString(): String { return this::class.simpleName!! } fun orElse(block:(Error)->T):T{ return when(this){ is Success->value else ->block(this as Error) } } fun fold( onError:(Error)->R, onSuccess:(Success)->R, ):R{ return when(this){ is Error -> { onError(this) } is Success -> { onSuccess(this) } } } data class Success(val value: T) : CommandResult() open class Error(val value: String) : CommandResult(){ override fun toString(): String { val name= super.toString() return """ $name , $value """.trimIndent() } } class ServerNotExists : Error("server not exists") class ClientError(value: String) : Error("client error $value") class ServerError(code: Int, message: String, body: String) : Error(""" server error statusCode: $code , message: $message body: $body """.trimIndent() ) } infix fun Command.bindSafe( handle: (Request) -> T, ): RoutingHttpHandler { return name bind { Response(Status.OK) .with(json(handle(it),type)) } } private val client by lazy { OkHttp() } internal fun typeSafeRequest( port:Int, command: Command ): CommandResult { val autoBody = Body.auto(command.type).toLens() val request = Request( method = Method.GET, uri = Uri.of("http://localhost:$port/${command.name}"), ) val response = try { client(request) } catch (e: Exception) { return CommandResult.ClientError(e.localizedMessage) } return if (response.status.successful) { try { CommandResult.Success(autoBody(response)) } catch (e: Exception) { CommandResult.Error(e.localizedMessage) } } else { CommandResult.ServerError( response.status.code, response.status.description, response.bodyString(), ) } } private val json by lazy { Json } fun toJson(data: T, serializer: KSerializer): String { return json.encodeToString(serializer, data) } fun fromJson(string: String, serializer: KSerializer): T { return json.decodeFromString(serializer, string) } inline fun toJson(data: T): String { return toJson(data, serializer()) } inline fun fromJson(string: String): T { return fromJson(string, serializer()) } inline fun Body.Companion.auto(): BiDiBodyLensSpec { return Body.string(ContentType.APPLICATION_JSON) .map( nextIn = { fromJson(it) }, nextOut = { toJson(it) }, ) } //unchecked auto!! fun Body.Companion.auto(kType: KType): BiDiBodyLensSpec { val serializer = serializer(kType) as KSerializer return Body.string(ContentType.APPLICATION_JSON) .map( nextIn = { fromJson(it, serializer) }, nextOut = { toJson(it, serializer) }, ) } inline fun json(data: T): (Response) -> Response { return Body.auto().toLens() of data } fun json(data: T,type: KType): (Response) -> Response { return Body.auto(type).toLens() of data } ================================================ FILE: desktop/app/src/main/kotlin/com/abdownloadmanager/desktop/utils/singleInstance/MutableSingleInstanceServerHandler.kt ================================================ package com.abdownloadmanager.desktop.utils.singleInstance import org.http4k.core.HttpHandler import org.http4k.core.Response import org.http4k.core.Status import org.http4k.routing.bind import org.http4k.routing.orElse import org.http4k.routing.routes class MutableSingleInstanceServerHandler : SingleInstanceServerHandler { private val handlers = mutableListOf, () -> Any>>() private var mainHandler = createRoutes() private fun createRoutes(): HttpHandler { val handlersArray = handlers.map { (cmd, handler) -> cmd bindSafe { handler() } }.toTypedArray() return routes( *handlersArray, // add this since empty routes will crash orElse bind { Response(Status.NOT_FOUND) } ) } override val handler: HttpHandler get() = mainHandler fun updateRoutes() { mainHandler = createRoutes() } fun add(command: Command, handle: () -> T) { synchronized(this) { val element = (command to handle) as Pair, () -> Any> handlers.add(element) updateRoutes() } } fun remove(command: Command) { synchronized(this) { handlers.removeIf { it.first == command } updateRoutes() } } } ================================================ FILE: desktop/app/src/main/kotlin/com/abdownloadmanager/desktop/utils/singleInstance/SingleAppInstanceLocker.kt ================================================ package com.abdownloadmanager.desktop.utils.singleInstance import okio.Path import java.io.RandomAccessFile import kotlin.concurrent.thread import kotlin.io.path.createParentDirectories class SingleAppInstanceLocker( private val lockPath: Path ) { private fun getLockPath(): Path { return lockPath } /** * @throws AnotherInstanceIsRunning */ fun tryLockInstance() { val file = getLockPath() .also { it.toNioPath().createParentDirectories() } .toFile() val raf = RandomAccessFile(file, "rw") val lock = kotlin .runCatching { raf.channel.tryLock() } .getOrElse { null } if (lock != null) { //we get a lock Runtime .getRuntime() .addShutdownHook(thread(start = false) { lock.release() raf.close() file.delete() }) return } else { throw AnotherInstanceIsRunning() } } } class AnotherInstanceIsRunning( ) : RuntimeException("Another Instance Is Running") ================================================ FILE: desktop/app/src/main/kotlin/com/abdownloadmanager/desktop/utils/singleInstance/SingleInstanceServer.kt ================================================ package com.abdownloadmanager.desktop.utils.singleInstance import ir.amirab.util.http4k.NanoHttp import okio.* import org.http4k.core.HttpHandler import org.http4k.core.Request import org.http4k.core.then import org.http4k.filter.ServerFilters import org.http4k.server.asServer import kotlin.concurrent.thread import kotlin.io.path.exists import kotlin.io.path.readText class SingleInstanceServer( private val portPath: Path ) { private var serverInfo: ServerInfo? = null fun start(handle: HttpHandler) { val server = createServer(handle) server.start() portPath.toFile().writeText(server.getPort().toString()) Runtime .getRuntime() .addShutdownHook(thread(start = false) { stop() }) } fun stop() { portPath.toFile().delete() serverInfo?.let { it.stop() } } private fun createServer(handle: HttpHandler): ServerInfo { val middlewares=ServerFilters.CatchAll.invoke{ it.printStackTrace() ServerFilters.CatchAll.originalBehaviour(it) } val appRoutes = {req:Request-> // println("new request $req") handle(req) } val server = middlewares .then(appRoutes) .asServer(NanoHttp("localhost",0)) return ServerInfo( getPort = { server.port() }, start = { server.start() }, stop = { server.stop() }, ) } fun sendMessage(message: Command): CommandResult { val port = portPath .toNioPath() .takeIf { it.exists() } ?.runCatching { readText() } ?.getOrNull() ?.toIntOrNull() ?: return CommandResult.ServerNotExists() return typeSafeRequest(port, message) } } private data class ServerInfo( val getPort: ()->Int, val start: () -> Unit, val stop: () -> Unit, ) ================================================ FILE: desktop/app/src/main/kotlin/com/abdownloadmanager/desktop/utils/singleInstance/SingleInstanceServerHandler.kt ================================================ package com.abdownloadmanager.desktop.utils.singleInstance import org.http4k.core.HttpHandler import org.http4k.core.Request import org.http4k.core.Response interface SingleInstanceServerHandler : HttpHandler { val handler: HttpHandler override fun invoke(request: Request): Response { return handler(request) } } ================================================ FILE: desktop/app/src/main/kotlin/com/abdownloadmanager/desktop/utils/singleInstance/SingleInstanceUtil.kt ================================================ package com.abdownloadmanager.desktop.utils.singleInstance import okio.Path class SingleInstanceUtil(baseFolder: Path) { private val locker by lazy { SingleAppInstanceLocker(baseFolder / "app.lock") } private val server by lazy { SingleInstanceServer(baseFolder / "app.port") } fun sendToInstance(msg: Command): CommandResult { return server.sendMessage(msg).also { // println("server respond with ${it}") } } @Throws(AnotherInstanceIsRunning::class) fun lockInstance( createMessageHandler: () -> SingleInstanceServerHandler, ) { locker.tryLockInstance() // we are alone so we create the server server.start(createMessageHandler()) } } ================================================ FILE: desktop/app/src/main/resources/configs/app_default.properties ================================================ app.debug="false" ================================================ FILE: desktop/app-utils/build.gradle.kts ================================================ plugins { id(MyPlugins.kotlin) id(MyPlugins.composeDesktop) } dependencies { api(project(":desktop:shared")) api(project(":shared:app")) implementation(libs.jbrApi) } ================================================ FILE: desktop/app-utils/src/main/kotlin/com/abdownloadmanager/desktop/window/BrowserUtil.kt ================================================ package com.abdownloadmanager.desktop.window import com.abdownloadmanager.shared.util.BrowserType import ir.amirab.util.platform.Platform import ir.amirab.util.platform.asDesktop import ir.amirab.util.toUpUntil import java.io.File abstract class Browser { abstract fun getPossibleExecutablePaths(): List fun isInstalled(): Boolean { return getExecutablePath() != null } open fun openLink(url: String): Boolean { val executablePath = getExecutablePath() if (executablePath == null) { return false } val cmd = when (Platform.asDesktop()) { Platform.Desktop.Linux -> arrayOf(executablePath.path, url) Platform.Desktop.Windows -> arrayOf(executablePath.path, url) Platform.Desktop.MacOS -> { val appFolder = executablePath.toUpUntil { // in macOS app folders are like /Applications/App Name.app/ it.name.endsWith(".app") }?.path if (appFolder == null) { return false } arrayOf("open", "-a", appFolder, url) } } Runtime.getRuntime().exec(cmd) return true } fun getExecutablePath(): File? { return getPossibleExecutablePaths().firstOrNull { it.exists() } } companion object { fun getBrowserByType(type: BrowserType): Browser? { return when (type) { BrowserType.Chrome -> ChromeBrowser BrowserType.Firefox -> FirefoxBrowser BrowserType.Edge -> EdgeBrowser BrowserType.Opera -> OperaBrowser } } } } object FirefoxBrowser : Browser() { override fun getPossibleExecutablePaths(): List { return when (Platform.asDesktop()) { Platform.Desktop.Windows -> { val firefoxExe = "Mozilla Firefox\\firefox.exe" buildList { listOf( "ProgramW6432", "ProgramFiles", "ProgramFiles(x86)", "LOCALAPPDATA", ).forEach { System.getenv(it)?.let { path -> add(File(path, firefoxExe)) } } } } Platform.Desktop.MacOS -> { listOf( File("/Applications/Firefox.app"), ) } Platform.Desktop.Linux -> { listOf( File("/usr/bin/firefox"), File("/usr/bin/firefox-bin"), ) } } } } object ChromeBrowser : Browser() { override fun getPossibleExecutablePaths(): List { return when (Platform.asDesktop()) { Platform.Desktop.Windows -> { val chromeExe = "Google\\Chrome\\Application\\chrome.exe" buildList { listOf( "ProgramW6432", "PROGRAMFILES", "PROGRAMFILES(X86)", "LOCALAPPDATA", ).forEach { System.getenv(it)?.let { path -> add(File(path, chromeExe)) } } } } Platform.Desktop.MacOS -> { listOf( File("/Applications/Google Chrome.app") ) } Platform.Desktop.Linux -> { listOf( File("/usr/bin/google-chrome"), File("/usr/bin/chromium-browser"), File("/usr/bin/chromium") ) } } } } object EdgeBrowser : Browser() { override fun getPossibleExecutablePaths(): List { return when (Platform.asDesktop()) { Platform.Desktop.Linux -> { listOf( File("/usr/bin/microsoft-edge"), ) } Platform.Desktop.MacOS -> { listOf( File("/Applications/Microsoft Edge.app"), ) } Platform.Desktop.Windows -> { val child = "Microsoft\\Edge\\Application\\msedge.exe" buildList { listOf( "ProgramW6432", "PROGRAMFILES", "PROGRAMFILES(X86)", "LOCALAPPDATA", ).forEach { System.getenv(it)?.let { path -> add(File(path, child)) } } } } } } } object OperaBrowser : Browser() { override fun getPossibleExecutablePaths(): List { return when (Platform.asDesktop()) { Platform.Desktop.Windows -> { val relativeExe = "Opera\\launcher.exe" buildList { System.getenv("LOCALAPPDATA")?.let { path -> add(File(path, "Programs\\$relativeExe")) } listOf( "ProgramW6432", "PROGRAMFILES", "PROGRAMFILES(X86)", ).forEach { System.getenv(it)?.let { path -> add(File(path, relativeExe)) } } } } Platform.Desktop.MacOS -> { listOf( File("/Applications/Opera.app"), ) } Platform.Desktop.Linux -> { listOf( File("/usr/bin/opera"), ) } } } } ================================================ FILE: desktop/app-utils/src/main/kotlin/com/abdownloadmanager/desktop/window/custom/CustomWindow.kt ================================================ package com.abdownloadmanager.desktop.window.custom import com.abdownloadmanager.shared.util.ui.WithContentColor import ir.amirab.util.compose.IconSource import androidx.compose.foundation.background import androidx.compose.foundation.border import androidx.compose.foundation.gestures.awaitEachGesture import androidx.compose.foundation.gestures.awaitFirstDown import androidx.compose.foundation.gestures.waitForUpOrCancellation import androidx.compose.foundation.layout.* import androidx.compose.foundation.window.WindowDraggableArea import androidx.compose.runtime.* import androidx.compose.ui.Modifier import androidx.compose.ui.composed import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.RectangleShape import androidx.compose.ui.graphics.painter.Painter import androidx.compose.ui.graphics.takeOrElse import androidx.compose.ui.input.key.KeyEvent import androidx.compose.ui.input.pointer.* import androidx.compose.ui.layout.onSizeChanged import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.platform.LocalFocusManager import androidx.compose.ui.platform.LocalViewConfiguration import androidx.compose.ui.platform.LocalWindowInfo import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.DpSize import androidx.compose.ui.unit.dp import androidx.compose.ui.window.FrameWindowScope import androidx.compose.ui.window.Window import androidx.compose.ui.window.WindowPlacement import androidx.compose.ui.window.WindowState import com.abdownloadmanager.desktop.window.custom.titlebar.TitleBar import com.abdownloadmanager.shared.util.PopUpContainer import com.abdownloadmanager.shared.util.ResponsiveBox import com.abdownloadmanager.shared.util.ui.WithTitleBarDirection import com.abdownloadmanager.shared.util.ui.icon.MyIcons import com.abdownloadmanager.shared.util.ui.myColors import com.abdownloadmanager.shared.util.ui.theme.LocalUiScale import com.abdownloadmanager.shared.util.ui.theme.UiScaledContent import com.jetbrains.JBR import com.jetbrains.WindowDecorations import com.jetbrains.WindowMove import ir.amirab.util.desktop.LocalFrameWindowScope import com.abdownloadmanager.shared.ui.util.LocalWindow import ir.amirab.util.desktop.screen.applyUiScale import ir.amirab.util.ifThen import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.currentCoroutineContext import kotlinx.coroutines.isActive import kotlinx.coroutines.withContext import java.awt.event.ComponentAdapter import java.awt.event.ComponentEvent import java.awt.event.MouseEvent // a window frame which totally rendered with compose @Composable private fun FrameWindowScope.CustomWindowFrame( onRequestMinimize: (() -> Unit)?, onRequestClose: () -> Unit, onRequestToggleMaximize: (() -> Unit)?, title: String, titlePosition: TitlePosition, windowIcon: Painter? = null, background: Color, onBackground: Color, isLight: Boolean, start: (@Composable () -> Unit)?, end: (@Composable () -> Unit)?, content: @Composable () -> Unit, ) { // val borderColor = MaterialTheme.colors.surface WithContentColor(onBackground) { Column( Modifier .fillMaxSize() .ifThen(!JBR.isWindowDecorationsSupported()) { ifThen(isWindowFloating()) { val borderColor = if (isLight) Color.LightGray else Color.DarkGray border(1.dp, borderColor, RectangleShape) .padding(1.dp) } } .background(background) ) { WithTitleBarDirection { SnapDraggableToolbar( title = title, windowIcon = windowIcon, titlePosition = titlePosition, start = start, end = end, onRequestMinimize = onRequestMinimize, onRequestClose = onRequestClose, onRequestToggleMaximize = onRequestToggleMaximize ) } content() } } } @Composable fun isWindowFocused(): Boolean { return LocalWindowInfo.current.isWindowFocused } @Composable fun isWindowMaximized(): Boolean { return LocalWindowState.current.placement == WindowPlacement.Maximized } @Composable fun isWindowFloating(): Boolean { return LocalWindowState.current.placement == WindowPlacement.Floating } @Composable fun FrameWindowScope.SnapDraggableToolbar( title: String, windowIcon: Painter? = null, titlePosition: TitlePosition, start: (@Composable () -> Unit)?, end: (@Composable () -> Unit)?, onRequestMinimize: (() -> Unit)?, onRequestToggleMaximize: (() -> Unit)?, onRequestClose: () -> Unit, ) { val titleBar = TitleBar.getPlatformTitleBar() if (JBR.isWindowDecorationsSupported()) { val density = LocalDensity.current val uiScale = LocalUiScale.current fun computeHeaderHeight(height: Dp): Float { return height.value.applyUiScale(uiScale) } var headerHeight by remember { mutableStateOf(computeHeaderHeight(titleBar.titleBarHeight)) } val customTitleBar = remember { JBR.getWindowDecorations().createCustomTitleBar() } LaunchedEffect(headerHeight) { customTitleBar.height = headerHeight customTitleBar.putProperty("controls.visible", false) val previousPlacement = window.placement JBR.getWindowDecorations().setCustomTitleBar(window, customTitleBar) // JBR resets window placement to Floating so we should restore our placement // is there a better way? if (window.placement != previousPlacement) { withContext(Dispatchers.Main) { window.placement = previousPlacement } } } Box( Modifier .onSizeChanged { headerHeight = computeHeaderHeight( density.run { it.height.toDp() } ) } ) { Spacer( Modifier .matchParentSize() .customTitleBarMouseEventHandler(customTitleBar) ) FrameContent( titleBar = titleBar, modifier = Modifier, title = title, windowIcon = windowIcon, titlePosition = titlePosition, start = start, end = end, onRequestMinimize = onRequestMinimize, onRequestToggleMaximize = onRequestToggleMaximize, onRequestClose = onRequestClose ) } } else { SystemDraggableSection( onRequestToggleMaximize = onRequestToggleMaximize, ) { modifier -> FrameContent( titleBar = titleBar, modifier = modifier, title = title, windowIcon = windowIcon, titlePosition = titlePosition, start = start, end = end, onRequestMinimize = onRequestMinimize, onRequestToggleMaximize = onRequestToggleMaximize, onRequestClose = onRequestClose ) } } } @Composable internal fun FrameWindowScope.SystemDraggableSection( onRequestToggleMaximize: (() -> Unit)?, content: @Composable (Modifier) -> Unit ) { val windowMove: WindowMove? = JBR.getWindowMove() val viewConfig = LocalViewConfiguration.current var lastPress by remember { mutableStateOf(0L) } if (windowMove != null) { content( Modifier .onPointerEvent(PointerEventType.Press, PointerEventPass.Main) { if ( this.currentEvent.button == PointerButton.Primary && this.currentEvent.changes.any { changed -> !changed.isConsumed } ) { windowMove.startMovingTogetherWithMouse(window, MouseEvent.BUTTON1) if ( System.currentTimeMillis() - lastPress in viewConfig.doubleTapMinTimeMillis..viewConfig.doubleTapTimeoutMillis ) { onRequestToggleMaximize?.invoke() } lastPress = System.currentTimeMillis() } }, ) } else { WindowDraggableArea { content(Modifier) } } } internal fun Modifier.customTitleBarMouseEventHandler( titleBar: WindowDecorations.CustomTitleBar ): Modifier = pointerInput(Unit) { val currentContext = currentCoroutineContext() awaitPointerEventScope { var inUserControl = false while (currentContext.isActive) { val event = awaitPointerEvent(PointerEventPass.Main) event.changes.forEach { if (!it.isConsumed && !inUserControl) { titleBar.forceHitTest(false) } else { if (event.type == PointerEventType.Press) { inUserControl = true } if (event.type == PointerEventType.Release) { inUserControl = false } titleBar.forceHitTest(true) } } } } } @Composable private fun FrameWindowScope.getCurrentWindowSize(): DpSize { var windowSize by remember { mutableStateOf(DpSize(window.width.dp, window.height.dp)) } //observe window size DisposableEffect(window) { val listener = object : ComponentAdapter() { override fun componentResized(p0: ComponentEvent?) { windowSize = DpSize(window.width.dp, window.height.dp) } } window.addComponentListener(listener) onDispose { window.removeComponentListener(listener) } } return windowSize } @Composable private fun FrameContent( titleBar: TitleBar, modifier: Modifier, title: String, windowIcon: Painter? = null, titlePosition: TitlePosition, start: (@Composable () -> Unit)?, end: (@Composable () -> Unit)?, onRequestMinimize: (() -> Unit)?, onRequestToggleMaximize: (() -> Unit)?, onRequestClose: () -> Unit, ) { titleBar.RenderTitleBar( titleBar = titleBar, modifier = modifier .fillMaxWidth(), title = title, windowIcon = windowIcon, titlePosition = titlePosition, start = start, end = end, onRequestMinimize = onRequestMinimize, onRequestToggleMaximize = onRequestToggleMaximize, onRequestClose = onRequestClose, ) } private val defaultAppIcon: IconSource @Composable get() { return MyIcons.appIcon } private fun Color.toWindowColorType() = java.awt.Color( red, green, blue ) @Composable fun CustomWindow( state: WindowState, onCloseRequest: () -> Unit, resizable: Boolean = true, onRequestMinimize: (() -> Unit)? = { state.isMinimized = true }, onRequestToggleMaximize: (() -> Unit)? = { if (state.placement == WindowPlacement.Maximized) { state.placement = WindowPlacement.Floating } else { state.placement = WindowPlacement.Maximized } }, windowController: WindowController = remember { WindowController() }, onKeyEvent: (KeyEvent) -> Boolean = { false }, alwaysOnTop: Boolean = false, preventMinimize: Boolean = onRequestMinimize == null, content: @Composable FrameWindowScope.() -> Unit, ) { val start = windowController.start val end = windowController.end val title = windowController.title.orEmpty() val titlePosition = windowController.titlePosition val icon = windowController.icon ?: defaultAppIcon.rememberPainter() val undecorated: Boolean val isAeroSnapSupported = JBR.isWindowDecorationsSupported() if (isAeroSnapSupported) { //we use aero snap undecorated = false } else { //we decorate window and add our custom layout undecorated = true } Window( state = state, transparent = false, undecorated = undecorated, icon = icon, title = title, resizable = resizable, onCloseRequest = onCloseRequest, onKeyEvent = onKeyEvent, alwaysOnTop = alwaysOnTop, ) { val isLight = myColors.isLight val background = myColors.background LaunchedEffect(background) { withContext(Dispatchers.Main) { //I set window background fix window edge flickering on window resize window.background = background.takeOrElse { if (isLight) Color.White else Color.Black }.toWindowColorType() } } UiScaledContent { CompositionLocalProvider( LocalWindowController provides windowController, LocalWindowState provides state, LocalWindow provides window, LocalFrameWindowScope provides this ) { if (preventMinimize) { PreventMinimize() } // a window frame which totally rendered with compose CustomWindowFrame( onRequestMinimize = onRequestMinimize, onRequestClose = onCloseRequest, onRequestToggleMaximize = onRequestToggleMaximize, title = title, titlePosition = titlePosition, windowIcon = icon, background = background, onBackground = myColors.onBackground, isLight = isLight, start = start, end = end, ) { ResponsiveBox { Box(Modifier.clearFocusOnTap()) { PopUpContainer { content() } } } } } } } } @Composable private fun PreventMinimize() { val state = LocalWindowState.current LaunchedEffect(state.isMinimized) { if (state.isMinimized) { state.isMinimized = false } } } private fun Modifier.clearFocusOnTap(): Modifier = composed { val focusManager = LocalFocusManager.current Modifier.pointerInput(Unit) { awaitEachGesture { awaitFirstDown(pass = PointerEventPass.Main) val upEvent = waitForUpOrCancellation(pass = PointerEventPass.Main) if (upEvent != null) { focusManager.clearFocus() } } } } class WindowController( title: String? = null, icon: Painter? = null, ) { var title by mutableStateOf(title) var titlePosition by mutableStateOf(TitlePosition.default()) var icon by mutableStateOf(icon) var start: (@Composable () -> Unit)? by mutableStateOf(null) var end: (@Composable () -> Unit)? by mutableStateOf(null) } @Immutable data class TitlePosition( val centered: Boolean = false, val afterStart: Boolean = false, val padding: PaddingValues = PaddingValues(0.dp), ) { companion object { fun default() = TitlePosition() } } @Composable fun rememberWindowController( title: String? = null, icon: Painter? = null, ): WindowController { val controller = remember { WindowController( title, icon ) } LaunchedEffect(title) { controller.title = title } LaunchedEffect(icon) { controller.icon = icon } return controller } private val LocalWindowController = compositionLocalOf { error("window controller not provided") } private val LocalWindowState = compositionLocalOf { error("window controller not provided") } @Composable fun WindowStart(content: @Composable () -> Unit) { val c = LocalWindowController.current c.start = content DisposableEffect(Unit) { onDispose { c.start = null } } } @Composable fun WindowEnd(content: @Composable () -> Unit) { val c = LocalWindowController.current c.end = content DisposableEffect(Unit) { onDispose { c.end = null } } } @Composable fun WindowTitle(title: String) { val c = LocalWindowController.current LaunchedEffect(title) { c.title = title } DisposableEffect(Unit) { onDispose { c.title = null } } } @Composable fun WindowTitlePosition(titlePosition: TitlePosition) { val c = LocalWindowController.current LaunchedEffect(titlePosition) { c.titlePosition = titlePosition } DisposableEffect(Unit) { onDispose { c.titlePosition = TitlePosition.default() } } } @Composable fun WindowIcon(icon: IconSource) { WindowIcon(icon.rememberPainter()) } @Composable fun WindowIcon(icon: Painter) { val current = LocalWindowController.current DisposableEffect(icon) { current.let { it.icon = icon } onDispose { current.let { it.icon = null } } } } ================================================ FILE: desktop/app-utils/src/main/kotlin/com/abdownloadmanager/desktop/window/custom/DropDownTooltip.kt ================================================ package com.abdownloadmanager.desktop.window.custom import androidx.compose.runtime.Composable import androidx.compose.ui.Alignment import com.abdownloadmanager.resources.Res import com.abdownloadmanager.shared.ui.widget.Tooltip import ir.amirab.util.compose.StringSource import ir.amirab.util.compose.asStringSource @Composable private fun SystemButtonTooltip( stringSource: StringSource, content: @Composable () -> Unit, ) { Tooltip( tooltip = stringSource, anchor = Alignment.BottomCenter, alignment = Alignment.BottomCenter, content = content, ) } @Composable internal fun WindowCloseButtonTooltip( content: @Composable () -> Unit ) { SystemButtonTooltip( stringSource = Res.string.window_close.asStringSource(), ) { content() } } @Composable internal fun WindowToggleMaximizeTooltip( content: @Composable () -> Unit ) { SystemButtonTooltip( stringSource = if (isWindowMaximized()) { Res.string.window_restore } else { Res.string.window_maximize }.asStringSource(), ) { content() } } @Composable internal fun WindowMinimizeTooltip( content: @Composable () -> Unit ) { SystemButtonTooltip( stringSource = Res.string.window_minimize.asStringSource(), ) { content() } } ================================================ FILE: desktop/app-utils/src/main/kotlin/com/abdownloadmanager/desktop/window/custom/OptionsDialog.kt ================================================ package com.abdownloadmanager.desktop.window.custom import androidx.compose.runtime.* import androidx.compose.ui.window.* import com.abdownloadmanager.shared.util.ui.theme.UiScaledContent import java.awt.event.WindowEvent import java.awt.event.WindowFocusListener @Composable fun BaseOptionDialog( onCloseRequest: () -> Unit, state: DialogState = rememberDialogState(), resizeable: Boolean = true, content: @Composable WindowScope.() -> Unit, ) { DialogWindow( visible = true, state = state, decoration = WindowDecoration.Undecorated(), transparent = true, resizable = resizeable, //we need this to allow click outside modalityType = DialogModalityType.Modeless, onCloseRequest = onCloseRequest, ) { val focusListener = remember { object : WindowFocusListener { override fun windowGainedFocus(e: WindowEvent?) { //do nothing } override fun windowLostFocus(e: WindowEvent) { onCloseRequest() } } } DisposableEffect(window) { window.addWindowFocusListener(focusListener) window.isAlwaysOnTop = true onDispose { window.removeWindowFocusListener(focusListener) } } // window.subtractInset() UiScaledContent { content() } } } ================================================ FILE: desktop/app-utils/src/main/kotlin/com/abdownloadmanager/desktop/window/custom/titlebar/SystemButtonPositionProvider.kt ================================================ package com.abdownloadmanager.desktop.window.custom.titlebar interface SystemButtonPositionProvider { fun getPositions(): SystemButtonsPosition? } ================================================ FILE: desktop/app-utils/src/main/kotlin/com/abdownloadmanager/desktop/window/custom/titlebar/TitleBar.kt ================================================ package com.abdownloadmanager.desktop.window.custom.titlebar import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.painter.Painter import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp import com.abdownloadmanager.desktop.window.custom.TitlePosition import com.abdownloadmanager.desktop.window.custom.titlebar.linux.LinuxTitleBar import com.abdownloadmanager.desktop.window.custom.titlebar.mac.MacTitleBar import com.abdownloadmanager.desktop.window.custom.titlebar.windows.WindowsTitleBar import ir.amirab.util.platform.Platform import ir.amirab.util.platform.asDesktop interface TitleBar { val titleBarHeight: Dp val systemButtonsPosition: SystemButtonsPosition @Composable fun RenderSystemButtons( onRequestClose: () -> Unit, onRequestMinimize: (() -> Unit)?, onToggleMaximize: (() -> Unit)?, ) @Composable fun RenderTitleBarContent( title: String, titlePosition: TitlePosition, modifier: Modifier, windowIcon: Painter?, start: (@Composable () -> Unit)?, end: (@Composable () -> Unit)?, ) @Composable fun RenderTitleBar( modifier: Modifier, titleBar: TitleBar, title: String, windowIcon: Painter?, titlePosition: TitlePosition, start: (@Composable () -> Unit)?, end: (@Composable () -> Unit)?, onRequestClose: () -> Unit, onRequestMinimize: (() -> Unit)?, onRequestToggleMaximize: (() -> Unit)?, ) companion object { val DefaultTitleBarHeigh = 32.dp fun getPlatformTitleBar(): TitleBar { return when (Platform.asDesktop()) { Platform.Desktop.Windows -> WindowsTitleBar Platform.Desktop.MacOS -> MacTitleBar Platform.Desktop.Linux -> LinuxTitleBar } } } } enum class SystemButtonType { Close, Minimize, Maximize, } data class SystemButtonsPosition( val buttons: List, val isLeft: Boolean, ) ================================================ FILE: desktop/app-utils/src/main/kotlin/com/abdownloadmanager/desktop/window/custom/titlebar/TitleBarShared.kt ================================================ package com.abdownloadmanager.desktop.window.custom.titlebar import androidx.compose.animation.core.animateFloatAsState import androidx.compose.foundation.Image import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxHeight import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.width import androidx.compose.foundation.layout.wrapContentWidth import androidx.compose.foundation.onClick import androidx.compose.runtime.Composable import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.painter.Painter import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp import com.abdownloadmanager.desktop.window.custom.TitlePosition import com.abdownloadmanager.desktop.window.custom.isWindowFocused import com.abdownloadmanager.shared.ui.widget.Text import com.abdownloadmanager.shared.util.ui.WithContentAlpha import com.abdownloadmanager.shared.util.ui.WithContentColor import com.abdownloadmanager.shared.util.ui.myColors import com.abdownloadmanager.shared.util.ui.theme.myTextSizes import ir.amirab.util.ifThen @Composable internal fun CommonTitleBarContent( modifier: Modifier, title: String, windowIcon: Painter?, titlePosition: TitlePosition, start: @Composable (() -> Unit)?, end: @Composable (() -> Unit)? ) { Row( modifier, verticalAlignment = Alignment.CenterVertically, ) { Row( Modifier .onClick { // capture pointer } .fillMaxHeight(), verticalAlignment = Alignment.CenterVertically, ) { Spacer(Modifier.width(16.dp)) windowIcon?.let { WithContentAlpha(1f) { Image(it, null, Modifier.size(16.dp)) } Spacer(Modifier.width(8.dp)) } } if (!titlePosition.afterStart) { Title( modifier = Modifier .ifThen(titlePosition.centered) { weight(1f) .ifThen(start == null) { wrapContentWidth() } } .padding(titlePosition.padding), title = title ) } start?.let { Row( Modifier ) { start() Spacer(Modifier.width(8.dp)) } } if (titlePosition.afterStart) { Title( modifier = Modifier .weight(1f) .ifThen(titlePosition.centered) { wrapContentWidth() } .padding(titlePosition.padding), title = title ) } if (!titlePosition.centered && !titlePosition.afterStart) { Spacer(Modifier.weight(1f)) } end?.let { Row( Modifier ) { end() Spacer(Modifier.width(8.dp)) } } } } @Composable fun Title( modifier: Modifier, title: String, ) { val isWindowFocused = isWindowFocused() WithContentColor(myColors.onBackground) { WithContentAlpha( animateFloatAsState( if (isWindowFocused) 1f else 0.5f ).value ) { Text( title, maxLines = 1, overflow = TextOverflow.Ellipsis, fontSize = myTextSizes.base, modifier = Modifier .then(modifier) ) } } } @Composable internal fun CommonRenderTitleBar( modifier: Modifier, titleBar: TitleBar, title: String, windowIcon: Painter? = null, titlePosition: TitlePosition, start: (@Composable () -> Unit)?, end: (@Composable () -> Unit)?, onRequestClose: () -> Unit, onRequestMinimize: (() -> Unit)?, onRequestToggleMaximize: (() -> Unit)?, ) { Row( modifier.height(titleBar.titleBarHeight), verticalAlignment = Alignment.CenterVertically, ) { val systemButtonsAtFirst = titleBar.systemButtonsPosition.isLeft if (systemButtonsAtFirst) { titleBar.RenderSystemButtons( onRequestClose = onRequestClose, onRequestMinimize = onRequestMinimize, onToggleMaximize = onRequestToggleMaximize, ) } titleBar.RenderTitleBarContent( title = title, titlePosition = titlePosition, modifier = Modifier.weight(1f), windowIcon = windowIcon, start = start, end = end ) if (!systemButtonsAtFirst) { titleBar.RenderSystemButtons( onRequestClose = onRequestClose, onRequestMinimize = onRequestMinimize, onToggleMaximize = onRequestToggleMaximize, ) } } } ================================================ FILE: desktop/app-utils/src/main/kotlin/com/abdownloadmanager/desktop/window/custom/titlebar/linux/LinuxSystemButtonsProvider.kt ================================================ package com.abdownloadmanager.desktop.window.custom.titlebar.linux import com.abdownloadmanager.desktop.window.custom.titlebar.SystemButtonPositionProvider import com.abdownloadmanager.desktop.window.custom.titlebar.SystemButtonType import com.abdownloadmanager.desktop.window.custom.titlebar.SystemButtonsPosition import java.io.File object LinuxSystemButtonsProvider : SystemButtonPositionProvider { override fun getPositions(): SystemButtonsPosition? { return runCatching { getSystemButtonLayout() }.getOrNull() } } private fun getSystemButtonLayout(): SystemButtonsPosition? { val desktop = System.getenv("XDG_CURRENT_DESKTOP")?.lowercase() ?: System.getenv("DESKTOP_SESSION")?.lowercase() ?: "unknown" return when { "gnome" in desktop -> parseColonLayout( runCommand( "gsettings", "get", "org.gnome.desktop.wm.preferences", "button-layout" ) ) "mate" in desktop -> parseColonLayout( runCommand( "gsettings", "get", "org.mate.Marco.general", "button-layout" ) ) "xfce" in desktop -> parsePipeLayout( runCommand( "xfconf-query", "-c", "xfwm4", "-p", "/general/button_layout" ) ) "kde" in desktop -> parseKDELayout(File(System.getProperty("user.home"), ".config/kwinrc")) else -> null }?.takeIf { it.buttons.isNotEmpty() } } // For GNOME / MATE → colon-separated layout private fun parseColonLayout(layoutRaw: String?): SystemButtonsPosition? { val layout = layoutRaw?.removeSurrounding("'", "'")?.trim() ?: return null val (left, right) = layout.split(":", limit = 2).map { it.trim() + "," }.let { parseButtons(it.getOrNull(0).orEmpty()) to parseButtons(it.getOrNull(1).orEmpty()) } return when { left.isNotEmpty() -> SystemButtonsPosition(left, isLeft = true) right.isNotEmpty() -> SystemButtonsPosition(right, isLeft = false) else -> null } } // For XFCE → pipe-separated layout private fun parsePipeLayout(layout: String?): SystemButtonsPosition? { val parts = layout?.split("|")?.map { it.trim() } ?: return null return if (parts.isNotEmpty() && parts[0].isNotEmpty()) { SystemButtonsPosition(parseButtons(parts[0]), isLeft = true) } else if (parts.size > 1 && parts[1].isNotEmpty()) { SystemButtonsPosition(parseButtons(parts[1]), isLeft = false) } else { null } } // For KDE → parse kwinrc file private fun parseKDELayout(file: File): SystemButtonsPosition? { if (!file.exists()) return null val lines = file.readLines() val left = lines.find { it.startsWith("ButtonsOnLeft=") }?.substringAfter("=")?.trim() val right = lines.find { it.startsWith("ButtonsOnRight=") }?.substringAfter("=")?.trim() return when { !left.isNullOrEmpty() -> SystemButtonsPosition(parseButtons(left), isLeft = true) !right.isNullOrEmpty() -> SystemButtonsPosition(parseButtons(right), isLeft = false) else -> null } } private fun parseButtons(raw: String): List { return raw.split(",", "|", " ") .mapNotNull { when (it.lowercase()) { "close" -> SystemButtonType.Close "maximize" -> SystemButtonType.Maximize "minimize" -> SystemButtonType.Minimize else -> null } } } private fun runCommand(vararg args: String): String? { return try { val process = ProcessBuilder(*args).redirectErrorStream(true).start() process.inputStream.reader().use { it.readText().trim() } } catch (_: Exception) { null } } ================================================ FILE: desktop/app-utils/src/main/kotlin/com/abdownloadmanager/desktop/window/custom/titlebar/linux/LinuxTitleBar.kt ================================================ package com.abdownloadmanager.desktop.window.custom.titlebar.linux import androidx.compose.animation.animateColorAsState import androidx.compose.foundation.background import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.painter.Painter import androidx.compose.ui.unit.Dp import com.abdownloadmanager.desktop.window.custom.TitlePosition import com.abdownloadmanager.desktop.window.custom.isWindowFocused import com.abdownloadmanager.desktop.window.custom.titlebar.CommonRenderTitleBar import com.abdownloadmanager.desktop.window.custom.titlebar.CommonTitleBarContent import com.abdownloadmanager.desktop.window.custom.titlebar.SystemButtonType import com.abdownloadmanager.desktop.window.custom.titlebar.SystemButtonsPosition import com.abdownloadmanager.desktop.window.custom.titlebar.TitleBar import com.abdownloadmanager.shared.util.div import com.abdownloadmanager.shared.util.ui.myColors object LinuxTitleBar : TitleBar { override val titleBarHeight: Dp = TitleBar.Companion.DefaultTitleBarHeigh override val systemButtonsPosition: SystemButtonsPosition by lazy { LinuxSystemButtonsProvider.getPositions() ?: SystemButtonsPosition( buttons = listOf( SystemButtonType.Minimize, SystemButtonType.Maximize, SystemButtonType.Close, ), isLeft = false, ) } @Composable override fun RenderSystemButtons( onRequestClose: () -> Unit, onRequestMinimize: (() -> Unit)?, onToggleMaximize: (() -> Unit)? ) { LinuxSystemButtons( onRequestClose = onRequestClose, onRequestMinimize = onRequestMinimize, onToggleMaximize = onToggleMaximize, buttons = systemButtonsPosition.buttons, ) } @Composable override fun RenderTitleBarContent( title: String, titlePosition: TitlePosition, modifier: Modifier, windowIcon: Painter?, start: @Composable (() -> Unit)?, end: @Composable (() -> Unit)? ) { CommonTitleBarContent( title = title, windowIcon = windowIcon, titlePosition = titlePosition, start = start, end = end, modifier = modifier, ) } @Composable override fun RenderTitleBar( modifier: Modifier, titleBar: TitleBar, title: String, windowIcon: Painter?, titlePosition: TitlePosition, start: @Composable (() -> Unit)?, end: @Composable (() -> Unit)?, onRequestClose: () -> Unit, onRequestMinimize: (() -> Unit)?, onRequestToggleMaximize: (() -> Unit)? ) { val windowFocused = isWindowFocused() CommonRenderTitleBar( modifier = modifier .background( animateColorAsState( if (windowFocused) Color.Companion.Transparent else myColors.onBackground / 0.05f ).value ), titleBar = titleBar, title = title, windowIcon = windowIcon, titlePosition = titlePosition, start = start, end = end, onRequestClose = onRequestClose, onRequestMinimize = onRequestMinimize, onRequestToggleMaximize = onRequestToggleMaximize, ) } } ================================================ FILE: desktop/app-utils/src/main/kotlin/com/abdownloadmanager/desktop/window/custom/titlebar/linux/SystemButtons.Linux.kt ================================================ package com.abdownloadmanager.desktop.window.custom.titlebar.linux import ir.amirab.util.compose.IconSource import com.abdownloadmanager.shared.util.ui.widget.MyIcon import androidx.compose.animation.animateColorAsState import androidx.compose.foundation.background import androidx.compose.foundation.hoverable import androidx.compose.foundation.interaction.MutableInteractionSource import androidx.compose.foundation.interaction.collectIsHoveredAsState import androidx.compose.foundation.layout.* import androidx.compose.foundation.onClick import androidx.compose.foundation.shape.CircleShape import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue import androidx.compose.runtime.remember import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color import androidx.compose.ui.unit.dp import com.abdownloadmanager.desktop.window.custom.WindowCloseButtonTooltip import com.abdownloadmanager.desktop.window.custom.WindowMinimizeTooltip import com.abdownloadmanager.desktop.window.custom.WindowToggleMaximizeTooltip import com.abdownloadmanager.desktop.window.custom.isWindowFocused import com.abdownloadmanager.desktop.window.custom.isWindowMaximized import com.abdownloadmanager.desktop.window.custom.titlebar.SystemButtonType import com.abdownloadmanager.desktop.window.custom.titlebar.SystemButtonType.* import com.abdownloadmanager.shared.util.div import com.abdownloadmanager.shared.util.ui.icon.MyIcons import com.abdownloadmanager.shared.util.ui.myColors @Composable private fun SystemButton( onClick: () -> Unit, icon: IconSource, modifier: Modifier = Modifier, ) { val onBackground = if (myColors.isLight) Color.Black else Color.White val background = onBackground / 0.1f val hoveredBackgroundColor: Color = onBackground / 0.2f val onHoveredBackgroundColor: Color = onBackground val isFocused = isWindowFocused() val interactionSource = remember { MutableInteractionSource() } val isHovered by interactionSource.collectIsHoveredAsState() MyIcon( icon = icon, contentDescription = null, tint = animateColorAsState( when { isHovered -> onHoveredBackgroundColor else -> onBackground }.copy( alpha = if (isFocused || isHovered) { 1f } else { 0.5f } ) ).value, modifier = modifier .hoverable(interactionSource) .onClick { onClick() } .fillMaxHeight() .wrapContentHeight() .padding(horizontal = 4.dp) .background( animateColorAsState( when { isHovered -> hoveredBackgroundColor else -> background } ).value, CircleShape ) .padding(6.dp) .requiredSize(6.dp) ) } @Composable internal fun LinuxSystemButtons( onRequestClose: () -> Unit, onRequestMinimize: (() -> Unit)?, onToggleMaximize: (() -> Unit)?, buttons: List, ) { Row( // Toolbar is aligned center vertically, so I fill that and place it on top modifier = Modifier .padding(horizontal = 6.dp) .fillMaxHeight().wrapContentHeight(Alignment.Top), verticalAlignment = Alignment.Top ) { buttons.forEach { when (it) { Close -> { WindowCloseButtonTooltip { SystemButton( onRequestClose, icon = MyIcons.windowClose, modifier = Modifier, ) } } Minimize -> { onRequestMinimize?.let { WindowMinimizeTooltip { SystemButton( icon = MyIcons.windowMinimize, onClick = onRequestMinimize, modifier = Modifier ) } } } Maximize -> { onToggleMaximize?.let { WindowToggleMaximizeTooltip { SystemButton( icon = if (isWindowMaximized()) { MyIcons.windowFloating } else { MyIcons.windowMaximize }, onClick = onToggleMaximize, modifier = Modifier ) } } } } } } } ================================================ FILE: desktop/app-utils/src/main/kotlin/com/abdownloadmanager/desktop/window/custom/titlebar/mac/MacTitleBar.kt ================================================ package com.abdownloadmanager.desktop.window.custom.titlebar.mac import androidx.compose.foundation.Image import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.width import androidx.compose.foundation.layout.wrapContentWidth import androidx.compose.runtime.Composable import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.painter.Painter import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp import com.abdownloadmanager.desktop.window.custom.TitlePosition import com.abdownloadmanager.desktop.window.custom.titlebar.CommonRenderTitleBar import com.abdownloadmanager.desktop.window.custom.titlebar.SystemButtonType import com.abdownloadmanager.desktop.window.custom.titlebar.SystemButtonsPosition import com.abdownloadmanager.desktop.window.custom.titlebar.Title import com.abdownloadmanager.desktop.window.custom.titlebar.TitleBar import com.abdownloadmanager.shared.util.ui.WithContentAlpha import ir.amirab.util.compose.layout.RelativeAlignment import ir.amirab.util.ifThen import kotlin.math.roundToInt object MacTitleBar : TitleBar { override val titleBarHeight: Dp = TitleBar.Companion.DefaultTitleBarHeigh override val systemButtonsPosition: SystemButtonsPosition = SystemButtonsPosition( buttons = listOf( SystemButtonType.Close, SystemButtonType.Minimize, SystemButtonType.Maximize, ), isLeft = true, ) @Composable override fun RenderSystemButtons( onRequestClose: () -> Unit, onRequestMinimize: (() -> Unit)?, onToggleMaximize: (() -> Unit)? ) { MacOSSystemButtons( onRequestClose = onRequestClose, onRequestMinimize = onRequestMinimize, onToggleMaximize = onToggleMaximize, buttons = systemButtonsPosition.buttons, ) } @Composable override fun RenderTitleBarContent( title: String, titlePosition: TitlePosition, modifier: Modifier, windowIcon: Painter?, start: @Composable (() -> Unit)?, end: @Composable (() -> Unit)? ) { MacTitleBarContent( modifier = modifier, title = title, windowIcon = windowIcon, titlePosition = titlePosition, systemButtonsWidth = 60.dp, start = start, end = end, ) } @Composable override fun RenderTitleBar( modifier: Modifier, titleBar: TitleBar, title: String, windowIcon: Painter?, titlePosition: TitlePosition, start: @Composable (() -> Unit)?, end: @Composable (() -> Unit)?, onRequestClose: () -> Unit, onRequestMinimize: (() -> Unit)?, onRequestToggleMaximize: (() -> Unit)? ) { CommonRenderTitleBar( modifier = modifier, titleBar = titleBar, title = title, windowIcon = windowIcon, titlePosition = titlePosition, start = start, end = end, onRequestClose = onRequestClose, onRequestMinimize = onRequestMinimize, onRequestToggleMaximize = onRequestToggleMaximize, ) } } @Composable private fun MacTitleBarContent( modifier: Modifier, title: String, windowIcon: Painter?, titlePosition: TitlePosition, systemButtonsWidth: Dp, start: @Composable (() -> Unit)?, end: @Composable (() -> Unit)? ) { val density = LocalDensity.current Row( modifier, verticalAlignment = Alignment.CenterVertically, ) { val titleShouldBeCentered = titlePosition.centered || start == null val afterStart = titlePosition.afterStart if (!afterStart) { Row( Modifier .ifThen(titleShouldBeCentered) { weight(1f) .wrapContentWidth( RelativeAlignment.Horizontal( mainAlignment = Alignment.CenterHorizontally, relative = -(density.run { systemButtonsWidth.toPx() }.roundToInt()) ) ) } .padding(titlePosition.padding), verticalAlignment = Alignment.CenterVertically, ) { Spacer(Modifier.width(8.dp)) windowIcon?.let { WithContentAlpha(1f) { Image(it, null, Modifier.size(16.dp)) } Spacer(Modifier.width(8.dp)) } Title( modifier = Modifier, title = title ) } } start?.let { Row( Modifier ) { start() Spacer(Modifier.width(8.dp)) } } if (afterStart) { Row( Modifier .weight(1f) .ifThen(titleShouldBeCentered) { wrapContentWidth() } .padding(titlePosition.padding), verticalAlignment = Alignment.CenterVertically, ) { Spacer(Modifier.width(8.dp)) windowIcon?.let { WithContentAlpha(1f) { Image(it, null, Modifier.size(16.dp)) } Spacer(Modifier.width(8.dp)) } Title( modifier = Modifier, title = title ) } } if (!titleShouldBeCentered && !titlePosition.afterStart) { Spacer(Modifier.weight(1f)) } end?.let { Row( Modifier ) { end() Spacer(Modifier.width(4.dp)) } } } } ================================================ FILE: desktop/app-utils/src/main/kotlin/com/abdownloadmanager/desktop/window/custom/titlebar/mac/SystemButtons.MacOS.kt ================================================ package com.abdownloadmanager.desktop.window.custom.titlebar.mac import androidx.compose.animation.animateColorAsState import androidx.compose.foundation.background import androidx.compose.foundation.hoverable import androidx.compose.foundation.interaction.MutableInteractionSource import androidx.compose.foundation.interaction.collectIsHoveredAsState import androidx.compose.foundation.layout.* import androidx.compose.foundation.onClick import androidx.compose.foundation.shape.CircleShape import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue import androidx.compose.runtime.remember import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color import androidx.compose.ui.unit.dp import com.abdownloadmanager.desktop.window.custom.WindowCloseButtonTooltip import com.abdownloadmanager.desktop.window.custom.WindowMinimizeTooltip import com.abdownloadmanager.desktop.window.custom.WindowToggleMaximizeTooltip import com.abdownloadmanager.desktop.window.custom.isWindowFocused import com.abdownloadmanager.desktop.window.custom.isWindowMaximized import com.abdownloadmanager.desktop.window.custom.titlebar.SystemButtonType import com.abdownloadmanager.shared.util.darker import com.abdownloadmanager.shared.util.div import com.abdownloadmanager.shared.util.ui.icon.MyIcons import com.abdownloadmanager.shared.util.ui.myColors import com.abdownloadmanager.shared.util.ui.widget.MyIcon import ir.amirab.util.compose.IconSource @Composable internal fun MacOSSystemButtons( onRequestClose: () -> Unit, onRequestMinimize: (() -> Unit)?, onToggleMaximize: (() -> Unit)?, buttons: List, ) { val interactionSource = remember { MutableInteractionSource() } val isUserInThisArea by interactionSource.collectIsHoveredAsState() Row( // Toolbar is aligned center vertically, so I fill that and place it on top modifier = Modifier .padding(horizontal = 6.dp) .hoverable(interactionSource) .fillMaxHeight().wrapContentHeight(Alignment.Top), verticalAlignment = Alignment.Top ) { buttons.forEach { when (it) { SystemButtonType.Close -> { CloseButton(onRequestClose, isUserInThisArea) } SystemButtonType.Minimize -> { MinimizeButton(onRequestMinimize, isUserInThisArea) } SystemButtonType.Maximize -> { ToggleMaximizeButton(onToggleMaximize, isUserInThisArea) } } } } } @Composable private fun MinimizeButton(onRequestMinimize: (() -> Unit)?, isUserInThisArea: Boolean) { onRequestMinimize?.let { WindowMinimizeTooltip { SystemButton( onClick = onRequestMinimize, modifier = Modifier, hoveredBackgroundColor = Color(0xFFFFBD2E), icon = MyIcons.windowMinimize, isUserInThisArea = isUserInThisArea, ) } } } @Composable private fun ToggleMaximizeButton(onToggleMaximize: (() -> Unit)?, isUserInThisArea: Boolean) { onToggleMaximize?.let { WindowToggleMaximizeTooltip { SystemButton( onClick = onToggleMaximize, modifier = Modifier, hoveredBackgroundColor = Color(0xFF28C840), icon = if (isWindowMaximized()) { MyIcons.windowFloating } else { MyIcons.windowMaximize }, isUserInThisArea = isUserInThisArea, ) } } } @Composable private fun CloseButton(onRequestClose: () -> Unit, isUserInThisArea: Boolean) { WindowCloseButtonTooltip { SystemButton( onRequestClose, modifier = Modifier, hoveredBackgroundColor = Color(0xFFFF5F57), icon = MyIcons.windowClose, isUserInThisArea = isUserInThisArea, ) } } @Composable private fun SystemButton( onClick: () -> Unit, hoveredBackgroundColor: Color, unfocusedBackgroundColor: Color = myColors.onBackground / 0.2f, icon: IconSource, isUserInThisArea: Boolean, modifier: Modifier = Modifier, ) { val isWindowFocused = isWindowFocused() val interactionSource = remember { MutableInteractionSource() } val isHovered by interactionSource.collectIsHoveredAsState() Box( modifier = modifier .hoverable(interactionSource) .onClick { onClick() } .fillMaxHeight() .wrapContentHeight() .padding(horizontal = 6.dp) .background( animateColorAsState( when { !isWindowFocused -> unfocusedBackgroundColor isHovered -> hoveredBackgroundColor.darker() else -> hoveredBackgroundColor } ).value, CircleShape ) .requiredSize(12.dp) ) { if ( isUserInThisArea && isWindowFocused ) { MyIcon( icon = icon, tint = Color.Black, modifier = Modifier .align(Alignment.Center) .size(5.dp), contentDescription = null, ) } } } ================================================ FILE: desktop/app-utils/src/main/kotlin/com/abdownloadmanager/desktop/window/custom/titlebar/windows/SystemButtons.Windows.kt ================================================ package com.abdownloadmanager.desktop.window.custom.titlebar.windows import com.abdownloadmanager.shared.util.ui.LocalContentColor import ir.amirab.util.compose.IconSource import com.abdownloadmanager.shared.util.ui.widget.MyIcon import androidx.compose.animation.animateColorAsState import androidx.compose.foundation.background import androidx.compose.foundation.clickable import androidx.compose.foundation.hoverable import androidx.compose.foundation.interaction.MutableInteractionSource import androidx.compose.foundation.interaction.collectIsHoveredAsState import androidx.compose.foundation.layout.* import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue import androidx.compose.runtime.remember import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color import androidx.compose.ui.unit.dp import com.abdownloadmanager.desktop.window.custom.WindowCloseButtonTooltip import com.abdownloadmanager.desktop.window.custom.WindowMinimizeTooltip import com.abdownloadmanager.desktop.window.custom.WindowToggleMaximizeTooltip import com.abdownloadmanager.desktop.window.custom.isWindowFocused import com.abdownloadmanager.desktop.window.custom.isWindowMaximized import com.abdownloadmanager.desktop.window.custom.titlebar.SystemButtonType import com.abdownloadmanager.shared.util.ui.icon.MyIcons import com.abdownloadmanager.shared.util.ui.myColors @Composable private fun SystemButton( onClick: () -> Unit, background: Color = Color.Transparent, onBackground: Color = LocalContentColor.current, hoveredBackgroundColor: Color = background, onHoveredBackgroundColor: Color = LocalContentColor.current, icon: IconSource, modifier: Modifier = Modifier, ) { val isFocused = isWindowFocused() val interactionSource = remember { MutableInteractionSource() } val isHovered by interactionSource.collectIsHoveredAsState() MyIcon( icon = icon, contentDescription = null, tint = animateColorAsState( when { isHovered -> onHoveredBackgroundColor else -> onBackground }.copy( alpha = if (isFocused || isHovered) { 1f } else { 0.5f } ) ).value, modifier = modifier .clickable { onClick() } .background( animateColorAsState( when { isHovered -> hoveredBackgroundColor else -> background } ).value ) .hoverable(interactionSource) .windowButton() ) } @Composable private fun CloseButton( onRequestClose: () -> Unit, modifier: Modifier, ) { SystemButton( onRequestClose, background = Color.Transparent, onBackground = myColors.onBackground, hoveredBackgroundColor = Color(0xFFc42b1c), onHoveredBackgroundColor = myColors.onError, icon = MyIcons.windowClose, modifier = modifier, ) } private fun Modifier.windowButton(): Modifier { return fillMaxHeight() .wrapContentHeight() .padding( horizontal = 20.dp, ) .requiredSize(8.dp) } @Composable internal fun WindowsSystemButtons( onRequestClose: () -> Unit, onRequestMinimize: (() -> Unit)?, onToggleMaximize: (() -> Unit)?, buttons: List, ) { Row( // Toolbar is aligned center vertically, so I fill that and place it on top modifier = Modifier.fillMaxHeight().wrapContentHeight(Alignment.Top), verticalAlignment = Alignment.Top ) { buttons.forEach { when (it) { SystemButtonType.Close -> { WindowCloseButtonTooltip { CloseButton( onRequestClose = onRequestClose, modifier = Modifier ) } } SystemButtonType.Minimize -> { onRequestMinimize?.let { WindowMinimizeTooltip { SystemButton( icon = MyIcons.windowMinimize, onClick = onRequestMinimize, modifier = Modifier ) } } } SystemButtonType.Maximize -> { onToggleMaximize?.let { WindowToggleMaximizeTooltip { SystemButton( icon = if (isWindowMaximized()) { MyIcons.windowFloating } else { MyIcons.windowMaximize }, onClick = onToggleMaximize, modifier = Modifier ) } } } } } } } ================================================ FILE: desktop/app-utils/src/main/kotlin/com/abdownloadmanager/desktop/window/custom/titlebar/windows/WindowsTitleBar.kt ================================================ package com.abdownloadmanager.desktop.window.custom.titlebar.windows import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.painter.Painter import androidx.compose.ui.unit.Dp import com.abdownloadmanager.desktop.window.custom.TitlePosition import com.abdownloadmanager.desktop.window.custom.titlebar.CommonRenderTitleBar import com.abdownloadmanager.desktop.window.custom.titlebar.CommonTitleBarContent import com.abdownloadmanager.desktop.window.custom.titlebar.SystemButtonType import com.abdownloadmanager.desktop.window.custom.titlebar.SystemButtonsPosition import com.abdownloadmanager.desktop.window.custom.titlebar.TitleBar object WindowsTitleBar : TitleBar { override val titleBarHeight: Dp = TitleBar.Companion.DefaultTitleBarHeigh override val systemButtonsPosition: SystemButtonsPosition = SystemButtonsPosition( buttons = listOf( SystemButtonType.Minimize, SystemButtonType.Maximize, SystemButtonType.Close, ), isLeft = false, ) @Composable override fun RenderSystemButtons( onRequestClose: () -> Unit, onRequestMinimize: (() -> Unit)?, onToggleMaximize: (() -> Unit)? ) { WindowsSystemButtons( onRequestClose = onRequestClose, onRequestMinimize = onRequestMinimize, onToggleMaximize = onToggleMaximize, buttons = systemButtonsPosition.buttons, ) } @Composable override fun RenderTitleBarContent( title: String, titlePosition: TitlePosition, modifier: Modifier, windowIcon: Painter?, start: @Composable (() -> Unit)?, end: @Composable (() -> Unit)? ) { CommonTitleBarContent( title = title, windowIcon = windowIcon, titlePosition = titlePosition, start = start, end = end, modifier = modifier, ) } @Composable override fun RenderTitleBar( modifier: Modifier, titleBar: TitleBar, title: String, windowIcon: Painter?, titlePosition: TitlePosition, start: @Composable (() -> Unit)?, end: @Composable (() -> Unit)?, onRequestClose: () -> Unit, onRequestMinimize: (() -> Unit)?, onRequestToggleMaximize: (() -> Unit)? ) { CommonRenderTitleBar( modifier = modifier, titleBar = titleBar, title = title, windowIcon = windowIcon, titlePosition = titlePosition, start = start, end = end, onRequestClose = onRequestClose, onRequestMinimize = onRequestMinimize, onRequestToggleMaximize = onRequestToggleMaximize, ) } } ================================================ FILE: desktop/app-utils/src/main/kotlin/com/abdownloadmanager/desktop/window/custom/utils.kt ================================================ package com.abdownloadmanager.desktop.window.custom import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import java.awt.Toolkit import java.awt.Window import kotlin.math.max @Composable fun Window.subtractInset() { LaunchedEffect(Unit) { val inset = Toolkit.getDefaultToolkit().getScreenInsets(graphicsConfiguration) val size = Toolkit.getDefaultToolkit().screenSize.apply { // Only one side will have an inset at any point in time, all others remain at 0 // We need to find the one side that has it and apply it to the appropriate dimension width -= max(inset.left, inset.right) height -= max(inset.top, inset.bottom) } val rangeX = 0..size.width val rangeY = 0..size.height // Works when taskbar is on top or left of screen if (x !in rangeX || y !in rangeY) { setLocation( x.coerceIn(rangeX), y.coerceIn(rangeY) ) } // Works for when taskbar is on right or bottom of screen if (x + width !in rangeX || y + height !in rangeY) { setLocation( (x + width).coerceIn(rangeX) - width, (y + height).coerceIn(rangeY) - height ) } } } ================================================ FILE: desktop/app-utils/src/main/kotlin/com/abdownloadmanager/desktop/window/moveSafe.kt ================================================ package com.abdownloadmanager.desktop.window import androidx.compose.ui.Alignment import androidx.compose.ui.geometry.Offset import androidx.compose.ui.unit.DpOffset import androidx.compose.ui.unit.IntOffset import androidx.compose.ui.unit.IntRect import androidx.compose.ui.unit.IntSize import androidx.compose.ui.window.PopupPositionProviderAtPosition import ir.amirab.util.desktop.GlobalLayoutDirection import java.awt.Component import java.awt.Insets import java.awt.Toolkit import java.awt.Window fun Window.moveSafe( position: DpOffset, alignment: Alignment = Alignment.BottomEnd, ) { val window = this val p = PopupPositionProviderAtPosition( positionPx = Offset.Zero, isRelativeToAnchor = true, offsetPx = Offset.Zero, alignment = alignment, windowMarginPx = 0, ) val screenSize = getScreenSize() val insets = getScreenInsets(window) val offset = p.calculatePosition( popupContentSize = IntSize( window.width, window.height ), layoutDirection = GlobalLayoutDirection, windowSize = screenSize - insets, anchorBounds = IntRect( position.x.value.toInt(), position.y.value.toInt(), position.x.value.toInt(), position.y.value.toInt(), ), ) + insets window.setLocation( offset.x, offset.y, ) } fun getScreenInsets(component: Component): Insets { return runCatching { Toolkit.getDefaultToolkit().getScreenInsets(component.graphicsConfiguration) }.getOrElse { Insets(0, 0, 0, 0) } } private operator fun IntSize.minus(insets: Insets): IntSize { return IntSize( width - (insets.left + insets.right), height - (insets.top + insets.bottom) ) } private operator fun IntOffset.plus(insets: Insets): IntOffset { return copy( x = x + insets.left, y = y + insets.top, ) } //it is dp size! private fun getScreenSize(): IntSize { Toolkit.getDefaultToolkit().screenSize.run { return IntSize( width, height ) } } ================================================ FILE: desktop/mac_utils/build.gradle.kts ================================================ plugins{ id(MyPlugins.kotlin) } ================================================ FILE: desktop/mac_utils/src/main/kotlin/ir/amirab/util/desktop/mac/event/MacEventHandler.kt ================================================ package ir.amirab.util.desktop.mac.event import java.awt.Desktop import java.awt.desktop.AppReopenedEvent import java.awt.desktop.AppReopenedListener import java.awt.desktop.QuitEvent import java.awt.desktop.QuitHandler import java.awt.desktop.QuitResponse object MacEventHandler { fun configure( onClickIcon: () -> Unit, onAboutClick: () -> Unit, onSettingsClick: () -> Unit, onQuit: () -> Unit, ) { if (Desktop.isDesktopSupported() && Desktop.getDesktop() .isSupported(Desktop.Action.APP_EVENT_REOPENED) ) { Desktop.getDesktop().apply { addAppEventListener(object : AppReopenedListener { override fun appReopened(e: AppReopenedEvent?) { onClickIcon.invoke() } }) if (isSupported(Desktop.Action.APP_ABOUT)) { setAboutHandler { onAboutClick.invoke() } } if (isSupported(Desktop.Action.APP_PREFERENCES)) { setPreferencesHandler { onSettingsClick.invoke() } } if (isSupported(Desktop.Action.APP_QUIT_HANDLER)) { setQuitHandler { _, response -> response.cancelQuit() onQuit.invoke() } } } } } } ================================================ FILE: desktop/shared/build.gradle.kts ================================================ plugins { id(MyPlugins.kotlin) id(MyPlugins.composeDesktop) } dependencies { // Jna implementation(libs.jna.core) implementation(libs.jna.platform) implementation(project(":shared:app")) implementation(project(":shared:utils")) } ================================================ FILE: desktop/shared/src/main/kotlin/ir/amirab/util/desktop/DesktopUtils.kt ================================================ package ir.amirab.util.desktop import ir.amirab.util.desktop.keepawake.KeepAwake import ir.amirab.util.desktop.poweraction.PowerAction import ir.amirab.util.desktop.utils.linux.LinuxUtils import ir.amirab.util.desktop.utils.mac.MacOSUtils import ir.amirab.util.desktop.utils.windows.WindowsUtils import ir.amirab.util.platform.Platform interface DesktopUtils { fun openSystemProxySettings() fun powerAction(): PowerAction fun keepAwakeService(): KeepAwake companion object : DesktopUtils by getDesktopUtilOfCurrentOS() } private fun getDesktopUtilOfCurrentOS(): DesktopUtils { val platform = Platform.getCurrentPlatform() as Platform.Desktop return when (platform) { Platform.Desktop.Windows -> WindowsUtils() Platform.Desktop.MacOS -> MacOSUtils() Platform.Desktop.Linux -> LinuxUtils() } } ================================================ FILE: desktop/shared/src/main/kotlin/ir/amirab/util/desktop/GlobalKeyboardModifiers.kt ================================================ package ir.amirab.util.desktop import androidx.compose.ui.input.pointer.isCtrlPressed import androidx.compose.ui.input.pointer.isMetaPressed import androidx.compose.ui.input.pointer.isShiftPressed import androidx.compose.ui.platform.WindowInfo import ir.amirab.util.platform.Platform import ir.amirab.util.platform.isMac fun isCtrlPressed(windowInfo: WindowInfo): Boolean { val keyboardModifiers = windowInfo.keyboardModifiers return if (Platform.isMac()) { keyboardModifiers.isMetaPressed } else { keyboardModifiers.isCtrlPressed } } fun isShiftPressed(windowInfo: WindowInfo): Boolean { return windowInfo.keyboardModifiers.isShiftPressed } ================================================ FILE: desktop/shared/src/main/kotlin/ir/amirab/util/desktop/OsUtils.kt ================================================ package ir.amirab.util.desktop import androidx.compose.runtime.compositionLocalOf import androidx.compose.ui.geometry.Size import androidx.compose.ui.unit.Density import androidx.compose.ui.unit.LayoutDirection import androidx.compose.ui.window.FrameWindowScope import java.awt.ComponentOrientation import java.awt.GraphicsConfiguration import java.awt.GraphicsEnvironment import java.util.* val LocalFrameWindowScope = compositionLocalOf { error("LocalFrameWindowScope not provided yet") } val GlobalDensity get() = GraphicsEnvironment.getLocalGraphicsEnvironment() .defaultScreenDevice .defaultConfiguration .density val GraphicsConfiguration.density: Density get() = Density( defaultTransform.scaleX.toFloat(), fontScale = 1f ) val GlobalLayoutDirection get() = Locale.getDefault().layoutDirection val Locale.layoutDirection: LayoutDirection get() = ComponentOrientation.getOrientation(this).layoutDirection val ComponentOrientation.layoutDirection: LayoutDirection get() = when { isLeftToRight -> LayoutDirection.Ltr isHorizontal -> LayoutDirection.Rtl else -> LayoutDirection.Ltr } val trayIconSize = when (DesktopPlatform.Current) { // https://doc.qt.io/qt-5/qtwidgets-desktop-systray-example.html (search 22x22) DesktopPlatform.Linux -> Size(22f, 22f) // https://doc.qt.io/qt-5/qtwidgets-desktop-systray-example.html (search 16x16) DesktopPlatform.Windows -> Size(16f, 16f) // https://medium.com/@acwrightdesign/creating-a-macos-menu-bar-application-using-swiftui-54572a5d5f87 DesktopPlatform.MacOS -> Size(22f, 22f) DesktopPlatform.Unknown -> Size(32f, 32f) } enum class DesktopPlatform { Linux, Windows, MacOS, Unknown; companion object { /** * Identify OS on which the application is currently running. */ val Current: DesktopPlatform by lazy { val name = System.getProperty("os.name") when { name?.startsWith("Linux") == true -> Linux name?.startsWith("Win") == true -> Windows name == "Mac OS X" -> MacOS else -> Unknown } } } } ================================================ FILE: desktop/shared/src/main/kotlin/ir/amirab/util/desktop/PlatformAppActivator.kt ================================================ package ir.amirab.util.desktop import ir.amirab.util.desktop.activator.mac.MacAppActivator import ir.amirab.util.platform.Platform interface PlatformAppActivator { fun active() companion object : PlatformAppActivator by getPlatformAppActivatorForCurrentOs() } class EmptyAppActivator : PlatformAppActivator { override fun active() { // no-op } } private fun getPlatformAppActivatorForCurrentOs() = when (Platform.getCurrentPlatform()) { Platform.Desktop.MacOS -> MacAppActivator() else -> EmptyAppActivator() } ================================================ FILE: desktop/shared/src/main/kotlin/ir/amirab/util/desktop/PlatformDockToggler.kt ================================================ package ir.amirab.util.desktop import ir.amirab.util.desktop.dock.mac.MacDockToggler import ir.amirab.util.platform.Platform interface PlatformDockToggler { fun show() fun hide() companion object : PlatformDockToggler by getForCurrentOs() } class EmptyDockDockToggler : PlatformDockToggler { override fun show() { // no-op } override fun hide() { // no-op } } private fun getForCurrentOs() = when (Platform.getCurrentPlatform()) { Platform.Desktop.MacOS -> MacDockToggler() else -> EmptyDockDockToggler() } ================================================ FILE: desktop/shared/src/main/kotlin/ir/amirab/util/desktop/WindowsRegistry.kt ================================================ package ir.amirab.util.desktop object WindowsRegistry { /** * Set value in registry * * @param path full path including HKey and path * @param key key name or `null` for default */ fun setValueInRegistry( path: String, key: String?, value: String, ) { val keySection = if (key == null) { arrayOf("/ve") } else { arrayOf("/v", quoted(key)) } Runtime.getRuntime().exec( arrayOf( "reg", "add", quoted(path), *keySection, "/t", "REG_SZ", "/d", value, "/f", ) ) } /** * gets a value in Registry or null * * @param path full path including HKey and path * @param key key name or `null` for default * * @return the value or `null` on fail or not found */ fun getValueInRegistry( path: String, key: String?,//null for default ): String? { val keySection = if (key == null) { arrayOf("/ve") } else { arrayOf("/v", quoted(key)) } return try { val p = Runtime.getRuntime().exec( arrayOf( "reg", "query", quoted(path), *keySection, ) ) p.inputStream.reader().use { val text = it.readText() val result = queryResultPattern.find(text) result?.groupValues?.getOrNull(1) } } catch (e: Throwable) { return null } } /** * remove entire path in registry. * * **BE CAREFUL** about this * * @param path full path including HKey and path */ fun removePathInRegistry(path: String) { Runtime.getRuntime().exec( arrayOf( "reg", "delete", quoted(path), "/f", ) ) } /** * remove value in registry * * @param path full path including HKey and path * @param key key name or `null` for default */ fun removeValueInRegistry(path: String, key: String?) { val keySection = if (key == null) { arrayOf("/ve") } else { arrayOf("/v", quoted(key)) } Runtime.getRuntime().exec( arrayOf( "reg", "delete", quoted(path), *keySection, "/f", ) ) } // utils /** * wrap the [value] with quote name -> "name" */ private fun quoted(value: String) = "\"$value\"" /** * the correct result for * ``` * reg query path [...params] * ``` * @see [getValueInRegistry] * * would be *``` * HKCU\path\to\destination * type name value *``` * if you use this so many times you may change it to `lazy` instead of `get` */ private val queryResultPattern get() = """\n(?:\s+)\w+(?:\s)+\w+(?:\s)+(.+)""".toRegex() } ================================================ FILE: desktop/shared/src/main/kotlin/ir/amirab/util/desktop/activator/mac/MacAppActivator.kt ================================================ package ir.amirab.util.desktop.activator.mac import ir.amirab.util.desktop.PlatformAppActivator import ir.amirab.util.desktop.utils.mac.FoundationLibrary class MacAppActivator : PlatformAppActivator { override fun active() { val requiredFoundation = FoundationLibrary.INSTANCE ?: return runCatching { val nsAppClass = requiredFoundation.objc_getClass("NSApplication") val sharedAppSel = requiredFoundation.sel_registerName("sharedApplication") val activateSel = requiredFoundation.sel_registerName("activateIgnoringOtherApps:") val nsApp = requiredFoundation.objc_msgSend(nsAppClass, sharedAppSel) requiredFoundation.objc_msgSend(nsApp, activateSel, true) } } } ================================================ FILE: desktop/shared/src/main/kotlin/ir/amirab/util/desktop/dock/mac/MacDockToggler.kt ================================================ package ir.amirab.util.desktop.dock.mac import ir.amirab.util.desktop.PlatformDockToggler import ir.amirab.util.desktop.utils.mac.FoundationLibrary class MacDockToggler : PlatformDockToggler { private val foundation = FoundationLibrary.INSTANCE private val isAvailable = foundation != null private val nsAppClass by lazy { foundation!!.objc_getClass("NSApplication") } private val sharedAppSel by lazy { foundation!!.sel_registerName("sharedApplication") } private val setPolicySel by lazy { foundation!!.sel_registerName("setActivationPolicy:") } private val nsRunningAppClass by lazy { foundation!!.objc_getClass("NSRunningApplication") } private val currentAppSel by lazy { foundation!!.sel_registerName("currentApplication") } private val activateSel by lazy { foundation!!.sel_registerName("activateWithOptions:") } private val NSApplicationActivationPolicyRegular = 0 private val NSApplicationActivationPolicyAccessory = 1 private val NSApplicationActivateIgnoringOtherApps = 1 override fun show() { if (isAvailable) { setPolicy(NSApplicationActivationPolicyRegular) } } override fun hide() { if (isAvailable) { hideAndKeepFocus() } } private fun hideAndKeepFocus() { val nsApp = foundation!!.objc_msgSend(nsAppClass, sharedAppSel) val nsRunningApp = foundation.objc_msgSend(nsRunningAppClass, currentAppSel) foundation.objc_msgSend(nsApp, setPolicySel, NSApplicationActivationPolicyAccessory) foundation.objc_msgSend(nsRunningApp, activateSel, NSApplicationActivateIgnoringOtherApps) } private fun setPolicy(policy: Int) { val nsApp = foundation!!.objc_msgSend(nsAppClass, sharedAppSel) foundation.objc_msgSend(nsApp, setPolicySel, policy) } } ================================================ FILE: desktop/shared/src/main/kotlin/ir/amirab/util/desktop/keepawake/KeepAwake.kt ================================================ package ir.amirab.util.desktop.keepawake interface KeepAwake { /** * Prevents the system from going to sleep. */ fun keepAwake() /** * Allows the system to go to sleep again. */ fun allowSleep() class NoOpKeepAwake : KeepAwake { override fun keepAwake() { // No operation, does nothing } override fun allowSleep() { // No operation, does nothing } } } ================================================ FILE: desktop/shared/src/main/kotlin/ir/amirab/util/desktop/keepawake/MacKeepAwake.kt ================================================ package ir.amirab.util.desktop.keepawake class MacKeepAwake : KeepAwake { var process: Process? = null @Synchronized override fun keepAwake() { process?.destroy() process = runCatching { ProcessBuilder("caffeinate", "-s") .redirectErrorStream(true) .start() }.getOrElse { null } } override fun allowSleep() { runCatching { process?.destroy() } } } ================================================ FILE: desktop/shared/src/main/kotlin/ir/amirab/util/desktop/keepawake/WindowsKeepAwake.kt ================================================ package ir.amirab.util.desktop.keepawake import com.sun.jna.platform.win32.Kernel32 import kotlin.concurrent.thread class WindowsKeepAwake : KeepAwake { /** * 1.keepAwake -> 2.cancellation require to be called in single thread */ @Volatile private var thread: Thread? = null @Synchronized override fun keepAwake() { if (thread != null) { // already active return } thread = thread( name = "WindowsKeepAwake", isDaemon = true, ) { try { // keep the system awake! Kernel32.INSTANCE.SetThreadExecutionState( Kernel32.ES_CONTINUOUS or Kernel32.ES_SYSTEM_REQUIRED ) Thread.sleep(Long.MAX_VALUE) } catch (_: InterruptedException) { // we expect this! } catch (e: Exception) { // it shouldn't happen, but we don't throw any exception here! e.printStackTrace() } finally { // thread interrupted! now we can allow system to go sleep! runCatching { Kernel32.INSTANCE.SetThreadExecutionState( Kernel32.ES_CONTINUOUS ) } thread = null } } } @Synchronized override fun allowSleep() { thread?.let { it.interrupt() it.join() } } } ================================================ FILE: desktop/shared/src/main/kotlin/ir/amirab/util/desktop/poweraction/PowerAction.kt ================================================ package ir.amirab.util.desktop.poweraction interface PowerAction { fun initiate(config: PowerActionConfig): Boolean } ================================================ FILE: desktop/shared/src/main/kotlin/ir/amirab/util/desktop/poweraction/PowerActionConfig.kt ================================================ package ir.amirab.util.desktop.poweraction data class PowerActionConfig( val type: Type, val force: Boolean, ) { enum class Type { Shutdown, Hibernate, Sleep, } } interface ContainsPowerActionConfigOnFinish { fun getPowerActionConfigOnFinish(): PowerActionConfig? } ================================================ FILE: desktop/shared/src/main/kotlin/ir/amirab/util/desktop/poweraction/PowerActionLinux.kt ================================================ package ir.amirab.util.desktop.poweraction import ir.amirab.util.execAndWait class PowerActionLinux : PowerAction { override fun initiate(config: PowerActionConfig): Boolean { return when (config.type) { PowerActionConfig.Type.Shutdown -> shutdown(config.force) PowerActionConfig.Type.Hibernate -> TODO() PowerActionConfig.Type.Sleep -> TODO() } } private fun shutdown(force: Boolean): Boolean { val commands = listOf( arrayOf( "dbus-send", "--system", "--print-reply", "--dest=org.freedesktop.login1", "/org/freedesktop/login1", "org.freedesktop.login1.Manager.PowerOff", "boolean:true", ), arrayOf( "systemctl", "poweroff" ), ) return commands.any { command -> runCatching { execAndWait(command) }.getOrElse { false } } } } ================================================ FILE: desktop/shared/src/main/kotlin/ir/amirab/util/desktop/poweraction/PowerActionMac.kt ================================================ package ir.amirab.util.desktop.poweraction import ir.amirab.util.execAndWait class PowerActionMac : PowerAction { override fun initiate(config: PowerActionConfig): Boolean { return when (config.type) { PowerActionConfig.Type.Shutdown -> shutdown(config.force) PowerActionConfig.Type.Hibernate -> TODO() PowerActionConfig.Type.Sleep -> TODO() } } private fun shutdown(force: Boolean): Boolean { return execAndWait( arrayOf( "osascript", "-e", "tell application \"System Events\" to shut down" ) ) } } ================================================ FILE: desktop/shared/src/main/kotlin/ir/amirab/util/desktop/poweraction/PowerActionWindows.kt ================================================ package ir.amirab.util.desktop.poweraction import ir.amirab.util.execAndWait class PowerActionWindows : PowerAction { override fun initiate(config: PowerActionConfig): Boolean { return when (config.type) { PowerActionConfig.Type.Shutdown -> shutdown(config.force) PowerActionConfig.Type.Hibernate -> TODO() PowerActionConfig.Type.Sleep -> TODO() } } private fun shutdown(force: Boolean): Boolean { val command = arrayOf("shutdown", "/s", "/t", "0") return execAndWait(command) } } ================================================ FILE: desktop/shared/src/main/kotlin/ir/amirab/util/desktop/screen/DesktopScreen.kt ================================================ package ir.amirab.util.desktop.screen import androidx.compose.ui.unit.* import com.abdownloadmanager.shared.util.ui.theme.DEFAULT_UI_SCALE import java.awt.GraphicsEnvironment fun getGlobalScale(): Float { val graphicsEnvironment = GraphicsEnvironment.getLocalGraphicsEnvironment() val defaultScreenDevice = graphicsEnvironment.defaultScreenDevice val defaultTransform = defaultScreenDevice.defaultConfiguration.defaultTransform return defaultTransform.scaleX.toFloat() // Assuming uniform scaling } fun Int.applyUiScale( userUiScale: Float, ): Int { if (userUiScale == DEFAULT_UI_SCALE) return this return (this * userUiScale).toInt() } fun Float.applyUiScale( userUiScale: Float, ): Float { if (userUiScale == DEFAULT_UI_SCALE) return this return (this * userUiScale) } fun Int.unApplyUiScale( userUiScale: Float, ): Int { if (userUiScale == DEFAULT_UI_SCALE) return this return (this / userUiScale).toInt() } fun Float.unApplyUiScale( userUiScale: Float, ): Float { if (userUiScale == DEFAULT_UI_SCALE) return this return (this / userUiScale) } fun DpSize.applyUiScale( userUiScale: Float, ): DpSize { if (userUiScale == DEFAULT_UI_SCALE) return this if (this == DpSize.Unspecified) return this return DpSize( width = width.let { if (isSpecified) it.value.toInt().applyUiScale(userUiScale).dp else it }, height = height.let { if (isSpecified) it.value.toInt().applyUiScale(userUiScale).dp else it }, ) } fun DpSize.unApplyUiScale( userUiScale: Float, ): DpSize { if (userUiScale == DEFAULT_UI_SCALE) return this if (this == DpSize.Unspecified) return this return DpSize( width = width.let { if (isSpecified) it.value.toInt().unApplyUiScale(userUiScale).dp else it }, height = height.let { if (isSpecified) it.value.toInt().applyUiScale(userUiScale).dp else it }, ) } ================================================ FILE: desktop/shared/src/main/kotlin/ir/amirab/util/desktop/utils/linux/LinuxUtils.kt ================================================ package ir.amirab.util.desktop.utils.linux import ir.amirab.util.desktop.DesktopUtils import ir.amirab.util.desktop.keepawake.KeepAwake import ir.amirab.util.desktop.poweraction.PowerAction import ir.amirab.util.desktop.poweraction.PowerActionLinux import ir.amirab.util.execAndWait class LinuxUtils : DesktopUtils { private val keepAwake = KeepAwake.NoOpKeepAwake() private val powerActionForLinux = PowerActionLinux() override fun openSystemProxySettings() { val desktopEnv = System.getenv("XDG_CURRENT_DESKTOP") when { desktopEnv?.contains("GNOME") ?: false -> { execAndWait( arrayOf( "gnome-control-center network" ) ) } desktopEnv?.contains("KDE") ?: false -> { execAndWait( arrayOf( "systemsettings5 proxy" ) ) } else -> { println("Can't open System Proxy Settings: Unsupported desktop environment: $desktopEnv") } } } override fun powerAction(): PowerAction { return powerActionForLinux } override fun keepAwakeService(): KeepAwake { return keepAwake } } ================================================ FILE: desktop/shared/src/main/kotlin/ir/amirab/util/desktop/utils/mac/FoundationLibrary.kt ================================================ package ir.amirab.util.desktop.utils.mac import com.sun.jna.Library import com.sun.jna.Native import com.sun.jna.Pointer internal interface FoundationLibrary : Library { fun objc_getClass(name: String): Pointer fun sel_registerName(name: String): Pointer fun objc_msgSend(receiver: Pointer, selector: Pointer): Pointer fun objc_msgSend(receiver: Pointer, selector: Pointer, b: Boolean): Pointer fun objc_msgSend(receiver: Pointer, selector: Pointer, i: Int): Pointer fun objc_msgSend(receiver: Pointer, selector: Pointer, l: Long): Pointer fun objc_msgSend(receiver: Pointer, selector: Pointer, p: Pointer): Pointer fun objc_msgSend(receiver: Pointer, selector: Pointer, o: Any): Pointer companion object { val INSTANCE by lazy { runCatching { Native.load("objc", FoundationLibrary::class.java) }.getOrNull() } } } ================================================ FILE: desktop/shared/src/main/kotlin/ir/amirab/util/desktop/utils/mac/MacOSUtils.kt ================================================ package ir.amirab.util.desktop.utils.mac import ir.amirab.util.desktop.DesktopUtils import ir.amirab.util.desktop.keepawake.KeepAwake import ir.amirab.util.desktop.keepawake.MacKeepAwake import ir.amirab.util.desktop.poweraction.PowerAction import ir.amirab.util.desktop.poweraction.PowerActionMac import ir.amirab.util.desktop.poweraction.PowerActionWindows import ir.amirab.util.execAndWait class MacOSUtils : DesktopUtils { private val keepAwakeService = MacKeepAwake() private val powerActionForMac = PowerActionMac() override fun openSystemProxySettings() { val commands = listOf( arrayOf("open", "x-apple.systempreferences:com.apple.Network-Settings.extension"), arrayOf("open", "/System/Library/PreferencePanes/Network.prefPane"), arrayOf("open", "/System/Preferences/Network") ) for (command in commands) { if (execAndWait(command)) return } } override fun powerAction(): PowerAction { return powerActionForMac } override fun keepAwakeService(): KeepAwake { return keepAwakeService } } ================================================ FILE: desktop/shared/src/main/kotlin/ir/amirab/util/desktop/utils/windows/WindowsUtils.kt ================================================ package ir.amirab.util.desktop.utils.windows import ir.amirab.util.desktop.DesktopUtils import ir.amirab.util.desktop.keepawake.KeepAwake import ir.amirab.util.desktop.keepawake.WindowsKeepAwake import ir.amirab.util.desktop.poweraction.PowerAction import ir.amirab.util.desktop.poweraction.PowerActionWindows import ir.amirab.util.execAndWait class WindowsUtils : DesktopUtils { private val keepAwakeService = WindowsKeepAwake() private val powerActionWindows = PowerActionWindows() override fun openSystemProxySettings() { val result = execAndWait( arrayOf( "cmd", "/c", "start", "ms-settings:network-proxy", ) ) if (!result) { execAndWait( arrayOf( "rundll32.exe shell32.dll,Control_RunDLL inetcpl.cpl,,4" ) ) } } override fun powerAction(): PowerAction { return powerActionWindows } override fun keepAwakeService(): KeepAwake { return keepAwakeService } } ================================================ FILE: downloader/core/build.gradle.kts ================================================ import org.jetbrains.kotlin.gradle.dsl.JvmTarget plugins { id(MyPlugins.kotlinMultiplatform) id(Plugins.Kotlin.serialization) id(Plugins.Android.library) } kotlin { jvm("desktop") androidTarget("android") { compilerOptions { jvmTarget.set(JvmTarget.JVM_21) } } sourceSets { commonMain { dependencies { implementation(libs.kotlin.stdlib) implementation(libs.kotlin.serialization.json) implementation(libs.kotlin.datetime) implementation(libs.kotlin.coroutines.core) api(libs.okio.okio) api(libs.okhttp.okhttp) api(libs.okhttp.coroutines) implementation(project(":shared:utils")) api("io.lindstrom:m3u8-parser:0.29") } } } } android { compileSdk = 36 namespace = "ir.amirab.downloader.core" defaultConfig { minSdk = 26 } } ================================================ FILE: downloader/core/src/androidMain/kotlin/ir/amirab/downloader/utils/SparseFile.android.kt ================================================ package ir.amirab.downloader.utils import java.io.File import java.nio.file.Files import java.nio.file.OpenOption import java.nio.file.StandardOpenOption actual object SparseFile : ISparseFile { override fun createSparseFile(file: File): Boolean { if (!file.exists()) { val options = arrayOf( StandardOpenOption.WRITE, StandardOpenOption.CREATE_NEW, StandardOpenOption.SPARSE ) return runCatching { Files.newByteChannel( file.toPath(), *options, ).use {} true }.getOrElse { false } } return false } override fun canWeCreateSparseFile(file: File): Boolean { // android doesn't tell us! return true } } ================================================ FILE: downloader/core/src/commonMain/kotlin/ir/amirab/downloader/DownloadManager.kt ================================================ package ir.amirab.downloader import arrow.core.Some import ir.amirab.downloader.db.IDownloadListDb import ir.amirab.downloader.db.IDownloadPartListDb import ir.amirab.downloader.downloaditem.* import ir.amirab.downloader.downloaditem.contexts.DuplicateRemoval import ir.amirab.downloader.downloaditem.contexts.RemovedBy import ir.amirab.downloader.downloaditem.DownloadJobStatus import ir.amirab.downloader.downloaditem.DownloadStatus import ir.amirab.downloader.utils.DuplicateFilterByPath import ir.amirab.downloader.utils.EmptyFileCreator import ir.amirab.downloader.utils.FileNameUtil import ir.amirab.downloader.utils.OnDuplicateStrategy.* import ir.amirab.util.FileNameValidator import ir.amirab.util.PathValidator import ir.amirab.util.suspendGuardedEntry import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.SupervisorJob import kotlinx.coroutines.async import kotlinx.coroutines.awaitAll import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.first import kotlinx.coroutines.launch import kotlinx.coroutines.sync.Mutex import kotlinx.coroutines.sync.withLock import okio.Throttler import java.io.File class DownloadManager( val dlListDb: IDownloadListDb, val partListDb: IDownloadPartListDb, val settings: DownloadSettings, val emptyFileCreator: EmptyFileCreator, private val downloaderRegistry: DownloaderRegistry, val downloadDataFolder: File ) : DownloadManagerMinimalControl { val scope = CoroutineScope(SupervisorJob()) private val bootGuard = suspendGuardedEntry() suspend fun awaitBoot() { bootGuard.awaitDone() } //make ready to resume download suspend fun boot() { bootGuard.action { createJobForPendingDownloads() } } private val contextContainer = ContextProvider() private suspend fun createJobForPendingDownloads() { dlListDb.getAll().filter { it.status != DownloadStatus.Completed }.forEach { createJob(it).boot() } } var downloadJobs = listOf() private set private val dbAddSync = Mutex() suspend fun addDownload( props: NewDownloadItemProps ): Long { val newItem = props.downloadItem val onDuplicateStrategy = props.onDuplicateStrategy val context = props.context val extraConfig = props.extraConfig newItem.validateItem() require(PathValidator.isValidPath(newItem.folder)) { "folder of new download is not valid: ${newItem.folder}" } require(PathValidator.canWriteToThisPath(newItem.folder)) { "can't write to this new download's folder: ${newItem.folder}" } require(FileNameValidator.isValidFileName(newItem.name)) { "name of new download is not valid: ${newItem.name}" } // thisLogger().info("adding download") val job = dbAddSync.withLock { val allDownloads = dlListDb.getAll() val duplicateFinder = DuplicateFilterByPath(File(newItem.folder, newItem.name)) val foundItems = allDownloads.filter(duplicateFinder::isDuplicate) var removedItems = emptyList() if (foundItems.isNotEmpty()) { when (onDuplicateStrategy) { AddNumbered -> { //we do nothing here instead we increment file name after all if necessary } OverrideDownload -> { foundItems.forEach { deleteDownload(it.id, { true }, RemovedBy(DuplicateRemoval)) } removedItems = foundItems } Abort -> { error("Aborting add download that already exists") } } } val name = FileNameUtil.numberedIfExists( File(newItem.folder, newItem.name) ).first { candidateNewFile -> val withSameDestination = allDownloads .filter { it !in removedItems } .find { it.name == candidateNewFile.name && it.folder == candidateNewFile.parent } withSameDestination == null }.name val id = dlListDb.getLastId() + 1 val dateAdded = newItem.dateAdded .takeIf { it != 0L } ?: System.currentTimeMillis() val downloadItem = newItem.copy( id = Some(id), name = Some(name), dateAdded = Some(dateAdded), startTime = Some(null), completeTime = Some(null), status = Some(DownloadStatus.Added) ) dlListDb.add(downloadItem) createJob(downloadItem) .apply { boot() } .apply { extraConfig?.let { extraConfigsReceived(it) } } } contextContainer.setContext(job.id, context) onDownloadAdded(job.downloadItem) // thisLogger().info("this download added $downloadItem") // println("download created ${job.id}") return job.id } private val jobModificationLock = Any() private fun createJob(downloadItem: IDownloadItem): DownloadJob { val job = downloaderRegistry.createJob( downloadItem, this, ) // thisLogger().info("download job for $id created") downloadJobs = downloadJobs + job return job } suspend fun deleteDownload( id: Long, alsoRemoveFile: (IDownloadItem) -> Boolean, context: DownloadItemContext = EmptyContext, ) { kotlin.runCatching { pause(id) } val itemToDelete = dlListDb.getById(id) ?: return val job = getDownloadJob(id) ?: run { createJob(itemToDelete).apply { boot() } } // at this point: job will be created (and booted) if it was not created before contextContainer.updateContext(id) { it + context } job.downloadRemoved( removeOutputFile = if (itemToDelete.status == DownloadStatus.Completed) { alsoRemoveFile(itemToDelete) } else { // always remove file if download is not finished! true }, ) deleteJob(job.id) dlListDb.remove(itemToDelete) partListDb.removeParts(id) listOfJobsEvents.tryEmit( DownloadManagerEvents.OnJobRemoved(itemToDelete, contextContainer.getContext(id)) ) contextContainer.removeContext(id) } private fun deleteJob( id: Long, ) { synchronized(jobModificationLock) { val jobToDelete = downloadJobs.find { it.id == id } jobToDelete?.let { it.close() downloadJobs = downloadJobs.minusElement(it) } } } suspend fun pause(id: Long, context: DownloadItemContext = EmptyContext) { val job = getDownloadJob(id) ?: return contextContainer.updateContext(id) { it + context } job.pause() } suspend fun resume(id: Long, context: DownloadItemContext = EmptyContext) { val job = getDownloadJob(id) ?: run { dlListDb.getById(id)?.let { createJob(it) } } job?.let { contextContainer.updateContext(id) { it + context } it.resume() } } suspend fun reset(id: Long, context: DownloadItemContext = EmptyContext) { val job = getDownloadJob(id) ?: run { dlListDb.getById(id)?.let { createJob(it) } } job?.let { contextContainer.updateContext(id) { it + context } it.reset() } } private fun getDownloadJob(id: Long): DownloadJob? { // thisLogger().info("finding job for $id") return downloadJobs.find { it.id == id }.also { if (it == null) { // thisLogger().info("there is no job for dl_$id") } else { // thisLogger().info("job found for dl_$id") } } } suspend fun getDownloadList(): List { return dlListDb.getAll() } fun onDownloadResuming(downloadItem: IDownloadItem) { listOfJobsEvents.tryEmit( DownloadManagerEvents.OnJobStarting( downloadItem, contextContainer.getContext(downloadItem.id) ) ) } fun onDownloadResumed(downloadItem: IDownloadItem) { listOfJobsEvents.tryEmit( DownloadManagerEvents.OnJobStarted( downloadItem, contextContainer.getContext(downloadItem.id) ) ) } fun onDownloadAdded(downloadItem: IDownloadItem) { listOfJobsEvents.tryEmit( DownloadManagerEvents.OnJobAdded( downloadItem, contextContainer.getContext(downloadItem.id) ) ) } fun onDownloadCanceled(downloadItem: IDownloadItem, throwable: Throwable) { listOfJobsEvents.tryEmit( DownloadManagerEvents.OnJobCanceled( downloadItem, contextContainer.getContext(downloadItem.id), throwable ) ) } fun onDownloadFinished(downloadItem: IDownloadItem) { scope.launch { listOfJobsEvents.tryEmit( DownloadManagerEvents.OnJobCompleted( downloadItem, contextContainer.getContext(downloadItem.id) ) ) deleteJob(downloadItem.id) } } fun onDownloadItemChange(downloadItem: IDownloadItem) { scope.launch { listOfJobsEvents.tryEmit( DownloadManagerEvents.OnJobChanged( downloadItem, contextContainer.getContext(downloadItem.id) ) ) } } override suspend fun startJob(id: Long, context: DownloadItemContext) { resume(id, context) } override suspend fun stopJob(id: Long, context: DownloadItemContext) { pause(id, context) } override fun canActivateJob(id: Long): Boolean { val job = downloadJobs.find { id == it.id } val status = job?.status?.value // println("job status $status") return status is DownloadJobStatus.CanBeResumed } suspend fun stopAll( context: DownloadItemContext = EmptyContext, ) { downloadJobs.filter { it.status.value == DownloadJobStatus.Downloading }.map { scope.async { pause(it.id, context) } }.awaitAll() } fun getActiveCount(): Int { return downloadJobs.filter { it.status.value is DownloadJobStatus.IsActive }.size } fun calculateOutputFile(downloadItem: IDownloadItem): File { return File(downloadItem.folder, downloadItem.name) } fun getJobStatusOf(id: Long): DownloadJobStatus? { return downloadJobs.find { it.id == id }?.status?.value } override val listOfJobsEvents: MutableSharedFlow = MutableSharedFlow(extraBufferCapacity = 64) //global speed limiter internal val throttler = Throttler() fun limitGlobalSpeed(bytePerSecond: Long) { throttler.bytesPerSecond(bytePerSecond) } fun reloadSetting() { for (downloadJob in downloadJobs) { downloadJob.reloadSettings() } } suspend fun updateDownloadItem( id: Long, downloadJobExtraConfig: DownloadJobExtraConfig?, updater: (IDownloadItem) -> Unit, ) { var wasCreated = false val job = getDownloadJob(id) ?: run { dlListDb.getById(id)?.let { wasCreated = true createJob(it) } } ?: return val updated = job.changeConfig(updater, downloadJobExtraConfig) if (wasCreated && updated.status == DownloadStatus.Completed) { deleteJob(job.id) } onDownloadItemChange(updated) } } private class ContextProvider { val contexts = mutableMapOf() fun getContext(id: Long): DownloadItemContext { return contexts.getOrDefault(id, EmptyContext) } fun setContext(id: Long, context: DownloadItemContext) { if (context == EmptyContext) { removeContext(id) return } contexts[id] = context } fun removeContext(id: Long) { contexts.remove(id) } fun updateContext(id: Long, block: (DownloadItemContext) -> DownloadItemContext) { setContext(id, getContext(id).let(block)) } } ================================================ FILE: downloader/core/src/commonMain/kotlin/ir/amirab/downloader/DownloadManagerMinimalControl.kt ================================================ package ir.amirab.downloader import ir.amirab.downloader.downloaditem.DownloadItemContext import ir.amirab.downloader.downloaditem.EmptyContext import ir.amirab.downloader.downloaditem.IDownloadItem import kotlinx.coroutines.flow.SharedFlow sealed interface DownloadManagerEvents { val downloadItem: IDownloadItem val context: DownloadItemContext data class OnJobAdded( override val downloadItem: IDownloadItem, override val context: DownloadItemContext ) : DownloadManagerEvents data class OnJobChanged( override val downloadItem: IDownloadItem, override val context: DownloadItemContext ) : DownloadManagerEvents data class OnJobStarting( override val downloadItem: IDownloadItem, override val context: DownloadItemContext ) : DownloadManagerEvents data class OnJobStarted( override val downloadItem: IDownloadItem, override val context: DownloadItemContext ) : DownloadManagerEvents data class OnJobCompleted( override val downloadItem: IDownloadItem, override val context: DownloadItemContext ) : DownloadManagerEvents data class OnJobCanceled( override val downloadItem: IDownloadItem, override val context: DownloadItemContext, val e: Throwable ) : DownloadManagerEvents data class OnJobRemoved( override val downloadItem: IDownloadItem, override val context: DownloadItemContext ) : DownloadManagerEvents } interface DownloadManagerMinimalControl { suspend fun startJob(id: Long, context: DownloadItemContext = EmptyContext) suspend fun stopJob(id: Long, context: DownloadItemContext = EmptyContext) fun canActivateJob(id: Long): Boolean val listOfJobsEvents: SharedFlow } ================================================ FILE: downloader/core/src/commonMain/kotlin/ir/amirab/downloader/DownloadSettings.kt ================================================ package ir.amirab.downloader data class DownloadSettings( //can be changed after boot! var defaultThreadCount: Int = 8, var dynamicPartCreationMode: Boolean = true, var useServerLastModifiedTime: Boolean = false, var globalSpeedLimit: Long = 0,//unlimited var useSparseFileAllocation: Boolean = true, val minPartSize: Long = 2048,//2kB var maxDownloadRetryCount: Int = 0, // WARNING: this is used in boot so make sure to update it before booting // make it val or add a way to reload it properly var appendExtensionToIncompleteDownloads: Boolean = false, ) ================================================ FILE: downloader/core/src/commonMain/kotlin/ir/amirab/downloader/Downloader.kt ================================================ package ir.amirab.downloader import ir.amirab.downloader.downloaditem.DownloadJob import ir.amirab.downloader.downloaditem.IDownloadCredentials import ir.amirab.downloader.downloaditem.IDownloadItem import kotlinx.serialization.KSerializer import kotlin.reflect.KClass interface Downloader< TDownloadItem : IDownloadItem, TDownloadJob : DownloadJob, TDownloadCredentials : IDownloadCredentials, > { fun createJob( item: TDownloadItem, downloadManager: DownloadManager, ): TDownloadJob /** * accept if and only if [IDownloadItem] is [TDownloadItem] * */ fun accept(item: IDownloadItem): Boolean val downloadItemClass: KClass val downloadCredentialsClass: KClass val downloadJobClass: KClass val downloadItemSerializer: KSerializer val downloadCredentialsSerializer: KSerializer } ================================================ FILE: downloader/core/src/commonMain/kotlin/ir/amirab/downloader/DownloaderRegistry.kt ================================================ package ir.amirab.downloader import ir.amirab.downloader.downloaditem.DownloadJob import ir.amirab.downloader.downloaditem.IDownloadCredentials import ir.amirab.downloader.downloaditem.IDownloadItem class DownloaderRegistry { private val list = mutableSetOf>() fun add(downloader: Downloader<*, *, *>) { @Suppress("UNCHECKED_CAST") list.add(downloader as Downloader) } fun remove(downloader: Downloader<*, *, *>) { list.remove(downloader) } fun createJob( downloadItem: IDownloadItem, downloadManager: DownloadManager, ): DownloadJob { val downloader = requireNotNull( list.firstOrNull { it.accept(downloadItem) } ) { "Download item '${downloadItem::class.qualifiedName}' not supported!" } return downloader.createJob(downloadItem, downloadManager) } fun getAll() = list.toList() } ================================================ FILE: downloader/core/src/commonMain/kotlin/ir/amirab/downloader/NewDownloadItemProps.kt ================================================ package ir.amirab.downloader import ir.amirab.downloader.downloaditem.DownloadItemContext import ir.amirab.downloader.downloaditem.DownloadJobExtraConfig import ir.amirab.downloader.downloaditem.IDownloadItem import ir.amirab.downloader.utils.OnDuplicateStrategy data class NewDownloadItemProps( val downloadItem: IDownloadItem, val extraConfig: DownloadJobExtraConfig?, val onDuplicateStrategy: OnDuplicateStrategy, val context: DownloadItemContext, ) ================================================ FILE: downloader/core/src/commonMain/kotlin/ir/amirab/downloader/anntation/Markers.kt ================================================ package ir.amirab.downloader.anntation /** * annotate that a method have long time operation and * should not used in main thread */ @Retention(AnnotationRetention.SOURCE) annotation class HeavyCall ================================================ FILE: downloader/core/src/commonMain/kotlin/ir/amirab/downloader/connection/Connection.kt ================================================ package ir.amirab.downloader.connection import okio.Source import java.io.Closeable data class Connection( val source: Source, val contentLength: Long, val responseInfo: TResponseInfo, ) : Closeable { override fun close() { source.close() } } ================================================ FILE: downloader/core/src/commonMain/kotlin/ir/amirab/downloader/connection/HttpDownloaderClient.kt ================================================ package ir.amirab.downloader.connection import ir.amirab.downloader.connection.response.HttpResponseInfo import ir.amirab.downloader.downloaditem.http.IHttpBasedDownloadCredentials import ir.amirab.downloader.downloaditem.http.IHttpDownloadCredentials import ir.amirab.downloader.utils.ExceptionUtils import ir.amirab.downloader.utils.throwIf import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.withContext abstract class HttpDownloaderClient { /** * these headers will be placed at first and maybe overridden by another header */ fun defaultHeadersInFirst() = linkedMapOf( //empty for now! ) /** * these headers will be added after others so they override existing headers */ fun defaultHeadersInLast() = linkedMapOf( "accept-encoding" to "identity", ) protected abstract suspend fun actualHead( credentials: IHttpDownloadCredentials, start: Long?, end: Long?, ): HttpResponseInfo protected abstract suspend fun actualConnect( credentials: IHttpBasedDownloadCredentials, start: Long?, end: Long?, ): Connection suspend fun head( credentials: IHttpDownloadCredentials, start: Long?, end: Long?, ): HttpResponseInfo { return usingNetwork { actualHead(credentials, start, end) } } suspend fun connect( credentials: IHttpBasedDownloadCredentials, start: Long?, end: Long?, ): Connection { return usingNetwork { actualConnect(credentials, start, end) } } suspend fun test(credentials: IHttpDownloadCredentials): HttpResponseInfo { try { val rangeStart = 0L val rangeEnd = 255L val rangeLength = rangeEnd - rangeStart + 1 // 256 val response = head(credentials, rangeStart, rangeEnd) if (response.isSuccessFul && response.totalLength != rangeLength) { return response } } catch (e: Exception) { e.throwIf { ExceptionUtils.isNormalCancellation(e) } // some servers may reset the connection (ECONNRESET) if we ask for bytes=0-255 // so we don't provide resume support for them } // server may return un-standard response we use headless (without resuming support) return head(credentials, null, null) } private suspend fun usingNetwork(block: suspend () -> T): T { return withContext(Dispatchers.IO) { block() } } companion object { fun createRangeHeader(start: Long, end: Long?) = "Range" to "bytes=$start-${end ?: ""}" fun getDefaultUserAgent(): String = UserAgent.getDefault() } } ================================================ FILE: downloader/core/src/commonMain/kotlin/ir/amirab/downloader/connection/IResponseInfo.kt ================================================ package ir.amirab.downloader.connection interface IResponseInfo { val isSuccessFul: Boolean val requiresAuth: Boolean val requireBasicAuth: Boolean val resumeSupport: Boolean val isWebPage: Boolean } ================================================ FILE: downloader/core/src/commonMain/kotlin/ir/amirab/downloader/connection/OkHttpHttpDownloaderClient.kt ================================================ package ir.amirab.downloader.connection import ir.amirab.downloader.connection.proxy.* import ir.amirab.downloader.connection.response.HttpResponseInfo import ir.amirab.downloader.downloaditem.http.IHttpBasedDownloadCredentials import ir.amirab.downloader.downloaditem.http.IHttpDownloadCredentials import ir.amirab.downloader.utils.await import okhttp3.* import java.net.InetSocketAddress import java.net.Proxy import java.net.ProxySelector class OkHttpHttpDownloaderClient( private val okHttpClient: OkHttpClient, private val customUserAgentProvider: UserAgentProvider, private val proxyStrategyProvider: ProxyStrategyProvider, private val systemProxySelectorProvider: SystemProxySelectorProvider, private val autoConfigurableProxyProvider: AutoConfigurableProxyProvider, ) : HttpDownloaderClient() { private fun newCall( downloadCredentials: IHttpBasedDownloadCredentials, start: Long?, end: Long?, extraBuilder: Request.Builder.() -> Unit, ): Call { val rangeHeader = start?.let { createRangeHeader(start, end) } return okHttpClient .applyProxy(downloadCredentials) .newCall( Request.Builder() .url(downloadCredentials.link) .apply { defaultHeadersInFirst().forEach { (k, v) -> header(k, v) } // we don't to add something that we sure that it will be overridden later if (downloadCredentials.userAgent == null) { // only add default user agent if we don't specify it val customUserAgent = customUserAgentProvider.getUserAgent() ?: getDefaultUserAgent() header("User-Agent", customUserAgent) } downloadCredentials.headers ?.filter { //OkHttp handles this header and if we override it, //makes redirected links to have this "Host" instead of their own!, and cause error !it.key.equals("Host", true) } ?.forEach { (k, v) -> header(k, v) } defaultHeadersInLast().forEach { (k, v) -> header(k, v) } val username = downloadCredentials.username val password = downloadCredentials.password if (username?.isNotBlank() == true && password?.isNotBlank() == true) { header("Authorization", Credentials.basic(username, password)) } downloadCredentials.userAgent?.let { userAgent -> header("User-Agent", userAgent) } } .apply(extraBuilder) .apply { if (rangeHeader != null) { header(rangeHeader.first, rangeHeader.second) } } .build() ) } private fun OkHttpClient.applyProxy( downloadCredentials: IHttpBasedDownloadCredentials, ): OkHttpClient { return when ( val strategy = proxyStrategyProvider.getProxyStrategyFor(downloadCredentials.link) ) { ProxyStrategy.Direct -> return this ProxyStrategy.UseSystem -> { newBuilder() .proxySelector( systemProxySelectorProvider.getSystemProxySelector() ?: ProxySelector.getDefault() ) .build() } is ProxyStrategy.ByScript -> { val proxySelector = autoConfigurableProxyProvider.getAutoConfigurableProxy(strategy.scriptPath) if (proxySelector != null) { newBuilder() .proxySelector(proxySelector) .build() } else { this } } is ProxyStrategy.ManualProxy -> { val proxy = strategy.proxy return newBuilder() .proxy( Proxy( when (proxy.type) { ProxyType.HTTP -> Proxy.Type.HTTP ProxyType.SOCKS -> Proxy.Type.SOCKS }, InetSocketAddress(proxy.host, proxy.port) ) ).let { if (proxy.username != null && proxy.type == ProxyType.HTTP) { it.proxyAuthenticator { _, r -> val credentials = Credentials.basic( proxy.username, proxy.password.orEmpty() ) r.request .newBuilder() .header("Proxy-Authorization", credentials) .build() } } else { it } }.build() } } } override suspend fun actualHead( credentials: IHttpDownloadCredentials, start: Long?, end: Long?, ): HttpResponseInfo { newCall( downloadCredentials = credentials, start = start, end = end, extraBuilder = { // head() } ).await().use { response -> // println(response.headers) return createFileInfo(response) } } private fun createFileInfo(response: Response): HttpResponseInfo { return HttpResponseInfo( statusCode = response.code, message = response.message, requestUrl = response.request.url.toString(), requestHeaders = response.request.headers.associate { (key, value) -> key.lowercase() to value }, responseHeaders = response.headers.associate { (key, value) -> key.lowercase() to value }, ) } override suspend fun actualConnect( credentials: IHttpBasedDownloadCredentials, start: Long?, end: Long?, ): Connection { val response = newCall( downloadCredentials = credentials, start = start, end = end, extraBuilder = { get() } ).await() val body = runCatching { requireNotNull(response.body) { "body is null" } }.onFailure { response.close() }.getOrThrow() return Connection( source = body.source(), contentLength = body.contentLength(), responseInfo = createFileInfo(response) ) } } ================================================ FILE: downloader/core/src/commonMain/kotlin/ir/amirab/downloader/connection/UserAgent.kt ================================================ package ir.amirab.downloader.connection object UserAgent { const val DEFAULT_USER_AGENT = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36" fun getDefault(): String { return DEFAULT_USER_AGENT } } ================================================ FILE: downloader/core/src/commonMain/kotlin/ir/amirab/downloader/connection/UserAgentProvider.kt ================================================ package ir.amirab.downloader.connection interface UserAgentProvider { fun getUserAgent(): String? } ================================================ FILE: downloader/core/src/commonMain/kotlin/ir/amirab/downloader/connection/proxy/AutoConfigurableProxyProvider.kt ================================================ package ir.amirab.downloader.connection.proxy import java.net.ProxySelector import java.net.URI interface AutoConfigurableProxyProvider { fun getAutoConfigurableProxy( uri: String ): ProxySelector? class NoOp : AutoConfigurableProxyProvider { override fun getAutoConfigurableProxy(uri: String): ProxySelector? { return null } } } ================================================ FILE: downloader/core/src/commonMain/kotlin/ir/amirab/downloader/connection/proxy/Proxy.kt ================================================ package ir.amirab.downloader.connection.proxy import kotlinx.serialization.Serializable @Serializable data class Proxy( val type: ProxyType, val host: String, val port: Int, val username: String?, val password: String?, ) { companion object { fun default() = Proxy( type = ProxyType.HTTP, host = "127.0.0.1", port = 2080, username = null, password = null, ) } } ================================================ FILE: downloader/core/src/commonMain/kotlin/ir/amirab/downloader/connection/proxy/ProxyStrategy.kt ================================================ package ir.amirab.downloader.connection.proxy sealed interface ProxyStrategy { data object Direct : ProxyStrategy data object UseSystem : ProxyStrategy data class ManualProxy(val proxy: Proxy) : ProxyStrategy data class ByScript(val scriptPath: String) : ProxyStrategy } ================================================ FILE: downloader/core/src/commonMain/kotlin/ir/amirab/downloader/connection/proxy/ProxyStrategyProvider.kt ================================================ package ir.amirab.downloader.connection.proxy interface ProxyStrategyProvider { fun getProxyStrategyFor(url: String): ProxyStrategy } ================================================ FILE: downloader/core/src/commonMain/kotlin/ir/amirab/downloader/connection/proxy/ProxyType.kt ================================================ package ir.amirab.downloader.connection.proxy import kotlinx.serialization.SerialName enum class ProxyType { @SerialName("http") HTTP, @SerialName("socks") SOCKS; } ================================================ FILE: downloader/core/src/commonMain/kotlin/ir/amirab/downloader/connection/proxy/SystemProxySelectorProvider.kt ================================================ package ir.amirab.downloader.connection.proxy import java.net.ProxySelector interface SystemProxySelectorProvider { fun getSystemProxySelector(): ProxySelector? } class NoopSystemProxySelectorProvider : SystemProxySelectorProvider { override fun getSystemProxySelector(): ProxySelector? { println("System proxy not available") return null } } ================================================ FILE: downloader/core/src/commonMain/kotlin/ir/amirab/downloader/connection/response/HttpResponseInfo.kt ================================================ package ir.amirab.downloader.connection.response import ir.amirab.downloader.connection.IResponseInfo import ir.amirab.downloader.connection.response.headers.getContentRange import ir.amirab.downloader.connection.response.headers.extractFileNameFromContentDisposition import ir.amirab.downloader.exception.UnSuccessfulResponseException import ir.amirab.downloader.utils.FileNameUtil import ir.amirab.util.HttpUrlUtils import ir.amirab.util.ifThen data class HttpResponseInfo( val statusCode: Int, val message: String, val requestUrl: String, val requestHeaders: Map = linkedMapOf(), val responseHeaders: Map = linkedMapOf(), ) : IResponseInfo { override val isSuccessFul by lazy { statusCode in 200..299 } val contentLength by lazy { responseHeaders["content-length"]?.toLongOrNull()?.takeIf { it >= 0L } } val contentRange by lazy { getContentRange() } //total length of whole file even if it is partial content val totalLength by lazy { val responseLength = contentLength ?: return@lazy null // partial length only valid when we have content-length header if (isPartial) { contentRange?.fullSize ?: responseLength } else responseLength } override val requiresAuth by lazy { statusCode == 401 } override val requireBasicAuth by lazy { requiresAuth && (responseHeaders["www-authenticate"]?.contains("basic", true) ?: false) } val isPartial by lazy { statusCode == 206 } override val resumeSupport by lazy { // maybe server does not give us content-length or content-range, so we ignore resume support isPartial && contentLength != null && contentRange?.fullSize != null } override val isWebPage: Boolean by lazy { responseHeaders["content-type"].orEmpty().contains("text/html", ignoreCase = true) } val fileName: String? by lazy { run { val nameFromHeader = responseHeaders["content-disposition"]?.let { extractFileNameFromContentDisposition(it) } nameFromHeader ?: HttpUrlUtils.extractNameFromLink(requestUrl) } .orEmpty() .ifThen(isWebPage) { FileNameUtil.replaceExtension( this, "html", true ) } .takeIf { it.isNotEmpty() } } // It is good to use these properties to check file is valid // for now we depend on size val lastModified: String? by lazy { responseHeaders["last-modified"] } val etag: String? by lazy { responseHeaders["etag"] } } fun HttpResponseInfo.expectSuccess() = apply { if (!isSuccessFul) { throw UnSuccessfulResponseException(statusCode, message) } } ================================================ FILE: downloader/core/src/commonMain/kotlin/ir/amirab/downloader/connection/response/headers/RangeHeaderExtractor.kt ================================================ package ir.amirab.downloader.connection.response.headers import ir.amirab.downloader.connection.response.HttpResponseInfo data class ContentRangeValue( val range: LongRange?, val fullSize: Long?, ) fun HttpResponseInfo.getContentRange(): ContentRangeValue? { val value = responseHeaders["content-range"] ?: return null val actualValue = runCatching { // some servers don't append "bytes " to the start of the value value.removePrefix("bytes ") }.getOrNull() ?: return null if (actualValue.isBlank()) { return null } val (rangeString, sizeString) = actualValue .split("/") .takeIf { it.size >= 2 } ?: return null val range = try { if (rangeString != "*") { rangeString.split("-").map { it.toLong() }.let { it[0]..it[1] } } else { null } } catch (e: Exception) { // NumberFormatException or IndexOutOfBoundException return null } val size: Long? = if (sizeString != "*") { // some servers not returning * nor integer value. sizeString.toLongOrNull() ?: return null } else null return ContentRangeValue( range = range, fullSize = size, ) } ================================================ FILE: downloader/core/src/commonMain/kotlin/ir/amirab/downloader/connection/response/headers/fileNameExtractor.kt ================================================ package ir.amirab.downloader.connection.response.headers import ir.amirab.util.FilenameDecoder import kotlin.io.encoding.Base64 import kotlin.io.encoding.ExperimentalEncodingApi fun extractFileNameFromContentDisposition(contentDispositionValue: String): String? { utf8FileNameRegex.find(contentDispositionValue) ?.groups?.get("fileName") ?.value?.let { runCatching { FilenameDecoder.decode(it, Charsets.UTF_8) } .getOrNull() }?.let { return it } asciiFileNameRegex.find(contentDispositionValue) ?.groups ?.get("fileName") ?.value?.let { var fileName = it fileName = runCatching { EmailMimeWordDecoder.decode(fileName) }.getOrNull() ?: fileName runCatching { FilenameDecoder.decode(fileName, Charsets.UTF_8) } .getOrNull() }?.let { return it } return null } private val asciiFileNameRegex = """filename=(["']?)(?.*?[^\\])\1(?:; ?|$)""" .toRegex(RegexOption.IGNORE_CASE) private val utf8FileNameRegex = """filename\*=UTF-8''(?[^;\s]+)(?:; ?|$)""" .toRegex(RegexOption.IGNORE_CASE) /** * we use this class to decode the filename in content-disposition header in mail servers * RFC 2047 */ private object EmailMimeWordDecoder { fun decode(string: String): String { return decodeMimeEncodedFilename(string) } private val regex by lazy { """=\?(?[^?]+)\?(?[BQ])\?(?[^?]+)\?=""" .toRegex(RegexOption.IGNORE_CASE) } @OptIn(ExperimentalEncodingApi::class) private fun decodeMimeEncodedFilename(input: String): String { return regex.replace(input) { runCatching { val match = it.groups val charset = match.requireName("charset").value val encoding = match.requireName("encoding").value.uppercase() val encodedText = match.requireName("encodedText").value val bytes = when (encoding) { "B" -> Base64.decode(encodedText) "Q" -> decodeMimeQuotedPrintable(encodedText) else -> return@replace input } String(bytes, charset(charset)) }.getOrNull() ?: it.value } } private fun decodeMimeQuotedPrintable(encoded: String): ByteArray { val sb = StringBuilder() var i = 0 while (i < encoded.length) { val c = encoded[i] when { c == '=' && i + 2 < encoded.length -> { val hex = encoded.substring(i + 1, i + 3) val byte = hex.toIntOrNull(16)?.toChar() if (byte != null) { sb.append(byte) i += 3 } else { sb.append(c) i++ } } c == '_' -> { sb.append(' ') // _ represents space in Q encoding i++ } else -> { sb.append(c) i++ } } } return sb.toString().toByteArray(Charsets.ISO_8859_1) } private fun MatchGroupCollection.requireName(name: String): MatchGroup { return requireNotNull(this[name]) { "Group $name not found" } } } ================================================ FILE: downloader/core/src/commonMain/kotlin/ir/amirab/downloader/db/DownloadListFileStorage.kt ================================================ package ir.amirab.downloader.db import ir.amirab.downloader.downloaditem.IDownloadItem import ir.amirab.downloader.utils.SuspendLockList import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.sync.Mutex import kotlinx.coroutines.sync.withLock import kotlinx.coroutines.withContext import java.io.File class DownloadListFileStorage( private val downloadListFolder: File, private val fileSaver: TransactionalFileSaver, ) : IDownloadListDb { private val fileLocks = SuspendLockList() fun getDownloadItemFile(id: Long): File { return downloadListFolder.resolve("$id.json") } override suspend fun getAll(): List { return withContext(Dispatchers.IO) { val jsonExtension = ".json" downloadListFolder.listFiles() ?.mapNotNull { file -> file.name .takeIf { it.endsWith(jsonExtension) } ?.removeSuffix(jsonExtension) ?.toLongOrNull() ?.let { get(file, it) } }.orEmpty() } } private suspend fun get(file: File, id: Long): IDownloadItem? { return fileLocks.withLock(id) { fileSaver.readObject(file) } } override suspend fun getById(id: Long): IDownloadItem? { return withContext(Dispatchers.IO) { get(getDownloadItemFile(id), id) } } private val addLock = Mutex() override suspend fun add(item: IDownloadItem) { withContext(Dispatchers.IO) { addLock.withLock { fileLocks.withLock(item.id) { fileSaver.writeObject(getDownloadItemFile(item.id), item) val lastId = getLastId() if (lastId < item.id) { setLastId(item.id) } } } } } override suspend fun update(item: IDownloadItem) { withContext(Dispatchers.IO) { // we don't use same lock for all items , but create lock for each item fileLocks.withLock(item.id) { fileSaver.writeObject(getDownloadItemFile(item.id), item) } } } override suspend fun removeById(itemId: Long) { getDownloadItemFile(itemId).delete() } override suspend fun remove(item: IDownloadItem) { removeById(item.id) } private val lastIdFile = downloadListFolder.resolve("last_id.txt") private fun setLastId(id: Long) { fileSaver.writeObject(lastIdFile, id) } override suspend fun getLastId(): Long { return withContext(Dispatchers.IO) { var lastId = fileSaver.readObject(lastIdFile) if (lastId == null) { lastId = getLastIdFromFiles() setLastId(lastId) } lastId } } private fun getLastIdFromFiles(): Long { return downloadListFolder.listFiles()!!.filter { it.name.endsWith(".json") && it.isFile }.maxOfOrNull { it.name .substring(0, it.name.length - ".json".length) .toLong() } ?: -1L } } ================================================ FILE: downloader/core/src/commonMain/kotlin/ir/amirab/downloader/db/DownloadQueuePersistedDataAccess.kt ================================================ package ir.amirab.downloader.db import ir.amirab.downloader.queue.ScheduleTimes import kotlinx.serialization.Serializable /** * this is all states of queue that need to be persisted * */ @Serializable data class QueueModel( val id:Long, val name: String, val maxConcurrent: Int = 2, val queueItems: List = emptyList(), val scheduledTimes: ScheduleTimes = ScheduleTimes.default(), val stopQueueOnEmpty:Boolean=false, ) /** * CRUD all queues * */ interface IDownloadQueueDatabase { suspend fun getAllQueueIds(): List suspend fun getAllQueues(): List suspend fun setAllQueues(queues: List) suspend fun deleteAllQueues() suspend fun getQueue(queueId:Long):QueueModel suspend fun deleteQueue(queue: Long) suspend fun updateQueue(queue: QueueModel) suspend fun addQueue(queue: QueueModel) } /** * update a single queue (it is a view of a single queue in database) * this is passed to queue for access its persistent data and update it * setters must be implemented thread safe */ interface DownloadQueuePersistedDataAccess { suspend fun setModel(queue: QueueModel) suspend fun getModel():QueueModel suspend fun update(update: (QueueModel) -> QueueModel) { setModel( update( getModel() ) ) } } ================================================ FILE: downloader/core/src/commonMain/kotlin/ir/amirab/downloader/db/IDownloadListDb.kt ================================================ package ir.amirab.downloader.db import ir.amirab.downloader.downloaditem.IDownloadItem interface IDownloadListDb { // modification/add implementations must be thread safe suspend fun getAll(): List suspend fun getById(id: Long): IDownloadItem? suspend fun add(item: IDownloadItem) suspend fun update(item: IDownloadItem) suspend fun remove(item: IDownloadItem) suspend fun removeById(itemId: Long) suspend fun getLastId(): Long // suspend fun allAsFlow(): Flow> } ================================================ FILE: downloader/core/src/commonMain/kotlin/ir/amirab/downloader/db/IDownloadPartListDb.kt ================================================ package ir.amirab.downloader.db import ir.amirab.downloader.part.Parts interface IDownloadPartListDb { suspend fun getParts(id: Long): Parts? suspend fun setParts(id: Long, parts: Parts) suspend fun clear() suspend fun removeParts(id: Long) } ================================================ FILE: downloader/core/src/commonMain/kotlin/ir/amirab/downloader/db/MemoryDownloadListDB.kt ================================================ package ir.amirab.downloader.db import ir.amirab.downloader.downloaditem.IDownloadItem import kotlinx.coroutines.channels.BufferOverflow import kotlinx.coroutines.flow.MutableSharedFlow class MemoryDownloadListDB : IDownloadListDb { private val list: MutableList = mutableListOf() override suspend fun getAll(): List { return list.toList() } override suspend fun getById(id: Long): IDownloadItem? { return list.find { it.id == id } } override suspend fun add(item: IDownloadItem) { require(list.all { it.id != item.id }) { "duplicate download id" } list.add(item) } override suspend fun update(item: IDownloadItem) { list.indexOfFirst { it.id == item.id }.takeIf { it != -1 }?.let { index -> list.set(index, item) } } override suspend fun remove(item: IDownloadItem) { removeById(item.id) } override suspend fun removeById(itemId: Long) { val index = list.indexOfFirst { it.id == itemId } list.removeAt(index) } override suspend fun getLastId(): Long { return list.maxByOrNull { it.id }?.id ?: -1 } private val flow = MutableSharedFlow>( replay = 1, onBufferOverflow = BufferOverflow.DROP_OLDEST ) suspend fun onDbUpdate() { flow.tryEmit(getAll()) } suspend fun allAsFlow() = flow } ================================================ FILE: downloader/core/src/commonMain/kotlin/ir/amirab/downloader/db/MemoryDownloadPartStatesDB.kt ================================================ package ir.amirab.downloader.db import ir.amirab.downloader.part.Parts class MemoryDownloadPartStatesDB : IDownloadPartListDb { private val list = mutableMapOf() override suspend fun getParts(id: Long): Parts? { return list[id] } override suspend fun setParts(id: Long, parts: Parts) { list[id] = parts.clone() } override suspend fun removeParts(id: Long) { list.remove(id) } override suspend fun clear() { list.clear() } } ================================================ FILE: downloader/core/src/commonMain/kotlin/ir/amirab/downloader/db/PartListFileStorage.kt ================================================ package ir.amirab.downloader.db import ir.amirab.downloader.part.Parts import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.withContext import java.io.File class PartListFileStorage( val folder: File, val fileSaver: TransactionalFileSaver, ) : IDownloadPartListDb { fun getFileForId(id: Long): File { val resolve = folder.resolve("$id.json") return resolve } override suspend fun getParts(id: Long): Parts? { return withContext(Dispatchers.IO) { fileSaver.readObject(getFileForId(id)) } } override suspend fun setParts(id: Long, parts: Parts) { withContext(Dispatchers.IO) { kotlin.runCatching { val file = getFileForId(id) fileSaver.writeObject(file, parts) } } } override suspend fun removeParts(id: Long) { getFileForId(id).delete() } override suspend fun clear() { kotlin.runCatching { folder.listFiles() }.getOrNull()?.let { for (file in it) { file.delete() } } } } ================================================ FILE: downloader/core/src/commonMain/kotlin/ir/amirab/downloader/db/QueueFileStorage.kt ================================================ package ir.amirab.downloader.db import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.sync.Mutex import kotlinx.coroutines.sync.withLock import kotlinx.coroutines.withContext import java.io.File private const val queueExtension = "json" class DownloadQueueFileStorageDatabase( val fileSaver: TransactionalFileSaver, val queueFolder: File, ) : IDownloadQueueDatabase { private val lock = Mutex() private fun getFileOfQueue(queue: QueueModel): File { return getFileOfQueue(queue.id) } private fun getFileOfQueue(id: Long): File { return queueFolder.resolve("$id.json") } private fun getQueueFiles(): List { return queueFolder.listFiles() .filter { it.isFile && it.extension == queueExtension } } override suspend fun getAllQueueIds(): List { return withContext(Dispatchers.IO) { getQueueFiles().map { it.name.substring(0,it.name.length-".$queueExtension".length) }.mapNotNull { it.toLongOrNull() } } } override suspend fun getQueue(queueId: Long): QueueModel { return withContext(Dispatchers.IO) { requireNotNull(fileSaver.readObject( getFileOfQueue(queueId) ) ) { "Queue with $queueId returned null" } } } override suspend fun getAllQueues(): List { return getAllQueueIds().mapNotNull { kotlin.runCatching { getQueue(it) }.getOrNull() } } override suspend fun deleteAllQueues() { getQueueFiles().forEach { it.delete() } } override suspend fun setAllQueues(queues: List) { lock.withLock { deleteAllQueues() queues.forEach { addQueue(it) } } } override suspend fun deleteQueue(queueId: Long) { lock.withLock { getFileOfQueue(queueId).delete() } } override suspend fun updateQueue(queue: QueueModel) { lock.withLock { withContext(Dispatchers.IO) { fileSaver.writeObject( getFileOfQueue(queue), queue ) } } } override suspend fun addQueue(queue: QueueModel) { val fileOfQueue = getFileOfQueue(queue) withContext(Dispatchers.IO) { fileSaver.writeObject( fileOfQueue, queue, ) } } } ================================================ FILE: downloader/core/src/commonMain/kotlin/ir/amirab/downloader/db/TransactionalFileSaver.kt ================================================ package ir.amirab.downloader.db import ir.amirab.util.tryAtomicMove import kotlinx.serialization.KSerializer import kotlinx.serialization.Serializer import kotlinx.serialization.json.Json import okio.FileSystem import okio.Path.Companion.toOkioPath import java.io.File class TransactionalFileSaver( val json: Json, ) { fun getBakFile(file: File) = File("$file.tmp") inline fun writeObject(file: File, t: T) { val text = json.encodeToString(t) writeText(file, text) } fun writeObject(file: File, t: T, kSerializer: KSerializer) { val text = json.encodeToString(kSerializer, t) writeText(file, text) } fun writeText(file: File, text: String) { val bakFile = getBakFile(file) kotlin.runCatching { FileSystem.SYSTEM.write( file = bakFile.toOkioPath() ) { writeUtf8(text) } }.onSuccess { bakFile.tryAtomicMove(file) }.getOrThrow() } fun readText(file: File): String? { return runCatching { FileSystem.SYSTEM.read(file.toOkioPath()) { readUtf8() } }.getOrNull() } inline fun readObject(file: File): T? { return kotlin.runCatching { val text = readText(file)!! json.decodeFromString(text) }.getOrNull() } fun readObject(file: File, serializer: KSerializer): T? { return kotlin.runCatching { val text = readText(file)!! json.decodeFromString(serializer, text) }.getOrNull() } } ================================================ FILE: downloader/core/src/commonMain/kotlin/ir/amirab/downloader/destination/DestWriter.kt ================================================ package ir.amirab.downloader.destination import ir.amirab.downloader.anntation.HeavyCall import okio.Buffer import okio.FileHandle import okio.FileSystem import okio.Sink import java.io.File /** * this class provide an interface for independent write buffer by Part Manager Crew * */ class DestWriter( val id: Long, val file: File, var seekPos: Long, val writer: FileHandle, ) { private var status: Status = Status.NotPrepared @Transient private var sink: Sink? = null @HeavyCall @Synchronized fun prepare() { if (status != Status.NotPrepared) { error("already prepared : status=$status") } if (!file.exists()) { error("file not exists, can't prepare file") } status = Status.Preparing sink = writer.sink(seekPos) status = Status.Prepared // println("part #$id started to write from $seekPos") } @Synchronized fun release() { sink?.close() status = Status.NotPrepared // println("part #$id stopped to write to $seekPos") } fun write(buffer: Buffer, length: Long = buffer.size) { val currentStatus = status if (currentStatus == Status.NotPrepared) { throw Exception("first prepare") } if (currentStatus == Status.Finished) { throw Exception("finished still writing?") } if (currentStatus == Status.Prepared) { status = Status.Writing } sink!!.write(buffer, length) seekPos += length // println("seek :$seekPos") } enum class Status { NotPrepared, Preparing, Prepared, Writing, Finished } fun use(block: (DestWriter) -> Unit) { // println("using dest") prepare() try { block(this) } catch (e: Exception) { throw e } finally { try { // println("release dest") release() } catch (_: Exception) { } } } } ================================================ FILE: downloader/core/src/commonMain/kotlin/ir/amirab/downloader/destination/DownloadDestination.kt ================================================ package ir.amirab.downloader.destination import ir.amirab.downloader.part.DownloadPart import ir.amirab.util.tryAtomicMove import java.io.File abstract class DownloadDestination( outputFile: File, ) { val outputFile = outputFile.canonicalFile.absoluteFile protected val fileParts = mutableListOf() protected var allPartsDownloaded = false protected var requestedToChangeLastModified: Long? = null protected open fun onAllFilePartsRemoved() { updateLastModified() } open fun onAllPartsCompleted( onProgressUpdate: (Int?) -> Unit = {} ) { allPartsDownloaded = true cleanUpJunkFiles() updateLastModified() } open fun cleanUpJunkFiles() {} abstract fun getWriterFor(part: DownloadPart): DestWriter abstract fun canGetFileWriter(): Boolean fun returnIfAlreadyHaveWriter(partId: Long): DestWriter? { synchronized(this) { return fileParts.find { val condition = it.id == partId // if (condition) { // logger.info("part id$partId already have an associated file") // } condition } } } open fun deleteOutputFile() { outputFile.delete() } abstract suspend fun prepareFile(onProgressUpdate: (Int?) -> Unit) abstract suspend fun isDownloadedPartsIsValid(): Boolean abstract fun flush() open fun onPartCancelled(part: DownloadPart) { synchronized(this) { val cleanAny = fileParts.removeAll { it.id == part.getID() } if (cleanAny) { if (fileParts.isEmpty()) { onAllFilePartsRemoved() } } } } /** * specify last modified time to be used when this destination finish its work * for example file paused / finished */ fun setLastModified(timestamp: Long?) { requestedToChangeLastModified = timestamp } protected open fun updateLastModified() { kotlin.runCatching { requestedToChangeLastModified?.let { outputFile.setLastModified(it) } } } /** * after you use this method this class must be recreated */ open fun moveOutput(to: File) { if (outputFile.exists()) { try { outputFile.tryAtomicMove(to) } catch (e: Exception) { throw IllegalStateException( "Failed to move output file to the new destination: ${e.localizedMessage}", e, ) } } } companion object { fun prepareDestinationFolder( outputFile: File, ) { outputFile.parentFile.let { it.canonicalFile.mkdirs() if (!it.exists()) { error("can't create folder for destination file $it") } if (!it.isDirectory) { error("${outputFile.parentFile} is not a directory") } } } } } ================================================ FILE: downloader/core/src/commonMain/kotlin/ir/amirab/downloader/destination/IncompleteFileUtil.kt ================================================ package ir.amirab.downloader.destination import ir.amirab.util.platform.Platform import ir.amirab.util.platform.isWindows import java.io.File object IncompleteFileUtil { private const val SYSTEM_MAXIMUM_FILE_LENGTH = 255 private const val SYSTEM_MAXIMUM_FULL_PATH_LENGTH = 259 private fun createExtension(id: Long): String { return ".dl-$id.abdm.part" } fun addIncompleteIndicator(file: File, id: Long): File { val ext = createExtension(id) if (!file.name.endsWith(ext)) { // if the file name is too long, we need to trim it // so that the full path length does not exceed the system limit // this is a workaround for Windows systems which have a maximum path length of 260 characters // see https://learn.microsoft.com/en-us/windows/win32/fileio/naming-a-file#maximum-path-length-limitation val trimmedFileName = if (Platform.isWindows()) { // this + 1 is to account for last slash in the path val parentPathLength = file.parentFile.path.length + 1 file.name.take( (SYSTEM_MAXIMUM_FULL_PATH_LENGTH - (parentPathLength + ext.length)) // maybe the remaining length is negative which means the file name is too long even after trim! // we hope that filesystem will allow us to create such a file otherwise we will crash! // in that case the user must reduce the path length and try again .coerceAtLeast(0) ) } else { // and some other systems which have a maximum file name length of 255 characters file.name.take(SYSTEM_MAXIMUM_FILE_LENGTH - ext.length) } return file.parentFile.resolve(trimmedFileName + ext) } return file } } ================================================ FILE: downloader/core/src/commonMain/kotlin/ir/amirab/downloader/destination/SegmentedDownloadDestination.kt ================================================ package ir.amirab.downloader.destination import ir.amirab.downloader.part.DownloadPart import ir.amirab.downloader.utils.calcPercent import okhttp3.internal.closeQuietly import okio.FileSystem import okio.Path.Companion.toOkioPath import java.io.File class SegmentedDownloadDestination( // a directory unique for this item! val tempDirectory: File, val getFileName: (DownloadPart) -> String, val getAllParts: () -> List, val appendMode: Boolean, outputFile: File, ) : DownloadDestination(outputFile) { private fun getFileOfPart(downloadPart: DownloadPart): File { val id = downloadPart.getID() return tempDirectory.resolve("$id") } override fun getWriterFor(part: DownloadPart): DestWriter { tempDirectory.mkdirs() val tFile = getFileOfPart(part) val writer = FileSystem.SYSTEM.openReadWrite(tFile.toOkioPath(), mustCreate = false, mustExist = false) val newSize = if (appendMode) { writer.size() } else { // part starts from the beginning 0 } writer.resize(newSize) val destWriter = DestWriter( id = part.getID(), file = tFile, seekPos = newSize, writer = writer, ) synchronized(this) { fileParts.add(destWriter) } return destWriter } override fun onPartCancelled(part: DownloadPart) { super.onPartCancelled(part) val id = part.getID() synchronized(this) { fileParts .find { it.id == id } ?.writer ?.closeQuietly() } } override fun canGetFileWriter(): Boolean { return true } override suspend fun prepareFile(onProgressUpdate: (Int?) -> Unit) { } override suspend fun isDownloadedPartsIsValid(): Boolean { return tempDirectory.exists() } fun isDownloadPartValid(part: DownloadPart): Boolean { return getFileOfPart(part).exists() } override fun cleanUpJunkFiles() { runCatching { FileSystem.SYSTEM.deleteRecursively(tempDirectory.toOkioPath()) } } override fun onAllPartsCompleted(onProgressUpdate: (Int?) -> Unit) { assemble( getAllParts() .sortedBy { it.getID() } .map { tempDirectory.resolve(getFileName(it)) }, outputFile, onProgressUpdate ) super.onAllPartsCompleted(onProgressUpdate) } fun assemble( sources: List, destination: File, onProgress: (Int) -> Unit ) { DownloadDestination.prepareDestinationFolder(outputFile) val totalLength = sources.sumOf { it.length() } var totalWritten = 0L val buffer = ByteArray(DEFAULT_BUFFER_SIZE) var percent = 0 destination.outputStream().use { dst -> sources.forEach { sourceFile -> sourceFile.inputStream().use { src -> onProgress(percent) while (true) { val len = src.read(buffer) if (len == -1) break dst.write(buffer, 0, len) totalWritten += len val newPercent = calcPercent(totalWritten, totalLength) if (newPercent != percent) { onProgress(newPercent) percent = newPercent } } } } } onProgress(100) } override fun flush() { synchronized(this) { fileParts.forEach { it.writer.flush() } } } } ================================================ FILE: downloader/core/src/commonMain/kotlin/ir/amirab/downloader/destination/SimpleDownloadDestination.kt ================================================ package ir.amirab.downloader.destination import ir.amirab.downloader.anntation.HeavyCall import ir.amirab.downloader.part.DownloadPart import ir.amirab.downloader.utils.EmptyFileCreator import ir.amirab.util.tryAtomicMove import okio.FileHandle import okio.FileSystem import okio.Path.Companion.toOkioPath import java.io.File class SimpleDownloadDestination( file: File, val appendExtensionToIncompleteDownloads: Boolean, val downloadId: Long, private val emptyFileCreator: EmptyFileCreator, ) : DownloadDestination( outputFile = file, ) { // this is only used when appendExtensionToIncompleteDownloads is true val incompleteFile by lazy { IncompleteFileUtil.addIncompleteIndicator(outputFile, downloadId) } private val fileToWrite: File = if (appendExtensionToIncompleteDownloads) { incompleteFile } else { outputFile } private var _fileHandle: FileHandle? = null private val fileHandle: FileHandle get() { synchronized(this) { if (_fileHandle == null) { return initFileHandle() } } return _fileHandle!! } private fun initFileHandle(): FileHandle { // let's open a file for writing to it! // it will be removed when all parts are cancelled so this method // maybe called multiple times val handle = FileSystem.SYSTEM.openReadWrite(fileToWrite.toOkioPath()) _fileHandle = handle return handle } private fun removeFileHandle() { //close and release handle to unlock the file synchronized(this) { _fileHandle?.close() _fileHandle = null } } override fun onAllFilePartsRemoved() { super.onAllFilePartsRemoved() // println("release handle") removeFileHandle() } override fun onAllPartsCompleted(onProgressUpdate: (Int?) -> Unit) { if (appendExtensionToIncompleteDownloads) { // this function maybe called at some point that we may not even start download yet. // for example when the download has already completed, the DownloadJob will call this function! so we should do nothing. val incompleteFile = incompleteFile if (!incompleteFile.exists()) { return } val completeFile = outputFile // delete old file if exists to override with new one! if (completeFile.exists()) { completeFile.delete() } try { incompleteFile.tryAtomicMove(completeFile) } catch (e: Exception) { // prevent remove the part file if it can't be moved by us! throw IllegalStateException( "failed to move .part file to the actual output file! ${e.localizedMessage}", e ) } } // clean up junk files called in the super class super.onAllPartsCompleted(onProgressUpdate) } var outputSize: Long = -1 override fun getWriterFor( part: DownloadPart, ): DestWriter { if (!canGetFileWriter()) { throw IllegalStateException("First check then ask for...") } val outFile = fileToWrite val returned = returnIfAlreadyHaveWriter(part.getID()) returned?.let { return it } val writer = DestWriter( part.getID(), outFile, part.current, fileHandle, ) synchronized(this) { fileParts.add(writer) } return writer } override fun flush() { runCatching { _fileHandle?.flush() } } fun prepareDestinationFolder() { DownloadDestination.prepareDestinationFolder(fileToWrite) } @HeavyCall override suspend fun prepareFile(onProgressUpdate: (Int?) -> Unit) { // println("preparing file ") // println("file info path=$outputFile size=${outputFile.runCatching { length() }.getOrNull()}") val incompleteFile = fileToWrite prepareDestinationFolder() emptyFileCreator .prepareFile(incompleteFile, outputSize, onProgressUpdate) } /** * restart download if file was deleted by user! * this function will be called when the download is resumed, and it's not completed yet. */ override suspend fun isDownloadedPartsIsValid(): Boolean { val targetFile = fileToWrite val fileExists = targetFile.exists() val fileEqualToContentSize = targetFile.length() == outputSize return fileExists && fileEqualToContentSize } override fun canGetFileWriter(): Boolean { return true } override fun updateLastModified() { runCatching { requestedToChangeLastModified?.let { fileToWrite.setLastModified(it) } } } override fun moveOutput(to: File) { if (appendExtensionToIncompleteDownloads) { val incompleteFile = incompleteFile if (incompleteFile.exists()) { try { incompleteFile.tryAtomicMove(IncompleteFileUtil.addIncompleteIndicator(to, downloadId)) } catch (e: Exception) { throw IllegalStateException( "Failed to move .part file to the new destination: ${e.localizedMessage}", e, ) } } } super.moveOutput(to) } override fun cleanUpJunkFiles() { // remove incomplete file if exists val incompleteFile = incompleteFile if (incompleteFile.exists()) { incompleteFile.delete() } } } ================================================ FILE: downloader/core/src/commonMain/kotlin/ir/amirab/downloader/downloaditem/DownloadItemContext.kt ================================================ package ir.amirab.downloader.downloaditem interface DownloadItemContext { operator fun get(key: Key): T? fun fold(initial: T, operation: (acc: T, element: Element) -> T): T fun minusKey(key: Key<*>): DownloadItemContext operator fun plus(context: DownloadItemContext): DownloadItemContext { if (context === EmptyContext) { return this } return context.fold(this) { acc, element -> //maybe same key alreadyExists val removed = acc.minusKey(element.getKey()) if (removed === EmptyContext) { element } else { CombinedContext(removed, element) } } } interface Key interface Element : DownloadItemContext { fun getKey(): Key<*> override fun get(key: Key): T? { @Suppress("UNCHECKED_CAST") return if (getKey() === key) return this as T else null } override fun minusKey(key: Key<*>): DownloadItemContext { return if (key === getKey()) { EmptyContext } else { this } } override fun fold(initial: T, operation: (acc: T, element: Element) -> T): T { return operation(initial, this) } } } data object EmptyContext : DownloadItemContext { override fun get(key: DownloadItemContext.Key): T? { return null } override fun fold(initial: T, operation: (acc: T, element: DownloadItemContext.Element) -> T): T { return initial } override fun plus(context: DownloadItemContext): DownloadItemContext { return context } override fun minusKey(key: DownloadItemContext.Key<*>): DownloadItemContext { return this } } private data class CombinedContext( val left: DownloadItemContext, val element: DownloadItemContext.Element, ) : DownloadItemContext { override fun get(key: DownloadItemContext.Key): T? { var cur = this while (true) { if (cur.element[key] != null) { @Suppress("UNCHECKED_CAST") return cur.element as T } val next = cur.left //make recursive function flatten if (next is CombinedContext) { cur = next } else { return next[key] } } } override fun fold(initial: T, operation: (acc: T, element: DownloadItemContext.Element) -> T): T { return operation(left.fold(initial, operation), element) } override fun minusKey(key: DownloadItemContext.Key<*>): DownloadItemContext { if (element.getKey() === key) { return left } val newLeft = left.minusKey(key) return when { newLeft === EmptyContext -> element newLeft === left -> this else -> CombinedContext(newLeft, element) } } override fun toString(): String { return fold("") { acc, element -> if (acc.isEmpty()) { "$element" } else { "$acc , $element" } }.let { " { $it } " } } } //extensions fun DownloadItemContext.map(transform: (DownloadItemContext.Element) -> T): List { return fold(mutableListOf()) { acc, element -> acc.apply { add(transform(element)) } } } fun DownloadItemContext.minusKeys(vararg keys: DownloadItemContext.Key<*>) { var cur = this for (key in keys) { cur = cur.minusKey(key) } } fun DownloadItemContext.toList() = map { it } fun DownloadItemContext.keys() = map { it.getKey() } fun DownloadItemContext.isEmpty(): Boolean { return this === EmptyContext } val DownloadItemContext.size: Int get() { return fold(0) { acc, _ -> acc + 1 } } fun DownloadItemContext.iterator(): Iterator { return toList().iterator() } fun DownloadItemContext.contains(element: DownloadItemContext.Element): Boolean { return contains(element.getKey()) } fun DownloadItemContext.contains(key: DownloadItemContext.Key<*>): Boolean { return this[key] != null } fun DownloadItemContext.containsAll(elements: Collection): Boolean { for (el in elements) { if (!contains(el)) { return false } } return true } ================================================ FILE: downloader/core/src/commonMain/kotlin/ir/amirab/downloader/downloaditem/DownloadJob.kt ================================================ package ir.amirab.downloader.downloaditem import ir.amirab.downloader.DownloadManager import ir.amirab.downloader.destination.DownloadDestination import ir.amirab.downloader.utils.ExceptionUtils import ir.amirab.util.suspendGuardedEntry import kotlinx.coroutines.CancellationException import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.SupervisorJob import kotlinx.coroutines.cancel import kotlinx.coroutines.delay import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.update import kotlinx.coroutines.job import kotlinx.coroutines.launch abstract class DownloadJob( val downloadManager: DownloadManager, ) { protected val _isDownloadActive = MutableStateFlow(false) val isDownloadActive = _isDownloadActive.asStateFlow() abstract val downloadItem: IDownloadItem val id get() = downloadItem.id val scope = CoroutineScope(SupervisorJob()) var activeDownloadScope: CoroutineScope? = null abstract fun getDestination(): DownloadDestination private val booted = suspendGuardedEntry() protected val _status = MutableStateFlow(DownloadJobStatus.IDLE) val status = _status.asStateFlow() suspend fun boot() { booted.action { actualBoot() } } abstract suspend fun actualBoot() abstract fun initializeDestination() abstract suspend fun reset() abstract suspend fun resume() abstract suspend fun pause(throwable: Throwable = CancellationException()) abstract suspend fun saveState() protected fun ensureBooted() { require(booted.isDone()) { "DownloadJob is not booted! Call boot() before using this object." } } protected fun startAutoSaver() { activeDownloadScope?.launch(Dispatchers.IO) { while (true) { saveState() delay(1000) } } } protected fun onDownloadResuming() { _status.update { DownloadJobStatus.Resuming } downloadManager.onDownloadResuming(downloadItem) } protected fun onDownloadResumed() { _status.update { DownloadJobStatus.Downloading } downloadManager.onDownloadResumed(downloadItem) } protected suspend fun onDownloadCanceled(throwable: Throwable) { _status.update { DownloadJobStatus.Canceled(throwable) } if (ExceptionUtils.isNormalCancellation(throwable)) { downloadItem.status = DownloadStatus.Paused } else { downloadItem.status = DownloadStatus.Error } _isDownloadActive.update { false } saveState() downloadManager.onDownloadCanceled(downloadItem, throwable) } protected fun onDownloadFinished() { scope.launch { try { getDestination().onAllPartsCompleted { _status.value = DownloadJobStatus.PreparingFile(it) } } catch (e: Exception) { pause(e) return@launch } downloadItem.status = DownloadStatus.Completed downloadItem.completeTime = System.currentTimeMillis() _status.value = DownloadJobStatus.Finished _isDownloadActive.update { false } onDownloadFinishedBeforeSave() saveState() downloadManager.onDownloadFinished(downloadItem) } } open fun onDownloadFinishedBeforeSave() {} abstract fun getDownloadedSize(): Long fun downloadRemoved( removeOutputFile: Boolean = true, ) { ensureBooted() getDestination().cleanUpJunkFiles() if (removeOutputFile) { getDestination().deleteOutputFile() } } abstract fun reloadSettings() fun newScopeBasedOn(scope: CoroutineScope): CoroutineScope { return CoroutineScope(scope.coroutineContext + SupervisorJob(scope.coroutineContext.job)) } fun close() { scope.cancel() } abstract suspend fun changeConfig( updater: (IDownloadItem) -> Unit, extraConfig: DownloadJobExtraConfig? ): IDownloadItem abstract suspend fun extraConfigsReceived(config: DownloadJobExtraConfig) } ================================================ FILE: downloader/core/src/commonMain/kotlin/ir/amirab/downloader/downloaditem/DownloadJobExtraConfig.kt ================================================ package ir.amirab.downloader.downloaditem interface DownloadJobExtraConfig ================================================ FILE: downloader/core/src/commonMain/kotlin/ir/amirab/downloader/downloaditem/DownloadJobStatus.kt ================================================ package ir.amirab.downloader.downloaditem import ir.amirab.downloader.utils.ExceptionUtils sealed class DownloadJobStatus( val order: Int, private val downloadStatus: DownloadStatus ) { fun asDownloadStatus() = downloadStatus data object Downloading : DownloadJobStatus(0, DownloadStatus.Downloading), IsActive data class Retrying(val timeUntilRetry: Long) : DownloadJobStatus(0, DownloadStatus.Paused), IsActive data object Resuming : DownloadJobStatus(0, DownloadStatus.Downloading), IsActive data class PreparingFile(val percent: Int?) : DownloadJobStatus(1, DownloadStatus.Downloading), IsActive data class Canceled(val e: Throwable) : DownloadJobStatus( 2, if (ExceptionUtils.isNormalCancellation(e)) DownloadStatus.Paused else DownloadStatus.Error ), CanBeResumed data object IDLE : DownloadJobStatus(2, DownloadStatus.Added), CanBeResumed data object Finished : DownloadJobStatus(3, DownloadStatus.Completed) sealed interface IsActive sealed interface CanBeResumed } ================================================ FILE: downloader/core/src/commonMain/kotlin/ir/amirab/downloader/downloaditem/DownloadStatus.kt ================================================ package ir.amirab.downloader.downloaditem enum class DownloadStatus { Error, Added, Paused, Downloading, Completed, } ================================================ FILE: downloader/core/src/commonMain/kotlin/ir/amirab/downloader/downloaditem/IDownloadCredentials.kt ================================================ package ir.amirab.downloader.downloaditem import arrow.core.None import arrow.core.Option import arrow.core.none interface IDownloadCredentials { val link: String val downloadPage: String? fun validateCredentials() fun copy( link: Option = None, downloadPage: Option = None, ): IDownloadCredentials } ================================================ FILE: downloader/core/src/commonMain/kotlin/ir/amirab/downloader/downloaditem/IDownloadItem.kt ================================================ package ir.amirab.downloader.downloaditem import arrow.core.None import arrow.core.Option interface IDownloadItem : IDownloadCredentials { var id: Long var folder: String var name: String override var link: String var contentLength: Long override var downloadPage: String? var dateAdded: Long var startTime: Long? var completeTime: Long? var status: DownloadStatus var preferredConnectionCount: Int? var speedLimit: Long var fileChecksum: String? fun copy( id: Option = None, folder: Option = None, name: Option = None, link: Option = None, contentLength: Option = None, downloadPage: Option = None, dateAdded: Option = None, startTime: Option = None, completeTime: Option = None, status: Option = None, preferredConnectionCount: Option = None, speedLimit: Option = None, fileChecksum: Option = None, ): IDownloadItem fun validateItem() fun withCredentials(credentials: IDownloadCredentials): IDownloadItem companion object { const val LENGTH_UNKNOWN = -1L } } ================================================ FILE: downloader/core/src/commonMain/kotlin/ir/amirab/downloader/downloaditem/contexts/DefaultContexts.kt ================================================ package ir.amirab.downloader.downloaditem.contexts import ir.amirab.downloader.downloaditem.DownloadItemContext interface CanPerformRemove interface CanPerformResume interface CanPerformPause object User:CanPerformPause,CanPerformResume,CanPerformRemove object DuplicateRemoval:CanPerformRemove data class Queue(val queue:Long):CanPerformPause,CanPerformResume,CanPerformRemove data class StoppedBy( val by: CanPerformPause ):DownloadItemContext.Element{ companion object Key : DownloadItemContext.Key override fun getKey(): DownloadItemContext.Key<*> = Key } data class ResumedBy( val by: CanPerformPause ):DownloadItemContext.Element{ companion object Key : DownloadItemContext.Key override fun getKey(): DownloadItemContext.Key<*> = Key } data class RemovedBy( val by: CanPerformRemove ):DownloadItemContext.Element{ companion object Key : DownloadItemContext.Key override fun getKey(): DownloadItemContext.Key<*> = Key } ================================================ FILE: downloader/core/src/commonMain/kotlin/ir/amirab/downloader/downloaditem/hls/HLSDownloadCredentials.kt ================================================ package ir.amirab.downloader.downloaditem.hls import arrow.core.Option import arrow.core.getOrElse import ir.amirab.downloader.downloaditem.IDownloadCredentials import ir.amirab.util.HttpUrlUtils import kotlinx.serialization.SerialName import kotlinx.serialization.Serializable @Serializable @SerialName("hls") data class HLSDownloadCredentials( override val headers: Map? = null, override val username: String? = null, override val password: String? = null, override val userAgent: String? = null, override val link: String, override val downloadPage: String? = null ) : IHLSCredentials { override fun copy( link: Option, downloadPage: Option ): IDownloadCredentials { return copy( link = link.getOrElse { this.link }, downloadPage = downloadPage.getOrElse { this.downloadPage }, ) } override fun validateCredentials() { HttpUrlUtils.isValidUrl(link) } } ================================================ FILE: downloader/core/src/commonMain/kotlin/ir/amirab/downloader/downloaditem/hls/HLSDownloadItem.kt ================================================ package ir.amirab.downloader.downloaditem.hls import arrow.core.Option import arrow.core.getOrElse import ir.amirab.downloader.downloaditem.DownloadStatus import ir.amirab.downloader.downloaditem.IDownloadCredentials import ir.amirab.downloader.downloaditem.IDownloadItem import ir.amirab.downloader.downloaditem.http.IHttpBasedDownloadCredentials import ir.amirab.util.HttpUrlUtils import kotlinx.serialization.Serializable @Serializable data class HLSDownloadItem( override var id: Long, override var folder: String, override var name: String, override var link: String, override var contentLength: Long = -1, override var downloadPage: String? = null, override var dateAdded: Long, override var startTime: Long? = null, override var completeTime: Long? = null, override var status: DownloadStatus = DownloadStatus.Added, override var preferredConnectionCount: Int? = null, override var speedLimit: Long = 0, override var fileChecksum: String? = null, override var headers: Map? = null, override var username: String? = null, override var password: String? = null, override var userAgent: String? = null, var duration: Double? = null, ) : IDownloadItem, IHLSCredentials { override fun copy( id: Option, folder: Option, name: Option, link: Option, contentLength: Option, downloadPage: Option, dateAdded: Option, startTime: Option, completeTime: Option, status: Option, preferredConnectionCount: Option, speedLimit: Option, fileChecksum: Option ): IDownloadItem { return copy( id = id.getOrElse { this.id }, folder = folder.getOrElse { this.folder }, name = name.getOrElse { this.name }, link = link.getOrElse { this.link }, contentLength = contentLength.getOrElse { this.contentLength }, downloadPage = downloadPage.getOrElse { this.downloadPage }, dateAdded = dateAdded.getOrElse { this.dateAdded }, startTime = startTime.getOrElse { this.startTime }, completeTime = completeTime.getOrElse { this.completeTime }, status = status.getOrElse { this.status }, preferredConnectionCount = preferredConnectionCount.getOrElse { this.preferredConnectionCount }, speedLimit = speedLimit.getOrElse { this.speedLimit }, fileChecksum = fileChecksum.getOrElse { this.fileChecksum }, ) } override fun copy( link: Option, downloadPage: Option ): HLSDownloadItem { return copy( link = link.getOrElse { this.link }, downloadPage = downloadPage.getOrElse { this.downloadPage }, ) } override fun validateItem() { validateCredentials() } override fun withCredentials(credentials: IDownloadCredentials): HLSDownloadItem { return if (credentials is IHLSCredentials) { withHlsCredentials(credentials) } else { this } } override fun validateCredentials() { HttpUrlUtils.isValidUrl(link) } companion object { fun createWithCredentials( credentials: IHLSCredentials, id: Long, folder: String, name: String, contentLength: Long = IDownloadItem.LENGTH_UNKNOWN, dateAdded: Long = 0, startTime: Long? = null, completeTime: Long? = null, status: DownloadStatus = DownloadStatus.Added, preferredConnectionCount: Int? = null, speedLimit: Long = 0, fileChecksum: String? = null, duration: Double? = null, ): HLSDownloadItem { return HLSDownloadItem( link = credentials.link, headers = credentials.headers, username = credentials.username, password = credentials.password, downloadPage = credentials.downloadPage, userAgent = credentials.userAgent, id = id, folder = folder, name = name, contentLength = contentLength, dateAdded = dateAdded, startTime = startTime, completeTime = completeTime, status = status, preferredConnectionCount = preferredConnectionCount, speedLimit = speedLimit, fileChecksum = fileChecksum, duration = duration, ) } } } private fun HLSDownloadItem.withHlsCredentials(credentials: IHttpBasedDownloadCredentials) = apply { link = credentials.link headers = credentials.headers username = credentials.username password = credentials.password downloadPage = credentials.downloadPage userAgent = credentials.userAgent } fun HLSDownloadItem.applyFrom(other: HLSDownloadItem) { link = other.link headers = other.headers username = other.username password = other.password downloadPage = other.downloadPage userAgent = other.userAgent id = other.id folder = other.folder name = other.name contentLength = other.contentLength dateAdded = other.dateAdded startTime = other.startTime completeTime = other.completeTime status = other.status preferredConnectionCount = other.preferredConnectionCount speedLimit = other.speedLimit fileChecksum = other.fileChecksum } ================================================ FILE: downloader/core/src/commonMain/kotlin/ir/amirab/downloader/downloaditem/hls/HLSDownloadJob.kt ================================================ package ir.amirab.downloader.downloaditem.hls import io.lindstrom.m3u8.model.MediaPlaylist import ir.amirab.downloader.DownloadManager import ir.amirab.downloader.connection.HttpDownloaderClient import ir.amirab.downloader.destination.DownloadDestination import ir.amirab.downloader.destination.SegmentedDownloadDestination import ir.amirab.downloader.downloaditem.DownloadJob import ir.amirab.downloader.downloaditem.DownloadJobExtraConfig import ir.amirab.downloader.downloaditem.DownloadJobStatus import ir.amirab.downloader.downloaditem.DownloadStatus import ir.amirab.downloader.downloaditem.IDownloadItem import ir.amirab.downloader.exception.DownloadValidationException import ir.amirab.downloader.exception.TooManyErrorException import ir.amirab.downloader.part.* import ir.amirab.downloader.utils.* import ir.amirab.util.tryLocked import kotlinx.coroutines.* import kotlinx.coroutines.flow.launchIn import kotlinx.coroutines.flow.onEach import kotlinx.coroutines.flow.update import kotlinx.coroutines.sync.Mutex import kotlinx.coroutines.sync.withLock import okio.Throttler import java.util.concurrent.ConcurrentHashMap /** * alive object that responsible for download a file */ class HLSDownloadJob( override val downloadItem: HLSDownloadItem, downloadManager: DownloadManager, val client: HttpDownloaderClient, ) : DownloadJob( downloadManager = downloadManager, ) { val listDb by downloadManager::dlListDb val partListDb by downloadManager::partListDb private val parts: MutableList = mutableListOf() private lateinit var destination: SegmentedDownloadDestination override fun getDestination(): DownloadDestination { return destination } var serverLastModified: Long? = null private set override suspend fun actualBoot() { initializeDestination() loadPartState() applySpeedLimit() downloadedSizeBeforeRetry = getDownloadedSize() } override fun initializeDestination() { val outFile = downloadManager.calculateOutputFile(downloadItem) destination = SegmentedDownloadDestination( outputFile = outFile, getAllParts = { getParts() }, tempDirectory = downloadManager.downloadDataFolder.resolve(id.toString()), getFileName = { it.getID().toString() }, appendMode = false, ) } private fun setParts(list: List) { this.parts.clear() list.forEach { if (it.isCompleted) { it.statusFlow.update { PartDownloadStatus.Completed } } } this.parts.addAll(list) } val itemSaveLock = Mutex() val partLock = Mutex() private suspend fun loadPartState() { val mediaSegments = partLock.withLock { partListDb.getParts(id) } as? MediaSegments setParts(mediaSegments?.list.orEmpty()) } override suspend fun reset() { pause() clearPartDownloaderList() setParts(emptyList()) downloadItem.contentLength = IDownloadItem.LENGTH_UNKNOWN downloadItem.status = DownloadStatus.Added downloadItem.startTime = null downloadItem.completeTime = null downloadItem.duration = null downloadedSizeBeforeRetry = 0 // nothing saveState() downloadManager.onDownloadItemChange(downloadItem) } override suspend fun resume() { if (isDownloadActive.value) { return } _isDownloadActive.update { true } resumeWithNewScope( newActiveScope = createAndInitializeDownloadScope(), isInFirstResume = true ) } fun createAndInitializeDownloadScope(): CoroutineScope { val newActiveScope = newScopeBasedOn(scope) .also { activeDownloadScope = it } return newActiveScope } private suspend fun resumeWithNewScope( newActiveScope: CoroutineScope, isInFirstResume: Boolean, ) { // println(parts.filter { !it.isCompleted }) return newActiveScope.launch { //boot download item from storage! boot() // if download item is booted and parts is not empty it means that we resumed that file in some point // but we should check if all parts are already downloaded to finish the job before hitting the server unnecessarily! if (parts.isNotEmpty() && parts.all { it.isCompleted }) { onDownloadFinished() return@launch } onDownloadResuming() try { fetchDownloadInfoAndValidate() createPartDownloaderList() // println("part downloaders created") beginDownloadParts() startAutoSaver() downloadItem.status = DownloadStatus.Downloading if (downloadItem.startTime == null) { downloadItem.startTime = System.currentTimeMillis() } saveState() onDownloadResumed() } catch (e: Exception) { e.printStackIfNOtUsual() val shouldStop = when { ExceptionUtils.isNormalCancellation(e) -> true e is DownloadValidationException -> e.isCritical() else -> false } if (shouldStop) { // this function called from activeDownloadScope // so we change the scope here to prevent cancel this suspend function scope.launch { pause(e) } } else { downloadFailedRetryOrPause( e = e, isInFirstResume = isInFirstResume, ) } } }.join() } override fun getDownloadedSize(): Long { return getParts().sumOf { it.howMuchProceed() } } fun onPreferredConnectionCountChanged() { activeDownloadScope?.launch { beginDownloadParts() } } override suspend fun changeConfig( updater: (IDownloadItem) -> Unit, extraConfig: DownloadJobExtraConfig? ): IDownloadItem { boot() val previousItem = downloadItem.copy() val newItem = previousItem.copy().apply(updater) val previousDestination = downloadManager.calculateOutputFile(previousItem) val newDestination = downloadManager.calculateOutputFile(newItem) val shouldUpdateDestination = previousDestination != newDestination if (shouldUpdateDestination) { if (isDownloadActive.value) { pause() } destination.moveOutput(newDestination) } // if there is no error update the actual download item downloadItem.applyFrom(newItem) if (shouldUpdateDestination) { // destination should be closed for now! initializeDestination() } if (previousItem.preferredConnectionCount != downloadItem.preferredConnectionCount) { onPreferredConnectionCountChanged() } if (previousItem.link != downloadItem.link) { onLinkChanged() } applySpeedLimit() extraConfig?.let { extraConfigsReceived(extraConfig) } saveDownloadItem() return downloadItem } private fun applySpeedLimit() { jobThrottler.bytesPerSecond(bytesPerSecond = downloadItem.speedLimit) } fun onLinkChanged() { scope.launch { if (activeDownloadScope?.isActive == true) { pause() resume() } } } fun getRequestedThreadCount(): Int { return downloadItem.preferredConnectionCount ?: downloadManager.settings.defaultThreadCount } private val partLoopLock = Mutex() // private val c = AtomicInteger(0) private fun beginDownloadParts() { if (partLoopLock.isLocked) { return } activeDownloadScope?.launch { if (!partLoopLock.tryLock()) { return@launch } // c.incrementAndGet() try { val activeCount = getPartDownloaderList() .count { it.active } val howMuchCreate = getRequestedThreadCount() - activeCount if (howMuchCreate > 0) { val mutableInactivePartDownloaderList = getPartDownloaderList() .filter { !it.active && !it.part.isCompleted } .sortedBy { it.part.getID() } .toMutableList() // println(mutableInactivePartDownloaderList) fun getPartDownloader(): HLSPartDownloader? { val inactivePart = runCatching { mutableInactivePartDownloaderList.removeAt(0) }.getOrNull() if (inactivePart != null) return inactivePart return null } for (i in 1..howMuchCreate) { val partDownloader = getPartDownloader() if (partDownloader == null) { // println("part downloader is null") break } if (partDownloader.part.isCompleted) { // println("it seems part is downloaded!") continue } // println("got new part downloader ${partDownloader.part}") partDownloader.start() } } if (howMuchCreate < 0) { // as we restart the parts each time we don't pause the active ones // partDownloaderList.values // .toList() // .filter { it.active } // .sortedByDescending { it.part.getID() } // .take(-howMuchCreate) // .onEach { // it.stop() // }.onEach { // it.join() // it.awaitIdle() // } } } catch (e: Exception) { throw e } finally { // println("C:" + c) partLoopLock.unlock() // c.decrementAndGet() } } } private fun onPartHaveToManyError(throwable: Throwable) { var paused = false if (throwable is DownloadValidationException) { if (throwable.isCritical()) { //stop the whole job! as we have big problem here paused = true scope.launch { pause(throwable) } } } val allHaveError = partDownloaderList.values .filter { it.active } .all { it.injured() } if (allHaveError && !paused) { // println("all have error!") downloadFailedRetryOrPause( e = throwable, isInFirstResume = false, ) } } // for this download job only, it has higher priority than download manager settings var _maxAllowedRetries: Int? = null fun getMaxAllowedRetries(): Int { return _maxAllowedRetries ?: downloadManager.settings.maxDownloadRetryCount } var failedDownloadTries = 0 val delayForEachRetry = 3_000L private var downloadedSizeBeforeRetry = 0L private var retryJob: Job? = null private val retryLock = Mutex() // I have to improve this function to not allow accessing it concurrently private fun downloadFailedRetryOrPause( e: Throwable, isInFirstResume: Boolean, ) { //moving to the main scope and request to cancel activeDownload scope! scope.launch { if (isInFirstResume && failedDownloadTries == 0 && shouldRetryIfInitialFailed()) { if (ExceptionUtils.isNetworkError(e) || ExceptionUtils.isResponseError(e)) { pause(e) return@launch } } // can't proceed if (e is DownloadValidationException && e.isCritical()) { pause(e) return@launch } val downloadedSize = getDownloadedSize() if (downloadedSize > downloadedSizeBeforeRetry) { // download had progress! so we reset it failedDownloadTries = 0 } else { failedDownloadTries++ } downloadedSizeBeforeRetry = downloadedSize // we always have one try (the initial resume action), after that others are retries! val retriedCount = (failedDownloadTries - 1).coerceAtLeast(0) if (retriedCount < getMaxAllowedRetries()) { retry(isInFirstResume) } else { pause(TooManyErrorException(e)) } } } fun retry(isInFirstResume: Boolean) { scope.launch { val newScopeResult = retryLock.tryLocked { val job = async { saveState() cancelDownloadScope() stopAllParts() _status.update { DownloadJobStatus.Retrying(delayForEachRetry) } delay(delayForEachRetry) createAndInitializeDownloadScope() } retryJob = job job.await() } newScopeResult.getOrNull()?.let { resumeWithNewScope(it, isInFirstResume) } } } fun shouldRetryIfInitialFailed(): Boolean { return true } private fun onPartStatusChanged( partDownloader: HLSPartDownloader, partStatus: PartDownloadStatus, ) { when (partStatus) { is PartDownloadStatus.Canceled -> { destination.onPartCancelled(partDownloader.part) } PartDownloadStatus.Completed -> { destination.onPartCancelled(partDownloader.part) if (getParts().all { it.isCompleted }) { onDownloadFinished() } else { scope.launch { beginDownloadParts() } } } PartDownloadStatus.ReceivingData -> {} PartDownloadStatus.Connecting -> {} PartDownloadStatus.IDLE -> {} } } @Synchronized private fun createPartDownloaderList() { synchronized(partDownloaderList) { // thisLogger().info("create part downloaders") parts.forEach { getOrCreatePartDownloader(it) } // println("created N parts = " + partDownloaderList.values.size) } } private fun clearPartDownloaderList() { // thisLogger().info("create part downloaders") parts.forEach { destroyPartDownloader(it) } } private val jobThrottler = Throttler() private val partDownloaderList = ConcurrentHashMap() private val listenerJobs: MutableMap = ConcurrentHashMap() private fun getPartDownloaderList(): List { synchronized(partDownloaderList) { return partDownloaderList.map { it.value } } } private fun getOrCreatePartDownloader(part: MediaSegment): HLSPartDownloader { synchronized(partDownloaderList) { return partDownloaderList.getOrPut(part.getID()) { HLSPartDownloader( baseURL = downloadItem.link, part = part, getDestWriter = { destination.getWriterFor(part) }, client = client, speedLimiters = listOf( downloadManager.throttler, jobThrottler, ), ).also { partDownloader: HLSPartDownloader -> partDownloader.onTooManyErrors = { onPartHaveToManyError(it) } //we should close that scope after we don't need it anymore! listenerJobs[part.getID()] = partDownloader.statusFlow.onEach { status -> //TODO probably bug here onPartStatusChanged(partDownloader, status) }.launchIn(scope) } } } } private fun destroyPartDownloader(part: MediaSegment) { listenerJobs.remove(part.getID())?.cancel() partDownloaderList.remove(part.getID()) } private suspend fun fetchDownloadInfoAndValidate( ) { if (parts.isEmpty()) { initialParts() } // at this point we have all the parts we need either by hitting remote or they are already in destination // before proceed we should check the downloaded parts and remove the "isCompleted" flag if they are missing parts .filter { it.isCompleted } .forEach { // redownload invalid parts if (!destination.isDownloadPartValid(it)) { it.isCompleted = false } } saveState() } // new segments received // we should compare those and update them // we reset the whole if we found different duration fun updateParts( playlist: MediaPlaylist ) { val newParts = playlist.mediaSegments() .mapIndexed { index, segment -> MediaSegment( segmentIndex = playlist.mediaSequence() + index, link = segment.uri(), duration = segment.duration() ) } val currentParts = parts downloadItem.duration = newParts.sumOf { it.duration } if (currentParts.isEmpty()) { setParts(newParts) return } val oldPartsMap = currentParts.associateBy { it.segmentIndex } val newPartsToSave = ArrayList(newParts.size) for (newPart in newParts) { val oldPart = oldPartsMap[newPart.segmentIndex] var newPartToAdd = newPart if (oldPart != null) { if (oldPart.duration == newPart.duration) { newPartToAdd = newPart.copy( isCompleted = oldPart.isCompleted ) } else { // inconsistencies found! reset the parts to the new one setParts(newParts) return } } newPartsToSave.add(newPartToAdd) } } private suspend fun initialParts(): HLSResponseInfo { val response = client.connect( downloadItem, null, null ).use { HLSResponseInfo.fromConnection(it) } updateParts( response.hlsManifest ) return response } suspend fun cancelDownloadScope() { activeDownloadScope?.coroutineContext?.job?.cancelAndJoin() activeDownloadScope = null } suspend fun cancelRetry() { retryJob?.cancel() retryJob = null } suspend fun stopAllParts() { withContext(Dispatchers.IO) { partDownloaderList.values.onEach { it.stop() }.onEach { it.join() it.awaitIdle() } } } override suspend fun pause(throwable: Throwable) { boot() failedDownloadTries = 0 cancelRetry() cancelDownloadScope() stopAllParts() onDownloadCanceled(throwable) } override fun onDownloadFinishedBeforeSave() { downloadItem.contentLength = destination.outputFile.length() } private var lastSavedDownloadItem: HLSDownloadItem? = null private var lastSavedParts: List? = null private suspend fun saveDownloadItem() { itemSaveLock.withLock { val copy = downloadItem.copy() if (lastSavedDownloadItem != downloadItem) { listDb.update(downloadItem) lastSavedDownloadItem = copy } } } private suspend fun saveParts() { partLock.withLock { val copy = getParts().map { it.copy() } if (lastSavedParts != copy) { destination.flush() partListDb.setParts(id, MediaSegments(copy)) lastSavedParts = copy } } } override suspend fun saveState() { saveDownloadItem() saveParts() } fun getParts(): List { //Make a copy because of CMException return parts.toList() } override fun reloadSettings() { onPreferredConnectionCountChanged() } override suspend fun extraConfigsReceived(config: DownloadJobExtraConfig) { if (config !is HLSDownloadJobExtraConfig) return config.hlsManifest?.let { updateParts(it) saveParts() } } } ================================================ FILE: downloader/core/src/commonMain/kotlin/ir/amirab/downloader/downloaditem/hls/HLSDownloadJobExtraConfig.kt ================================================ package ir.amirab.downloader.downloaditem.hls import io.lindstrom.m3u8.model.MediaPlaylist import ir.amirab.downloader.downloaditem.DownloadJobExtraConfig data class HLSDownloadJobExtraConfig( val hlsManifest: MediaPlaylist? = null ) : DownloadJobExtraConfig ================================================ FILE: downloader/core/src/commonMain/kotlin/ir/amirab/downloader/downloaditem/hls/HLSDownloader.kt ================================================ package ir.amirab.downloader.downloaditem.hls import ir.amirab.downloader.DownloadManager import ir.amirab.downloader.Downloader import ir.amirab.downloader.connection.HttpDownloaderClient import ir.amirab.downloader.downloaditem.IDownloadItem import kotlinx.serialization.KSerializer import kotlin.reflect.KClass class HLSDownloader( client: Lazy ) : Downloader { val client: HttpDownloaderClient by client override fun createJob( item: HLSDownloadItem, downloadManager: DownloadManager ): HLSDownloadJob { return HLSDownloadJob( downloadItem = item, downloadManager = downloadManager, client = client, ) } override fun accept(item: IDownloadItem): Boolean { return item is HLSDownloadItem } override val downloadItemClass: KClass = HLSDownloadItem::class override val downloadCredentialsClass: KClass = HLSDownloadCredentials::class override val downloadJobClass: KClass = HLSDownloadJob::class override val downloadItemSerializer: KSerializer = HLSDownloadItem.serializer() override val downloadCredentialsSerializer: KSerializer = HLSDownloadCredentials.serializer() } ================================================ FILE: downloader/core/src/commonMain/kotlin/ir/amirab/downloader/downloaditem/hls/HLSPartDownloader.kt ================================================ package ir.amirab.downloader.downloaditem.hls import ir.amirab.downloader.connection.Connection import ir.amirab.downloader.connection.HttpDownloaderClient import ir.amirab.downloader.connection.response.HttpResponseInfo import ir.amirab.downloader.connection.response.expectSuccess import ir.amirab.downloader.destination.DestWriter import ir.amirab.downloader.downloaditem.http.HttpDownloadCredentials import ir.amirab.downloader.part.MediaSegment import ir.amirab.downloader.part.PartDownloader import ir.amirab.util.HttpUrlUtils import kotlinx.coroutines.currentCoroutineContext import kotlinx.coroutines.isActive import okio.Throttler import kotlin.coroutines.cancellation.CancellationException class HLSPartDownloader( part: MediaSegment, getDestWriter: () -> DestWriter, private val baseURL: String, private val client: HttpDownloaderClient, private val speedLimiters: List, ) : PartDownloader< MediaSegment >( part = part, getDestWriter = getDestWriter, ) { override fun howMuchCanRead(maxAllowed: Long): Long { return maxAllowed } override suspend fun connectAndVerify(): Connection { val fullLink = part.link .takeIf { HttpUrlUtils.isValidUrl(it) } ?.let(HttpUrlUtils::createURL) ?: HttpUrlUtils.createURL(baseURL).resolve(part.link) requireNotNull(fullLink) { "link is incorrect! ${part.link}" } val connect = client.connect( HttpDownloadCredentials(fullLink.toString()), null, null, ) if (stop || !currentCoroutineContext().isActive) { connect.close() throw CancellationException() } if (!connect.responseInfo.isSuccessFul) { connect.close() throw CancellationException() } part.length = connect.responseInfo.contentLength return connect.let { it.copy( source = speedLimiters.fold(it.source) { acc, thr -> thr.source(acc) } ) } } override fun onCanceled(e: Throwable) { if (!part.isCompleted) { // we should restart failed parts part.resetCurrent() } super.onCanceled(e) } override fun onFinish() { part.isCompleted = true super.onFinish() } } ================================================ FILE: downloader/core/src/commonMain/kotlin/ir/amirab/downloader/downloaditem/hls/HLSResponseInfo.kt ================================================ package ir.amirab.downloader.downloaditem.hls import io.lindstrom.m3u8.model.KeyMethod import io.lindstrom.m3u8.model.MediaPlaylist import io.lindstrom.m3u8.parser.MediaPlaylistParser import io.lindstrom.m3u8.parser.ParsingMode import ir.amirab.downloader.connection.Connection import ir.amirab.downloader.connection.IResponseInfo import ir.amirab.downloader.connection.response.HttpResponseInfo import ir.amirab.downloader.connection.response.expectSuccess import ir.amirab.downloader.utils.FileNameUtil import ir.amirab.util.HttpUrlUtils import okio.buffer import java.io.IOException data class HLSResponseInfo( val httpResponseInfo: HttpResponseInfo, val hlsManifest: MediaPlaylist ) : IResponseInfo { val name: String? get() = httpResponseInfo.fileName val duration = hlsManifest.mediaSegments() .sumOf { it.duration() } .takeIf { it > 0 } override val isSuccessFul: Boolean get() = httpResponseInfo.isSuccessFul override val requiresAuth: Boolean get() = httpResponseInfo.requireBasicAuth override val requireBasicAuth: Boolean get() = httpResponseInfo.requireBasicAuth override val isWebPage: Boolean get() = httpResponseInfo.isWebPage override val resumeSupport: Boolean get() = true companion object { fun fromConnection(connection: Connection): HLSResponseInfo { expectSuccess(connection) val data = connection.source.buffer().use { it.readUtf8() } val playlist = try { parseHLSAsMediaPlaylist(data) } catch (e: Exception) { throw BadHLSResponseException("can't parse HLS playlist", e) } val mediaSegments = playlist.mediaSegments() if (mediaSegments.isEmpty()) { throw UnsupportedOperationException( "playlist has no segments" ) } val firstSegmentExtension = HttpUrlUtils .createURL(connection.responseInfo.requestUrl) .resolve(mediaSegments[0].uri())?.toString() ?.let(HttpUrlUtils::extractNameFromLink) ?.let(FileNameUtil::getExtensionOrNull) ?.lowercase() if (firstSegmentExtension != "ts") { throw UnsupportedOperationException( "Only HLS .ts segments supported at the moment, but '$firstSegmentExtension' provided" ) } if (isMediaPlayListEncrypted(playlist)) { throw UnsupportedOperationException( "Encrypted HLS playlists are not supported" ) } return HLSResponseInfo( connection.responseInfo, playlist, ) } private fun parseHLSAsMediaPlaylist(data: String): MediaPlaylist { val playlistParser = MediaPlaylistParser(ParsingMode.LENIENT) return playlistParser.readPlaylist(data) } private val HALS_POSSIBLE_HEADERS = listOf( "application/x-mpegurl", "application/vnd.apple.mpegurl", ) /** * if no hls content-size received we check this */ private const val MAXIMUM_ALLOWED_SIZE = 2 * 1024 * 1024 // 2MiB private fun expectSuccess(connection: Connection) { connection.responseInfo.expectSuccess() val hlsPossibleHeaders = HALS_POSSIBLE_HEADERS val contentType = connection.responseInfo.responseHeaders["content-type"] var error: String? = null if (contentType == null) { error = "no content type is provided" } else { val isHLSContentType = hlsPossibleHeaders.any { it.startsWith(contentType, ignoreCase = true) } if (!isHLSContentType) { error = "content type is not hls compatible: $contentType" } } if (error != null) { val contentLength = connection.responseInfo.contentLength if (contentLength == null) { error += ", and content length is unknown" } else { if (contentLength > MAXIMUM_ALLOWED_SIZE) { error += ", and returned content length is too big for hls playlist" } else { error = null } } } if (error != null) { throw BadHLSResponseException(error) } } private fun isMediaPlayListEncrypted(playlist: MediaPlaylist): Boolean { return playlist.mediaSegments().any { it.segmentKeys().any { key -> key.method() != KeyMethod.NONE } } } } } class BadHLSResponseException( message: String, cause: Throwable? = null ) : IOException(message, cause) ================================================ FILE: downloader/core/src/commonMain/kotlin/ir/amirab/downloader/downloaditem/hls/IHLSCredentials.kt ================================================ package ir.amirab.downloader.downloaditem.hls import ir.amirab.downloader.downloaditem.http.IHttpBasedDownloadCredentials interface IHLSCredentials : IHttpBasedDownloadCredentials ================================================ FILE: downloader/core/src/commonMain/kotlin/ir/amirab/downloader/downloaditem/http/HttpDownloadCredentials.kt ================================================ package ir.amirab.downloader.downloaditem.http import arrow.core.Option import arrow.core.getOrElse import ir.amirab.downloader.downloaditem.IDownloadCredentials import ir.amirab.util.HttpUrlUtils import kotlinx.serialization.SerialName import kotlinx.serialization.Serializable @Serializable @SerialName("http") data class HttpDownloadCredentials( override val link: String, override val headers: Map? = null, override val username: String? = null, override val password: String? = null, override val downloadPage: String? = null, override val userAgent: String? = null, ) : IHttpDownloadCredentials { override fun validateCredentials() { validate(this) } override fun copy( link: Option, downloadPage: Option ): IDownloadCredentials { return copy( link = link.getOrElse { this.link }, downloadPage = downloadPage.getOrElse { this.downloadPage } ) } companion object { fun empty() = HttpDownloadCredentials( link = "" ) fun from(credentials: IHttpDownloadCredentials): HttpDownloadCredentials { credentials.run { return when (this) { is HttpDownloadCredentials -> this else -> HttpDownloadCredentials( link = link, headers = headers, username = username, password = password, downloadPage = downloadPage, userAgent = userAgent, ) } } } fun validate(credentials: IHttpDownloadCredentials) { //make sure url is valid require(HttpUrlUtils.isValidUrl(credentials.link)) { "url is not valid" } } } } interface IHttpDownloadCredentials : IHttpBasedDownloadCredentials ================================================ FILE: downloader/core/src/commonMain/kotlin/ir/amirab/downloader/downloaditem/http/HttpDownloadItem.kt ================================================ package ir.amirab.downloader.downloaditem.http import arrow.core.Option import arrow.core.getOrElse import ir.amirab.downloader.downloaditem.DownloadStatus import ir.amirab.downloader.downloaditem.IDownloadCredentials import ir.amirab.downloader.downloaditem.IDownloadItem import ir.amirab.downloader.downloaditem.IDownloadItem.Companion.LENGTH_UNKNOWN import kotlinx.serialization.SerialName import kotlinx.serialization.Serializable @Serializable @SerialName("http") data class HttpDownloadItem( override var link: String, override var headers: Map? = null, override var username: String? = null, override var password: String? = null, override var downloadPage: String? = null, override var userAgent: String? = null, var serverETag: String? = null, // IDownloadItem override var id: Long, override var folder: String, override var name: String, override var contentLength: Long = LENGTH_UNKNOWN, override var dateAdded: Long = 0, override var startTime: Long? = null, override var completeTime: Long? = null, override var status: DownloadStatus = DownloadStatus.Added, override var preferredConnectionCount: Int? = null, override var speedLimit: Long = 0,//0 is unlimited override var fileChecksum: String? = null, ) : IDownloadItem, IHttpDownloadCredentials { override fun copy( id: Option, folder: Option, name: Option, link: Option, contentLength: Option, downloadPage: Option, dateAdded: Option, startTime: Option, completeTime: Option, status: Option, preferredConnectionCount: Option, speedLimit: Option, fileChecksum: Option ): HttpDownloadItem { val id = id.getOrElse { this.id } val folder = folder.getOrElse { this.folder } val name = name.getOrElse { this.name } val link = link.getOrElse { this.link } val contentLength = contentLength.getOrElse { this.contentLength } val downloadPage = downloadPage.getOrElse { this.downloadPage } val dateAdded = dateAdded.getOrElse { this.dateAdded } val startTime = startTime.getOrElse { this.startTime } val completeTime = completeTime.getOrElse { this.completeTime } val status = status.getOrElse { this.status } val preferredConnectionCount = preferredConnectionCount.getOrElse { this.preferredConnectionCount } val speedLimit = speedLimit.getOrElse { this.speedLimit } val fileChecksum = fileChecksum.getOrElse { this.fileChecksum } return copy( id = id, folder = folder, name = name, link = link, contentLength = contentLength, downloadPage = downloadPage, dateAdded = dateAdded, startTime = startTime, completeTime = completeTime, status = status, preferredConnectionCount = preferredConnectionCount, speedLimit = speedLimit, fileChecksum = fileChecksum, ) } override fun copy( link: Option, downloadPage: Option ): HttpDownloadItem { val link = link.getOrElse { this.link } val downloadPage = downloadPage.getOrElse { this.downloadPage } return copy( link = link, downloadPage = downloadPage, ) } override fun validateCredentials() { //make sure url is valid HttpDownloadCredentials.validate(this) } override fun validateItem() { validateCredentials() } override fun withCredentials(credentials: IDownloadCredentials): HttpDownloadItem { return if (credentials is HttpDownloadCredentials) { withHttpCredentials(credentials) } else { this } } companion object { fun createWithCredentials( credentials: HttpDownloadCredentials, id: Long, folder: String, name: String, contentLength: Long = IDownloadItem.LENGTH_UNKNOWN, serverETag: String? = null, dateAdded: Long = 0, startTime: Long? = null, completeTime: Long? = null, status: DownloadStatus = DownloadStatus.Added, preferredConnectionCount: Int? = null, speedLimit: Long = 0, fileChecksum: String? = null, ): HttpDownloadItem { return HttpDownloadItem( link = credentials.link, headers = credentials.headers, username = credentials.username, password = credentials.password, downloadPage = credentials.downloadPage, userAgent = credentials.userAgent, id = id, folder = folder, name = name, contentLength = contentLength, serverETag = serverETag, dateAdded = dateAdded, startTime = startTime, completeTime = completeTime, status = status, preferredConnectionCount = preferredConnectionCount, speedLimit = speedLimit, fileChecksum = fileChecksum, ) } } } fun HttpDownloadItem.applyFrom(other: HttpDownloadItem) { link = other.link headers = other.headers username = other.username password = other.password downloadPage = other.downloadPage userAgent = other.userAgent id = other.id folder = other.folder name = other.name contentLength = other.contentLength serverETag = other.serverETag dateAdded = other.dateAdded startTime = other.startTime completeTime = other.completeTime status = other.status preferredConnectionCount = other.preferredConnectionCount speedLimit = other.speedLimit fileChecksum = other.fileChecksum } fun HttpDownloadItem.withHttpCredentials(credentials: IHttpDownloadCredentials) = apply { link = credentials.link headers = credentials.headers username = credentials.username password = credentials.password downloadPage = credentials.downloadPage userAgent = credentials.userAgent } ================================================ FILE: downloader/core/src/commonMain/kotlin/ir/amirab/downloader/downloaditem/http/HttpDownloadJob.kt ================================================ package ir.amirab.downloader.downloaditem.http import ir.amirab.downloader.DownloadManager import ir.amirab.downloader.connection.HttpDownloaderClient import ir.amirab.downloader.connection.response.expectSuccess import ir.amirab.downloader.destination.DownloadDestination import ir.amirab.downloader.destination.SimpleDownloadDestination import ir.amirab.downloader.downloaditem.DownloadJob import ir.amirab.downloader.downloaditem.DownloadJobExtraConfig import ir.amirab.downloader.downloaditem.DownloadJobStatus import ir.amirab.downloader.downloaditem.DownloadStatus import ir.amirab.downloader.downloaditem.IDownloadItem import ir.amirab.downloader.exception.DownloadValidationException import ir.amirab.downloader.exception.PrepareDestinationFailedException import ir.amirab.downloader.exception.FileChangedException import ir.amirab.downloader.exception.ServerResumeSupportChangeException import ir.amirab.downloader.exception.TooManyErrorException import ir.amirab.downloader.part.* import ir.amirab.downloader.utils.* import ir.amirab.util.tryLocked import kotlinx.coroutines.* import kotlinx.coroutines.flow.launchIn import kotlinx.coroutines.flow.onEach import kotlinx.coroutines.flow.update import kotlinx.coroutines.sync.Mutex import kotlinx.coroutines.sync.withLock import okio.Throttler import java.util.concurrent.ConcurrentHashMap /** * alive object that responsible for download a file */ class HttpDownloadJob( override val downloadItem: HttpDownloadItem, downloadManager: DownloadManager, val client: HttpDownloaderClient, ) : DownloadJob( downloadManager = downloadManager, ) { val listDb by downloadManager::dlListDb val partListDb by downloadManager::partListDb private val parts: MutableList = mutableListOf() private lateinit var destination: SimpleDownloadDestination override fun getDestination(): DownloadDestination { return destination } var supportsConcurrent: Boolean? = null private set var serverLastModified: Long? = null private set override suspend fun actualBoot() { initializeDestination() loadPartState() supportsConcurrent = when (getParts().size) { in 2..Int.MAX_VALUE -> true else -> null } applySpeedLimit() downloadedSizeBeforeRetry = getDownloadedSize() } override fun initializeDestination() { val outFile = downloadManager.calculateOutputFile(downloadItem) destination = SimpleDownloadDestination( file = outFile, emptyFileCreator = downloadManager.emptyFileCreator, appendExtensionToIncompleteDownloads = downloadManager.settings.appendExtensionToIncompleteDownloads, downloadId = id ) } private fun setParts(list: List) { this.parts.clear() list.forEach { if (it.isCompleted) { it.statusFlow.update { PartDownloadStatus.Completed } } } this.parts.addAll(list) } val itemSaveLock = Mutex() val partLock = Mutex() private suspend fun loadPartState() { val rangedParts = partLock.withLock { partListDb.getParts(id) } as? RangedParts setParts(rangedParts?.list.orEmpty()) } // if strict mode is false part downloader going to download data without any validation of content length // this is only acceptable when resume is not supported and multiple get requests results multiple result @Volatile private var strictDownload = true fun expectValid(size: Long, parts: List) { val parts = parts.sortedBy { it.first } require(parts.first().first == 0L) require(parts.last().last == size - 1) for (i in 1.. true e is DownloadValidationException -> e.isCritical() else -> false } if (shouldStop) { // this function called from activeDownloadScope // so we change the scope here to prevent cancel this suspend function scope.launch { pause(e) } } else { downloadFailedRetryOrPause( e = e, isInFirstResume = isInFirstResume, ) } } }.join() } private suspend fun prepareDestination( onProgressUpdate: (Int?) -> Unit, ) { withContext(Dispatchers.IO) { destination.outputSize = downloadItem.contentLength .takeIf { // reset size if we have a non-strict download (webpage etc. strictDownload } ?.takeIf { // reset output file if we can't support the file supportsConcurrent != false } ?: IDownloadItem.LENGTH_UNKNOWN // first we try to create the folder // maybe the storage wasn't mounted yet, in that case we get an exception here // it should be here to prevent resetting the download try { destination.prepareDestinationFolder() } catch (e: Exception) { e.throwIfCancelled() throw PrepareDestinationFailedException(e) } if (!destination.isDownloadedPartsIsValid()) { //file deleted or something! parts.forEach { it.resetCurrent() } saveState() } // thisLogger().info("preparing file") try { destination.prepareFile(onProgressUpdate) } catch (e: Exception) { e.throwIfCancelled() throw PrepareDestinationFailedException(e) } val lastModified = serverLastModified.takeIf { downloadManager.settings.useServerLastModifiedTime } destination.setLastModified(lastModified) // thisLogger().info("file prepared") } } override fun getDownloadedSize(): Long { return getParts().sumOf { it.howMuchProceed() } // return partDownloaderList.values.sumOf { // it.progressFlow.value.value // } } fun onPreferredConnectionCountChanged() { activeDownloadScope?.launch { beginDownloadParts() } } override suspend fun changeConfig( updater: (IDownloadItem) -> Unit, extraConfig: DownloadJobExtraConfig? ): IDownloadItem { boot() val previousItem = downloadItem.copy() val newItem = previousItem.copy().apply(updater) val previousDestination = downloadManager.calculateOutputFile(previousItem) val newDestination = downloadManager.calculateOutputFile(newItem) val shouldUpdateDestination = previousDestination != newDestination if (shouldUpdateDestination) { if (isDownloadActive.value) { pause() } destination.moveOutput(newDestination) } // if there is no error update the actual download item downloadItem.applyFrom(newItem) if (shouldUpdateDestination) { // destination should be closed for now! initializeDestination() } if (previousItem.preferredConnectionCount != downloadItem.preferredConnectionCount) { onPreferredConnectionCountChanged() } if (previousItem.link != downloadItem.link) { onLinkChanged() } applySpeedLimit() extraConfig?.let { extraConfigsReceived(it) } saveDownloadItem() return downloadItem } private fun applySpeedLimit() { jobThrottler.bytesPerSecond(bytesPerSecond = downloadItem.speedLimit) } fun onLinkChanged() { scope.launch { if (activeDownloadScope?.isActive == true) { pause() resume() } } } fun getRequestedPartitionCount(): Int { return downloadItem.preferredConnectionCount ?: downloadManager.settings.defaultThreadCount } private suspend fun createPartsIfNotCreated() { if (parts.isNotEmpty()) { return } if (downloadItem.contentLength == IDownloadItem.LENGTH_UNKNOWN) { setParts( listOf(RangedPart(0, null, 0)) ) } else { if (supportsConcurrent == true) { //split parts setParts( splitToRange( minPartSize = downloadManager.settings.minPartSize, maxPartCount = getRequestedPartitionCount().toLong(), size = downloadItem.contentLength, ).map { RangedPart(it.first, it.last) }) } else { setParts( listOf(RangedPart(0, (downloadItem.contentLength - 1).takeIf { it >= 0 }, 0)) ) } } // thisLogger().info("dl_$id parts created $parts") saveState() } private val partSplitLock = Any() private val partLoopLock = Mutex() // private val c = AtomicInteger(0) private fun beginDownloadParts() { if (partLoopLock.isLocked) { return } activeDownloadScope?.launch { if (!partLoopLock.tryLock()) { return@launch } // c.incrementAndGet() try { val activeCount = getPartDownloaderList() .count { it.active } val howMuchCreate = getRequestedPartitionCount() - activeCount if (howMuchCreate > 0) { val mutableInactivePartDownloaderList = getPartDownloaderList() .filter { !it.active && !it.part.isCompleted } .sortedBy { it.part.from } .toMutableList() // println(mutableInactivePartDownloaderList) fun getPartDownloader(): HttpPartDownloader? { val inactivePart = runCatching { mutableInactivePartDownloaderList.removeAt(0) }.getOrNull() if (inactivePart != null) return inactivePart if (supportsConcurrent == true && downloadManager.settings.dynamicPartCreationMode) { synchronized(partSplitLock) { val candidates = getPartDownloaderList() .toList() .filter { it.canBeSplit() } .sortedByDescending { it.part.remainingLength } for (i in candidates) { val newPart = i.splitPart() if (newPart != null) { // println("a part split") parts.add(newPart) parts.sortBy { it.from } return getOrCreatePartDownloader(newPart) } } } } return null } for (i in 1..howMuchCreate) { val partDownloader = getPartDownloader() if (partDownloader == null) { // println("part downloader is null") break } if (partDownloader.part.isCompleted) { // println("it seems part is downloaded!") continue } // println("got new part downloader ${partDownloader.part}") partDownloader.start() } } if (howMuchCreate < 0) { partDownloaderList.values .toList() .filter { it.active } .sortedByDescending { it.part.from } .take(-howMuchCreate) .onEach { it.stop() }.onEach { it.join() it.awaitIdle() } } } catch (e: Exception) { throw e } finally { // println("C:" + c) partLoopLock.unlock() // c.decrementAndGet() } } } private fun onPartHaveToManyError(throwable: Throwable) { var paused = false if (throwable is DownloadValidationException) { if (throwable.isCritical()) { //stop the whole job! as we have big problem here paused = true scope.launch { pause(throwable) } } } val allHaveError = partDownloaderList.values .filter { it.active } .all { it.injured() } if (allHaveError && !paused) { // println("all have error!") downloadFailedRetryOrPause( e = throwable, isInFirstResume = false, ) } } // for this download job only, it has higher priority than download manager settings var _maxAllowedRetries: Int? = null fun getMaxAllowedRetries(): Int { return _maxAllowedRetries ?: downloadManager.settings.maxDownloadRetryCount } var failedDownloadTries = 0 val delayForEachRetry = 3_000L private var downloadedSizeBeforeRetry = 0L private var retryJob: Job? = null private val retryLock = Mutex() // I have to improve this function to not allow accessing it concurrently private fun downloadFailedRetryOrPause( e: Throwable, isInFirstResume: Boolean, ) { //moving to the main scope and request to cancel activeDownload scope! scope.launch { if (isInFirstResume && failedDownloadTries == 0 && shouldRetryIfInitialFailed()) { if (ExceptionUtils.isNetworkError(e) || ExceptionUtils.isResponseError(e)) { pause(e) return@launch } } // can't proceed if (e is DownloadValidationException && e.isCritical()) { pause(e) return@launch } val downloadedSize = getDownloadedSize() if (downloadedSize > downloadedSizeBeforeRetry) { // download had progress! so we reset it failedDownloadTries = 0 } else { failedDownloadTries++ } downloadedSizeBeforeRetry = downloadedSize // we always have one try (the initial resume action), after that others are retries! val retriedCount = (failedDownloadTries - 1).coerceAtLeast(0) if (retriedCount < getMaxAllowedRetries()) { retry(isInFirstResume) } else { pause(TooManyErrorException(e)) } } } fun retry(isInFirstResume: Boolean) { scope.launch { val newScopeResult = retryLock.tryLocked { val job = async { saveState() cancelDownloadScope() stopAllParts() _status.update { DownloadJobStatus.Retrying(delayForEachRetry) } delay(delayForEachRetry) createAndInitializeDownloadScope() } retryJob = job job.await() } newScopeResult.getOrNull()?.let { resumeWithNewScope(it, isInFirstResume) } } } fun shouldRetryIfInitialFailed(): Boolean { return true } private fun onPartStatusChanged( partDownloader: HttpPartDownloader, partStatus: PartDownloadStatus, ) { when (partStatus) { is PartDownloadStatus.Canceled -> { destination.onPartCancelled(partDownloader.part) } PartDownloadStatus.Completed -> { destination.onPartCancelled(partDownloader.part) if (getParts().all { it.isCompleted }) { onDownloadFinished() } else { scope.launch { beginDownloadParts() } } } PartDownloadStatus.ReceivingData -> {} PartDownloadStatus.Connecting -> {} PartDownloadStatus.IDLE -> {} } } @Synchronized private fun createPartDownloaderList() { synchronized(partDownloaderList) { // thisLogger().info("create part downloaders") parts.forEach { getOrCreatePartDownloader(it) } // println("created N parts = " + partDownloaderList.values.size) } } private fun clearPartDownloaderList() { // thisLogger().info("create part downloaders") parts.forEach { destroyPartDownloader(it) } } private val jobThrottler = Throttler() private val partDownloaderList = ConcurrentHashMap() private val listenerJobs: MutableMap = ConcurrentHashMap() private fun getPartDownloaderList(): List { synchronized(partDownloaderList) { return partDownloaderList.map { it.value } } } private fun getOrCreatePartDownloader(part: RangedPart): HttpPartDownloader { synchronized(partDownloaderList) { return partDownloaderList.getOrPut(part.from) { HttpPartDownloader( credentials = downloadItem, part = part, getDestWriter = { destination.getWriterFor(part) }, client = client, speedLimiters = listOf( downloadManager.throttler, jobThrottler, ), strictMode = strictDownload, partSplitLock = partSplitLock ).also { partDownloader: HttpPartDownloader -> partDownloader.onTooManyErrors = { onPartHaveToManyError(it) } //we should close that scope after we don't need it anymore! listenerJobs[part.from] = partDownloader.statusFlow.onEach { status -> //TODO probably bug here onPartStatusChanged(partDownloader, status) }.launchIn(scope) } } } } private fun destroyPartDownloader(part: RangedPart) { listenerJobs.remove(part.from)?.cancel() partDownloaderList.remove(part.from) } private fun isDownloadItemIsAWebpage(): Boolean { return downloadItem.name.endsWith(".html", true) } private suspend fun fetchDownloadInfoAndValidate( ) { // println("fetch download ") // thisLogger().info("fetchDownloadInfoAndValidate") val response = client.test(downloadItem).expectSuccess() supportsConcurrent?.let { previouslyConcurrentWasSupported -> if (previouslyConcurrentWasSupported && !response.resumeSupport) { // server at some point tell us it supports resuming, and we created more than 1 part! // and now it says not resuming isn't supported! // we must stop here! // user must manually restart download or we should retry throw ServerResumeSupportChangeException() } } supportsConcurrent = response.resumeSupport serverLastModified = runCatching { response.lastModified?.let(TimeUtils::convertLastModifiedHeaderToTimestamp) }.getOrNull() if (response.isWebPage) { if (isDownloadItemIsAWebpage()) { // don't strict if it's a webpage let it download without checks strictDownload = false // this makes the file not resume able // we don't want to page downloaded with multi connection // so the download will be restarted [@see prepareDestination] supportsConcurrent = false downloadItem.contentLength = IDownloadItem.LENGTH_UNKNOWN downloadItem.serverETag = null } else { // if download was not a webpage and now this is a webpage // it means maybe user have to change its download link // we should not restart download here! throw FileChangedException.GotAWebPage() } } val totalLength = response.totalLength val oldServerETag = downloadItem.serverETag val newServerETag = response.etag if (downloadItem.contentLength == IDownloadItem.LENGTH_UNKNOWN) { //new download / or restart downloadItem.contentLength = totalLength ?: -1 downloadItem.serverETag = newServerETag } else { // check if we file not changed from remote if (totalLength != downloadItem.contentLength) { throw FileChangedException.LengthChangedException(downloadItem.contentLength, totalLength ?: -1) } if (oldServerETag != null && newServerETag != null) { // we already know that sizes are the same, // but we also have etag header // so, we have chance to compare file contents of local and server if (oldServerETag != newServerETag) { throw FileChangedException.ETagChangedException(oldServerETag, newServerETag) } } } // thisLogger().info("fetchDownloadInfoAndValidate :${response.code},${response.headers} ") saveState() } suspend fun cancelDownloadScope() { activeDownloadScope?.coroutineContext?.job?.cancelAndJoin() activeDownloadScope = null } suspend fun cancelRetry() { retryJob?.cancel() retryJob = null } suspend fun stopAllParts() { withContext(Dispatchers.IO) { partDownloaderList.values.onEach { it.stop() }.onEach { it.join() it.awaitIdle() } } } override suspend fun pause(throwable: Throwable) { boot() failedDownloadTries = 0 cancelRetry() cancelDownloadScope() stopAllParts() onDownloadCanceled(throwable) } override fun onDownloadFinishedBeforeSave() { if (downloadItem.contentLength == IDownloadItem.LENGTH_UNKNOWN) { //in case of blind part, update download item length if (parts.size == 1) { downloadItem.contentLength = parts[0].howMuchProceed() } } } private var lastSavedDownloadItem: HttpDownloadItem? = null private var lastSavedParts: List? = null private suspend fun saveDownloadItem() { itemSaveLock.withLock { val copy = downloadItem.copy() if (lastSavedDownloadItem != downloadItem) { listDb.update(downloadItem) lastSavedDownloadItem = copy } } } private suspend fun saveParts() { partLock.withLock { val copy = getParts().map { it.copy() } if (lastSavedParts != copy) { destination.flush() partListDb.setParts(id, RangedParts(copy)) lastSavedParts = copy } } } override suspend fun saveState() { saveDownloadItem() saveParts() } fun getParts(): List { //Make a copy because of CMException return parts.toList() } override fun reloadSettings() { onPreferredConnectionCountChanged() } override suspend fun extraConfigsReceived(config: DownloadJobExtraConfig) { // we don't have extra configs } } ================================================ FILE: downloader/core/src/commonMain/kotlin/ir/amirab/downloader/downloaditem/http/HttpDownloader.kt ================================================ package ir.amirab.downloader.downloaditem.http import ir.amirab.downloader.DownloadManager import ir.amirab.downloader.Downloader import ir.amirab.downloader.connection.HttpDownloaderClient import ir.amirab.downloader.downloaditem.IDownloadItem import kotlinx.serialization.KSerializer import kotlin.reflect.KClass class HttpDownloader( httpDownloaderClient: Lazy ) : Downloader { val httpDownloaderClient by httpDownloaderClient override fun createJob( item: HttpDownloadItem, downloadManager: DownloadManager, ): HttpDownloadJob { return HttpDownloadJob( item, downloadManager, httpDownloaderClient, ) } override fun accept(item: IDownloadItem): Boolean { return item is HttpDownloadItem } override val downloadItemClass: KClass = HttpDownloadItem::class override val downloadCredentialsClass: KClass = HttpDownloadCredentials::class override val downloadJobClass: KClass = HttpDownloadJob::class override val downloadItemSerializer: KSerializer = HttpDownloadItem.serializer() override val downloadCredentialsSerializer: KSerializer = HttpDownloadCredentials.serializer() } ================================================ FILE: downloader/core/src/commonMain/kotlin/ir/amirab/downloader/downloaditem/http/IHttpBasedDownloadCredentials.kt ================================================ package ir.amirab.downloader.downloaditem.http import ir.amirab.downloader.downloaditem.IDownloadCredentials interface IHttpBasedDownloadCredentials : IDownloadCredentials { val headers: Map? val username: String? val password: String? val userAgent: String? } ================================================ FILE: downloader/core/src/commonMain/kotlin/ir/amirab/downloader/exception/DownloadNotSuccessFullException.kt ================================================ package ir.amirab.downloader.exception import java.io.IOException class UnSuccessfulResponseException(val code:Int,msg:String):IOException( "$code | $msg" ) ================================================ FILE: downloader/core/src/commonMain/kotlin/ir/amirab/downloader/exception/DownloadValidationException.kt ================================================ package ir.amirab.downloader.exception abstract class DownloadValidationException( msg: String, cause: Throwable? = null, ) : Exception(msg, cause) { abstract fun isCritical(): Boolean } ================================================ FILE: downloader/core/src/commonMain/kotlin/ir/amirab/downloader/exception/FileChangedException.kt ================================================ package ir.amirab.downloader.exception sealed class FileChangedException(msg: String) : DownloadValidationException(msg) { override fun isCritical(): Boolean { // download must stop immediately return true } class LengthChangedException( val lastContentLength: Long, val newContentLength: Long ) : FileChangedException( "File size changed since last download! last time was $lastContentLength now it's $newContentLength" ) class ETagChangedException( val oldETag: String, val newETag: String ) : FileChangedException( "File content changed since last download! last time was $oldETag now it's $newETag" ) class GotAWebPage : FileChangedException( "link is a webpage" ) } ================================================ FILE: downloader/core/src/commonMain/kotlin/ir/amirab/downloader/exception/NoSpaceInStorageException.kt ================================================ package ir.amirab.downloader.exception class NoSpaceInStorageException( val available: Long, val required: Long ) : DownloadValidationException( "No space available required=$required , available=$available" ) { override fun isCritical(): Boolean { // there is no space in users file system so we should stop return true } } ================================================ FILE: downloader/core/src/commonMain/kotlin/ir/amirab/downloader/exception/PartTooManyErrorException.kt ================================================ package ir.amirab.downloader.exception import ir.amirab.downloader.part.DownloadPart class PartTooManyErrorException( part: DownloadPart, override val cause: Throwable ) : Exception( "this part $part have too many errors", cause, ) ================================================ FILE: downloader/core/src/commonMain/kotlin/ir/amirab/downloader/exception/PrepareDestinationFailedException.kt ================================================ package ir.amirab.downloader.exception class PrepareDestinationFailedException( e: Exception ) : DownloadValidationException( "Problem in preparing output: ${e.localizedMessage}", e, ) { override fun isCritical(): Boolean { // there is a problem when preparing destination. retry doesn't work here return true } } ================================================ FILE: downloader/core/src/commonMain/kotlin/ir/amirab/downloader/exception/ServerPartIsNotTheSameAsWeExpectException.kt ================================================ package ir.amirab.downloader.exception //it should not happen unless web server is not respect our header class ServerPartIsNotTheSameAsWeExpectException( start:Long, end:Long?, expectedLength:Long?, actualLength:Long?, ) : DownloadValidationException ( "Response Length not match.expecting '${expectedLength}',but we got '$actualLength',requested range is range is ${start}-${end}" // + "\n request headers ${conn.responseInfo.requestHeaders}" // + "\n response headers ${conn.responseInfo.responseHeaders}" ){ override fun isCritical(): Boolean { // some webservers somehow does not return the expected size at the first place // but after some try... they do!!! // because of them, I have to make this error non-critical // I have to investigate why! return false } } ================================================ FILE: downloader/core/src/commonMain/kotlin/ir/amirab/downloader/exception/ServerResumeSupportChangeException.kt ================================================ package ir.amirab.downloader.exception // this happens on some CDN (multiple servers/load balancers, serving the same file) // let's say we ask for 10 connections 7 times they say support resuming and 3 time they say I'm not support resuming. // if the first initial connection sees that the download doesn't support resuming. so the app shows you it won't support resuming at all. // if we are detected that the file supports resume we throw this exception // we shouldn't automatically reset the download, we should retry, or user needs to manually restart the download class ServerResumeSupportChangeException : DownloadValidationException( "Server resume support changed, please restart the download manually" ) { override fun isCritical(): Boolean { return false } } ================================================ FILE: downloader/core/src/commonMain/kotlin/ir/amirab/downloader/exception/TooManyErrorException.kt ================================================ package ir.amirab.downloader.exception class TooManyErrorException( override val cause: Throwable ) : Exception( "Download is stopped because all parts exceeds max retries", ) { fun findActualDownloadErrorCause(): Throwable { return when (cause) { is PartTooManyErrorException -> cause.cause else -> cause } } } ================================================ FILE: downloader/core/src/commonMain/kotlin/ir/amirab/downloader/part/DownloadPart.kt ================================================ package ir.amirab.downloader.part import kotlinx.coroutines.flow.MutableStateFlow interface DownloadPart { var current: Long // internal usage do not change it! val statusFlow: MutableStateFlow val status get() = statusFlow.value val isCompleted: Boolean val percent: Int? fun howMuchProceed(): Long fun resetCurrent() // an id which also is sortable among the other parts // 1, 2, 3, ... fun getID(): Long } ================================================ FILE: downloader/core/src/commonMain/kotlin/ir/amirab/downloader/part/HttpPartDownloader.kt ================================================ package ir.amirab.downloader.part import ir.amirab.downloader.connection.HttpDownloaderClient import ir.amirab.downloader.connection.Connection import ir.amirab.downloader.connection.response.HttpResponseInfo import ir.amirab.downloader.connection.response.expectSuccess import ir.amirab.downloader.destination.DestWriter import ir.amirab.downloader.downloaditem.http.IHttpDownloadCredentials import ir.amirab.downloader.exception.ServerPartIsNotTheSameAsWeExpectException import kotlinx.coroutines.CancellationException import kotlinx.coroutines.currentCoroutineContext import kotlinx.coroutines.isActive import okio.* /** * @param strictMode * `false` - this is not the purpose of its app, so we don't strict here * * download part without checking for length validation * this is where we want to only copy data arrived from server * for example, a web page link that maybe get us different response length * we only need to download it no matter what is inside * * `true` - main purpose of this class * * validate download size before trying to write to the filesystem */ class HttpPartDownloader( val credentials: IHttpDownloadCredentials, getDestWriter: () -> DestWriter, part: RangedPart, val client: HttpDownloaderClient, val speedLimiters: List, val strictMode: Boolean, partSplitLock: Any, ) : PartDownloader( part = part, getDestWriter = getDestWriter ) { private suspend fun establishConnection( from: Long, to: Long?, ): Connection { val connect = client.connect(credentials, from, to) // make sure this is a 2xx response kotlin.runCatching { connect.responseInfo.expectSuccess() } .onFailure { // close connection before throwing exception kotlin.runCatching { connect.close() } } .getOrThrow() val source = speedLimiters.fold(connect.source) { acc, throttler -> throttler.source(acc) } return connect.copy( source = source ) } override fun onFinish() { synchronized(partSplitSupport) { if (part.isBlind) { part.setBlindAsCompleted() } } super.onFinish() } private val partSplitSupport = PartSplitSupport(part, partSplitLock) //this method is invoked only in one thread for every instance override fun howMuchCanRead(maxAllowed: Long): Long { return partSplitSupport.howMuchCanRead( expandToBufferSize = maxAllowed, tryToExtendSafeZone = true ) } fun canBeSplit(): Boolean { return partSplitSupport.canSplit() } override suspend fun connectAndVerify(): Connection { // thisLogger().info("going to copy data to destination") //we copy part because maybe part::to property will change during part split, //so we make backup of current part to validate http response val partCopy = part.copy() val conn = establishConnection(partCopy.current, partCopy.to) // thisLogger().info("connection established") if (stop || !currentCoroutineContext().isActive) { conn.close() throw CancellationException() } val contentLength = conn.contentLength.let { if (it == -1L) { //in case of no end is come from headers null } else { it } } if (contentLength != partCopy.remainingLength) { var throwServerPartIsNotTheSameAsWeExpectException: Boolean if (strictMode) { throwServerPartIsNotTheSameAsWeExpectException = true // allow pass through if the request range start and response range start are the same conn.responseInfo.contentRange?.range?.let { range -> if (range.first == partCopy.current) { // if I request from 1..10 then I expect that server give me 1-X the X is not important // but the start should be the same as requested otherwise we can't trust the server response // X may be smaller/bigger than our requested range however we download it as much as we want if it wasn't enough we re request again later throwServerPartIsNotTheSameAsWeExpectException = false } } } else { // just download it we don't want to validate anything here throwServerPartIsNotTheSameAsWeExpectException = true } val serverPartIsNotTheSameAsWeExpectException = ServerPartIsNotTheSameAsWeExpectException( start = partCopy.current, end = partCopy.to, expectedLength = partCopy.remainingLength, actualLength = contentLength ) if (throwServerPartIsNotTheSameAsWeExpectException) { conn.close() throw serverPartIsNotTheSameAsWeExpectException } else { println("WARNING: ${serverPartIsNotTheSameAsWeExpectException.message}") } } return conn } //should be sync with part split lock fun splitPart(): RangedPart? { return partSplitSupport.splitPart() } } ================================================ FILE: downloader/core/src/commonMain/kotlin/ir/amirab/downloader/part/MediaSegment.kt ================================================ package ir.amirab.downloader.part import ir.amirab.downloader.utils.calcPercent import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.serialization.Serializable import kotlinx.serialization.Transient @Serializable data class MediaSegment( val segmentIndex: Long, val link: String, var duration: Double, override var isCompleted: Boolean = false, var length: Long? = null, @Transient override var current: Long = 0, ) : DownloadPart { override fun howMuchProceed() = current override fun resetCurrent() { current = 0 } @Transient override val statusFlow = MutableStateFlow(PartDownloadStatus.IDLE) override val percent: Int? get() = length?.let { calcPercent(howMuchProceed(), it) } override fun getID(): Long { return segmentIndex.toLong() } companion object { } } ================================================ FILE: downloader/core/src/commonMain/kotlin/ir/amirab/downloader/part/PartDownloadStatus.kt ================================================ package ir.amirab.downloader.part import ir.amirab.downloader.utils.ExceptionUtils sealed class PartDownloadStatus { interface IsActive interface IsInactive override fun toString(): String { return this::class.simpleName!! } object IDLE : PartDownloadStatus(),IsInactive data class Canceled(val e: Throwable) : PartDownloadStatus(),IsInactive { fun isNormalCancellation(): Boolean { return ExceptionUtils.isNormalCancellation(e) } } object Completed : PartDownloadStatus(),IsInactive object Connecting : PartDownloadStatus(), IsActive object ReceivingData : PartDownloadStatus(),IsActive } ================================================ FILE: downloader/core/src/commonMain/kotlin/ir/amirab/downloader/part/PartDownloader.kt ================================================ package ir.amirab.downloader.part import ir.amirab.downloader.anntation.HeavyCall import ir.amirab.downloader.connection.Connection import ir.amirab.downloader.connection.IResponseInfo import ir.amirab.downloader.destination.DestWriter import ir.amirab.downloader.exception.DownloadValidationException import ir.amirab.downloader.exception.PartTooManyErrorException import ir.amirab.downloader.utils.ExceptionUtils import ir.amirab.downloader.utils.printStackIfNOtUsual import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.GlobalScope import kotlinx.coroutines.NonCancellable import kotlinx.coroutines.SupervisorJob import kotlinx.coroutines.delay import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.filter import kotlinx.coroutines.flow.first import kotlinx.coroutines.isActive import kotlinx.coroutines.job import kotlinx.coroutines.launch import kotlinx.coroutines.withContext import kotlinx.coroutines.withTimeoutOrNull import okio.Buffer import okio.Source import okio.use import kotlin.concurrent.thread import kotlin.math.min const val PART_MAX_TRIES = 10 const val RetryDelay = 1_000L abstract class PartDownloader< TPart : DownloadPart >( val part: TPart, val getDestWriter: () -> DestWriter ) { private var thread: Thread? = null private var scope: CoroutineScope? = null private val _statusFlow = part.statusFlow val statusFlow = _statusFlow.asStateFlow() @Volatile internal var active = false abstract fun howMuchCanRead(maxAllowed: Long): Long @Volatile internal var tries = 0 // make sure to not lake resource in this exception @Volatile private var lastCriticalException: Throwable? = null // make sure to not lake resource in this exception @Volatile private var lastException: Throwable? = null //just turn on (fast) fun start() { synchronized(this) { if (active) { return } stop = false active = true } val scope = CoroutineScope(SupervisorJob()).also { this.scope = it } scope.launch { tries = 0 lastCriticalException = null lastException = null val result = runCatching { while (coroutineContext.isActive || !stop) { if (tries > 0) { delay(RetryDelay) // println("#${part.from}retrying $tries") } if (haveToManyErrors()) { // println("tell them we have error!") iCantRetryAnymore( PartTooManyErrorException( part, lastException ?: Exception("BUG : if you see me please report it to the developer! when we encounter error so it have to be a least one last exception"), ) ) } if (part.isCompleted) { println("WARNING $part is completed") } try { download() } catch (e: Exception) { tries++ onCanceled(e) when (canRetry(e)) { CanRetryResult.Yes -> continue CanRetryResult.No -> {} CanRetryResult.NoAndStopDownloadJob -> iCantRetryAnymore(e) } break } //download progress started, but maybe we have errors //wait for a finish/error event... //await for cancel status to be emitted! val status = withContext(NonCancellable) { awaitFinishOrError() } when (status) { is PartDownloadStatus.Canceled -> { tries++ when (canRetry(status.e)) { CanRetryResult.Yes -> continue CanRetryResult.No -> {} CanRetryResult.NoAndStopDownloadJob -> iCantRetryAnymore(status.e) } break } PartDownloadStatus.Completed -> break else -> throw ShouldNotHappened("should not happened!") } } } active = false if (!part.isCompleted) { part.statusFlow.value = PartDownloadStatus.IDLE } result.onFailure { if (it is ShouldNotHappened) { throw it } } } } @Volatile var stop = false fun stop() { stop = true thread?.interrupt() scope?.coroutineContext?.job?.cancel() } suspend fun join() { withContext(Dispatchers.IO) { scope?.coroutineContext?.job?.join() thread?.join() } } private fun canRetry(e: Throwable): CanRetryResult { return when { ExceptionUtils.isNormalCancellation(e) -> { CanRetryResult.No } e is DownloadValidationException -> if (e.isCritical()) { //download validation occurs, and also it is critical, //so we can't proceed any further CanRetryResult.NoAndStopDownloadJob } else { CanRetryResult.Yes } else -> { CanRetryResult.Yes } } } lateinit var onTooManyErrors: ((Throwable) -> Unit) private fun iCantRetryAnymore(throwable: Throwable) { lastCriticalException = throwable GlobalScope.launch { onTooManyErrors(throwable) } } private fun haveToManyErrors(): Boolean { return tries >= PART_MAX_TRIES } private fun haveCriticalError(): Boolean { return lastCriticalException != null } internal fun injured(): Boolean { return haveToManyErrors() || haveCriticalError() } abstract suspend fun connectAndVerify(): Connection<*> private suspend fun download() { onNewStatus(PartDownloadStatus.Connecting) val conn = connectAndVerify() thread = thread { if (stop || Thread.currentThread().isInterrupted) { conn.close() onCanceled(kotlinx.coroutines.CancellationException()) return@thread } // thisLogger().info("going to copy data to destination $conn") try { conn.use { // connection automatically closes the source val connectionStream = it.source getDestWriter().use { writer -> copyDataSync(connectionStream, writer) } } } catch (e: Exception) { onCanceled(e) } finally { thread = null } } } protected open fun onCanceled(e: Throwable) { lastException = e val canceled = PartDownloadStatus.Canceled(e) onNewStatus(canceled) e.printStackIfNOtUsual() // if (!canceled.isNormalCancellation()) { // e.printStackTrace() // } else { // println("part cancelled because of ${e.localizedMessage ?: e::class.simpleName}") // e.printStackTrace() // } } protected open fun onFinish() { onNewStatus(PartDownloadStatus.Completed) } fun onNewStatus(partDownloadStatus: PartDownloadStatus) { _statusFlow.value = partDownloadStatus } @HeavyCall private fun copyDataSync(source: Source, destWriter: DestWriter) { // println("copying range to file --- ${part.current}-${part.to}") val buffer = Buffer() var totalReadCount = 0L var firstLoop = true val bufferSize = DEFAULT_BUFFER_SIZE.toLong() while (true) { if (stop || Thread.currentThread().isInterrupted) { onCanceled(kotlinx.coroutines.CancellationException()) break } val howMuchICanReadAllowed = howMuchCanRead(bufferSize) val homMuchReadFromBuffer = min(bufferSize, howMuchICanReadAllowed) // require(part.current + homMuchReadFromBuffer <= part.maxAllowedCurrent) { // """$partSplitSupport // canRead:${homMuchReadFromBuffer}""" // } // require(part.current + homMuchReadFromBuffer <= partSplitSupport.safeZone + 1) { // """a // part=${part} isCompleted =${part.isCompleted} // split part $partSplitSupport // howMuch:${homMuchReadFromBuffer} // actual:${part.current + homMuchReadFromBuffer} // expected:${partSplitSupport.safeZone} // """.trimIndent() // } if (howMuchICanReadAllowed <= 0) { if (part.isCompleted) { onFinish() } else { onCanceled(kotlinx.coroutines.CancellationException("it seems our part was split so we are canceled $part")) } break } val readCount = source.read(buffer, homMuchReadFromBuffer) // require(readCount <= homMuchReadFromBuffer) { // "read count $readCount is bigger than homMuchReadFromBuffer $homMuchReadFromBuffer" // } if (readCount == -1L) { onFinish() break } destWriter.write(buffer, readCount) totalReadCount += readCount part.current += readCount // require (part.current-part.from == totalReadCount) if (firstLoop) { tries = 0 onNewStatus(PartDownloadStatus.ReceivingData) firstLoop = false } } } suspend fun awaitFinishOrError(): PartDownloadStatus { return statusFlow.filter { when (it) { PartDownloadStatus.Completed, is PartDownloadStatus.Canceled, -> true PartDownloadStatus.ReceivingData, PartDownloadStatus.Connecting, PartDownloadStatus.IDLE, -> false } }.first() } suspend fun awaitToEnsureDataBeingTransferred(): Boolean { return withTimeoutOrNull(5_000) { val isThatOk = statusFlow.filter { when (it) { PartDownloadStatus.Completed, PartDownloadStatus.ReceivingData, -> true is PartDownloadStatus.Canceled, PartDownloadStatus.Connecting, PartDownloadStatus.IDLE, -> false } }.first().let { when (it) { is PartDownloadStatus.Canceled -> false PartDownloadStatus.Completed -> true PartDownloadStatus.ReceivingData -> true PartDownloadStatus.Connecting, PartDownloadStatus.IDLE, -> error("should not happen") } } isThatOk } ?: false } suspend fun awaitIdle() { statusFlow.filter { when (it) { is PartDownloadStatus.Canceled, PartDownloadStatus.Completed, PartDownloadStatus.IDLE, -> true PartDownloadStatus.Connecting, PartDownloadStatus.ReceivingData, -> false } }.first() } class ShouldNotHappened(msg: String?) : RuntimeException(msg) private sealed interface CanRetryResult { data object Yes : CanRetryResult data object No : CanRetryResult data object NoAndStopDownloadJob : CanRetryResult } } ================================================ FILE: downloader/core/src/commonMain/kotlin/ir/amirab/downloader/part/PartSplitSupport.kt ================================================ package ir.amirab.downloader.part import kotlin.math.min //fun main() { // split = PartSplitSupport(part=Part(from=9530669, to=10565655, current=9664288), safeZone=9694508, remainingSafe=30221 ,old = Part(from=9530669, to=11436801, current=9664288) ,changed = Part(from=9530669, to=10565655, current=9664288) ,newPart= Part(from=10565655, to=11436801, current=10565655) ,valid = false , // split = PartSplitSupport(part=Part(from=9530669, to=10565655, current=9664288), safeZone=9694508, remainingSafe=30221 ,old = Part(from=9530669, to=11436801, current=9664288) ,changed = Part(from=9530669, to=10565655, current=9664288) ,newPart= Part(from=10565655, to=11436801, current=10565655) ,valid = false , // val p1 =Part(from=0, to=0, current=0) // val ps = PartSplitSupport(p1).apply { // safeZone = 9694508 // } // println(ps.splitPart()) //} class PartSplitSupport( val part: RangedPart, private val partEndLock: Any = Any(), ) { //initial remainingSafe will be 0 @Volatile var safeZone = part.current - 1 //pure fun remainingSafe(): Long { // +1 is because of end inclusive return (safeZone + 1 - part.current).coerceAtLeast(0) } fun howMuchCanRead( expandToBufferSize: Long? = null, tryToExtendSafeZone: Boolean = expandToBufferSize != null, ): Long { val defaultRemaining = remainingSafe() if (tryToExtendSafeZone) { if (expandToBufferSize != null) { if (defaultRemaining < expandToBufferSize) { if (extendSafeZone()) { return remainingSafe() } } } else if (defaultRemaining == 0L) { if (extendSafeZone()) { return remainingSafe() } } } return defaultRemaining } fun extendSafeZone(): Boolean { synchronized(partEndLock) { //remaining val remaining = part.remainingLength?:Long.MAX_VALUE if (remaining == 0L) { return false } val oldSafeZone = safeZone val newSafeZone = (oldSafeZone + min(remaining, SAFE_ZONE_SIZE)) .coerceAtMost(part.to ?: Long.MAX_VALUE) if (oldSafeZone==newSafeZone){ return false } safeZone=newSafeZone // require(safeZone<=part.to) return true } } fun splitPart(): RangedPart? { synchronized(partEndLock) { if (!canSplit()) return null val delta = part.to!! - safeZone val safeZoneToEnd = safeZone + (delta / 2) + delta % 2 // val oldPart = part.copy() if (safeZoneToEnd + 1 > part.to!!) { //new part will exceed current part boundaries return null } val newPart = RangedPart( from = safeZoneToEnd + 1, to = part.to!! ) part.to = safeZoneToEnd // val isValid = isSplitValid(oldPart, part, newPart) // val safeZoneRespected = safeZone <= part.to!! // println( // "split = $this ," + // "safezone respected =$safeZoneRespected ,"+ // "old = $oldPart ," + // "changed = ${part.copy()} ," + // "newPart= ${newPart.copy()} ," + // "valid = $isValid ," // ) return newPart } } fun canSplit(): Boolean { if (part.to == null) { return false } val delta = part.to!! - safeZone //We only want split a part that worth it! return delta >= SAFE_ZONE_SIZE } override fun toString(): String { return "PartSplitSupport(part=$part, safeZone=$safeZone, remainingSafe=${remainingSafe()}" } companion object { private fun isSplitValid( oldPart: RangedPart, reducedPart: RangedPart, newPart: RangedPart, ): Boolean { return (reducedPart.to == newPart.from - 1) && (oldPart.to == newPart.to) } // const val SAFE_ZONE_SIZE: Long = 5 const val SAFE_ZONE_SIZE: Long = 128 * 8192 } } ================================================ FILE: downloader/core/src/commonMain/kotlin/ir/amirab/downloader/part/Parts.kt ================================================ package ir.amirab.downloader.part import kotlinx.serialization.SerialName import kotlinx.serialization.Serializable /** * the base interface for any downloader that may have parts * it is saved into storage for future resuming support */ @Serializable sealed interface Parts { fun clone(): Parts } /** * this type is used for downloaders that split download into multiple byte ranges, * like http */ @SerialName("ranges") @Serializable data class RangedParts( val list: List ) : Parts { override fun clone(): Parts { return copy() } } /** * this type is used for downloaders that contains multiple links to download like hls */ @SerialName("mediaSegments") @Serializable data class MediaSegments( val list: List ) : Parts { override fun clone(): Parts { return copy() } } ================================================ FILE: downloader/core/src/commonMain/kotlin/ir/amirab/downloader/part/RangedPart.kt ================================================ package ir.amirab.downloader.part import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.serialization.Serializable import kotlinx.serialization.Transient @Serializable data class RangedPart( var from: Long, @Volatile var to: Long?, @Volatile override var current: Long = from, ) : DownloadPart { override fun howMuchProceed(): Long { return current - from } override fun resetCurrent() { current = from } @Transient override val statusFlow = MutableStateFlow(PartDownloadStatus.IDLE) val remainingLength get() = to?.let { (it - current) + 1 } val partLength get() = to?.let { (it - from) + 1 } override val percent get() = run { val partLength = partLength ?: return@run null (howMuchProceed().toDouble() / partLength.toDouble()) * 100 }?.toInt() // because of end inclusive completed position will be $to + 1 override val isCompleted: Boolean get() = to?.let { current == it + 1 } ?: false fun setBlindAsCompleted() { to = current - 1 } val range get() = to?.let { from..it } val isBlind get() = to == null override fun getID(): Long { return from } companion object { } } ================================================ FILE: downloader/core/src/commonMain/kotlin/ir/amirab/downloader/queue/DownloadQueue.kt ================================================ package ir.amirab.downloader.queue import ir.amirab.downloader.DownloadManagerEvents import ir.amirab.downloader.DownloadManagerMinimalControl import ir.amirab.downloader.db.DownloadQueuePersistedDataAccess import ir.amirab.downloader.db.QueueModel import ir.amirab.downloader.downloaditem.contexts.Queue import ir.amirab.downloader.downloaditem.contexts.ResumedBy import ir.amirab.downloader.downloaditem.contexts.StoppedBy import ir.amirab.downloader.utils.swap import ir.amirab.downloader.utils.swapped import ir.amirab.util.coroutines.debounce import ir.amirab.util.guardedEntry import kotlinx.coroutines.* import kotlinx.coroutines.flow.* class DownloadQueue( persistedModel: QueueModel, val persistedData: DownloadQueuePersistedDataAccess, val downloadEvents: DownloadManagerMinimalControl, val onQueueEvent: (QueueEvent) -> Unit, ) { private val scope = CoroutineScope(SupervisorJob()) // private val mutex = Mutex() private var booted = guardedEntry() private val _queueModel = MutableStateFlow( persistedModel ) val queueModel = _queueModel.asStateFlow() fun getQueueModel() = queueModel.value // this must not change val id: Long = getQueueModel().id private val stopQueueOnEmpty: Boolean get() = getQueueModel().stopQueueOnEmpty private val maxConcurrent get() = getQueueModel().maxConcurrent private val scheduleTimes: ScheduleTimes get() = getQueueModel().scheduledTimes private val activeItems = mutableSetOf() private val canceledItems = mutableSetOf() private val trimmedItems = mutableSetOf() private val _queueActiveFlow = MutableStateFlow(false) val activeFlow = _queueActiveFlow.asStateFlow() val isQueueActive: Boolean get() = activeFlow.value fun onEvent(event: QueueEvent) { this.onQueueEvent(event) } fun boot() { booted.action { startListenerJob() setupAutoStartAndStop() setupAutoSave() } } private fun setupAutoSave() { queueModel.onEach { persist() }.launchIn(scope) } private fun setupAutoStartAndStop() { setUpAutoStartJob() setUpAutoStopJob() } private fun setActive(v: Boolean) { _queueActiveFlow.value = v if (v) { // println("start queue job") } else { // println("stop queue job") } } private fun onDownloadCanceled(id: Long, e: Throwable) { val removed = activeItems.remove(id) if (isQueueActive) { if (!trimmedItems.remove(id)) { canceledItems.add(id) } } shake( itemChangeHappened = removed, ) } private fun onDownloadFinished(id: Long) { removeFromQueue(id) shake( itemChangeHappened = true, ) } fun start() { if (stopping) return // println("on start queue") canceledItems.clear() trimmedItems.clear() ensureBooted() setActive(true) // println("starting") shake( delayed = false ) return } private fun ensureBooted() { if (!booted.isDone()) { error("queue is not booted!") } } private fun shake( itemChangeHappened: Boolean = false, delayed: Boolean = true, ) { if (delayed) { debouncedShake(itemChangeHappened) } else { actualShake(itemChangeHappened) } } private fun actualShake( itemChangeHappened: Boolean = false ): Boolean { // println("shake queue") return when { !isQueueActive -> false activeItems.isEmpty() && (getDownloadableItemFromQueue() == null) -> { if (stopQueueOnEmpty) { stop() } if (itemChangeHappened) { onEvent(QueueEvent.OnQueueBecomesEmpty(id)) } false } else -> { if (activeItems.size < maxConcurrent) { extend() } else if (activeItems.size > maxConcurrent) { trim() } true } } } // Note: it mostly happens in ManualDownloadQueue. couldn't fully reproduce it here yet. adding it just for safety. // If multiple downloads are canceled at the same time, multiple `shake` calls may occur. // In these situations, there is an issue where other downloads might resume // (even though they are already being stopped but their events have not been received yet). // So we use a debounced call to ensure all events are received first. val debouncedShake = scope.debounce( fn = ::actualShake, delayMillis = 500, previousValueMerge = { wasItemChangeHappened, itemChangeHappened -> wasItemChangeHappened || itemChangeHappened } ) private var listenerJob: Job? = null private fun startListenerJob() { listenerJob = downloadEvents.listOfJobsEvents.onEach { if (!getQueueModel().queueItems.contains(it.downloadItem.id)) { //skip this event return@onEach } // println("we (${getQueueModel().name}) found ${it.downloadItem.id} in ${getQueueModel().queueItems} command ${it}") when (it) { is DownloadManagerEvents.OnJobAdded -> { } is DownloadManagerEvents.OnJobCanceled -> onDownloadCanceled( it.downloadItem.id, it.e ) is DownloadManagerEvents.OnJobCompleted -> onDownloadFinished(it.downloadItem.id) is DownloadManagerEvents.OnJobStarted -> {} is DownloadManagerEvents.OnJobStarting -> {} is DownloadManagerEvents.OnJobChanged -> {} is DownloadManagerEvents.OnJobRemoved -> onDownloadRemoved(it.downloadItem.id) } }.launchIn(scope) } private var autoStartJob: Job? = null private fun cancelAutoStartJob() { autoStartJob?.cancel() autoStartJob = null } private fun setUpAutoStartJob() { cancelAutoStartJob() val scheduleTimes = scheduleTimes if (scheduleTimes.enabledStartTime) { autoStartJob = scope.launch { val now = System.currentTimeMillis() delay(scheduleTimes.getNearestTimeToStart() - now) val wasActive = isQueueActive onEvent(QueueEvent.OnQueueStartTimeReached(id, wasActive)) start() //wait a little delay(1000) //for tomorrow setUpAutoStartJob() } } } private var autoStopJob: Job? = null private fun cancelAutoStopJob() { autoStopJob?.cancel() autoStopJob = null } private fun setUpAutoStopJob() { cancelAutoStopJob() val scheduleTimes = scheduleTimes if (scheduleTimes.enabledEndTime) { autoStopJob = scope.launch { val now = System.currentTimeMillis() delay(scheduleTimes.getNearestTimeToStop() - now) val wasActive = isQueueActive onEvent(QueueEvent.QueueEndTimeReached(id, wasActive)) stop() //wait a little delay(1000) //for tomorrow setUpAutoStopJob() } } } private fun onDownloadRemoved(id: Long) { removeFromQueue(id) } fun stop() { scope.launch { // println("stopping") stopAsync() } } @Volatile var stopping = false suspend fun stopAsync() { if (stopping) return setActive(false) stopping = true //active item is a synchronized list so we should iterate over it FAST! val stopJobs = activeItems.map { scope.async { downloadEvents.stopJob(it, StoppedBy(me)) } } kotlin.runCatching { stopJobs.awaitAll() }.onFailure { // should not happen! // it.printStackTrace() } stopping = false } fun setScheduledTimes( updater: ScheduleTimes.() -> ScheduleTimes ) { _queueModel.update { it.copy(scheduledTimes = updater(it.scheduledTimes)) } setupAutoStartAndStop() } fun setName(newValue: String) { _queueModel.update { it.copy(name = newValue) } } fun setMaxConcurrent(value: Int) { _queueModel.update { it.copy(maxConcurrent = value) } shake() } fun setStopQueueOnEmpty(enabled: Boolean) { _queueModel.update { it.copy( stopQueueOnEmpty = enabled ) } shake() } fun move(listOfIds: List, diff: Int) { if (diff == 0) return _queueModel.update { q -> val movingIndexes = listOfIds.mapNotNull { q.queueItems.indexOf(it).takeIf { index -> index != -1 } } //from big to small .sortedDescending() .let { if (diff < 0) it.reversed() else it } if (movingIndexes.isEmpty()) { return@update q } val m = q.queueItems.toMutableList() val queueIndices = q.queueItems.indices val dontMovedPositions = mutableSetOf() // println("moving indexes $movingIndexes") for (index in movingIndexes) { val newPosition = index + diff //don't move out of list index if (newPosition !in queueIndices) { // println("we don't move index $index to $newPosition") dontMovedPositions.add(index) continue } //we don't want to swap to an item that already wants swap if (newPosition in dontMovedPositions) { // println("we don't move index $index to $newPosition cause of $dontMovedPositions") dontMovedPositions.add(index) continue } m.swap(index, newPosition) } q.copy( queueItems = m.toList() ) } } fun moveUp(listOfIds: List) { move(listOfIds, -1) } fun moveDown(listOfIds: List) { move(listOfIds, 1) } fun swapOrders(order: Int, toOrder: (List) -> Int) { _queueModel.update { it.copy( queueItems = it.queueItems.swapped(order, toOrder(it.queueItems)) ) } } fun swapQueueItem(item: Long, toPosition: (List) -> Int) { _queueModel.update { val q = it.queueItems val currentIndex = q.indexOf(item).takeIf { it >= 0 } if (currentIndex == null) { it } else { val swapToThisPosition = toPosition(q) val modifiedItems = q .swapped(currentIndex, swapToThisPosition) it.copy( queueItems = modifiedItems ) } } // println("going to swipe $currentIndex , ${queue.size}") } private fun trim() { val count = activeItems.size - maxConcurrent repeat(count) { if (!removeAnActiveQueueItem()) { return } } } private val me by lazy { Queue(id) } private fun removeAnActiveQueueItem(): Boolean { val id = activeItems.lastOrNull() ?: return false trimmedItems.add(id) val result = activeItems.remove(id) scope.launch { downloadEvents.stopJob(id, StoppedBy(me)) } return result } private fun extend() { repeat(maxConcurrent - activeItems.size) { val queueItemStarted = downloadAQueueItemIfPossible() if (!queueItemStarted) { return } } } /** * @return is any item from queue started ? */ private fun downloadAQueueItemIfPossible(): Boolean { return getDownloadableItemFromQueue()?.let { activeItems.add(it) scope.launch { downloadEvents.startJob(it, ResumedBy(me)) } true } ?: false } private fun getDownloadableItemFromQueue(): Long? { // println(queue) return getQueueModel().queueItems.firstOrNull { when { it in activeItems -> { // println("it is not in active items") false } it in canceledItems -> { // println("it is in canceled items") false } !downloadEvents.canActivateJob(it) -> { // println("it is not cultivatable") false } else -> true } }.also { // println("found downloadable queue item $it") } } private suspend fun persist() { val queue = getQueueModel() persistedData.setModel(queue) } // suspend fun swapQueueItemToEnd(item: Long){ // val currentIndex = queue.indexOf(item).takeIf { it > 0 } ?: return // queue.removeAt(currentIndex) // queue.add(item) // saveQueue() // } fun getOrder(item: Long): Int { return getQueueModel().queueItems.indexOf(item) } fun getQueueItemFromOrder(order: Int): Long { return getQueueModel().queueItems.toList()[order] } suspend fun addToQueue(item: Long) { _queueModel.update { it.copy( queueItems = it.queueItems .plus(item) .distinct() ) } shake( itemChangeHappened = true ) } fun clearQueue() { _queueModel.update { it.copy(queueItems = emptyList()) } activeItems.clear() canceledItems.clear() trimmedItems.clear() } fun removeFromQueue(ids: List) { _queueModel.update { it.copy( queueItems = it.queueItems.filter { it !in ids }.distinct() ) } for (id in ids) { activeItems.remove(id) canceledItems.remove(id) trimmedItems.remove(id) } } fun removeFromQueue(id: Long) { removeFromQueue(listOf(id)) } fun dispose() { cancelAutoStartJob() cancelAutoStopJob() listenerJob?.cancel() scope.cancel() listenerJob = null } } ================================================ FILE: downloader/core/src/commonMain/kotlin/ir/amirab/downloader/queue/ManualDownloadQueue.kt ================================================ package ir.amirab.downloader.queue import ir.amirab.downloader.DownloadManagerEvents import ir.amirab.downloader.DownloadManagerMinimalControl import ir.amirab.downloader.downloaditem.contexts.ResumedBy import ir.amirab.downloader.downloaditem.contexts.StoppedBy import ir.amirab.downloader.downloaditem.contexts.User import ir.amirab.downloader.utils.swap import ir.amirab.downloader.utils.swapped import ir.amirab.util.coroutines.debounce import ir.amirab.util.guardedEntry import kotlinx.coroutines.* import kotlinx.coroutines.flow.* import java.util.Collections /** * this queue is used to limit global concurrent download limit */ class ManualDownloadQueue( private val downloadEvents: DownloadManagerMinimalControl, private val scope: CoroutineScope, ) { private var booted = guardedEntry() // how many downloads can be active at the same time // 0 or fewer means unlimited! // this is protected with a set method private var maxConcurrent = Int.MAX_VALUE // downloads that are currently active private val activeItemsFlow: MutableStateFlow> = MutableStateFlow(emptyList()) // just a shortcut private val activeItems get() = activeItemsFlow.value // this is called from the pause function which can be called from any thread // so we should make it thread safe private val trimmedItems = Collections.synchronizedCollection(mutableSetOf()) private var totalItemsFlow: MutableStateFlow> = MutableStateFlow(emptyList()) val pendingItems = combine( activeItemsFlow.map { it.toSet() }, totalItemsFlow, ) { active, total -> total - active }.stateIn(scope, SharingStarted.Eagerly, emptyList()) fun boot() { booted.action { startListenerJob() } } private fun removeActiveItem(id: Long): Boolean { var removed = false activeItemsFlow.update { val size = it.size val newList = it - id if (size != newList.size) { removed = true } newList } return removed } private fun addAnActiveItem(id: Long) { return activeItemsFlow.update { (it + id).distinct() } } private fun clearActiveItems() { activeItemsFlow.update { emptyList() } } private fun clearTotalItems() { totalItemsFlow.update { emptyList() } } private fun onDownloadCanceled(id: Long, e: Throwable) { if (trimmedItems.remove(id)) { // it was trimmed so we shouldn't remove it from queue // it should be processed after removeActiveItem(id) } else { removeFromQueue(id) } shake() } private fun onDownloadFinished(id: Long) { removeFromQueue(id) shake() } private fun ensureBooted() { if (booted.isDone()) { error("headless queue is not booted!") } } private fun actualShake(): Boolean { val activeItems = activeItems val maxConcurrent = maxConcurrent if (activeItems.size < maxConcurrent) { extend() } else if (activeItems.size > maxConcurrent) { trim() } return true } // If multiple downloads are canceled at the same time, multiple `shake` calls may occur. // In these situations, there is an issue where other downloads might resume // (even though they are already being stopped but their events have not been received yet). // So we use a debounced call to ensure all events are received first. private val shakeDebounce = scope.debounce( ::actualShake, 500, // this should be enough even for slow devices ) private fun shake(delayed: Boolean = true) { if (delayed) { shakeDebounce() } else { actualShake() } } private var listenerJob: Job? = null private fun startListenerJob() { listenerJob = downloadEvents.listOfJobsEvents.onEach { if (!totalItemsFlow.value.contains(it.downloadItem.id)) { //skip this event return@onEach } when (it) { is DownloadManagerEvents.OnJobAdded -> { } is DownloadManagerEvents.OnJobCanceled -> onDownloadCanceled( it.downloadItem.id, it.e ) is DownloadManagerEvents.OnJobCompleted -> onDownloadFinished(it.downloadItem.id) is DownloadManagerEvents.OnJobStarted -> {} is DownloadManagerEvents.OnJobStarting -> { if (it.context[ResumedBy]?.by != me) { // someone else resumed the download // we just remove it removeFromQueue(it.downloadItem.id) } } is DownloadManagerEvents.OnJobChanged -> {} is DownloadManagerEvents.OnJobRemoved -> onDownloadRemoved(it.downloadItem.id) } }.launchIn(scope) } private fun onDownloadRemoved(id: Long) { removeFromQueue(id) } fun setMaxConcurrent(value: Int) { maxConcurrent = if (value <= 0) { Int.MAX_VALUE } else { value } shake() } fun move(listOfIds: List, diff: Int) { if (diff == 0) return totalItemsFlow.update { queueItems -> val movingIndexes = listOfIds.mapNotNull { queueItems.indexOf(it).takeIf { index -> index != -1 } } //from big to small .sortedDescending() .let { if (diff < 0) it.reversed() else it } if (movingIndexes.isEmpty()) { return@update queueItems } val m = queueItems.toMutableList() val queueIndices = queueItems.indices val dontMovedPositions = mutableSetOf() // println("moving indexes $movingIndexes") for (index in movingIndexes) { val newPosition = index + diff //don't move out of list index if (newPosition !in queueIndices) { // println("we don't move index $index to $newPosition") dontMovedPositions.add(index) continue } //we don't want to swap to an item that already wants swap if (newPosition in dontMovedPositions) { // println("we don't move index $index to $newPosition cause of $dontMovedPositions") dontMovedPositions.add(index) continue } m.swap(index, newPosition) } m.toList() } } fun moveUp(listOfIds: List) { move(listOfIds, -1) } fun moveDown(listOfIds: List) { move(listOfIds, 1) } fun swapOrders(order: Int, toOrder: (List) -> Int) { totalItemsFlow.update { items -> items.swapped(order, toOrder(items)) } } private fun trim() { while (activeItems.size > maxConcurrent) { if (!removeAnActiveQueueItemForTrimming()) { return } } } private val me = User private fun removeAnActiveQueueItemForTrimming(): Boolean { val id = activeItems.lastOrNull() ?: return false trimmedItems.add(id) val result = removeActiveItem(id) scope.launch { downloadEvents.stopJob(id, StoppedBy(me)) } return result } private fun extend() { while (maxConcurrent > activeItems.size) { val queueItemStarted = downloadAQueueItemIfPossible() if (!queueItemStarted) { return } } } /** * @return is any item from queue started ? */ private fun downloadAQueueItemIfPossible(): Boolean { val downloadableItemFromQueue = getAnInactiveITemFromTheQueue() return downloadableItemFromQueue?.let { addAnActiveItem(it) scope.launch { downloadEvents.startJob(it, ResumedBy(me)) } true } ?: false } private fun getAnInactiveITemFromTheQueue(): Long? { while (true) { val activeItems = activeItems val item = totalItemsFlow.value .firstOrNull { it !in activeItems } if (item == null) { // no item returning now! return null } if (!downloadEvents.canActivateJob(item)) { // finished or in status that we can't use it anymore // remove it! removeFromQueue(item) continue } return item } } fun getOrder(item: Long): Int { return totalItemsFlow.value.indexOf(item) } fun getQueueItemFromOrder(order: Int): Long? { return totalItemsFlow.value.getOrNull(order) } fun clearQueue() { trimmedItems.clear() clearActiveItems() clearTotalItems() } fun removeFromQueue(ids: Set) { totalItemsFlow.update { it.filter { id -> id !in ids } } activeItemsFlow.update { it.filter { it !in ids } } trimmedItems.removeAll(ids) } fun removeFromQueue(id: Long) { return removeFromQueue(setOf(id)) } fun resume(item: Long) { totalItemsFlow.update { it + item } shake( // immediately shake the queue delayed = false ) } // called by user suspend fun pause(item: Long) { // this should be removed here as it indicates that user manually paused it // so the trimmed item list should not contain it anymore trimmedItems.remove(item) downloadEvents.stopJob(item, StoppedBy(me)) } } ================================================ FILE: downloader/core/src/commonMain/kotlin/ir/amirab/downloader/queue/QueueEvent.kt ================================================ package ir.amirab.downloader.queue sealed interface QueueEvent { val queueId: Long data class QueueEndTimeReached( override val queueId: Long, val wasActive: Boolean, ) : QueueEvent data class OnQueueBecomesEmpty( override val queueId: Long, ) : QueueEvent data class OnQueueStartTimeReached( override val queueId: Long, val wasActive: Boolean, ) : QueueEvent } ================================================ FILE: downloader/core/src/commonMain/kotlin/ir/amirab/downloader/queue/QueueManager.kt ================================================ package ir.amirab.downloader.queue import ir.amirab.downloader.DownloadManagerMinimalControl import ir.amirab.downloader.db.IDownloadQueueDatabase import ir.amirab.downloader.db.DownloadQueuePersistedDataAccess import ir.amirab.downloader.db.QueueModel import ir.amirab.util.suspendGuardedEntry import kotlinx.coroutines.channels.BufferOverflow import kotlinx.coroutines.flow.* import kotlinx.coroutines.sync.Mutex import kotlinx.coroutines.sync.withLock object DefaultQueueInfo { const val ID = 0L const val NAME = "Main" } class QueueManager( private val queueDb: IDownloadQueueDatabase, private val listOfJobs: DownloadManagerMinimalControl, ) { companion object { // we save this ids maybe later we want to add some queues const val RESERVED_UNTIL_QUEUE_ID = 10L } val queues = MutableStateFlow( emptyList() ) private val _queueEvent = MutableSharedFlow( // send events without suspending extraBufferCapacity = 64, onBufferOverflow = BufferOverflow.DROP_OLDEST, ) val queueEvents: SharedFlow = _queueEvent.asSharedFlow() private fun onQueueEvent(queueEvent: QueueEvent) { _queueEvent.tryEmit(queueEvent) } private suspend fun addDefaultQueue() { val queueModel = QueueModel( id = DefaultQueueInfo.ID, name = DefaultQueueInfo.NAME, ) // println("creating default queue") queueDb.addQueue(queueModel) val queue = createQueue(queueModel) queues.update { currentList -> buildList { add(queue) addAll(currentList) } } queue.boot() } suspend fun addQueue( name: String, ) { val maxId = queueDb .getAllQueueIds() .maxOrNull() ?.coerceAtLeast(RESERVED_UNTIL_QUEUE_ID) ?: RESERVED_UNTIL_QUEUE_ID // this is reserved id val queueModel = QueueModel( id = maxId + 1, name = name, ) queueDb.addQueue( queueModel ) val queue = createQueue(queueModel) queues.update { it.plus(queue) } queue.boot() } suspend fun deleteQueue( queue: DownloadQueue ) { if (queue.isMainQueue()) { return } queue.dispose() queueDb.deleteQueue(queue.id) queues.update { it.filter { it.id != queue.id } } } suspend fun deleteQueue( id: Long, ) { val foundQueue = queues.value.find { it.id == id } foundQueue?.let { deleteQueue(foundQueue) } } private var booted = suspendGuardedEntry() private fun ensureBooted() { require(booted.isDone()) { "please first boot QueueManager" } } suspend fun boot() { booted.action { val queueModels = queueDb .getAllQueues() val dbQueues = queueModels.map { createQueue(it) } for (queue in dbQueues) { queue.boot() } queues.update { dbQueues } val ids = queueModels.map { it.id } if (DefaultQueueInfo.ID !in ids) { addDefaultQueue() } } } fun getMainQueue(): DownloadQueue { ensureBooted() return requireNotNull( queues.value.find { it.id == DefaultQueueInfo.ID } ) { "we can't find main queue" } } private fun createQueue(queueModel: QueueModel): DownloadQueue { return DownloadQueue( persistedData = QueueInfoPersistedData( queueDb, queueModel.id ), downloadEvents = listOfJobs, persistedModel = queueModel, onQueueEvent = ::onQueueEvent, ) } fun getAll(): List { return queues.value } fun getQueue(queue: Long): DownloadQueue { return requireNotNull( queues.value.find { it.id == queue } ) } fun canDelete(queue: Long): Boolean { return queue != DefaultQueueInfo.ID // return if (queue in 0..RESERVED_UNTIL_QUEUE_ID) { // false // } else true } fun isItemInQueue(downloadId: Long): Boolean { return findItemInQueue(downloadId) != null } fun findItemInQueue(downloadId: Long): Long? { for (queue in queues.value) { for (queueItem in queue.getQueueModel().queueItems) { if (downloadId == queueItem) { return queue.id } } } return null } suspend fun addToQueue( queueId: Long, downloadId: Long, ) { val foundInQueue = findItemInQueue(downloadId = downloadId) if (foundInQueue == queueId) { //already in same queue return } if (foundInQueue != null) { getQueue(foundInQueue).removeFromQueue(downloadId) } getQueue(queueId).addToQueue(downloadId) } suspend fun addToQueue(queueId: Long, downloadIds: List) { downloadIds.forEach { addToQueue(queueId, it) } } fun clearQueue(queueId: Long) { val queue = getQueue(queueId) queue.clearQueue() } } private class QueueInfoPersistedData( val db: IDownloadQueueDatabase, val id: Long, ) : DownloadQueuePersistedDataAccess { var cached: QueueModel? = null val lock = Mutex() override suspend fun getModel(): QueueModel { if (cached == null) { cached = db.getQueue(id) } // println("getModel() == $cached") return cached!! } override suspend fun setModel(model: QueueModel) { if (model == cached) { //nothing to update // println("Noting to update") return } lock.withLock { db.updateQueue(model) // println("setModel() == $model") cached = model } } } private fun QueueManager.getActiveOrInactiveQueues( active: Boolean, ): Flow> { return queues.flatMapLatest { latestQueues -> if (latestQueues.isEmpty()) { flowOf(emptyList()) } else { combine( latestQueues.map { it.activeFlow } ) { it.mapIndexedNotNull { index, value -> val shouldAdd = if (active) value else !value if (shouldAdd) { latestQueues[index] } else { null } } } } } } fun QueueManager.activeQueuesFlow(): Flow> { return getActiveOrInactiveQueues(true) } fun QueueManager.inactiveQueuesFlow(): Flow> { return getActiveOrInactiveQueues(false) } fun QueueManager.queueModelsFlow(): Flow> { return queues.flatMapLatest { queues -> if (queues.isEmpty()) { flowOf(emptyList()) } else { combine( queues.map { it.queueModel } ) { it.toList() } } } } fun DownloadQueue.isMainQueue(): Boolean { return DefaultQueueInfo.ID == id } ================================================ FILE: downloader/core/src/commonMain/kotlin/ir/amirab/downloader/queue/ScheduleTimes.kt ================================================ @file:OptIn(ExperimentalTime::class) package ir.amirab.downloader.queue import kotlinx.datetime.DateTimeUnit import kotlinx.datetime.DayOfWeek import kotlinx.datetime.LocalDateTime import kotlinx.datetime.LocalTime import kotlinx.datetime.TimeZone import kotlinx.datetime.plus import kotlinx.datetime.toInstant import kotlinx.datetime.toLocalDateTime import kotlinx.serialization.Serializable import kotlin.time.Clock import kotlin.time.ExperimentalTime @Serializable data class ScheduleTimes( val daysOfWeek: Set, val startTime: LocalTime, val endTime: LocalTime, val enabledStartTime: Boolean, val enabledEndTime: Boolean, ) { init { require(daysOfWeek.isNotEmpty()) { "we have always have one day" } } companion object { fun default() = ScheduleTimes( daysOfWeek = DayOfWeek.entries.toSet(), startTime = LocalTime(2, 30), endTime = LocalTime(7, 30), enabledStartTime = false, enabledEndTime = false, ) } private fun containsThisDay(day: DayOfWeek): Boolean { return day in daysOfWeek } private fun getNearestDayOfWork(forTime: LocalTime): Int { val now = Clock.System.now().toLocalDateTime(TimeZone.currentSystemDefault()) var currentDay = now.dayOfWeek val currentTime = now.time var count = 0 while (true) { if (containsThisDay(currentDay)) { if (count == 0) { if (currentTime < forTime) { return count // ==0 today } //else => today's start time has been passed so we don't want today //we continue for tomorrow } else { return count } } currentDay = currentDay.plus(1) count++ if (count > 7) { error("there is a bug in our code stoping loop") } } } fun getNearestTimeToStart(): Long { val now = Clock.System.now() val nextTime = now .plus( getNearestDayOfWork(startTime), DateTimeUnit.DAY, TimeZone.currentSystemDefault() ) .toLocalDateTime(TimeZone.currentSystemDefault()) .let { LocalDateTime(it.date, startTime) }.toInstant(TimeZone.currentSystemDefault()) return nextTime.toEpochMilliseconds() } fun getNearestTimeToStop(): Long { val stopTime = this.endTime val now = Clock.System.now() val nextTime = now .plus( getNearestDayOfWork(stopTime), DateTimeUnit.DAY, TimeZone.currentSystemDefault() ) .toLocalDateTime(TimeZone.currentSystemDefault()) .let { LocalDateTime(it.date, stopTime) }.toInstant(TimeZone.currentSystemDefault()) return nextTime.toEpochMilliseconds() } } private fun DayOfWeek.plus(days: Int): DayOfWeek { val entries = DayOfWeek.entries val index = (entries.indexOf(this) + days) % entries.size return entries[index] } ================================================ FILE: downloader/core/src/commonMain/kotlin/ir/amirab/downloader/utils/CallAwait.kt ================================================ package ir.amirab.downloader.utils import okhttp3.Call import okhttp3.Response import okhttp3.coroutines.executeAsync suspend fun Call.await(): Response { return executeAsync() } ================================================ FILE: downloader/core/src/commonMain/kotlin/ir/amirab/downloader/utils/CollectionUtils.kt ================================================ package ir.amirab.downloader.utils internal fun MutableList.swap( index: Int,toPosition: Int ): MutableList =apply { val p=set(toPosition,this[index]) set(index,p) } internal fun List.swapped(index:Int, toPosition: Int): List { val l=toMutableList() l.swap(index,toPosition) return l.toList() } internal fun Set.swapped(a:T, b: T): Set { val l=toMutableList() val indexA=indexOf(a) val indexB=indexOf(b) val tmp=l.set(indexB,l[indexA]) l.set(indexA,tmp) return l.toList().toSet() } ================================================ FILE: downloader/core/src/commonMain/kotlin/ir/amirab/downloader/utils/DuplicateFilter.kt ================================================ package ir.amirab.downloader.utils import ir.amirab.downloader.downloaditem.IDownloadItem import java.io.File interface DuplicateDownloadFilter { fun isDuplicate(downloadItem: IDownloadItem): Boolean } // I moved this logic here because it used multiple times class DuplicateFilterByPath( private val file: File, ) : DuplicateDownloadFilter { override fun isDuplicate(downloadItem: IDownloadItem): Boolean { return file == File(downloadItem.folder, downloadItem.name) } } ================================================ FILE: downloader/core/src/commonMain/kotlin/ir/amirab/downloader/utils/EmptyFileCreator.kt ================================================ package ir.amirab.downloader.utils import ir.amirab.downloader.exception.NoSpaceInStorageException import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.isActive import kotlinx.coroutines.withContext import java.io.File import java.io.FileOutputStream import java.io.IOException import java.io.RandomAccessFile class EmptyFileCreator( private val diskStat: IDiskStat, private val useSparseFile: () -> Boolean ) { private fun canWeUseSparse(file: File): Boolean { return useSparseFile() && SparseFile.canWeCreateSparseFile(file) } /** * @param length must be -1 , or positive */ suspend fun prepareFile( file: File, length: Long, onProgressUpdate: (percent: Int?) -> Unit, ) { require(length >= -1) { "length must be -1 , or positive value but we got ${length}" } withContext(Dispatchers.IO) { val canWeUseSparse = canWeUseSparse(file) onProgressUpdate(0) if (length == -1L) { RandomAccessFile(file, "rw").use { it.setLength(0) } onProgressUpdate(100) return@withContext } val remainingSpace = diskStat.getRemainingSpace(file.parentFile) if (file.exists()) { val currentLength = file.length() val requiredLength = length - currentLength if (remainingSpace < requiredLength) { throw NoSpaceInStorageException(remainingSpace, requiredLength) } when { currentLength > length -> { RandomAccessFile(file, "rw").use { it.setLength(length) } onProgressUpdate(100) return@withContext } currentLength < length -> { if (canWeUseSparse) { if (!file.delete()) { throw IOException("can't delete file") } if (SparseFile.createSparseFile(file)) { onProgressUpdate(null) writeAtLast(file, length) } else { file.createNewFile() fillOutput(file, length, onProgressUpdate) } } else { fillOutput(file, length, onProgressUpdate) } onProgressUpdate(100) return@withContext } else -> { onProgressUpdate(100) return@withContext } } } else { if (remainingSpace < length) { throw NoSpaceInStorageException(remainingSpace, length) } if (canWeUseSparse && SparseFile.createSparseFile(file)) { onProgressUpdate(null) writeAtLast(file, length) } else { file.createNewFile() fillOutput(file, length, onProgressUpdate) } onProgressUpdate(100) } } } /** * manually write a single byte to the last of file! * if the sparse is not supported for the file, at least * waits for OS to create empty file for us */ private fun writeAtLast(file: File, length: Long) { RandomAccessFile(file, "rw").use { it.seek(length - 1) it.write(0) } } private suspend fun fillOutput(outputFile: File, length: Long, onProgressUpdate: (percent: Int) -> Unit) { val much = length - outputFile.length() val remainingSpace = diskStat.getRemainingSpace(outputFile.parentFile) if (remainingSpace < much) { throw NoSpaceInStorageException(remainingSpace, much) } // println("how much to be appended $much") withContext(Dispatchers.IO) { FileOutputStream(outputFile, true).use { val buffer = ByteArray(DEFAULT_BUFFER_SIZE) var written = 0L while (isActive) { val writeInThisLoop = if (much - written > buffer.size) { buffer.size } else { much - written }.toInt() if (writeInThisLoop == 0) break // println(writeInThisLoop) it.write(buffer, 0, writeInThisLoop) written += writeInThisLoop onProgressUpdate(calcPercent(written, much)) } } } } } ================================================ FILE: downloader/core/src/commonMain/kotlin/ir/amirab/downloader/utils/ExceptionUtils.kt ================================================ package ir.amirab.downloader.utils import ir.amirab.downloader.exception.UnSuccessfulResponseException import kotlinx.coroutines.CancellationException import java.io.InterruptedIOException import java.net.SocketException import java.net.SocketTimeoutException import java.net.UnknownHostException object ExceptionUtils { fun isNormalCancellation(e: Throwable): Boolean { return e is CancellationException } fun isIOInterrupted(e: Throwable): Boolean { return e is InterruptedIOException } fun isNetworkError(e: Throwable): Boolean { return e is UnknownHostException || e is SocketException || e is SocketTimeoutException } fun isResponseError(e: Throwable): Boolean { return e is UnSuccessfulResponseException } } inline fun T.throwIf(condition: (T) -> Boolean) { if (condition(this)) { throw this } } inline fun T.throwIfCancelled() { throwIf { ExceptionUtils.isNormalCancellation(this) } } fun Throwable.printStackIfNOtUsual() { if ( ExceptionUtils.isNormalCancellation(this) || ExceptionUtils.isNetworkError(this) || ExceptionUtils.isIOInterrupted(this) || ExceptionUtils.isResponseError(this) ) { return } printStackTrace() } ================================================ FILE: downloader/core/src/commonMain/kotlin/ir/amirab/downloader/utils/FileNameUtil.kt ================================================ package ir.amirab.downloader.utils import kotlinx.coroutines.currentCoroutineContext import kotlinx.coroutines.flow.* import kotlinx.coroutines.isActive import java.io.File object FileNameUtil { /** * make sure to validate name before using this function */ fun getExtensionOrNull(filename: String): String? { return filename .substringAfterLast('.', "") .takeIf { it.isNotEmpty() } } fun numberedIfExists(filename: File): Flow { return flow { if (!filename.exists()) { emit(filename) } val ext = filename.extension .takeIf { it.isNotEmpty() } ?.let { ".$it" }.orEmpty() val name = filename.nameWithoutExtension var counter = 1 while (currentCoroutineContext().isActive) { val newFile = filename.parentFile.resolve( "${name}_${counter}${ext}" ) if (!newFile.exists()) { emit(newFile) } counter++ } } } fun replaceExtension(filename: String, newExtension: String, appendIfNotExists: Boolean = true): String { val ext = getExtensionOrNull(filename) ?: if (appendIfNotExists) { return "$filename.$newExtension" } else { return filename } val filenameWithoutExtension = filename.dropLast(ext.length) return "$filenameWithoutExtension$newExtension" } } ================================================ FILE: downloader/core/src/commonMain/kotlin/ir/amirab/downloader/utils/FlowUtils.kt ================================================ package ir.amirab.downloader.utils import kotlinx.coroutines.currentCoroutineContext import kotlinx.coroutines.delay import kotlinx.coroutines.flow.flow import kotlinx.coroutines.isActive fun intervalFlow(interval: Long) = flow { while (currentCoroutineContext().isActive) { emit(Unit) delay(interval) } } ================================================ FILE: downloader/core/src/commonMain/kotlin/ir/amirab/downloader/utils/IDistStat.kt ================================================ package ir.amirab.downloader.utils import java.io.File interface IDiskStat { fun getRemainingSpace(path: File): Long } ================================================ FILE: downloader/core/src/commonMain/kotlin/ir/amirab/downloader/utils/LockList.kt ================================================ package ir.amirab.downloader.utils import kotlinx.coroutines.sync.Mutex import kotlinx.coroutines.sync.withLock import java.util.concurrent.ConcurrentHashMap class LockList { private data class LockWithCounter( val lock: Any = Any(), var counter: Int = 1, ) private val locks: ConcurrentHashMap = ConcurrentHashMap() fun withLock(item: T, block: (T) -> R): R { val itemLock = locks.compute(item) { k, existing -> if (existing == null) { return@compute LockWithCounter() } existing.counter++ existing }!! try { return synchronized(itemLock.lock) { block(item) } } finally { locks.compute(item) { k, existing -> if (existing == null) { return@compute null } if ((--existing.counter) == 0) { return@compute null } existing } } } } class SuspendLockList { private data class LockWithCounter( val lock: Mutex = Mutex(), var counter: Int = 1, ) private val locks: ConcurrentHashMap = ConcurrentHashMap() suspend fun withLock(item: T, block: suspend (T) -> R): R { val itemLock = locks.compute(item) { key, existing -> if (existing == null) { return@compute LockWithCounter() } existing.counter++ existing }!! return try { itemLock.lock.withLock { block(item) } } finally { locks.compute(item) { key, existing -> if (existing == null) { return@compute null } if ((--existing.counter) == 0) { return@compute null } existing } } } } ================================================ FILE: downloader/core/src/commonMain/kotlin/ir/amirab/downloader/utils/Logger.kt ================================================ package ir.amirab.downloader.utils import java.util.logging.Logger inline fun T.thisLogger(): Logger { return Logger.getLogger(T::class.qualifiedName) } ================================================ FILE: downloader/core/src/commonMain/kotlin/ir/amirab/downloader/utils/NumUtil.kt ================================================ package ir.amirab.downloader.utils fun calcPercent(proceed: Long, contentLength: Long): Int { return ((proceed.toDouble() / contentLength.toDouble()) * 100).toInt() } fun calcPercent(proceed: Int, contentLength: Int): Int { return ((proceed.toDouble() / contentLength.toDouble()) * 100).toInt() } fun calcPercent(proceed: Double, contentLength: Double): Int { return ((proceed / contentLength) * 100).toInt() } ================================================ FILE: downloader/core/src/commonMain/kotlin/ir/amirab/downloader/utils/OnDuplicateStrategy.kt ================================================ package ir.amirab.downloader.utils enum class OnDuplicateStrategy { AddNumbered, OverrideDownload, Abort,; companion object{ fun default() = AddNumbered } } fun OnDuplicateStrategy?.orDefault() = this?:OnDuplicateStrategy.default() ================================================ FILE: downloader/core/src/commonMain/kotlin/ir/amirab/downloader/utils/SparseFile.kt ================================================ package ir.amirab.downloader.utils import java.io.File import java.nio.file.Files import java.nio.file.OpenOption import java.nio.file.StandardOpenOption import kotlin.io.path.fileStore interface ISparseFile { fun createSparseFile(file: File): Boolean fun canWeCreateSparseFile(file: File): Boolean } expect object SparseFile : ISparseFile ================================================ FILE: downloader/core/src/commonMain/kotlin/ir/amirab/downloader/utils/SplitToRange.kt ================================================ package ir.amirab.downloader.utils fun splitToRange(size: Long, minPartSize: Long, maxPartCount: Long): List { require(size >= 1) { "size must be >=1 passed :$size" } require(minPartSize >= 1) { "minPartSize must be >=1 passed :$minPartSize" } require(maxPartCount >= 1) { "maxPartCount must be >=1 passed :$maxPartCount" } val minParts = (size + minPartSize - 1) / minPartSize // round up division val actualPartCount = minOf(maxPartCount, minParts) val idealPartSize = size / actualPartCount val ranges = mutableListOf() var start = 0L var end = 0L for (i in 1..actualPartCount) { end = start + idealPartSize - 1 if (i <= size % actualPartCount) { end++ } ranges.add(start..end) start = end + 1 } return ranges } ================================================ FILE: downloader/core/src/commonMain/kotlin/ir/amirab/downloader/utils/TimeUtils.kt ================================================ package ir.amirab.downloader.utils import java.text.SimpleDateFormat import java.util.* object TimeUtils { fun convertLastModifiedHeaderToTimestamp(lastModified: String): Long { // Define the format of the Last-Modified header val dateFormat = SimpleDateFormat("EEE, dd MMM yyyy HH:mm:ss z", Locale.US) // Parse the date string into a Date object val date = dateFormat.parse(lastModified) // Convert the Date object to a timestamp (milliseconds since epoch) return date?.time ?: throw IllegalArgumentException("Invalid Last-Modified header") } } ================================================ FILE: downloader/core/src/desktopMain/kotlin/ir/amirab/downloader/utils/SparseFile.desktop.kt ================================================ package ir.amirab.downloader.utils import java.io.File import java.nio.file.Files import java.nio.file.OpenOption import java.nio.file.StandardOpenOption import kotlin.io.path.fileStore actual object SparseFile : ISparseFile { private val fileSystemsSupportingSparseFiles = listOf( // Windows "NTFS", "ReFS", // Linux / Unix "ext4", "ext3", "ext2", "XFS", "Btrfs", "ZFS", "ReiserFS", "JFS", "F2FS", "UFS", "UFS2", "tmpfs", "OverlayFS", // macOS "APFS", "HFS+", // Network file systems (if server supports sparse files) "SMB", "CIFS", "NFS", "NFSv4", ) .map { it.lowercase() } .toSet() override fun createSparseFile(file: File): Boolean { if (!file.exists()) { val options = arrayOf( StandardOpenOption.WRITE, StandardOpenOption.CREATE_NEW, StandardOpenOption.SPARSE ) return runCatching { Files.newByteChannel( file.toPath(), *options, ).use {} true }.getOrElse { false } } return false } /** * I assume that its parent are created before so make sure of that */ override fun canWeCreateSparseFile(file: File): Boolean { return kotlin.runCatching { val nearestFileExist = file.findNearestExistingFile() ?: return false val type = nearestFileExist .toPath() .fileStore() .type() .lowercase() // both must be lowercase fileSystemsSupportingSparseFiles.contains(type) }.getOrElse { false } } private fun File.findNearestExistingFile(): File? { var f: File? = this while (true) { if (f == null) { return null } if (f.exists()) { return f } else { f = f.parentFile } } } } ================================================ FILE: downloader/monitor/build.gradle.kts ================================================ import org.jetbrains.kotlin.gradle.dsl.JvmTarget plugins { id(MyPlugins.kotlinMultiplatform) id(Plugins.Kotlin.serialization) id(MyPlugins.composeBase) id(Plugins.Android.library) } kotlin { jvm("desktop") androidTarget("android") { compilerOptions { jvmTarget.set(JvmTarget.JVM_21) } } sourceSets { commonMain { dependencies { implementation(project(":downloader:core")) implementation(project(":shared:utils")) implementation(libs.kotlin.coroutines.core) implementation(libs.compose.runtime) } } } } android { compileSdk = 36 namespace = "ir.amirab.downloader.monitor" defaultConfig { minSdk = 26 } } ================================================ FILE: downloader/monitor/src/commonMain/kotlin/ir/amirab/downloader/monitor/CompletedDownloadItemState.kt ================================================ package ir.amirab.downloader.monitor import androidx.compose.runtime.Immutable import ir.amirab.downloader.downloaditem.IDownloadItem @Immutable data class CompletedDownloadItemState( override val id: Long, override val folder: String, override val name: String, override val downloadLink: String, override val contentLength: Long, override val saveLocation: String, override val dateAdded: Long, override val startTime: Long, override val completeTime: Long, ) : IDownloadItemState { companion object { fun fromDownloadItem(item: IDownloadItem): CompletedDownloadItemState { return CompletedDownloadItemState( id = item.id, folder = item.folder, name = item.name, downloadLink = item.link, contentLength = item.contentLength, saveLocation = item.name, dateAdded = item.dateAdded, startTime = item.startTime ?: -1, completeTime = item.completeTime ?: -1, ) } } } ================================================ FILE: downloader/monitor/src/commonMain/kotlin/ir/amirab/downloader/monitor/DownloadItemStateFactory.kt ================================================ package ir.amirab.downloader.monitor import androidx.compose.runtime.Immutable import ir.amirab.downloader.downloaditem.DownloadJob import ir.amirab.downloader.downloaditem.IDownloadItem @Immutable data class ProcessingDownloadItemFactoryInputs< out TDownloadJob : DownloadJob >( val downloadJob: TDownloadJob, val speed: Long, val isWaiting: Boolean, ) interface DownloadItemStateFactory< in TDownloadItem : IDownloadItem, in TDownloadJob : DownloadJob > { fun createProcessingDownloadItemState( props: ProcessingDownloadItemFactoryInputs ): ProcessingDownloadItemState fun createCompletedDownloadItemState( downloadItem: TDownloadItem, ): CompletedDownloadItemState } ================================================ FILE: downloader/monitor/src/commonMain/kotlin/ir/amirab/downloader/monitor/DownloadMonitor.kt ================================================ package ir.amirab.downloader.monitor import ir.amirab.downloader.DownloadManagerEvents import ir.amirab.downloader.downloaditem.DownloadJobStatus import ir.amirab.downloader.downloaditem.DownloadStatus import ir.amirab.downloader.utils.intervalFlow import ir.amirab.util.flow.saved import ir.amirab.downloader.DownloadManager import ir.amirab.downloader.downloaditem.DownloadJob import ir.amirab.downloader.downloaditem.IDownloadItem import ir.amirab.downloader.queue.ManualDownloadQueue import ir.amirab.util.flow.combineStateFlows import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Job import kotlinx.coroutines.SupervisorJob import kotlinx.coroutines.delay import kotlinx.coroutines.flow.* import kotlinx.coroutines.isActive import kotlinx.coroutines.launch class DownloadMonitor( private val downloadManager: DownloadManager, private val manualDownloadQueue: ManualDownloadQueue, downloadItemStateFactory: Lazy> ) : IDownloadMonitor { val downloadItemStateFactory by downloadItemStateFactory private val scope = CoroutineScope(SupervisorJob()) private var avSpeedCollectorJob: Job? = null override var useAverageSpeed = false set(value) { if (value == field) return field = value //always cancel current job updateUseAverageSpeedFlow(value) } private fun updateUseAverageSpeedFlow(useAverageSpeed: Boolean) { avSpeedCollectorJob?.cancel() avSpeedCollectorJob = if (useAverageSpeed) { // enabling average speed calculator flow only if nececary // it will add a subscriber count into averageSpeedFlow and causes to start working scope.launch { averageDownloadSpeedFlow.collect() } } else { // disable average speed null } } override val activeDownloadListFlow = MutableStateFlow>(emptyList()) override val completedDownloadListFlow = MutableStateFlow>(emptyList()) override val downloadListFlow: StateFlow> = combineStateFlows(activeDownloadListFlow, completedDownloadListFlow) { a, b -> a + b } init { activeDownloadListFlow .subscriptionCount .map { it > 0 } .distinctUntilChanged() .onEach { isUsed -> if (isUsed) { downloadManager.awaitBoot() startUpdateActiveDownloadList() startSpeedMeter() } else { stopUpdateDownloadList() stopSpeedMeter() } } .launchIn(scope) completedDownloadListFlow .subscriptionCount .map { it > 0 } .distinctUntilChanged() .onEach { isUsed -> if (isUsed) { //wait for boot to initialize part downloaders! downloadManager.awaitBoot() startUpdateCompletedList() } else { stopUpdateCompletedList() } }.launchIn(scope) } private val downloadSpeedFlow = MutableStateFlow(SpeedAtTime.empty()) private val averageDownloadSpeedFlow = downloadSpeedFlow .saved(5) .map { lastSpeedHistory -> val lastSpeeds = lastSpeedHistory.lastOrNull()?.speed ?: return@map SpeedAtTime.empty() SpeedAtTime( lastSpeeds .mapValues { (id, _) -> lastSpeedHistory .mapNotNull { it.speed.getOrElse(id) { null } } .average() .toLong() } ) }.stateIn(scope, SharingStarted.WhileSubscribed(), SpeedAtTime.empty()) private var speedMeterJob: Job? = null private fun startSpeedMeter() { speedMeterJob?.cancel() updateUseAverageSpeedFlow(useAverageSpeed) speedMeterJob = scope.launch { var lastWrites = mapOf() while (isActive) { val newWrites = downloadManager.downloadJobs.associate { it.id to it.getDownloadedSize() } downloadSpeedFlow.value = SpeedAtTime( newWrites.mapValues { (id, newWrite) -> val lastWrittenData = lastWrites.getOrElse(id) { null } val newSpeed = when { lastWrittenData != null -> { if (newWrite < lastWrittenData) { // maybe download was restarted our lastWrittenData is not valid anymore newWrite } else { newWrite - lastWrittenData } } else -> { // this item seen for the first time 0 } } newSpeed } ) lastWrites = newWrites delay(1_000) } } } private fun stopSpeedMeter() { speedMeterJob?.cancel() speedMeterJob = null avSpeedCollectorJob?.cancel() avSpeedCollectorJob = null } private fun getPreferedSpeedFlow(): StateFlow { return when { useAverageSpeed -> averageDownloadSpeedFlow else -> downloadSpeedFlow } } private fun getSpeedOf(id: Long): Long { val speed = getPreferedSpeedFlow().value.speed.getOrElse(id) { -1 } // println("speed of $id is $speed") return speed } private var completedDownloadListUpdaterJob: Job? = null private fun startUpdateCompletedList() { completedDownloadListUpdaterJob?.cancel() completedDownloadListUpdaterJob = scope.launch { val initialData = downloadManager.getDownloadList().filter { it.status == DownloadStatus.Completed }.map { downloadItemStateFactory.createCompletedDownloadItemState(it) } completedDownloadListFlow.update { initialData } downloadManager.listOfJobsEvents .onEach { event -> when (event) { is DownloadManagerEvents.OnJobCompleted -> { val item = downloadItemStateFactory .createCompletedDownloadItemState(event.downloadItem) completedDownloadListFlow.update { current -> //replace if this id is already in the completed list // this is happened when we are creating a job from a completed download val found = current.find { it.id == item.id } if (found != null) { current.map { if (it.id == item.id) { item } else { it } } } else { current + item } } } is DownloadManagerEvents.OnJobRemoved -> { completedDownloadListFlow.update { it.filter { it.id != event.downloadItem.id } } } is DownloadManagerEvents.OnJobChanged -> { val shouldAdd = event.downloadItem.status == DownloadStatus.Completed completedDownloadListFlow.update { current -> if (shouldAdd) { val item = downloadItemStateFactory.createCompletedDownloadItemState(event.downloadItem) val exists = current.find { it.id == item.id } != null if (exists) { //replace existing current.map { if (it.id == item.id) { item } else { it } } } else { current + item } } else { current.filter { it.id != event.downloadItem.id } } } } else -> {} } } .launchIn(this) } } private fun stopUpdateCompletedList() { completedDownloadListUpdaterJob?.cancel() completedDownloadListUpdaterJob = null } private var downloadListUpdaterJob: Job? = null private val headlessQueuePendingItemsFlow = manualDownloadQueue.pendingItems private fun startUpdateActiveDownloadList() { downloadListUpdaterJob?.cancel() downloadListUpdaterJob = merge( downloadManager.listOfJobsEvents.map { }, downloadSpeedFlow, headlessQueuePendingItemsFlow, intervalFlow(500) ).onEach { val newList = downloadManager.downloadJobs.filter { it.status.value != DownloadJobStatus.Finished }.map { val status = it.status.value val speed = if (status is DownloadJobStatus.IsActive) { getSpeedOf(it.id) } else 0L val isWaiting = headlessQueuePendingItemsFlow.value.contains(it.id) downloadItemStateFactory.createProcessingDownloadItemState( ProcessingDownloadItemFactoryInputs( downloadJob = it, speed = speed, isWaiting = isWaiting, ) ) } activeDownloadListFlow.update { newList } } .launchIn(scope) } private fun stopUpdateDownloadList() { downloadListUpdaterJob?.cancel() // println("turn off list updater") downloadListUpdaterJob = null } override val activeDownloadCount = downloadManager.listOfJobsEvents.map { downloadManager.getActiveCount() }.stateIn( scope, SharingStarted.Eagerly, downloadManager.getActiveCount() ) override suspend fun waitForDownloadToFinishOrCancel( id: Long, ) { val event = downloadManager .listOfJobsEvents .filter { it.downloadItem.id == id } .first { when (it) { is DownloadManagerEvents.OnJobAdded -> false is DownloadManagerEvents.OnJobCanceled -> true is DownloadManagerEvents.OnJobChanged -> false is DownloadManagerEvents.OnJobCompleted -> true is DownloadManagerEvents.OnJobRemoved -> true is DownloadManagerEvents.OnJobStarted -> false is DownloadManagerEvents.OnJobStarting -> false } } if (event is DownloadManagerEvents.OnJobCanceled) { throw event.e } } } data class SpeedAtTime( val speed: Map, val time: Long = System.currentTimeMillis(), ) { companion object { fun empty() = SpeedAtTime(emptyMap()) } } ================================================ FILE: downloader/monitor/src/commonMain/kotlin/ir/amirab/downloader/monitor/DownloadStateUtil.kt ================================================ package ir.amirab.downloader.monitor import ir.amirab.downloader.downloaditem.DownloadJobStatus fun IDownloadItemState.statusOrFinished(): DownloadJobStatus { return (this as? ProcessingDownloadItemState)?.status ?: DownloadJobStatus.Finished } fun IDownloadItemState.isFinished(): Boolean { return this is CompletedDownloadItemState } fun IDownloadItemState.isNotFinished(): Boolean { return this is ProcessingDownloadItemState } fun IDownloadItemState.speedOrNull(): Long? { return (this as? ProcessingDownloadItemState)?.speed } fun IDownloadItemState.remainingOrNull(): Long? { return (this as? ProcessingDownloadItemState)?.remainingTime } ================================================ FILE: downloader/monitor/src/commonMain/kotlin/ir/amirab/downloader/monitor/IDownloadItemState.kt ================================================ package ir.amirab.downloader.monitor import androidx.compose.runtime.Immutable import java.io.File @Immutable sealed interface IDownloadItemState { val id: Long val folder: String val name: String val contentLength: Long val saveLocation: String val dateAdded: Long val startTime: Long val completeTime: Long val downloadLink: String fun getFullPath() = File(folder, name) } ================================================ FILE: downloader/monitor/src/commonMain/kotlin/ir/amirab/downloader/monitor/IDownloadMonitor.kt ================================================ package ir.amirab.downloader.monitor import ir.amirab.downloader.downloaditem.DownloadJobStatus import ir.amirab.util.flow.mapStateFlow import kotlinx.coroutines.flow.StateFlow interface IDownloadMonitor { var useAverageSpeed: Boolean val activeDownloadListFlow: StateFlow> val completedDownloadListFlow: StateFlow> val downloadListFlow: StateFlow> val activeDownloadCount: StateFlow suspend fun waitForDownloadToFinishOrCancel( id: Long, ) } fun IDownloadMonitor.isDownloadActiveFlow( downloadId: Long, ): StateFlow { return activeDownloadListFlow.mapStateFlow { activeDownloadList -> activeDownloadList.find { downloadId == it.id }?.canBePaused() ?: false } } ================================================ FILE: downloader/monitor/src/commonMain/kotlin/ir/amirab/downloader/monitor/ProcessingDownloadItemState.kt ================================================ package ir.amirab.downloader.monitor import androidx.compose.runtime.Immutable import ir.amirab.downloader.downloaditem.DownloadJobStatus import ir.amirab.downloader.downloaditem.IDownloadItem import ir.amirab.downloader.part.PartDownloadStatus import ir.amirab.downloader.utils.calcPercent sealed interface ProcessingDownloadItemState : IDownloadItemState { val status: DownloadJobStatus val speed: Long val supportResume: Boolean? val parts: List val gotAnyProgress: Boolean val progress: Long val hasProgress: Boolean val percent: Int? // 0..100 //remaining time in seconds val remainingTime: Long? val isWaiting: Boolean fun canBePaused() = isWaiting || status is DownloadJobStatus.IsActive fun canBeResumed() = status is DownloadJobStatus.CanBeResumed && !isWaiting } @Immutable data class RangeBasedProcessingDownloadItemState( override val id: Long, override val folder: String, override val name: String, override val downloadLink: String, override val contentLength: Long, override val saveLocation: String, override val dateAdded: Long, override val startTime: Long, override val completeTime: Long, override val status: DownloadJobStatus, override val speed: Long, override val parts: List, override val supportResume: Boolean?, override val isWaiting: Boolean, ) : ProcessingDownloadItemState { override val progress = parts.sumOf { it.howMuchProceed } override val hasProgress get() = progress > 0 override val gotAnyProgress = progress > 0L override val percent: Int? = if (contentLength == IDownloadItem.LENGTH_UNKNOWN) { null } else { calcPercent(progress, contentLength) } //remaining time in seconds override val remainingTime: Long? = kotlin.run { when { contentLength <= 0 || speed <= 0 -> null else -> (contentLength - progress) / speed } } companion object } @Immutable data class DurationBasedProcessingDownloadItemState( override val id: Long, override val folder: String, override val name: String, override val downloadLink: String, override val contentLength: Long, override val saveLocation: String, override val dateAdded: Long, override val startTime: Long, override val completeTime: Long, override val status: DownloadJobStatus, override val speed: Long, override val parts: List, override val supportResume: Boolean?, val optimisticLength: Long, val duration: Double?, override val progress: Long, override val percent: Int, override val isWaiting: Boolean, ) : ProcessingDownloadItemState { override val hasProgress get() = progress > 0 override val gotAnyProgress = progress > 0L // override val percent: Int? = run { // val length = getLengthOrOptimistic(contentLength, optimisticLength) // if (length == IDownloadItem.LENGTH_UNKNOWN) { // val partsSize = parts.size // if (partsSize > 0) { // calcPercent(finishedPartsCount, partsSize) // } else { // null // } // } else { // calcPercent(progress, length) // } // } override val remainingTime: Long? = kotlin.run { val length = getLengthOrOptimistic(contentLength, optimisticLength) when { length <= 0 || speed <= 0 -> null else -> (length - progress) / speed } } companion object { private fun getLengthOrOptimistic( exactLength: Long, optimisticLength: Long ): Long { return when { exactLength > 0 -> exactLength optimisticLength > 0 -> optimisticLength else -> -1 } } } } ================================================ FILE: downloader/monitor/src/commonMain/kotlin/ir/amirab/downloader/monitor/UiPart.kt ================================================ package ir.amirab.downloader.monitor import androidx.compose.runtime.Immutable import ir.amirab.downloader.part.MediaSegment import ir.amirab.downloader.part.RangedPart import ir.amirab.downloader.part.PartDownloadStatus @Immutable sealed interface UiPart { val id: Long val status: PartDownloadStatus val howMuchProceed: Long val percent: Int? val length: Long? val partSpace: Float } @Immutable data class UiRangedPart( override val id: Long, override val status: PartDownloadStatus, override val howMuchProceed: Long, override val percent: Int?, override val length: Long?, override val partSpace: Float ) : UiPart { companion object { fun fromPart( part: RangedPart, totalLength: Long, ): UiRangedPart { val length = part.partLength return UiRangedPart( id = part.getID(), status = part.status, howMuchProceed = part.howMuchProceed(), percent = part.percent, length = length, partSpace = when { totalLength <= 0 || length == null || length <= 0L -> 0f else -> (length.toDouble() / totalLength.toDouble()).toFloat() }, ) } } } @Immutable data class UiDurationBasedPart( override val id: Long, override val status: PartDownloadStatus, override val howMuchProceed: Long, override val percent: Int?, override val length: Long?, override val partSpace: Float, // val duration: Double, ) : UiPart { companion object { fun fromPart( part: MediaSegment, totalPartsCount: Int, ): UiDurationBasedPart { val index = part.segmentIndex return UiDurationBasedPart( id = index, status = part.status, howMuchProceed = part.howMuchProceed(), percent = part.percent, length = part.length, partSpace = if (totalPartsCount == 0) { 0f } else { 1f / totalPartsCount } // duration = part.duration, ) } } } ================================================ FILE: gradle/libs.versions.toml ================================================ [versions] fastscrollerCore = "0.3.2" kotlin = "2.3.10" ksp = "2.3.3" compose = "1.10.1" kotlin-serialization = "1.10.0" okhttp = "5.3.2" okio = "3.16.4" coroutines = "1.10.2" koin = "4.1.1" koinAnnotation = "2.3.1" decompose = "3.4.0" essenty = "2.5.0" logback = "1.5.22" buildConfig = "6.0.7" changelog = "2.5.0" http4k = "6.28.1.0" arrow = "2.2.1.1" datastore = "1.2.0" aboutLibraries = "13.2.1" jna = "5.18.1" datetime = "0.7.1" fileKit = "0.8.8" reorderable = "3.0.0" semver = "3.0.0" jgit = "7.3.0.202506031305-r" osThemeDetector = "3.9.1" kotlinFileWatcher = "1.4.0" markdownRenderer = "0.39.2" proxyVole = "2.0.0" jbrApi = "1.10.1" gradleVersions = "0.53.0" handlebars = "4.5.0" composeNativeTray = "1.1.0" agp = "8.12.3" androidx-core = "1.17.0" androidx-activity-compose = "1.12.4" [libraries] kotlin-stdlib = { module = "org.jetbrains.kotlin:kotlin-stdlib", version.ref = "kotlin" } kotlin-reflect = { module = "org.jetbrains.kotlin:kotlin-reflect", version.ref = "kotlin" } kotlin-serialization-core = { module = "org.jetbrains.kotlinx:kotlinx-serialization-core", version.ref = "kotlin-serialization" } kotlin-serialization-json = { module = "org.jetbrains.kotlinx:kotlinx-serialization-json", version.ref = "kotlin-serialization" } okhttp-loggingInterceptor = { module = "com.squareup.okhttp3:logging-interceptor", version.ref = "okhttp" } okhttp-okhttp = { module = "com.squareup.okhttp3:okhttp", version.ref = "okhttp" } okhttp-coroutines = { module = "com.squareup.okhttp3:okhttp-coroutines", version.ref = "okhttp" } okio-okio = { module = "com.squareup.okio:okio", version.ref = "okio" } koin-core = { module = "io.insert-koin:koin-core", version.ref = "koin" } koin-compose = { module = "io.insert-koin:koin-compose", version = "1.0.1" } koin-compiler = { module = "io.insert-koin:koin-ksp-compiler", version.ref = "koinAnnotation" } koin-annotations = { module = "io.insert-koin:koin-annotations", version.ref = "koinAnnotation" } decompose = { module = "com.arkivanov.decompose:decompose", version.ref = "decompose" } decompose-jbCompose = { module = "com.arkivanov.decompose:extensions-compose", version.ref = "decompose" } essenty-lifecycleCoroutines = { module = "com.arkivanov.essenty:lifecycle-coroutines", version.ref = "essenty" } kotlin-coroutines-core = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-core", version.ref = "coroutines" } kotlin-coroutines-debug = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-debug", version.ref = "coroutines" } kotlin-coroutines-android = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-android", version.ref = "coroutines" } kotlin-coroutines-swing = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-swing", version.ref = "coroutines" } kotlin-datetime = { module = "org.jetbrains.kotlinx:kotlinx-datetime", version.ref = "datetime" } composeFileKit = { module = "io.github.vinceglb:filekit-compose", version.ref = "fileKit" } fastscroller-core = { module = "io.github.oikvpqya.compose.fastscroller:fastscroller-core", version.ref = "fastscrollerCore" } loggers-logback-core = { module = "ch.qos.logback:logback-core", version.ref = "logback" } loggers-logback-classic = { module = "ch.qos.logback:logback-classic", version.ref = "logback" } nanoHttpd-core = "org.nanohttpd:nanohttpd:2.3.1" http4k-core = { module = "org.http4k:http4k-core", version.ref = "http4k" } http4k-server-jetty = { module = "org.http4k:http4k-server-jetty", version.ref = "http4k" } http4k-client-okhttp = { module = "org.http4k:http4k-client-okhttp", version.ref = "http4k" } compose-material-rippleEffect = { module = "org.jetbrains.compose.material:material-ripple", version.ref = "compose" } compose-reorderable = { module = "sh.calvin.reorderable:reorderable", version.ref = "reorderable" } compose-runtime = { module = "org.jetbrains.compose.runtime:runtime", version.ref = "compose" } compose-foundation = { module = "org.jetbrains.compose.foundation:foundation", version.ref = "compose" } compose-ui = { module = "org.jetbrains.compose.ui:ui", version.ref = "compose" } semver = { module = "io.github.z4kn4fein:semver", version.ref = "semver" } jgit = { module = "org.eclipse.jgit:org.eclipse.jgit", version.ref = "jgit" } aboutLibraries-core = { module = "com.mikepenz:aboutlibraries-core", version.ref = "aboutLibraries" } aboutLibraries-compose = { module = "com.mikepenz:aboutlibraries-compose", version.ref = "aboutLibraries" } jna-core = { module = "net.java.dev.jna:jna", version.ref = "jna" } jna-platform = { module = "net.java.dev.jna:jna-platform", version.ref = "jna" } pluginAndroidGradle = { module = "com.android.tools.build:gradle", version.ref = "agp" } pluginComposeMultiplatform = { module = "org.jetbrains.compose:compose-gradle-plugin", version.ref = "compose" } pluginComposeCompiler = { module = "org.jetbrains.kotlin:compose-compiler-gradle-plugin", version.ref = "kotlin" } pluginKotlin = { module = "org.jetbrains.kotlin:kotlin-gradle-plugin", version.ref = "kotlin" } pluginSerialization = { module = "org.jetbrains.kotlin:kotlin-serialization", version.ref = "kotlin" } pluginChangeLog = { module = "org.jetbrains.intellij.plugins:gradle-changelog-plugin", version.ref = "changelog" } pluginBuildConfig = { module = "com.github.gmazzo.buildconfig:plugin", version.ref = "buildConfig" } pluginKsp = { module = "com.google.devtools.ksp:symbol-processing-gradle-plugin", version.ref = "ksp" } pluginAboutLibraries = { module = "com.mikepenz.aboutlibraries.plugin:aboutlibraries-plugin", version.ref = "aboutLibraries" } pluginGradleVersions = { module = "com.github.ben-manes:gradle-versions-plugin", version.ref = "gradleVersions" } arrow-core = { module = "io.arrow-kt:arrow-core", version.ref = "arrow" } arrow-optics = { module = "io.arrow-kt:arrow-optics", version.ref = "arrow" } arrow-opticKsp = { module = "io.arrow-kt:arrow-optics-ksp-plugin", version.ref = "arrow" } androidx-core-ktx = { module = "androidx.core:core-ktx", version.ref = "androidx-core" } androidx-datastore = { module = "androidx.datastore:datastore", version.ref = "datastore" } androidx-activity-compose = { module = "androidx.activity:activity-compose", version.ref = "androidx-activity-compose" } osThemeDetector = { module = "com.github.Dansoftowner:jSystemThemeDetector", version.ref = "osThemeDetector" } markdownRenderer-core = { module = "com.mikepenz:multiplatform-markdown-renderer", version.ref = "markdownRenderer" } handlebarsJava = { module = "com.github.jknack:handlebars", version.ref = "handlebars" } kotlinFileWatcher = { module = "io.github.irgaly.kfswatch:kfswatch", version.ref = "kotlinFileWatcher" } composeNativeTray = { module = "io.github.kdroidfilter:composenativetray", version.ref = "composeNativeTray" } proxyVole = { module = "org.bidib.com.github.markusbernhardt:proxy-vole", version.ref = "proxyVole" } jbrApi = { module = "org.jetbrains.runtime:jbr-api", version.ref = "jbrApi" } [plugins] kotlin-jvm = { id = "org.jetbrains.kotlin.jvm", version.ref = "kotlin" } compose = { id = "org.jetbrains.compose", version.ref = "compose" } kotlin-android = { id = "org.jetbrains.kotlin.android", version.ref = "kotlin" } kotlin-serialization = { id = "org.jetbrains.kotlin.plugin.serialization", version.ref = "kotlin" } buildConfig = { id = "com.github.gmazzo.buildconfig", version.ref = "buildConfig" } changeLog = { id = "org.jetbrains.changelog", version.ref = "changelog" } ================================================ FILE: gradle/wrapper/gradle-wrapper.properties ================================================ distributionBase=GRADLE_USER_HOME distributionPath=wrapper/dists distributionUrl=https\://services.gradle.org/distributions/gradle-9.3.1-bin.zip networkTimeout=10000 validateDistributionUrl=true zipStoreBase=GRADLE_USER_HOME zipStorePath=wrapper/dists ================================================ FILE: gradle.properties ================================================ # Project-wide Gradle settings. # IDE (e.g. Android Studio) users: # Gradle settings configured through the IDE *will override* # any settings specified in this file. # For more details on how to configure your build environment visit # http://www.gradle.org/docs/current/userguide/build_environment.html # Specifies the JVM arguments used for the daemon process. # The setting is particularly useful for tweaking memory settings. org.gradle.jvmargs=-Xmx2048m -Dfile.encoding=UTF-8 # When configured, Gradle will run in incubating parallel mode. # This option should only be used with decoupled projects. For more details, visit # https://developer.android.com/r/tools/gradle-multi-project-decoupled-projects org.gradle.parallel=true #org.gradle.configuration-cache=true # AndroidX package structure to make it clearer which packages are bundled with the # Android operating system, and which are packaged with your app's APK # https://developer.android.com/topic/libraries/support-library/androidx-rn android.useAndroidX=true # Kotlin code style for this project: "official" or "obsolete": kotlin.code.style=official # Enables namespacing of each library's R class so that its R class includes only the # resources declared in the library itself and none from the library's dependencies, # thereby reducing the size of the R class for that library android.nonTransitiveRClass=true ================================================ FILE: gradlew ================================================ #!/bin/sh # # Copyright © 2015-2021 the original authors. # # Licensed under the Apache License, Version 2.0 (the "License"); # you may not use this file except in compliance with the License. # You may obtain a copy of the License at # # https://www.apache.org/licenses/LICENSE-2.0 # # Unless required by applicable law or agreed to in writing, software # distributed under the License is distributed on an "AS IS" BASIS, # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. # See the License for the specific language governing permissions and # limitations under the License. # # SPDX-License-Identifier: Apache-2.0 # ############################################################################## # # Gradle start up script for POSIX generated by Gradle. # # Important for running: # # (1) You need a POSIX-compliant shell to run this script. If your /bin/sh is # noncompliant, but you have some other compliant shell such as ksh or # bash, then to run this script, type that shell name before the whole # command line, like: # # ksh Gradle # # Busybox and similar reduced shells will NOT work, because this script # requires all of these POSIX shell features: # * functions; # * expansions «$var», «${var}», «${var:-default}», «${var+SET}», # «${var#prefix}», «${var%suffix}», and «$( cmd )»; # * compound commands having a testable exit status, especially «case»; # * various built-in commands including «command», «set», and «ulimit». # # Important for patching: # # (2) This script targets any POSIX shell, so it avoids extensions provided # by Bash, Ksh, etc; in particular arrays are avoided. # # The "traditional" practice of packing multiple parameters into a # space-separated string is a well documented source of bugs and security # problems, so this is (mostly) avoided, by progressively accumulating # options in "$@", and eventually passing that to Java. # # Where the inherited environment variables (DEFAULT_JVM_OPTS, JAVA_OPTS, # and GRADLE_OPTS) rely on word-splitting, this is performed explicitly; # see the in-line comments for details. # # There are tweaks for specific operating systems such as AIX, CygWin, # Darwin, MinGW, and NonStop. # # (3) This script is generated from the Groovy template # https://github.com/gradle/gradle/blob/HEAD/platforms/jvm/plugins-application/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt # within the Gradle project. # # You can find Gradle at https://github.com/gradle/gradle/. # ############################################################################## # Attempt to set APP_HOME # Resolve links: $0 may be a link app_path=$0 # Need this for daisy-chained symlinks. while APP_HOME=${app_path%"${app_path##*/}"} # leaves a trailing /; empty if no leading path [ -h "$app_path" ] do ls=$( ls -ld "$app_path" ) link=${ls#*' -> '} case $link in #( /*) app_path=$link ;; #( *) app_path=$APP_HOME$link ;; esac done # This is normally unused # shellcheck disable=SC2034 APP_BASE_NAME=${0##*/} # Discard cd standard output in case $CDPATH is set (https://github.com/gradle/gradle/issues/25036) APP_HOME=$( cd -P "${APP_HOME:-./}" > /dev/null && printf '%s\n' "$PWD" ) || exit # Use the maximum available, or set MAX_FD != -1 to use that value. MAX_FD=maximum warn () { echo "$*" } >&2 die () { echo echo "$*" echo exit 1 } >&2 # OS specific support (must be 'true' or 'false'). cygwin=false msys=false darwin=false nonstop=false case "$( uname )" in #( CYGWIN* ) cygwin=true ;; #( Darwin* ) darwin=true ;; #( MSYS* | MINGW* ) msys=true ;; #( NONSTOP* ) nonstop=true ;; esac CLASSPATH="\\\"\\\"" # Determine the Java command to use to start the JVM. if [ -n "$JAVA_HOME" ] ; then if [ -x "$JAVA_HOME/jre/sh/java" ] ; then # IBM's JDK on AIX uses strange locations for the executables JAVACMD=$JAVA_HOME/jre/sh/java else JAVACMD=$JAVA_HOME/bin/java fi if [ ! -x "$JAVACMD" ] ; then die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME Please set the JAVA_HOME variable in your environment to match the location of your Java installation." fi else JAVACMD=java if ! command -v java >/dev/null 2>&1 then die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. Please set the JAVA_HOME variable in your environment to match the location of your Java installation." fi fi # Increase the maximum file descriptors if we can. if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then case $MAX_FD in #( max*) # In POSIX sh, ulimit -H is undefined. That's why the result is checked to see if it worked. # shellcheck disable=SC2039,SC3045 MAX_FD=$( ulimit -H -n ) || warn "Could not query maximum file descriptor limit" esac case $MAX_FD in #( '' | soft) :;; #( *) # In POSIX sh, ulimit -n is undefined. That's why the result is checked to see if it worked. # shellcheck disable=SC2039,SC3045 ulimit -n "$MAX_FD" || warn "Could not set maximum file descriptor limit to $MAX_FD" esac fi # Collect all arguments for the java command, stacking in reverse order: # * args from the command line # * the main class name # * -classpath # * -D...appname settings # * --module-path (only if needed) # * DEFAULT_JVM_OPTS, JAVA_OPTS, and GRADLE_OPTS environment variables. # For Cygwin or MSYS, switch paths to Windows format before running java if "$cygwin" || "$msys" ; then APP_HOME=$( cygpath --path --mixed "$APP_HOME" ) CLASSPATH=$( cygpath --path --mixed "$CLASSPATH" ) JAVACMD=$( cygpath --unix "$JAVACMD" ) # Now convert the arguments - kludge to limit ourselves to /bin/sh for arg do if case $arg in #( -*) false ;; # don't mess with options #( /?*) t=${arg#/} t=/${t%%/*} # looks like a POSIX filepath [ -e "$t" ] ;; #( *) false ;; esac then arg=$( cygpath --path --ignore --mixed "$arg" ) fi # Roll the args list around exactly as many times as the number of # args, so each arg winds up back in the position where it started, but # possibly modified. # # NB: a `for` loop captures its iteration list before it begins, so # changing the positional parameters here affects neither the number of # iterations, nor the values presented in `arg`. shift # remove old arg set -- "$@" "$arg" # push replacement arg done fi # Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"' # Collect all arguments for the java command: # * DEFAULT_JVM_OPTS, JAVA_OPTS, and optsEnvironmentVar are not allowed to contain shell fragments, # and any embedded shellness will be escaped. # * For example: A user cannot expect ${Hostname} to be expanded, as it is an environment variable and will be # treated as '${Hostname}' itself on the command line. set -- \ "-Dorg.gradle.appname=$APP_BASE_NAME" \ -classpath "$CLASSPATH" \ -jar "$APP_HOME/gradle/wrapper/gradle-wrapper.jar" \ "$@" # Stop when "xargs" is not available. if ! command -v xargs >/dev/null 2>&1 then die "xargs is not available" fi # Use "xargs" to parse quoted args. # # With -n1 it outputs one arg per line, with the quotes and backslashes removed. # # In Bash we could simply go: # # readarray ARGS < <( xargs -n1 <<<"$var" ) && # set -- "${ARGS[@]}" "$@" # # but POSIX shell has neither arrays nor command substitution, so instead we # post-process each arg (as a line of input to sed) to backslash-escape any # character that might be a shell metacharacter, then use eval to reverse # that process (while maintaining the separation between arguments), and wrap # the whole thing up as a single "set" statement. # # This will of course break if any of these variables contains a newline or # an unmatched quote. # eval "set -- $( printf '%s\n' "$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS" | xargs -n1 | sed ' s~[^-[:alnum:]+,./:=@_]~\\&~g; ' | tr '\n' ' ' )" '"$@"' exec "$JAVACMD" "$@" ================================================ FILE: gradlew.bat ================================================ @rem @rem Copyright 2015 the original author or authors. @rem @rem Licensed under the Apache License, Version 2.0 (the "License"); @rem you may not use this file except in compliance with the License. @rem You may obtain a copy of the License at @rem @rem https://www.apache.org/licenses/LICENSE-2.0 @rem @rem Unless required by applicable law or agreed to in writing, software @rem distributed under the License is distributed on an "AS IS" BASIS, @rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. @rem See the License for the specific language governing permissions and @rem limitations under the License. @rem @rem SPDX-License-Identifier: Apache-2.0 @rem @if "%DEBUG%"=="" @echo off @rem ########################################################################## @rem @rem Gradle startup script for Windows @rem @rem ########################################################################## @rem Set local scope for the variables with windows NT shell if "%OS%"=="Windows_NT" setlocal set DIRNAME=%~dp0 if "%DIRNAME%"=="" set DIRNAME=. @rem This is normally unused set APP_BASE_NAME=%~n0 set APP_HOME=%DIRNAME% @rem Resolve any "." and ".." in APP_HOME to make it shorter. for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi @rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m" @rem Find java.exe if defined JAVA_HOME goto findJavaFromJavaHome set JAVA_EXE=java.exe %JAVA_EXE% -version >NUL 2>&1 if %ERRORLEVEL% equ 0 goto execute echo. 1>&2 echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 1>&2 echo. 1>&2 echo Please set the JAVA_HOME variable in your environment to match the 1>&2 echo location of your Java installation. 1>&2 goto fail :findJavaFromJavaHome set JAVA_HOME=%JAVA_HOME:"=% set JAVA_EXE=%JAVA_HOME%/bin/java.exe if exist "%JAVA_EXE%" goto execute echo. 1>&2 echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 1>&2 echo. 1>&2 echo Please set the JAVA_HOME variable in your environment to match the 1>&2 echo location of your Java installation. 1>&2 goto fail :execute @rem Setup the command line set CLASSPATH= @rem Execute Gradle "%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" -jar "%APP_HOME%\gradle\wrapper\gradle-wrapper.jar" %* :end @rem End local scope for the variables with windows NT shell if %ERRORLEVEL% equ 0 goto mainEnd :fail rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of rem the _cmd.exe /c_ return code! set EXIT_CODE=%ERRORLEVEL% if %EXIT_CODE% equ 0 set EXIT_CODE=1 if not ""=="%GRADLE_EXIT_CONSOLE%" exit %EXIT_CODE% exit /b %EXIT_CODE% :mainEnd if "%OS%"=="Windows_NT" endlocal :omega ================================================ FILE: integration/server/build.gradle.kts ================================================ plugins{ id(MyPlugins.kotlin) id(Plugins.Kotlin.serialization) } dependencies { implementation(libs.kotlin.coroutines.core) implementation(libs.kotlin.serialization.json) implementation(project(":shared:utils")) implementation(project(":shared:nanohttp4k")) } ================================================ FILE: integration/server/src/main/kotlin/com/abdownloadmanager/integration/ApiQueueModel.kt ================================================ package com.abdownloadmanager.integration import kotlinx.serialization.Serializable @Serializable data class ApiQueueModel( val id: Long, val name: String, ) ================================================ FILE: integration/server/src/main/kotlin/com/abdownloadmanager/integration/DownloadCredentialsFromIntegration.kt ================================================ package com.abdownloadmanager.integration import kotlinx.serialization.SerialName import kotlinx.serialization.Serializable import kotlinx.serialization.SerializationException import kotlinx.serialization.json.Json @Serializable sealed interface IDownloadCredentialsFromIntegration { val link: String val downloadPage: String? val suggestedName: String? } @SerialName("http") @Serializable data class HttpDownloadCredentialsFromIntegration( override val link: String, val headers: Map? = null, override val downloadPage: String? = null, override val suggestedName: String? = null, ) : IDownloadCredentialsFromIntegration @SerialName("hls") @Serializable data class HLSDownloadCredentialsFromIntegration( override val link: String, val headers: Map? = null, override val downloadPage: String? = null, override val suggestedName: String? = null, ) : IDownloadCredentialsFromIntegration @Serializable data class AddDownloadOptionsFromIntegration( val silentAdd: Boolean = false, val silentStart: Boolean = false, ) @Serializable data class AddDownloadsFromIntegration( val items: List, val options: AddDownloadOptionsFromIntegration = AddDownloadOptionsFromIntegration() ) { companion object { fun createFromRequest(json: Json, jsonData: String): AddDownloadsFromIntegration { return try { json.decodeFromString(jsonData) } catch (_: SerializationException) { // TODO Remove this after a while AddDownloadsFromIntegration( items = json.decodeFromString>(jsonData), AddDownloadOptionsFromIntegration( silentAdd = false, silentStart = false, ) ) } } } } ================================================ FILE: integration/server/src/main/kotlin/com/abdownloadmanager/integration/Integration.kt ================================================ package com.abdownloadmanager.integration import com.abdownloadmanager.integration.http4k.MyHttp4KServer import kotlinx.coroutines.* import kotlinx.coroutines.flow.* import kotlinx.serialization.json.Json import kotlinx.serialization.builtins.ListSerializer sealed interface IntegrationResult { data object Inactive : IntegrationResult data class Fail(val throwable: Throwable) : IntegrationResult data class Success(val port: Int) : IntegrationResult } class Integration( val integrationHandler: IntegrationHandler, val scope: CoroutineScope, private val json: Json, val debugMode: Boolean, ) { private val portFlow = MutableStateFlow(null) val integrationStatus = MutableStateFlow(IntegrationResult.Inactive) fun enable(port: Int) { portFlow.update { port } } fun disable() { portFlow.update { null } } fun boot() { scope.launch { kotlin.runCatching { portFlow.collect { port -> runCatching { if (port != null) { startServer(port) integrationStatus.update { IntegrationResult.Success(port) } } else { stopServer() integrationStatus.update { IntegrationResult.Inactive } } }.onFailure { throwable -> integrationStatus.update { IntegrationResult.Fail(throwable) } kotlin.runCatching { disable() } } } } } } @Volatile private var server: MyServer? = null private suspend fun startServer(port: Int) { stopServer() val server = createServer(port) this.server = server withContext(Dispatchers.IO) { // println("start server") server.startMyServer() } } private suspend fun stopServer() { server?.let { // println("stop server") withContext(Dispatchers.IO) { it.stopMyServer() } } server = null } private fun createServer(port: Int): MyServer { val handlers = HandlerMap().apply { post("/add") { runBlocking { val itemsToAdd = kotlin.runCatching { val message = it.getBody().orEmpty() AddDownloadsFromIntegration.createFromRequest( json = json, jsonData = message ) } itemsToAdd.onFailure { it.printStackTrace() } itemsToAdd.getOrThrow().let { newImportRequest -> integrationHandler.addDownload( newImportRequest.items, newImportRequest.options, ) } } MyResponse.Text("OK") } get("/queues") { runBlocking { val queues = integrationHandler.listQueues() val jsonResponse = json.encodeToString(ListSerializer(ApiQueueModel.serializer()), queues) MyResponse.Text(jsonResponse) } } post("/start-headless-download") { runBlocking { val itemsToAdd = kotlin.runCatching { val message = it.getBody().orEmpty() json.decodeFromString(message) } itemsToAdd.onFailure { it.printStackTrace() } integrationHandler.addDownloadTask(itemsToAdd.getOrThrow()) } MyResponse.Text("OK") } post("/ping") { MyResponse.Text("pong") } } return MyHttp4KServer(port, handlers, debugMode) } } ================================================ FILE: integration/server/src/main/kotlin/com/abdownloadmanager/integration/IntegrationHandler.kt ================================================ package com.abdownloadmanager.integration interface IntegrationHandler{ suspend fun addDownload( list: List, options: AddDownloadOptionsFromIntegration, ) fun listQueues(): List suspend fun addDownloadTask(task: NewDownloadTask) } ================================================ FILE: integration/server/src/main/kotlin/com/abdownloadmanager/integration/MyRequestAndResponse.kt ================================================ package com.abdownloadmanager.integration typealias Header = Map data class MyRequest( val uri:String, val method:String,//: GET | POST val getBody:()->String? ) sealed class MyResponse( val statusCode: Int, val headers: Header, ) { abstract fun getContent(): String class Json(val jsonData: String, headers: Header = emptyMap()) : MyResponse(statusCode = 200, headers = headers) { override fun getContent() = jsonData } class Text( val text: String, headers: Header = emptyMap(), statusCode: Int=200, ) : MyResponse(statusCode = statusCode, headers = headers) { override fun getContent() = text } class BadRequest( val errorText: String, headers: Header = emptyMap(), statusCode: Int=400, ) : MyResponse( statusCode = statusCode, headers = headers, ) { override fun getContent() = errorText } } typealias Handler = (MyRequest) -> MyResponse class HandlerMap { private val handlers = mutableListOf>() private fun add(uri: String, method: String, handler: Handler) { handlers.add(Pair(uri, handler)) } fun get(uri: String,handler: Handler){ add(uri,"GET",handler) } fun post(uri: String,handler: Handler){ add(uri,"POST",handler) } fun findMatch(session: MyRequest): Handler? { val handler = handlers.find { session.uri == it.first }?.second return handler } } ================================================ FILE: integration/server/src/main/kotlin/com/abdownloadmanager/integration/MyServer.kt ================================================ package com.abdownloadmanager.integration interface MyServer{ fun stopMyServer() fun startMyServer() } ================================================ FILE: integration/server/src/main/kotlin/com/abdownloadmanager/integration/MySunHttpServer.kt ================================================ package com.abdownloadmanager.integration import com.sun.net.httpserver.HttpExchange import com.sun.net.httpserver.HttpHandler import com.sun.net.httpserver.HttpServer import java.net.InetSocketAddress import java.util.concurrent.ExecutorService import java.util.concurrent.Executors class MySunHttpServer( val port: Int, val handlerMap: HandlerMap, val isDebugMode: Boolean, ) : MyServer { private var server: HttpServer? = null private fun createServer(): HttpServer { val httpServer = HttpServer.create( InetSocketAddress("localhost", port), 1000, ) httpServer.createContext( /* path = */ "/", /* handler = */ HttpHandlerImpl( handlerMap = handlerMap, isDebugMode = isDebugMode ) ) httpServer.executor = Executors.newWorkStealingPool() return httpServer } override fun stopMyServer() { server?.run { (executor as ExecutorService).shutdownNow() stop(0) } server = null } override fun startMyServer() { stopMyServer() server = createServer().also { it.start() } } } private class HttpHandlerImpl( val handlerMap: HandlerMap, val isDebugMode: Boolean, ) : HttpHandler { fun createMyRequest(exchange: HttpExchange): MyRequest { return MyRequest( uri = exchange.requestURI.toString(), method = exchange.requestMethod, getBody = { exchange.requestBody.reader().readText() }, ) } fun fillExchangeWithResponse(exchange: HttpExchange, response: MyResponse) { response.headers.forEach { (key, value) -> exchange.responseHeaders.add(key, value) } exchange.sendResponseHeaders(response.statusCode, response.getContent().length.toLong()) exchange.responseBody.writer().use { it.write(response.getContent()) } } override fun handle(exchange: HttpExchange) { val request = createMyRequest(exchange) val handler = handlerMap.findMatch(request) try { val response = handler?.invoke(request) ?: MyResponse.BadRequest( errorText = "Not Found", statusCode = 404, ) fillExchangeWithResponse(exchange, response) } catch (e: Exception) { val internalServerErrorResponse = MyResponse.Text( if (isDebugMode) "Error ${e.localizedMessage}" else "Error", statusCode = 500, ) fillExchangeWithResponse(exchange, internalServerErrorResponse) return } finally { exchange.close() } } } ================================================ FILE: integration/server/src/main/kotlin/com/abdownloadmanager/integration/NewDownloadTask.kt ================================================ package com.abdownloadmanager.integration import kotlinx.serialization.Serializable @Serializable data class NewDownloadTask( val downloadSource: IDownloadCredentialsFromIntegration, var folder: String? = null, var name: String? = null, var queueId: Long? = null, ) ================================================ FILE: integration/server/src/main/kotlin/com/abdownloadmanager/integration/http4k/MyHttp4KServer.kt ================================================ package com.abdownloadmanager.integration.http4k import ir.amirab.util.http4k.NanoHttp import com.abdownloadmanager.integration.HandlerMap import com.abdownloadmanager.integration.MyRequest import com.abdownloadmanager.integration.MyResponse import com.abdownloadmanager.integration.MyServer import org.http4k.core.* import org.http4k.server.Http4kServer import org.http4k.server.asServer class MyHttp4KServer( val port: Int, val handlerMap: HandlerMap, val isDebugMode: Boolean, ) : MyServer { private fun toMyRequest(request: Request): MyRequest { return MyRequest( uri = request.uri.toString(), method = request.method.toString(), getBody = { if (request.body == Body.EMPTY) null else request.bodyString() }, ) } private fun toHttp4kResponse(response: MyResponse): Response { val status = Status.serverValues.find { it.code == response.statusCode }!! return Response(status) .headers(response.headers.map { it.key to it.value }) .body(response.getContent()) } private var server: Http4kServer? = null private fun createServer(): Http4kServer { // val logAll = Filter { next -> // { // println("req: $it") // next(it).also { // println("res: $it") // } // } // } val appRoute = { req: Request -> val myRequest = toMyRequest(req) val handler = handlerMap.findMatch(myRequest) if (handler != null) { toHttp4kResponse(handler(myRequest)) } else { Response(Status.NOT_FOUND) .body("Not Found") } } return Filter.NoOp // .then(logAll) .then(appRoute) .asServer(NanoHttp("localhost",port)) } override fun stopMyServer() { server?.stop() server = null } override fun startMyServer() { stopMyServer() server = createServer().also { it.start() } } } ================================================ FILE: integration/server/src/main/resources/logback.xml ================================================ %d{HH:mm:ss.SSS} [%thread] %-5level %logger{36} MDC=%X{user} - %msg%n ================================================ FILE: integration/server/src/main/resources/rules.pro ================================================ ================================================ FILE: scripts/install.sh ================================================ #!/usr/bin/env bash # Downloads the latest tarball from https://github.com/amir1376/ab-download-manager/releases and unpacks it into ~/.local/. # Creates a .desktop entry for the app in ~/.local/share/applications based on FreeDesktop specifications. set -euo pipefail DEPENDENCIES=(curl tar) LOG_FILE="/tmp/ab-dm-installer.log" # --- Custom Logger logger() { timestamp=$(date +"%Y/%m/%d %H:%M:%S") if [[ "$1" == "error" ]]; then # Red color for errors echo -e "${timestamp} -- ABDM-Installer [Error]: \033[0;31m$@\033[0m" | tee -a ${LOG_FILE} else # Default color for non-error messages echo -e "${timestamp} -- ABDM-Installer [Info]: $@" | tee -a ${LOG_FILE} fi } remove_if_exists() { local target="$1" if [ -z "$target" ]; then logger "No target specified in remove_if_exists function" return 1 fi if [ -e "$target" ]; then logger "File \"$target\" Removed" rm -rf "$target" else logger "File \"$target\" does not exist" fi } # --- Detect OS and The Package Manager to use detect_package_manager() { if [ -f /etc/os-release ]; then source /etc/os-release local OS=${NAME} elif type lsb_release >/dev/null 2>&1; then local OS=$(lsb_release -si) elif [ -f /etc/lsb-release ]; then source /etc/lsb-release local OS="${DISTRIB_ID}" elif [ -f /etc/debian_version ]; then local OS=Debian else logger error "Your Linux Distro is not Supperted." logger error "Please install ${DEPENDENCIES[@]} Manually." exit 1 fi if `grep -E 'Debian|Ubuntu' <<< $OS > /dev/null` ; then systemPackage="apt" elif `grep -E 'Fedora|CentOS|Red Hat|AlmaLinux' <<< $OS > /dev/null`; then systemPackage="dnf" fi } detect_package_manager # --- Install dependencies install_dependencies() { local answer read -p "Do you want to install $1? [Y/n]: " -r answer answer=${answer:-Y} # Set default to 'Y' if no input is given case $answer in [Yy]* ) sudo ${systemPackage} update -y logger "installing $1 package ..." sudo ${systemPackage} install -y $1 ;; [Nn]* ) logger "Skipping the installation of $1." ;; * ) logger error "Please answer yes or no." install_dependencies "$1" # re-prompt for the same package ;; esac } # Check dependencies and install if missing check_dependencies() { for pkg in "${DEPENDENCIES[@]}"; do if ! command -v "$pkg" >/dev/null 2>&1; then logger "$pkg is not installed. Installing..." install_dependencies "$pkg" else logger "$pkg is already installed." fi done } get_arch() { case "$(uname -m)" in x86_64 | amd64) echo "x64" ;; aarch64 | arm64) echo "arm64" ;; *) logger error "Unsupported architecture: $(uname -m)" return 1 ;; esac } APP_NAME="ABDownloadManager" PLATFORM="linux" ARCH="$(get_arch)" || exit 1 EXT="tar.gz" RELEASE_URL="https://api.github.com/repos/amir1376/ab-download-manager/releases/latest" GITHUB_RELEASE_DOWNLOAD="https://github.com/amir1376/ab-download-manager/releases/download" LATEST_VERSION=$(curl -fSs "${RELEASE_URL}" | grep '"tag_name":' | sed -E 's/.*"tag_name": ?"([^"]+)".*/\1/') ASSET_NAME="${APP_NAME}_${LATEST_VERSION:1}_${PLATFORM}_${ARCH}.${EXT}" DOWNLOAD_URL="$GITHUB_RELEASE_DOWNLOAD/${LATEST_VERSION}/$ASSET_NAME" APP_PATH="$HOME/.local/$APP_NAME" BINARY_PATH="$APP_PATH/bin/$APP_NAME" ICON_PATH="$APP_PATH/lib/$APP_NAME.png" # --- Delete the old version Application if exists delete_old_version() { # Find the PID(s) of the application PIDS=$(pidof "$APP_NAME") || true if [ -n "$PIDS" ]; then echo "Found $APP_NAME with PID(s): $PIDS. Attempting to kill..." # Attempt to terminate the process gracefully kill $PIDS 2>/dev/null || echo "Graceful kill failed..." # Wait for a short period to allow graceful shutdown sleep 2 # Check if the process is still running PIDS=$(pidof "$APP_NAME") || true if [ -n "$PIDS" ]; then echo "Process still running. Force killing..." kill -9 $PIDS 2>/dev/null || echo "Force kill failed..." else echo "$APP_NAME terminated successfully." fi else echo "$APP_NAME is not running." fi # Remove old version directories # First Remove link to "$HOME/.local/$APP_NAME" remove_if_exists "$HOME/.local/bin/$APP_NAME" # then Remove the main binary files directory remove_if_exists "$HOME/.local/$APP_NAME" # Log the removal action logger "Removed old version of $APP_NAME" } # --- Generate a .desktop file for the app generate_desktop_file() { cat < "$HOME/.local/share/applications/com.abdownloadmanager.desktop" [Desktop Entry] Name=AB Download Manager Comment=Manage and organize your download files better than before GenericName=Downloader Categories=Utility;Network; Exec="$BINARY_PATH" Icon=$ICON_PATH Terminal=false Type=Application StartupWMClass=com-abdownloadmanager-desktop-AppKt EOF } # --- Download the latest version of the app download_zip() { # Remove the app tarball if it exists in /tmp remove_if_exists "/tmp/$ASSET_NAME" logger "downloading AB Download Manager ..." # Perform the download with curl if curl --progress-bar -fSL -o "/tmp/$ASSET_NAME" "${DOWNLOAD_URL}"; then logger "download finished successfully" else logger error "Download failed! Something Went Wrong" logger error "Check Your Internet Connectivity" # Optionally remove the partially downloaded file remove_if_exists "/tmp/$ASSET_NAME" fi } # --- Install the app install_app() { logger "Installing AB Download Manager ..." # --- Setup ~/.local directories mkdir -p "$HOME/.local/bin" "$HOME/.local/share/applications" tar -xzf "/tmp/$ASSET_NAME" -C "$HOME/.local" # --- remove tarball after installation remove_if_exists "/tmp/$ASSET_NAME" # Link the binary to ~/.local/bin ln -sf "$BINARY_PATH" "$HOME/.local/bin/$APP_NAME" # Create a .desktop file in ~/.local/share/applications generate_desktop_file logger "AB Download Manager installed successfully" logger "it can be found in Applications menu or run '$APP_NAME' in terminal" logger "Make sure $HOME/.local/bin exists in PATH" logger "installation logs saved in: ${LOG_FILE}" } # --- Check if the app is installed check_if_installed() { local installed_version installed_version=$($APP_NAME --version 2>/dev/null) if [ -n "$installed_version" ]; then echo "$installed_version" else echo "" fi } # --- Update the app update_app() { logger "checking update" if [ "$1" != "${LATEST_VERSION:1}" ]; then logger "new version is available: v${LATEST_VERSION:1}. Updating..." download_zip delete_old_version install_app else logger "You have the latest version installed." exit 0 fi } main() { echo "" > "$LOG_FILE" local installed_version check_dependencies installed_version=$(check_if_installed) if [ -n "$installed_version" ]; then logger "AB Download Manager v$installed_version is currently installed." update_app "$installed_version" else download_zip install_app fi } main "$@" ================================================ FILE: scripts/uninstall.sh ================================================ #!/usr/bin/env bash set -euo pipefail # set -x APP_NAME="ABDownloadManager" LOG_FILE="/tmp/ab-dm-uninstaller.log" # --- Custom Logger logger() { timestamp=$(date +"%Y/%m/%d %H:%M:%S") if [[ "$1" == "error" ]]; then # Red color for errors echo -e "${timestamp} -- ABDM-Uninstaller [Error]: \033[0;31m$@\033[0m" | tee -a ${LOG_FILE} else # Default color for non-error messages echo -e "${timestamp} -- ABDM-Uninstaller [Info]: $@" | tee -a ${LOG_FILE} fi } remove_if_exists() { local target="$1" if [ -z "$target" ]; then logger "No target specified in remove_if_exists function" return 1 fi if [ -e "$target" ]; then logger "File \"$target\" Removed" rm -rf "$target" else logger "File \"$target\" does not exist" fi } delete_app_config_dir() { local answer read -p "Do you want to continue? [Y/n]: " -r answer answer=${answer:-Y} # Set default to 'Y' if no input is given case $answer in [Yy]* ) remove_if_exists "$HOME/.abdm" ;; [Nn]* ) logger "Remove The $HOME/.abdm directory manually." ;; * ) logger error "Please answer yes or no." delete_app_config_dir ;; esac } delete_app() { # Find the PID(s) of the application PIDS=$(pidof "$APP_NAME") || true if [ -n "$PIDS" ]; then echo "Found $APP_NAME with PID(s): $PIDS. Attempting to kill..." # Attempt to terminate the process gracefully kill $PIDS 2>/dev/null || echo "Graceful kill failed..." # Wait for a short period to allow graceful shutdown sleep 2 # Check if the process is still running PIDS=$(pidof "$APP_NAME") || true if [ -n "$PIDS" ]; then echo "Process still running. Force killing..." kill -9 $PIDS 2>/dev/null || echo "Force kill failed..." else echo "$APP_NAME terminated successfully." fi else echo "$APP_NAME is not running." fi logger "removing $APP_NAME desktop file ..." # --- Remove the .desktop file in ~/.local/share/applications remove_if_exists "$HOME/.local/share/applications/com.abdownloadmanager.desktop" logger "removing $APP_NAME link ..." remove_if_exists "$HOME/.local/bin/$APP_NAME" logger "removing $APP_NAME binary ..." remove_if_exists "$HOME/.local/$APP_NAME" logger "removing $APP_NAME autostart at boot file ..." remove_if_exists "$HOME/.config/autostart/com.abdownloadmanager.desktop" if [ -e "$HOME/.abdm" ]; then logger "removing $APP_NAME settings and download lists $HOME/.abdm" delete_app_config_dir fi logger "AB Download Manager completely removed" } main() { delete_app } main "$@" ================================================ FILE: settings.gradle.kts ================================================ pluginManagement { } plugins{ // id("org.gradle.toolchains.foojay-resolver-convention") version "0.8.0" } dependencyResolutionManagement { repositories { mavenCentral() google() maven("https://maven.pkg.jetbrains.space/public/p/compose/dev") } } rootProject.name = "ABDownloadManager" include("android:app") include("desktop:app") include("desktop:app-utils") include("desktop:shared") include("desktop:mac_utils") include("downloader:core") include("downloader:monitor") include("integration:server") include("shared:utils") include("shared:app") include("shared:compose-utils") include("shared:resources") include("shared:resources:contracts") include("shared:config") include("shared:updater") include("shared:auto-start") include("shared:nanohttp4k") includeBuild("./compositeBuilds/shared"){ name="build-shared" } includeBuild("./compositeBuilds/plugins") ================================================ FILE: shared/app/build.gradle.kts ================================================ import buildlogic.versioning.getAppDataDirName import buildlogic.versioning.getAppName import buildlogic.versioning.getAppVersionString import buildlogic.versioning.getApplicationPackageName import buildlogic.versioning.getPrettifiedAppName import org.jetbrains.kotlin.gradle.dsl.JvmTarget plugins { id(MyPlugins.kotlinMultiplatform) id(MyPlugins.composeBase) id(Plugins.Kotlin.serialization) id(Plugins.Android.library) id(Plugins.buildConfig) } kotlin { jvm("desktop") androidTarget("android") { compilerOptions { jvmTarget.set(JvmTarget.JVM_21) } } sourceSets { commonMain.dependencies { api(libs.compose.runtime) api(libs.compose.foundation) api(libs.compose.ui) api(project(":downloader:core")) api(project(":downloader:monitor")) api(project(":shared:config")) api(project(":shared:utils")) api(project(":shared:compose-utils")) api(project(":shared:resources")) api(project(":shared:auto-start")) api(project(":shared:updater")) api(libs.kotlin.coroutines.core) api(libs.kotlin.serialization.json) api(libs.decompose) api(libs.essenty.lifecycleCoroutines) api(libs.koin.core) api(libs.androidx.datastore) implementation(libs.kotlinFileWatcher) //because we don't have material design, but we use ripple effect implementation(libs.compose.material.rippleEffect) // multiplatform scrollbars api(libs.fastscroller.core) api(libs.markdownRenderer.core) api(libs.compose.reorderable) } androidMain.dependencies { api(libs.androidx.core.ktx) api(libs.androidx.activity.compose) } val desktopMain by getting desktopMain.dependencies { implementation(libs.osThemeDetector.get().toString()) { exclude(group = "net.java.dev.jna") } } } } android { compileSdk = 36 namespace = "com.abdownloadmanager.shared" defaultConfig { minSdk = 26 } } // generate a file with these constants buildConfig { packageName = "com.abdownloadmanager.shared" buildConfigField( "PACKAGE_NAME", provider { getApplicationPackageName() } ) buildConfigField( "APP_DISPLAY_NAME", provider { getPrettifiedAppName() } ) buildConfigField( "DATA_DIR_NAME", provider { getAppDataDirName() } ) buildConfigField( "APP_VERSION", provider { getAppVersionString() } ) buildConfigField( "APP_NAME", provider { getAppName() } ) buildConfigField( "PROJECT_WEBSITE", provider { "https://abdownloadmanager.com" } ) buildConfigField( "PROJECT_SOURCE_CODE", provider { "https://github.com/amir1376/ab-download-manager" } ) buildConfigField( "DONATE_LINK", provider { "https://github.com/amir1376/ab-download-manager/blob/master/DONATE.md" } ) buildConfigField( "PROJECT_GITHUB_OWNER", provider { "amir1376" } ) buildConfigField( "PROJECT_GITHUB_REPO", provider { "ab-download-manager" } ) buildConfigField( "PROJECT_TRANSLATIONS", provider { "https://crowdin.com/project/ab-download-manager" } ) buildConfigField( "INTEGRATION_CHROME_LINK", provider { "https://chromewebstore.google.com/detail/ab-download-manager-brows/bbobopahenonfdgjgaleledndnnfhooj" } ) buildConfigField( "INTEGRATION_FIREFOX_LINK", provider { "https://addons.mozilla.org/en-US/firefox/addon/ab-download-manager/" } ) buildConfigField( "TELEGRAM_GROUP", provider { "https://t.me/abdownloadmanager_discussion" } ) buildConfigField( "TELEGRAM_CHANNEL", provider { "https://t.me/abdownloadmanager" } ) } ================================================ FILE: shared/app/src/androidMain/kotlin/com/abdownloadmanager/shared/ui/modifier/PointerHoverIcon.android.kt ================================================ package com.abdownloadmanager.shared.ui.modifier import androidx.compose.ui.Modifier actual fun Modifier.myPointerHoverIcon(pointerHoverIcon: MyPointerHoverIcon, overrideDescendants: Boolean): Modifier { // No-op return this } ================================================ FILE: shared/app/src/androidMain/kotlin/com/abdownloadmanager/shared/ui/theme/PlatformThemeDefinitions.android.kt ================================================ package com.abdownloadmanager.shared.ui.theme import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.runtime.Composable import androidx.compose.runtime.CompositionLocalProvider import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.RectangleShape import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import com.abdownloadmanager.shared.util.div import com.abdownloadmanager.shared.util.ui.myColors import com.abdownloadmanager.shared.util.ui.theme.MyShapes import com.abdownloadmanager.shared.util.ui.theme.MySpacings import com.abdownloadmanager.shared.util.ui.theme.TextSizes import io.github.oikvpqya.compose.fastscroller.ScrollbarStyle import io.github.oikvpqya.compose.fastscroller.ThumbStyle import io.github.oikvpqya.compose.fastscroller.TrackStyle @Composable actual fun PlatformDependentProviders(content: @Composable (() -> Unit)) { CompositionLocalProvider( // no providers yet, content = content, ) } @Composable actual fun myPlatformScrollbarStyle(): ScrollbarStyle { val shape = RoundedCornerShape(4.dp) return ScrollbarStyle( minimalHeight = 16.dp, thickness = 6.dp, thumbStyle = ThumbStyle( shape = shape, unhoverColor = myColors.onBackground / 10, hoverColor = myColors.onBackground / 30, ), trackStyle = TrackStyle( unhoverColor = Color.Transparent, hoverColor = Color.Transparent, shape = RectangleShape, ), hoverDurationMillis = 300, ) } private val androidTextSizes = TextSizes( xs = 10.sp, sm = 12.sp, base = 14.sp, lg = 16.sp, xl = 18.sp, x2l = 20.sp, x3l = 22.sp, x4l = 24.sp, x5l = 26.sp, ) private val androidShapes = MyShapes( defaultRounded = RoundedCornerShape(12.dp), ) @Composable actual fun myPlatformTextSizes(): TextSizes { return androidTextSizes } @Composable actual fun myPlatformShapes(): MyShapes { return androidShapes } private val androidSpacings = MySpacings( thumbSize = 48.dp, iconSize = 24.dp, smallSpace = 4.dp, mediumSpace = 8.dp, largeSpace = 16.dp, ) @Composable actual fun myPlatformSpacing(): MySpacings { return androidSpacings } ================================================ FILE: shared/app/src/androidMain/kotlin/com/abdownloadmanager/shared/ui/widget/Tooltip.android.kt ================================================ package com.abdownloadmanager.shared.ui.widget import androidx.compose.foundation.gestures.awaitEachGesture import androidx.compose.foundation.gestures.awaitFirstDown import androidx.compose.foundation.gestures.awaitLongPressOrCancellation import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.MutableState import androidx.compose.runtime.getValue import androidx.compose.runtime.rememberUpdatedState import androidx.compose.ui.Modifier import androidx.compose.ui.composed import androidx.compose.ui.input.pointer.PointerEventPass import androidx.compose.ui.input.pointer.pointerInput actual fun Modifier.detectTooltip( state: MutableState, ): Modifier = pointerInput(Unit) { awaitEachGesture { val down = awaitFirstDown( requireUnconsumed = false, pass = PointerEventPass.Main ) val longPress = awaitLongPressOrCancellation(down.id) if (longPress != null) { state.value = true down.consume() // Consume everything until release while (true) { val event = awaitPointerEvent(PointerEventPass.Initial) event.changes.forEach { it.consume() } if (event.changes.all { !it.pressed }) { break } } state.value = false } } } ================================================ FILE: shared/app/src/androidMain/kotlin/com/abdownloadmanager/shared/ui/widget/menu/custom/Option.android.kt ================================================ package com.abdownloadmanager.shared.ui.widget.menu.custom import androidx.compose.runtime.Composable import androidx.compose.ui.Alignment import androidx.compose.ui.window.Popup import com.abdownloadmanager.shared.ui.widget.rememberMyComponentRectPositionProvider import ir.amirab.util.compose.action.MenuItem /** * TODO (KMP) implement it based on design * it's our context menu! */ @Composable actual fun ShowOptionsInPopup( menu: MenuItem.SubMenu, onDismissRequest: () -> Unit ) { Popup( popupPositionProvider = rememberMyComponentRectPositionProvider( alignment = Alignment.BottomEnd ), onDismissRequest = onDismissRequest ) { RenderOptions(menu, onDismissRequest) } } ================================================ FILE: shared/app/src/androidMain/kotlin/com/abdownloadmanager/shared/ui/widget/menu/custom/WithContextMenu.android.kt ================================================ package com.abdownloadmanager.shared.ui.widget.menu.custom import androidx.compose.foundation.gestures.detectTapGestures import androidx.compose.foundation.layout.Box import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.geometry.Offset import androidx.compose.ui.input.pointer.pointerInput import androidx.compose.ui.window.Popup import com.abdownloadmanager.shared.ui.widget.rememberMyPopupPositionProviderAtPosition import ir.amirab.util.compose.action.MenuItem @Composable actual fun WithContextMenu( menuProvider: () -> List, modifier: Modifier, content: @Composable (() -> Unit) ) { val menu = remember(menuProvider) { mutableStateOf(emptyList()) } val onDismissRequest = { menu.value = emptyList() } var lastClickPosition: Offset? by remember { mutableStateOf(null) } Box( modifier.onLongPress { menu.value = menuProvider() lastClickPosition = it } ) { content() if (menu.value.isNotEmpty()) { Popup( popupPositionProvider = rememberMyPopupPositionProviderAtPosition( positionPx = lastClickPosition // shouldn't happen! just to not use !! ?: Offset.Zero, alignment = Alignment.BottomEnd ), onDismissRequest = onDismissRequest ) { SubMenu( subMenu = menu.value, onRequestClose = onDismissRequest, ) } } } } private fun Modifier.onLongPress( onLongPress: (Offset) -> Unit ): Modifier = pointerInput(Unit) { detectTapGestures( onLongPress = { onLongPress(it) } ) } ================================================ FILE: shared/app/src/androidMain/kotlin/com/abdownloadmanager/shared/util/ClipboardUtil.android.kt ================================================ package com.abdownloadmanager.shared.util import android.content.ClipData import android.content.ClipboardManager import android.content.Context import androidx.core.content.getSystemService import org.koin.core.component.KoinComponent import org.koin.core.component.inject actual object ClipboardUtil : KoinComponent { private val context: Context by inject() private val clipboardManager get() = context.getSystemService() actual fun copy(text: String) { runCatching { clipboardManager?.let { val clip = ClipData.newPlainText("Copied Text", text) it.setPrimaryClip(clip) } } } actual fun read(): String? { return runCatching { clipboardManager?.let { val clip: ClipData? = it.primaryClip if (clip != null && clip.itemCount > 0) { return clip.getItemAt(0).coerceToText(context) .toString() } return null } }.getOrNull() } } ================================================ FILE: shared/app/src/androidMain/kotlin/com/abdownloadmanager/shared/util/DesktopDiskStat.android.kt ================================================ package com.abdownloadmanager.shared.util import ir.amirab.downloader.utils.IDiskStat import java.io.File actual typealias PlatformDiskStat = AndroidDiskStat class AndroidDiskStat : IDiskStat { override fun getRemainingSpace(path: File): Long { return path.freeSpace } } ================================================ FILE: shared/app/src/androidMain/kotlin/com/abdownloadmanager/shared/util/PlatformThemeDetector.android.kt ================================================ package com.abdownloadmanager.shared.util import android.app.Activity import android.app.Application import android.content.Context import android.content.res.Configuration import android.os.Bundle import com.abdownloadmanager.shared.util.ui.theme.ISystemThemeDetector import kotlinx.coroutines.channels.awaitClose import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.callbackFlow actual typealias PlatformThemeDetector = AndroidSystemThemeDetector class AndroidSystemThemeDetector( private val context: Context, ) : ISystemThemeDetector { override val isSupported: Boolean = true override fun isDark(): Boolean { return (context.resources.configuration.uiMode and Configuration.UI_MODE_NIGHT_MASK) == Configuration.UI_MODE_NIGHT_YES } override val systemThemeFlow: Flow = callbackFlow { trySend(isDark()) val callback = GlobalActivityLifecycleCallbacks { trySend(isDark()) } val application = context.applicationContext as? Application application?.registerActivityLifecycleCallbacks(callback) awaitClose { application?.unregisterActivityLifecycleCallbacks(callback) } } } private class GlobalActivityLifecycleCallbacks( private val recheck: () -> Unit, ) : Application.ActivityLifecycleCallbacks { override fun onActivityCreated(activity: Activity, savedInstanceState: Bundle?) { recheck() } override fun onActivityDestroyed(activity: Activity) { } override fun onActivityPaused(activity: Activity) { } override fun onActivityResumed(activity: Activity) { } override fun onActivitySaveInstanceState(activity: Activity, outState: Bundle) { } override fun onActivityStarted(activity: Activity) {} override fun onActivityStopped(activity: Activity) {} } ================================================ FILE: shared/app/src/androidMain/kotlin/com/abdownloadmanager/shared/util/downloadlocation/PlatformDownloadLocationProvider.android.kt ================================================ package com.abdownloadmanager.shared.util.downloadlocation import android.os.Environment import com.abdownloadmanager.shared.util.SystemDownloadLocationProvider import java.io.File class AndroidDownloadLocationProvider : SystemDownloadLocationProvider() { override fun getCommonDownloadLocation(): File { return Environment .getExternalStoragePublicDirectory(Environment.DIRECTORY_DOWNLOADS) .absoluteFile } override fun getCurrentDownloadLocation(): File { return getCommonDownloadLocation() } } actual fun getPlatformDownloadLocationProvider(): SystemDownloadLocationProvider { return AndroidDownloadLocationProvider() } ================================================ FILE: shared/app/src/androidMain/kotlin/com/abdownloadmanager/shared/util/ui/widget/MPBackHandler.android.kt ================================================ package com.abdownloadmanager.shared.util.ui.widget import androidx.activity.compose.BackHandler import androidx.compose.runtime.Composable @Composable actual fun MPBackHandler(isEnabled: Boolean, onBack: () -> Unit) { BackHandler(enabled = isEnabled, onBack = onBack) } ================================================ FILE: shared/app/src/commonMain/kotlin/com/abdownloadmanager/shared/action/CommonActionFactories.kt ================================================ package com.abdownloadmanager.shared.action import com.abdownloadmanager.resources.Res import com.abdownloadmanager.shared.pagemanager.AboutPageManager import com.abdownloadmanager.shared.pagemanager.AddDownloadDialogManager import com.abdownloadmanager.shared.pagemanager.BatchDownloadPageManager import com.abdownloadmanager.shared.pagemanager.EnterNewURLDialogManager import com.abdownloadmanager.shared.pagemanager.ExitApplicationRequestManager import com.abdownloadmanager.shared.pagemanager.NewQueuePageManager import com.abdownloadmanager.shared.pagemanager.OpenSourceLibrariesPageManager import com.abdownloadmanager.shared.pagemanager.PerHostSettingsPageManager import com.abdownloadmanager.shared.pagemanager.QueuePageManager import com.abdownloadmanager.shared.pagemanager.SettingsPageManager import com.abdownloadmanager.shared.pagemanager.TranslatorsPageManager import com.abdownloadmanager.shared.pages.adddownload.AddDownloadCredentialsInUiProps import com.abdownloadmanager.shared.pages.updater.UpdateComponent import com.abdownloadmanager.shared.util.ClipboardUtil import com.abdownloadmanager.shared.util.DownloadSystem import com.abdownloadmanager.shared.util.SharedConstants import com.abdownloadmanager.shared.util.category.Category import com.abdownloadmanager.shared.util.extractors.linkextractor.DownloadCredentialFromStringExtractor import com.abdownloadmanager.shared.util.extractors.linkextractor.DownloadCredentialsFromCurl import com.abdownloadmanager.shared.util.ui.icon.MyIcons import ir.amirab.downloader.downloaditem.IDownloadCredentials import ir.amirab.downloader.queue.DownloadQueue import ir.amirab.downloader.queue.QueueManager import ir.amirab.downloader.queue.inactiveQueuesFlow import ir.amirab.util.URLOpener import ir.amirab.util.compose.action.AnAction import ir.amirab.util.compose.action.MenuItem import ir.amirab.util.compose.action.buildMenu import ir.amirab.util.compose.action.simpleAction import ir.amirab.util.compose.asStringSource import ir.amirab.util.flow.combineStateFlows import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.launchIn import kotlinx.coroutines.flow.onEach import kotlinx.coroutines.launch fun createNewDownloadAction( enterNewURLDialogManager: EnterNewURLDialogManager, ): AnAction { return simpleAction( Res.string.new_download.asStringSource(), MyIcons.add, ) { enterNewURLDialogManager.openEnterNewURLWindow() } } fun createDownloadFromClipboardAction( addDownloadDialogManager: AddDownloadDialogManager, ): AnAction { return simpleAction( Res.string.import_from_clipboard.asStringSource(), MyIcons.paste, ) { val contentsInClipboard = ClipboardUtil.read() if (contentsInClipboard.isNullOrEmpty()) { return@simpleAction } val curlItems = DownloadCredentialsFromCurl.extract(contentsInClipboard) if (curlItems.isNotEmpty()) { addDownloadDialogManager.openAddDownloadDialog( curlItems.map { AddDownloadCredentialsInUiProps(it) } ) return@simpleAction } val items: List = DownloadCredentialFromStringExtractor .extract(contentsInClipboard) .distinctBy { it.link } if (items.isEmpty()) { return@simpleAction } addDownloadDialogManager.openAddDownloadDialog(items.map { AddDownloadCredentialsInUiProps(it) }) } } fun createOpenBatchDownloadAction( batchDownloadPageManager: BatchDownloadPageManager ): AnAction { return simpleAction( title = Res.string.batch_download.asStringSource(), icon = MyIcons.download ) { batchDownloadPageManager.openBatchDownloadPage() } } fun createStopQueueGroupAction( scope: CoroutineScope, activeQueuesFlow: StateFlow> ): MenuItem.SubMenu { return MenuItem.SubMenu( icon = MyIcons.queueStop, title = Res.string.stop_queue.asStringSource(), items = emptyList() ).apply { activeQueuesFlow .onEach { setItems(it.map { createStopQueueAction(scope, it) }) }.launchIn(scope) } } fun createStartQueueGroupAction( scope: CoroutineScope, queueManager: QueueManager, ): MenuItem.SubMenu { return MenuItem.SubMenu( icon = MyIcons.queueStart, title = Res.string.start_queue.asStringSource(), items = emptyList() ).apply { queueManager .inactiveQueuesFlow() .onEach { setItems(it.map { createStartQueueAction(scope, it) }) }.launchIn(scope) } } // ui exit fun createRequestExitAction( scope: CoroutineScope, exitAppRequestManager: ExitApplicationRequestManager ): AnAction { return simpleAction( Res.string.exit.asStringSource(), MyIcons.exit, ) { scope.launch { exitAppRequestManager.requestExitApp() } } } fun createPerHostSettingsPage( perHostSettingsPageManager: PerHostSettingsPageManager ): AnAction { return simpleAction( Res.string.settings_per_host_settings.asStringSource(), MyIcons.earth, ) { perHostSettingsPageManager.openPerHostSettings(null) } } fun createOpenSettingsAction( settingsPageManager: SettingsPageManager ): AnAction { return simpleAction( Res.string.settings.asStringSource(), MyIcons.settings, ) { settingsPageManager.openSettings() } } fun createCheckForUpdateAction( updaterComponent: UpdateComponent ): AnAction { return simpleAction( title = Res.string.update_check_for_update.asStringSource(), icon = MyIcons.refresh, checkEnable = MutableStateFlow( updaterComponent.isUpdateSupported() ) ) { updaterComponent.requestCheckForUpdate() } } fun createOpenAboutPage(aboutPageManager: AboutPageManager): AnAction { return simpleAction( title = Res.string.about.asStringSource(), icon = MyIcons.info, ) { aboutPageManager.openAboutPage() } } fun createOpenOpenSourceThirdPartyLibrariesPage( openSourceLibrariesPageManager: OpenSourceLibrariesPageManager, ): AnAction { return simpleAction( title = Res.string.view_the_open_source_licenses.asStringSource(), icon = MyIcons.openSource, ) { openSourceLibrariesPageManager.openOpenSourceLibrariesPage() } } fun createOpenTranslatorsPageAction( opeTranslatorsPageManager: TranslatorsPageManager, ): AnAction { return simpleAction( title = Res.string.meet_the_translators.asStringSource(), icon = MyIcons.language, ) { opeTranslatorsPageManager.openTranslatorsPage() } } val donate = simpleAction( title = Res.string.donate.asStringSource(), icon = MyIcons.hearth, ) { URLOpener.openUrl(SharedConstants.donateLink) } val supportActionGroup = MenuItem.SubMenu( title = Res.string.support_and_community.asStringSource(), icon = MyIcons.group, items = buildMenu { item(Res.string.website.asStringSource(), MyIcons.appIcon) { URLOpener.openUrl(SharedConstants.projectWebsite) } item(Res.string.source_code.asStringSource(), MyIcons.openSource) { URLOpener.openUrl(SharedConstants.projectSourceCode) } subMenu(Res.string.telegram.asStringSource(), MyIcons.telegram) { item(Res.string.channel.asStringSource(), MyIcons.speaker) { URLOpener.openUrl(SharedConstants.telegramChannelUrl) } item(Res.string.group.asStringSource(), MyIcons.group) { URLOpener.openUrl(SharedConstants.telegramGroupUrl) } } } ) fun createOpenQueuesAction( queuePageManager: QueuePageManager ): AnAction { return simpleAction( title = Res.string.queues.asStringSource(), icon = MyIcons.queue ) { queuePageManager.openQueues() } } fun createMoveToQueueAction( scope: CoroutineScope, downloadSystem: DownloadSystem, queue: DownloadQueue, itemId: List, ): AnAction { return simpleAction(queue.getQueueModel().name.asStringSource()) { scope.launch { downloadSystem .queueManager .addToQueue( queueId = queue.id, downloadIds = itemId, ) } } } fun createMoveToCategoryAction( scope: CoroutineScope, downloadSystem: DownloadSystem, category: Category, itemIds: List, ): AnAction { return simpleAction(category.name.asStringSource()) { scope.launch { downloadSystem .categoryManager .addItemsToCategory( categoryId = category.id, itemIds = itemIds, ) } } } fun createStopQueueAction( scope: CoroutineScope, queue: DownloadQueue, ): AnAction { return simpleAction(queue.getQueueModel().name.asStringSource()) { scope.launch { queue.stop() } } } fun createStartQueueAction( scope: CoroutineScope, queue: DownloadQueue, ): AnAction { return simpleAction(queue.getQueueModel().name.asStringSource()) { scope.launch { queue.start() } } } fun createNewQueueAction( scope: CoroutineScope, queuePageManager: NewQueuePageManager, ): AnAction { return simpleAction(Res.string.add_new_queue.asStringSource()) { scope.launch { queuePageManager.openNewQueueDialog() } } } fun createStopAllAction( scope: CoroutineScope, downloadSystem: DownloadSystem, extraJobs: () -> Unit, activeQueuesFlow: StateFlow> ): AnAction { return simpleAction( Res.string.stop_all.asStringSource(), MyIcons.stop, checkEnable = combineStateFlows( downloadSystem.downloadMonitor.activeDownloadCount, activeQueuesFlow ) { downloadCount, activeQueues -> downloadCount > 0 || activeQueues.isNotEmpty() } ) { scope.launch { downloadSystem.stopAnything() extraJobs() } } } ================================================ FILE: shared/app/src/commonMain/kotlin/com/abdownloadmanager/shared/action/DevActionFactories.kt ================================================ package com.abdownloadmanager.shared.action import com.abdownloadmanager.shared.pagemanager.NotificationSender import com.abdownloadmanager.shared.ui.widget.MessageDialogType import com.abdownloadmanager.shared.util.ui.icon.MyIcons import ir.amirab.util.compose.action.AnAction import ir.amirab.util.compose.action.MenuItem import ir.amirab.util.compose.action.simpleAction import ir.amirab.util.compose.asStringSource fun createDummyExceptionAction(): AnAction { return simpleAction( "Dummy Exception".asStringSource(), MyIcons.info ) { error("This is a dummy exception that is thrown by developer") } } fun createDummyMessageAction( notificationSender: NotificationSender, ): MenuItem.SubMenu { return MenuItem.SubMenu( title = "Show Dialog Message".asStringSource(), icon = MyIcons.info, items = listOf( MessageDialogType.Info, MessageDialogType.Error, MessageDialogType.Warning, MessageDialogType.Success, ).map { createDummyMessage(it, notificationSender) } ) } private fun createDummyMessage( type: MessageDialogType, notificationSender: NotificationSender, ): AnAction { return simpleAction( "$type Message".asStringSource(), MyIcons.info, ) { notificationSender.sendDialogNotification( type = type, title = "Dummy Message".asStringSource(), description = "This is a test message".asStringSource() ) } } ================================================ FILE: shared/app/src/commonMain/kotlin/com/abdownloadmanager/shared/downloaderinui/BasicDownloadItem.kt ================================================ package com.abdownloadmanager.shared.downloaderinui data class BasicDownloadItem( var folder: String, var name: String, var contentLength: Long = -1, var preferredConnectionCount: Int? = null, var speedLimit: Long = 0, var fileChecksum: String? = null ) ================================================ FILE: shared/app/src/commonMain/kotlin/com/abdownloadmanager/shared/downloaderinui/CredentialAndItemMapper.kt ================================================ package com.abdownloadmanager.shared.downloaderinui import ir.amirab.downloader.downloaditem.IDownloadCredentials import ir.amirab.downloader.downloaditem.IDownloadItem /** * the resulting value is a copied object so implementations doesn't have side effects */ interface CredentialAndItemMapper { fun itemToCredentials(item: TDownloadItem): TCredentials fun appliedCredentialsToItem(item: TDownloadItem, credentials: TCredentials): TDownloadItem // I believe that these two are redundant it needs to be improved fun itemWithEditedName(item: TDownloadItem, name: String): TDownloadItem fun credentialsWithEditedLink(credentials: TCredentials, link: String): TCredentials } ================================================ FILE: shared/app/src/commonMain/kotlin/com/abdownloadmanager/shared/downloaderinui/DownloadSize.kt ================================================ package com.abdownloadmanager.shared.downloaderinui import androidx.compose.runtime.Composable import androidx.compose.runtime.remember import com.abdownloadmanager.shared.util.LocalSizeUnit import com.abdownloadmanager.shared.util.convertDurationToHumanReadable import com.abdownloadmanager.shared.util.convertPositiveSizeToHumanReadable import ir.amirab.util.compose.StringSource import ir.amirab.util.datasize.ConvertSizeConfig sealed class DownloadSize : Comparable { abstract fun comparePriority(): Int abstract fun plus(other: DownloadSize): DownloadSize data class Bytes( val bytes: Long, ) : DownloadSize() { fun asStringSource( sizeUnit: ConvertSizeConfig ): StringSource { return convertPositiveSizeToHumanReadable(bytes, sizeUnit) } override fun comparePriority() = 2 override fun plus(other: DownloadSize): DownloadSize { return if (other !is Bytes || other.bytes == 0L) { this } else { return Bytes(bytes + other.bytes) } } override fun compareTo(other: DownloadSize): Int { if (other is Bytes) { return bytes.compareTo(other.bytes) } return super.compareTo(other) } companion object{ val Zero = Bytes(0) } } data class Duration(val duration: Double) : DownloadSize() { fun asStringSource(): StringSource { return convertDurationToHumanReadable(duration) } override fun comparePriority() = 1 override fun plus(other: DownloadSize): DownloadSize { return if (other !is Duration || other.duration == 0.0) { this } else { Duration(duration + other.duration) } } override fun compareTo(other: DownloadSize): Int { if (other is Duration) { return duration.compareTo(other.duration) } return super.compareTo(other) } companion object{ val Zero = Duration(0.0) } } override fun compareTo(other: DownloadSize): Int { return comparePriority().compareTo(other.comparePriority()) } } @Composable fun DownloadSize.rememberString(): String { return when (this) { is DownloadSize.Bytes -> { val sizeUnit = LocalSizeUnit.current remember(this, sizeUnit) { asStringSource(sizeUnit) } } is DownloadSize.Duration -> { remember(this) { asStringSource() } } }.rememberString() } ================================================ FILE: shared/app/src/commonMain/kotlin/com/abdownloadmanager/shared/downloaderinui/DownloadUiChecker.kt ================================================ package com.abdownloadmanager.shared.downloaderinui import com.abdownloadmanager.shared.downloaderinui.add.AddDownloadChecker import com.abdownloadmanager.shared.util.DownloadSystem import ir.amirab.downloader.connection.IResponseInfo import ir.amirab.downloader.downloaditem.IDownloadCredentials import ir.amirab.util.flow.onEachLatest import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.channels.BufferOverflow import kotlinx.coroutines.flow.* abstract class DownloadUiChecker< TCredentials : IDownloadCredentials, TResponseInfoType : IResponseInfo, TDownloadSize : DownloadSize, TLinkChecker : LinkChecker, >( initialCredentials: TCredentials, linkCheckerFactory: LinkCheckerFactory, initialFolder: String, initialName: String = "", downloadSystem: DownloadSystem, scope: CoroutineScope, ) { val credentials = MutableStateFlow(initialCredentials) val name = MutableStateFlow(initialName) val folder = MutableStateFlow(initialFolder) protected val linkChecker = linkCheckerFactory.createLinkChecker(credentials.value) protected val addDownloadChecker = AddDownloadChecker( linkChecker = linkChecker, initialName = name.value, initialFolder = folder.value, downloadSystem = downloadSystem, parentScope = scope, ) val downloadSize: StateFlow = linkChecker.downloadSize val gettingResponseInfo = linkChecker.isLoading val responseInfo = linkChecker.responseInfo val canAddToDownloadResult = addDownloadChecker.canAddResult val canAdd = addDownloadChecker.canAdd val isDuplicate = addDownloadChecker.isDuplicate private val refreshResponseInfoImmediately = MutableSharedFlow( replay = 1, onBufferOverflow = BufferOverflow.DROP_LATEST ) private val scheduleRefreshResponseInfo = MutableSharedFlow( replay = 1, onBufferOverflow = BufferOverflow.DROP_LATEST ) private val scheduleRecheckAddToDownloadIsPossible = MutableSharedFlow( replay = 1, onBufferOverflow = BufferOverflow.DROP_LATEST ) fun refresh() { refreshResponseInfoImmediately.tryEmit(Unit) } private fun scheduleRefresh( alsoRecheckLink: Boolean ) { if (alsoRecheckLink) { scheduleRefreshResponseInfo.tryEmit(Unit) } scheduleRecheckAddToDownloadIsPossible.tryEmit(Unit) } init { merge( scheduleRefreshResponseInfo.debounce(500), refreshResponseInfoImmediately ).onEachLatest { linkChecker.check() }.launchIn(scope) merge( scheduleRecheckAddToDownloadIsPossible, // ... ).onEachLatest { addDownloadChecker.check() }.launchIn(scope) linkChecker.suggestedName .onEach { it?.let { name -> this.name.update { name } } }.launchIn(scope) credentials.onEach { credentials -> linkChecker.credentials.update { credentials } scheduleRefresh(alsoRecheckLink = true) }.launchIn(scope) name.onEach { name -> if (addDownloadChecker.name.value != name) { addDownloadChecker.name.update { name } scheduleRefresh(alsoRecheckLink = false) } }.launchIn(scope) folder.onEach { folder -> if (addDownloadChecker.folder.value != folder) { addDownloadChecker.folder.update { folder } scheduleRefresh(alsoRecheckLink = false) } }.launchIn(scope) } } ================================================ FILE: shared/app/src/commonMain/kotlin/com/abdownloadmanager/shared/downloaderinui/DownloaderInUi.kt ================================================ package com.abdownloadmanager.shared.downloaderinui import com.abdownloadmanager.shared.downloaderinui.add.NewDownloadInputs import com.abdownloadmanager.shared.downloaderinui.add.NewDownloadInputsFactory import com.abdownloadmanager.shared.downloaderinui.edit.EditDownloadCheckerFactory import com.abdownloadmanager.shared.downloaderinui.edit.EditDownloadInputs import com.abdownloadmanager.shared.downloaderinui.edit.EditDownloadInputsFactory import com.abdownloadmanager.shared.util.DownloadSystem import ir.amirab.downloader.Downloader import ir.amirab.downloader.connection.IResponseInfo import ir.amirab.downloader.downloaditem.DownloadJob import ir.amirab.downloader.downloaditem.IDownloadCredentials import ir.amirab.downloader.downloaditem.IDownloadItem import ir.amirab.downloader.monitor.CompletedDownloadItemState import ir.amirab.downloader.monitor.DownloadItemStateFactory import ir.amirab.downloader.monitor.ProcessingDownloadItemFactoryInputs import ir.amirab.downloader.monitor.ProcessingDownloadItemState import ir.amirab.util.compose.StringSource import kotlinx.coroutines.CoroutineScope /** * This is a class that represent a downloader implementation details tight to the Application not just the downloader logic * including ui, component factories and every thing that app need work with */ abstract class DownloaderInUi< TCredentials : IDownloadCredentials, TResponseInfo : IResponseInfo, TDownloadSize : DownloadSize, TLinkChecker : LinkChecker, TDownloadItem : IDownloadItem, TNewDownloadInputs : NewDownloadInputs, TEditDownloadInputs : EditDownloadInputs, TCredentialAndItemMapper : CredentialAndItemMapper, TDownloadJob : DownloadJob, TDownloader : Downloader >( val downloader: TDownloader ) : LinkCheckerFactory, EditDownloadCheckerFactory, NewDownloadInputsFactory, EditDownloadInputsFactory, DownloadItemStateFactory { abstract fun newDownloadUiChecker( initialCredentials: TCredentials, initialFolder: String, initialName: String, downloadSystem: DownloadSystem, scope: CoroutineScope, ): DownloadUiChecker abstract fun acceptDownloadCredentials(item: IDownloadCredentials): Boolean abstract fun supportsThisLink(link: String): Boolean abstract fun createMinimumCredentials(link: String): TCredentials abstract fun createBareDownloadItem( credentials: TCredentials, basicDownloadItem: BasicDownloadItem ): TDownloadItem abstract override fun createProcessingDownloadItemState( props: ProcessingDownloadItemFactoryInputs, ): ProcessingDownloadItemState override fun createCompletedDownloadItemState( downloadItem: TDownloadItem, ): CompletedDownloadItemState { return CompletedDownloadItemState.fromDownloadItem(downloadItem) } abstract val name: StringSource } ================================================ FILE: shared/app/src/commonMain/kotlin/com/abdownloadmanager/shared/downloaderinui/DownloaderInUiRegistry.kt ================================================ package com.abdownloadmanager.shared.downloaderinui import com.abdownloadmanager.shared.downloaderinui.add.NewDownloadInputs import com.abdownloadmanager.shared.downloaderinui.edit.EditDownloadInputs import ir.amirab.downloader.Downloader import ir.amirab.downloader.connection.IResponseInfo import ir.amirab.downloader.downloaditem.DownloadJob import ir.amirab.downloader.downloaditem.IDownloadCredentials import ir.amirab.downloader.downloaditem.IDownloadItem import ir.amirab.downloader.monitor.CompletedDownloadItemState import ir.amirab.downloader.monitor.DownloadItemStateFactory import ir.amirab.downloader.monitor.ProcessingDownloadItemFactoryInputs import ir.amirab.downloader.monitor.ProcessingDownloadItemState import kotlin.reflect.KClass typealias TADownloaderInUI = DownloaderInUi< IDownloadCredentials, IResponseInfo, DownloadSize, LinkChecker, IDownloadItem, NewDownloadInputs>, EditDownloadInputs, CredentialAndItemMapper>, CredentialAndItemMapper, DownloadJob, Downloader> class DownloaderInUiRegistry : DownloadItemStateFactory { private val list = mutableListOf() private val componentHashes = hashMapOf() fun add(downloaderInUi: DownloaderInUi<*, *, *, *, *, *, *, *, *, *>) { // the compiler gave me error when I add these two generics (TDownloadJob, TDownloader) into the DownloaderInUi val element = downloaderInUi as TADownloaderInUI @Suppress("UNCHECKED_CAST") list.add(element) getComponentsOf(element).forEach { componentHashes[it] = element } } fun remove(downloaderInUi: DownloaderInUi<*, *, *, *, *, *, *, *, *, *>) { list.remove(downloaderInUi) @Suppress("UNCHECKED_CAST") getComponentsOf( downloaderInUi as TADownloaderInUI ).forEach { componentHashes.remove(it) } } fun getDownloaderOf(downloadItem: IDownloadCredentials): TADownloaderInUI? { componentHashes.get(downloadItem::class)?.let { // fast path without iterating the list return it } // IDownloadCredentials is an intermediate interface so we should iterate the list instead return list.firstOrNull { it.acceptDownloadCredentials(downloadItem) } } private fun getDownloaderOf(downloadJob: DownloadJob): TADownloaderInUI? { return getDownloaderOf(downloadJob.downloadItem) } fun bestMatchForThisLink(link: String): TADownloaderInUI? { return list.firstOrNull { it.supportsThisLink(link) } } fun getAll(): List { return list.toList() } override fun createProcessingDownloadItemState( props: ProcessingDownloadItemFactoryInputs, ): ProcessingDownloadItemState { val downloadJob = props.downloadJob return requireNotNull(getDownloaderOf(downloadJob)) { "there is no downloader in UI registered for this download job: ${downloadJob::class.qualifiedName}" }.createProcessingDownloadItemState(props) } override fun createCompletedDownloadItemState(downloadItem: IDownloadItem): CompletedDownloadItemState { return requireNotNull(getDownloaderOf(downloadItem)) { "there is no downloader in UI registered for this download item: ${downloadItem::class.qualifiedName}" }.createCompletedDownloadItemState(downloadItem) } companion object { private fun getComponentsOf(element: TADownloaderInUI): List> { return listOf( element.downloader.downloadJobClass, element.downloader.downloadItemClass, ) } } } ================================================ FILE: shared/app/src/commonMain/kotlin/com/abdownloadmanager/shared/downloaderinui/LinkChecker.kt ================================================ package com.abdownloadmanager.shared.downloaderinui import ir.amirab.downloader.connection.IResponseInfo import ir.amirab.downloader.downloaditem.IDownloadCredentials import ir.amirab.util.HttpUrlUtils import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.update abstract class LinkChecker< Credentials : IDownloadCredentials, ResponseInfo : IResponseInfo, TDownloadSize : DownloadSize, >( initialCredentials: Credentials ) { //input val credentials = MutableStateFlow(initialCredentials) abstract val suggestedName: StateFlow abstract val downloadSize: StateFlow private val _isLoading = MutableStateFlow(false) val isLoading = _isLoading.asStateFlow() private val _responseInfo = MutableStateFlow(null) val responseInfo = _responseInfo.asStateFlow() private val _isValid = MutableStateFlow(false) private val isValid = _isValid.asStateFlow() abstract fun infoUpdated(responseInfo: ResponseInfo?) abstract suspend fun actualCheck(credentials: Credentials): ResponseInfo fun isValidCredentials(credentials: Credentials): Boolean { return runCatching { credentials.validateCredentials() true }.getOrElse { false } } private fun setInfo(responseInfo: ResponseInfo?) { _responseInfo.update { responseInfo } infoUpdated(responseInfo) validate() } private fun validate() { val isValid = isValidCredentials(this.credentials.value) _isValid.update { isValid } } suspend fun check() { val downloadCredentials = credentials.value val link = downloadCredentials.link val isValidUrl = HttpUrlUtils.isValidUrl(link) setInfo(null) if (link.isBlank() || !isValidUrl) { return } _isLoading.update { true } val info = runCatching { actualCheck(downloadCredentials) }.getOrNull() _isLoading.update { false } setInfo(info) } } ================================================ FILE: shared/app/src/commonMain/kotlin/com/abdownloadmanager/shared/downloaderinui/LinkCheckerFactory.kt ================================================ package com.abdownloadmanager.shared.downloaderinui import ir.amirab.downloader.connection.IResponseInfo import ir.amirab.downloader.downloaditem.IDownloadCredentials interface LinkCheckerFactory< TCredentials : IDownloadCredentials, TResponseInfo : IResponseInfo, TDownloadSize : DownloadSize, TLinkChecker : LinkChecker, > { fun createLinkChecker( initialCredentials: TCredentials ): TLinkChecker } ================================================ FILE: shared/app/src/commonMain/kotlin/com/abdownloadmanager/shared/downloaderinui/add/AddDownloadChecker.kt ================================================ package com.abdownloadmanager.shared.downloaderinui.add import com.abdownloadmanager.shared.downloaderinui.DownloadSize import com.abdownloadmanager.shared.downloaderinui.LinkChecker import com.abdownloadmanager.shared.util.DownloadSystem import ir.amirab.downloader.connection.IResponseInfo import ir.amirab.downloader.downloaditem.IDownloadCredentials import ir.amirab.downloader.utils.DuplicateFilterByPath import ir.amirab.util.FileNameValidator import ir.amirab.util.osfileutil.FileUtils import ir.amirab.util.flow.mapStateFlow import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.flow.* import java.io.File class AddDownloadChecker< Credentials : IDownloadCredentials, ResponseInfo : IResponseInfo, TDownloadSize : DownloadSize, >( initialName: String, initialFolder: String, private val linkChecker: LinkChecker, private val downloadSystem: DownloadSystem, private val parentScope: CoroutineScope, ) { val canAddResult = MutableStateFlow(null as CanAddResult?) val canAdd = canAddResult.mapStateFlow() { it is CanAddResult.CanAdd } val isDuplicate = canAddResult.mapStateFlow() { it is CanAddResult.DownloadAlreadyExists } val name = MutableStateFlow(initialName) val folder = MutableStateFlow(initialFolder) init { combine( this.name, this.folder, transform = { _ -> canAddResult.update { null } } ).launchIn(parentScope) } suspend fun check() { canAddResult.update { null } val newResult = validate() canAddResult.update { newResult } } private suspend fun validate(): CanAddResult { if (!linkChecker.isValidCredentials(linkChecker.credentials.value)) { return CanAddResult.InvalidUrl } if (!fileNameValid()) { return CanAddResult.InvalidFileName } val name = name.value val folder = folder.value val file = File(folder, name) val duplicateFilterByPath = DuplicateFilterByPath(file) val items = downloadSystem .getDownloadItemsBy(duplicateFilterByPath::isDuplicate) // val fileExists = File(folder, name).exists() if (items.isNotEmpty()) { return CanAddResult.DownloadAlreadyExists(items.first().id) } if (!FileUtils.canWriteInThisFolder(folder)) { return CanAddResult.CantWriteInThisFolder } // if((length?:0)>File(folder).length()){ // return CanAddResult.NotEnoughMemory // } return CanAddResult.CanAdd } private fun fileNameValid(): Boolean { return FileNameValidator.isValidFileName(name.value) } } ================================================ FILE: shared/app/src/commonMain/kotlin/com/abdownloadmanager/shared/downloaderinui/add/CanAddResult.kt ================================================ package com.abdownloadmanager.shared.downloaderinui.add sealed interface CanAddResult { data class DownloadAlreadyExists(val itemId: Long) : CanAddResult data object InvalidFileName : CanAddResult data object CantWriteInThisFolder : CanAddResult data object InvalidUrl : CanAddResult data object CanAdd : CanAddResult } ================================================ FILE: shared/app/src/commonMain/kotlin/com/abdownloadmanager/shared/downloaderinui/add/NewDownloadInputs.kt ================================================ package com.abdownloadmanager.shared.downloaderinui.add import com.abdownloadmanager.resources.Res import com.abdownloadmanager.shared.downloaderinui.DownloadSize import com.abdownloadmanager.shared.downloaderinui.DownloadUiChecker import com.abdownloadmanager.shared.downloaderinui.LinkChecker import com.abdownloadmanager.shared.ui.configurable.Configurable import com.abdownloadmanager.shared.util.perhostsettings.PerHostSettingsItem import ir.amirab.downloader.connection.IResponseInfo import ir.amirab.downloader.downloaditem.DownloadJobExtraConfig import ir.amirab.downloader.downloaditem.IDownloadCredentials import ir.amirab.downloader.downloaditem.IDownloadItem import ir.amirab.util.compose.StringSource import ir.amirab.util.compose.asStringSource import ir.amirab.util.flow.mapStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.update abstract class NewDownloadInputs< TDownloadItem : IDownloadItem, TCredentials : IDownloadCredentials, TResponseInfoType : IResponseInfo, TDownloadSize : DownloadSize, TLinkChecker : LinkChecker, >( val downloadUiChecker: DownloadUiChecker ) { val openedTime = System.currentTimeMillis() val name = downloadUiChecker.name val folder = downloadUiChecker.folder val credentials = downloadUiChecker.credentials val downloadSize = downloadUiChecker.downloadSize abstract val downloadItem: StateFlow abstract val downloadJobConfig: StateFlow abstract val configurableList: List> abstract fun applyHostSettingsToExtraConfig(extraConfig: PerHostSettingsItem) fun setCredentials(credentials: TCredentials) { downloadUiChecker.credentials.update { credentials } } abstract fun downloadSizeToStringSource(downloadSize: TDownloadSize): StringSource? val lengthStringFlow: StateFlow = downloadSize.mapStateFlow { it ?.let(::downloadSizeToStringSource) ?: Res.string.unknown.asStringSource() } fun getLengthString(): StringSource { return lengthStringFlow.value } fun getUniqueId(): NewDownloadInputsUniqueIdType = hashCode() } typealias TANewDownloadInputs = NewDownloadInputs> typealias NewDownloadInputsUniqueIdType = Int ================================================ FILE: shared/app/src/commonMain/kotlin/com/abdownloadmanager/shared/downloaderinui/add/NewDownloadInputsFactory.kt ================================================ package com.abdownloadmanager.shared.downloaderinui.add import com.abdownloadmanager.shared.downloaderinui.DownloadSize import com.abdownloadmanager.shared.downloaderinui.LinkChecker import com.abdownloadmanager.shared.util.DownloadSystem import ir.amirab.downloader.connection.IResponseInfo import ir.amirab.downloader.downloaditem.IDownloadCredentials import ir.amirab.downloader.downloaditem.IDownloadItem import kotlinx.coroutines.CoroutineScope interface NewDownloadInputsFactory< TDownloadItem : IDownloadItem, TCredentials : IDownloadCredentials, TResponseInfoType : IResponseInfo, TDownloadSize : DownloadSize, TLinkChecker : LinkChecker, TNewDownloadInputs : NewDownloadInputs< TDownloadItem, TCredentials, TResponseInfoType, TDownloadSize, TLinkChecker, > > { fun createNewDownloadInputs( initialCredentials: TCredentials, initialFolder: String, initialName: String, downloadSystem: DownloadSystem, scope: CoroutineScope ): TNewDownloadInputs } ================================================ FILE: shared/app/src/commonMain/kotlin/com/abdownloadmanager/shared/downloaderinui/edit/CanEditDownloadResult.kt ================================================ package com.abdownloadmanager.shared.downloaderinui.edit sealed interface CanEditDownloadResult { data object FileNameAlreadyExists : CanEditDownloadResult data object InvalidURL : CanEditDownloadResult data object InvalidFileName : CanEditDownloadResult data object NothingChanged : CanEditDownloadResult data object Waiting : CanEditDownloadResult data class CanEdit( val warnings: List, ) : CanEditDownloadResult } ================================================ FILE: shared/app/src/commonMain/kotlin/com/abdownloadmanager/shared/downloaderinui/edit/CanEditWarnings.kt ================================================ package com.abdownloadmanager.shared.downloaderinui.edit import com.abdownloadmanager.resources.Res import com.abdownloadmanager.shared.util.convertDurationToHumanReadable import ir.amirab.util.compose.StringSource import ir.amirab.util.compose.asStringSource import ir.amirab.util.compose.asStringSourceWithARgs sealed interface CanEditWarnings { fun asStringSource(): StringSource data class FileSizeNotMatch( val currentSize: Long, val newSize: Long, ) : CanEditWarnings { override fun asStringSource(): StringSource { return Res.string.edit_download_saved_download_item_size_not_match .asStringSourceWithARgs( Res.string.edit_download_saved_download_item_size_not_match_createArgs( currentSize = "$currentSize", newSize = "$newSize", ) ) } } data class DurationNotMatch( val currentDuration: Double?, val newDuration: Double?, ) : CanEditWarnings { val notAvailableString = Res.string.unknown.asStringSource() override fun asStringSource(): StringSource { val currentDurationString = currentDuration?.let { convertDurationToHumanReadable(it) } ?: notAvailableString val newDurationString = newDuration?.let { convertDurationToHumanReadable(it) } ?: notAvailableString return Res.string.edit_download_saved_download_item_size_not_match .asStringSourceWithARgs( Res.string.edit_download_saved_download_item_size_not_match_createArgs( currentSize = currentDurationString.getString(), newSize = newDurationString.getString(), ) ) } } } ================================================ FILE: shared/app/src/commonMain/kotlin/com/abdownloadmanager/shared/downloaderinui/edit/DownloadConflictDetector.kt ================================================ package com.abdownloadmanager.shared.downloaderinui.edit import com.abdownloadmanager.shared.util.DownloadSystem import ir.amirab.downloader.downloaditem.IDownloadItem interface IDownloadConflictDetector { fun checkAlreadyExists( current: TDownloadItem, edited: TDownloadItem, ): Boolean } class DownloadConflictDetector( private val downloadSystem: DownloadSystem ) : IDownloadConflictDetector { override fun checkAlreadyExists(current: IDownloadItem, edited: IDownloadItem): Boolean { val editedDownloadFile = downloadSystem.getDownloadFile(edited) val alreadyExists = editedDownloadFile.exists() if (alreadyExists) { return true } return downloadSystem .getAllRegisteredDownloadFiles() .contains(editedDownloadFile) } } ================================================ FILE: shared/app/src/commonMain/kotlin/com/abdownloadmanager/shared/downloaderinui/edit/EditDownloadCheckerFactory.kt ================================================ package com.abdownloadmanager.shared.downloaderinui.edit import com.abdownloadmanager.shared.downloaderinui.DownloadSize import com.abdownloadmanager.shared.downloaderinui.LinkChecker import com.abdownloadmanager.shared.downloaderinui.http.edit.EditDownloadChecker import ir.amirab.downloader.connection.IResponseInfo import ir.amirab.downloader.downloaditem.IDownloadCredentials import ir.amirab.downloader.downloaditem.IDownloadItem import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.flow.MutableStateFlow interface EditDownloadCheckerFactory< TDownloadItem : IDownloadItem, TCredentials : IDownloadCredentials, TResponseInfo : IResponseInfo, TDownloadSize : DownloadSize, TLinkChecker : LinkChecker> { fun createEditDownloadChecker( currentDownloadItem: MutableStateFlow, editedDownloadItem: MutableStateFlow, linkChecker: TLinkChecker, conflictDetector: DownloadConflictDetector, scope: CoroutineScope, ): EditDownloadChecker } ================================================ FILE: shared/app/src/commonMain/kotlin/com/abdownloadmanager/shared/downloaderinui/edit/EditDownloadInputs.kt ================================================ package com.abdownloadmanager.shared.downloaderinui.edit import com.abdownloadmanager.resources.Res import com.abdownloadmanager.shared.downloaderinui.CredentialAndItemMapper import com.abdownloadmanager.shared.downloaderinui.DownloadSize import com.abdownloadmanager.shared.downloaderinui.LinkChecker import com.abdownloadmanager.shared.downloaderinui.LinkCheckerFactory import com.abdownloadmanager.shared.ui.configurable.Configurable import ir.amirab.downloader.connection.IResponseInfo import ir.amirab.downloader.downloaditem.DownloadJobExtraConfig import ir.amirab.downloader.downloaditem.IDownloadCredentials import ir.amirab.downloader.downloaditem.IDownloadItem import ir.amirab.util.compose.StringSource import ir.amirab.util.compose.asStringSource import ir.amirab.util.flow.mapStateFlow import ir.amirab.util.flow.mapTwoWayStateFlow import ir.amirab.util.flow.onEachLatest import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.channels.BufferOverflow import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.debounce import kotlinx.coroutines.flow.launchIn import kotlinx.coroutines.flow.merge import kotlinx.coroutines.flow.onEach import kotlinx.coroutines.flow.update typealias TAEditDownloadInputs = EditDownloadInputs<*, *, *, *, *, *> abstract class EditDownloadInputs< TDownloadItem : IDownloadItem, TCredentials : IDownloadCredentials, TResponseInfo : IResponseInfo, TDownloadSize : DownloadSize, TLinkChecker : LinkChecker, TCredentialAndItemMapper : CredentialAndItemMapper >( val currentDownloadItem: MutableStateFlow, val editedDownloadItem: MutableStateFlow, val mapper: TCredentialAndItemMapper, val scope: CoroutineScope, val conflictDetector: DownloadConflictDetector, linkCheckerFactory: LinkCheckerFactory, editDownloadCheckerFactory: EditDownloadCheckerFactory, ) { private val _showMoreSettings = MutableStateFlow(false) val showMoreSettings = _showMoreSettings.asStateFlow() fun setShowMoreSettings(showMoreSettings: Boolean) { _showMoreSettings.value = showMoreSettings } val credentials: MutableStateFlow = editedDownloadItem.mapTwoWayStateFlow( map = { mapper.itemToCredentials(it) }, unMap = { mapper.appliedCredentialsToItem(this, it) } ) val name = editedDownloadItem.mapTwoWayStateFlow( map = { it.name }, unMap = { mapper.itemWithEditedName(this, it) } ) abstract val downloadJobConfig: StateFlow abstract val configurableList: List> abstract fun applyEditedItemTo(item: TDownloadItem) fun setName(name: String) { this.name.value = name } val link = credentials.mapTwoWayStateFlow( map = { it.link }, unMap = { mapper.credentialsWithEditedLink(this, it) } ) fun setLink(link: String) { credentials.update { mapper.credentialsWithEditedLink(it, link) } } fun importCredentials(importedCredentials: TCredentials) { this.credentials.update { importedCredentials } } protected val linkChecker = linkCheckerFactory.createLinkChecker(credentials.value) private val httpEditDownloadChecker = editDownloadCheckerFactory.createEditDownloadChecker( currentDownloadItem = currentDownloadItem, editedDownloadItem = editedDownloadItem, linkChecker = linkChecker, conflictDetector = conflictDetector, scope = scope, ) val isLinkLoading = linkChecker.isLoading val gettingResponseInfo = linkChecker.isLoading val responseInfo = linkChecker.responseInfo val canEditDownloadResult = httpEditDownloadChecker.canEditResult val canEdit = httpEditDownloadChecker.canEdit private val refreshResponseInfoImmediately = MutableSharedFlow( replay = 1, onBufferOverflow = BufferOverflow.DROP_LATEST ) private val scheduleRefreshResponseInfo = MutableSharedFlow( replay = 1, onBufferOverflow = BufferOverflow.DROP_LATEST ) private val scheduleRecheckEditDownloadIsPossible = MutableSharedFlow( replay = 1, onBufferOverflow = BufferOverflow.DROP_LATEST ) fun refresh() { refreshResponseInfoImmediately.tryEmit(Unit) } protected fun scheduleRefresh( alsoRecheckLink: Boolean, ) { if (alsoRecheckLink) { scheduleRefreshResponseInfo.tryEmit(Unit) } scheduleRecheckEditDownloadIsPossible.tryEmit(Unit) } init { merge( scheduleRefreshResponseInfo.debounce(500), refreshResponseInfoImmediately ).onEachLatest { linkChecker.check() }.launchIn(scope) merge( scheduleRecheckEditDownloadIsPossible.debounce(500), // ... ).onEachLatest { httpEditDownloadChecker.check() }.launchIn(scope) credentials.onEach { credentials -> linkChecker.credentials.update { credentials } scheduleRefresh(alsoRecheckLink = true) }.launchIn(scope) editedDownloadItem.onEach { scheduleRefresh(alsoRecheckLink = false) }.launchIn(scope) } abstract fun downloadSizeToStringSource(downloadSize: TDownloadSize): StringSource val lengthStringFlow: StateFlow = linkChecker.downloadSize.mapStateFlow { it ?.let(::downloadSizeToStringSource) ?: Res.string.unknown.asStringSource() } fun getLengthString(): StringSource { return lengthStringFlow.value } } ================================================ FILE: shared/app/src/commonMain/kotlin/com/abdownloadmanager/shared/downloaderinui/edit/EditDownloadInputsFactory.kt ================================================ package com.abdownloadmanager.shared.downloaderinui.edit import com.abdownloadmanager.shared.downloaderinui.CredentialAndItemMapper import com.abdownloadmanager.shared.downloaderinui.DownloadSize import com.abdownloadmanager.shared.downloaderinui.LinkChecker import ir.amirab.downloader.connection.IResponseInfo import ir.amirab.downloader.downloaditem.IDownloadCredentials import ir.amirab.downloader.downloaditem.IDownloadItem import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.flow.MutableStateFlow interface EditDownloadInputsFactory< TDownloadItem : IDownloadItem, TCredentials : IDownloadCredentials, TResponseInfo : IResponseInfo, TDownloadSize : DownloadSize, TLinkChecker : LinkChecker, TCredentialsToItemMapper : CredentialAndItemMapper, TEditDownloadInputs : EditDownloadInputs > { fun createEditDownloadInputs( currentDownloadItem: MutableStateFlow, editedDownloadItem: MutableStateFlow, conflictDetector: DownloadConflictDetector, scope: CoroutineScope, ): TEditDownloadInputs } ================================================ FILE: shared/app/src/commonMain/kotlin/com/abdownloadmanager/shared/downloaderinui/hls/HLSDownloaderInUi.kt ================================================ package com.abdownloadmanager.shared.downloaderinui.hls import com.abdownloadmanager.shared.downloaderinui.BasicDownloadItem import com.abdownloadmanager.shared.downloaderinui.DownloaderInUi import com.abdownloadmanager.shared.downloaderinui.DownloadSize import com.abdownloadmanager.shared.downloaderinui.edit.DownloadConflictDetector import com.abdownloadmanager.shared.downloaderinui.hls.add.HLSDownloadUIChecker import com.abdownloadmanager.shared.downloaderinui.hls.add.HLSNewDownloadInputs import com.abdownloadmanager.shared.downloaderinui.hls.edit.HLSEditDownloadChecker import com.abdownloadmanager.shared.downloaderinui.hls.edit.HLSEditDownloadInputs import com.abdownloadmanager.shared.downloaderinui.http.edit.EditDownloadChecker import com.abdownloadmanager.shared.util.SizeAndSpeedUnitProvider import com.abdownloadmanager.shared.util.DownloadSystem import ir.amirab.downloader.downloaditem.IDownloadCredentials import ir.amirab.downloader.downloaditem.hls.HLSDownloadCredentials import ir.amirab.downloader.downloaditem.hls.HLSDownloadItem import ir.amirab.downloader.downloaditem.hls.HLSDownloadJob import ir.amirab.downloader.downloaditem.hls.HLSDownloader import ir.amirab.downloader.downloaditem.hls.HLSResponseInfo import ir.amirab.downloader.downloaditem.hls.IHLSCredentials import ir.amirab.downloader.monitor.ProcessingDownloadItemFactoryInputs import ir.amirab.downloader.monitor.ProcessingDownloadItemState import ir.amirab.util.HttpUrlUtils import ir.amirab.util.compose.StringSource import ir.amirab.util.compose.asStringSource import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.flow.MutableStateFlow class HLSDownloaderInUi( downloader: HLSDownloader, private val sizeAndSpeedUnitProvider: SizeAndSpeedUnitProvider, ) : DownloaderInUi< HLSDownloadCredentials, HLSResponseInfo, DownloadSize.Duration, HLSLinkChecker, HLSDownloadItem, HLSNewDownloadInputs, HLSEditDownloadInputs, HlsItemToCredentialMapper, HLSDownloadJob, HLSDownloader >(downloader) { override fun newDownloadUiChecker( initialCredentials: HLSDownloadCredentials, initialFolder: String, initialName: String, downloadSystem: DownloadSystem, scope: CoroutineScope ): HLSDownloadUIChecker { return HLSDownloadUIChecker( initCredentials = initialCredentials, linkCheckerFactory = this, initialFolder = initialFolder, initialName = initialName, downloadSystem = downloadSystem, scope = scope, ) } override fun acceptDownloadCredentials(item: IDownloadCredentials): Boolean { return item is IHLSCredentials } override fun supportsThisLink(link: String): Boolean { return HttpUrlUtils.isValidUrl(link) } override fun createMinimumCredentials(link: String): HLSDownloadCredentials { return HLSDownloadCredentials(link = link) } override fun createBareDownloadItem( credentials: HLSDownloadCredentials, basicDownloadItem: BasicDownloadItem ): HLSDownloadItem { return HLSDownloadItem.createWithCredentials( id = -1, credentials = credentials, folder = basicDownloadItem.folder, name = basicDownloadItem.name, contentLength = basicDownloadItem.contentLength, preferredConnectionCount = basicDownloadItem.preferredConnectionCount, speedLimit = basicDownloadItem.speedLimit, fileChecksum = basicDownloadItem.fileChecksum, ) } override fun createProcessingDownloadItemState( props: ProcessingDownloadItemFactoryInputs ): ProcessingDownloadItemState { return UiProcessingItemForHSLFactory.create( props, ) } override val name: StringSource = "HLS".asStringSource() override fun createLinkChecker(initialCredentials: HLSDownloadCredentials): HLSLinkChecker { return HLSLinkChecker( credentials = initialCredentials, client = downloader.client ) } override fun createEditDownloadChecker( currentDownloadItem: MutableStateFlow, editedDownloadItem: MutableStateFlow, linkChecker: HLSLinkChecker, conflictDetector: DownloadConflictDetector, scope: CoroutineScope ): EditDownloadChecker { return HLSEditDownloadChecker( currentDownloadItem = currentDownloadItem, editedDownloadItem = editedDownloadItem, linkChecker = linkChecker, conflictDetector = conflictDetector, scope = scope, ) } override fun createNewDownloadInputs( initialCredentials: HLSDownloadCredentials, initialFolder: String, initialName: String, downloadSystem: DownloadSystem, scope: CoroutineScope ): HLSNewDownloadInputs { return HLSNewDownloadInputs( newDownloadUiChecker( initialCredentials = initialCredentials, initialFolder = initialFolder, initialName = initialName, downloadSystem = downloadSystem, scope = scope, ), sizeAndSpeedUnitProvider, scope, ) } override fun createEditDownloadInputs( currentDownloadItem: MutableStateFlow, editedDownloadItem: MutableStateFlow, conflictDetector: DownloadConflictDetector, scope: CoroutineScope ): HLSEditDownloadInputs { return HLSEditDownloadInputs( currentDownloadItem = currentDownloadItem, editedDownloadItem = editedDownloadItem, mapper = HlsItemToCredentialMapper(), conflictDetector = conflictDetector, scope = scope, linkCheckerFactory = this, editDownloadCheckerFactory = this, sizeAndSpeedUnitProvider = sizeAndSpeedUnitProvider, ) } } ================================================ FILE: shared/app/src/commonMain/kotlin/com/abdownloadmanager/shared/downloaderinui/hls/HLSLinkChecker.kt ================================================ package com.abdownloadmanager.shared.downloaderinui.hls import com.abdownloadmanager.shared.downloaderinui.DownloadSize import com.abdownloadmanager.shared.downloaderinui.LinkChecker import ir.amirab.downloader.connection.HttpDownloaderClient import ir.amirab.downloader.downloaditem.hls.HLSDownloadCredentials import ir.amirab.downloader.downloaditem.hls.HLSResponseInfo import ir.amirab.util.flow.mapStateFlow import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow class HLSLinkChecker( credentials: HLSDownloadCredentials, private val client: HttpDownloaderClient, ) : LinkChecker( initialCredentials = credentials ) { private val _suggestedName: MutableStateFlow = MutableStateFlow(null) override val suggestedName: StateFlow = MutableStateFlow(null) private val _duration: MutableStateFlow = MutableStateFlow(null) val duration: StateFlow = _duration.asStateFlow() override val downloadSize: StateFlow = _duration.mapStateFlow { it?.let(DownloadSize::Duration) } override fun infoUpdated(responseInfo: HLSResponseInfo?) { _suggestedName.value = responseInfo?.name _duration.value = responseInfo?.duration } override suspend fun actualCheck(credentials: HLSDownloadCredentials): HLSResponseInfo { return client.connect(credentials, null, null) .use { HLSResponseInfo.fromConnection(it) } } } ================================================ FILE: shared/app/src/commonMain/kotlin/com/abdownloadmanager/shared/downloaderinui/hls/HlsItemToCredentialMapper.kt ================================================ package com.abdownloadmanager.shared.downloaderinui.hls import com.abdownloadmanager.shared.downloaderinui.CredentialAndItemMapper import ir.amirab.downloader.downloaditem.hls.HLSDownloadCredentials import ir.amirab.downloader.downloaditem.hls.HLSDownloadItem class HlsItemToCredentialMapper : CredentialAndItemMapper< HLSDownloadCredentials, HLSDownloadItem> { override fun itemToCredentials(item: HLSDownloadItem): HLSDownloadCredentials { return HLSDownloadCredentials( link = item.link, downloadPage = item.downloadPage, headers = item.headers, username = item.username, password = item.password, userAgent = item.userAgent, ) } override fun appliedCredentialsToItem( item: HLSDownloadItem, credentials: HLSDownloadCredentials ): HLSDownloadItem { return item.copy().withCredentials(credentials) } override fun itemWithEditedName( item: HLSDownloadItem, name: String ): HLSDownloadItem { return item.copy(name = name) } override fun credentialsWithEditedLink( credentials: HLSDownloadCredentials, link: String ): HLSDownloadCredentials { return credentials.copy(link = link) } } ================================================ FILE: shared/app/src/commonMain/kotlin/com/abdownloadmanager/shared/downloaderinui/hls/UiProcessingItemForHSLFactory.kt ================================================ package com.abdownloadmanager.shared.downloaderinui.hls import ir.amirab.downloader.downloaditem.hls.HLSDownloadJob import ir.amirab.downloader.monitor.DurationBasedProcessingDownloadItemState import ir.amirab.downloader.monitor.ProcessingDownloadItemFactoryInputs import ir.amirab.downloader.monitor.UiDurationBasedPart import ir.amirab.downloader.part.PartDownloadStatus object UiProcessingItemForHSLFactory { fun create( props: ProcessingDownloadItemFactoryInputs ): DurationBasedProcessingDownloadItemState { val downloadJob = props.downloadJob val item = downloadJob.downloadItem val downloadParts = downloadJob.getParts() val totalPartsCount = downloadParts.size val uiParts = downloadParts.map { UiDurationBasedPart.fromPart( part = it, totalPartsCount = totalPartsCount, ) } var finishedPartsCount = 0 var downloadedBytes = 0L // only those who has length var activeCount = 0 var activeCountTotalLength = 0L var activeCountProgress = 0L // ---------- for (part in uiParts) { val status = part.status val howMuchProceed = part.howMuchProceed if (status is PartDownloadStatus.Completed) { finishedPartsCount++ } else if (status is PartDownloadStatus.IsActive) { val length = part.length if (length != null) { activeCount++ activeCountProgress += howMuchProceed activeCountTotalLength += length } } downloadedBytes += howMuchProceed } val percentFraction = getPercentFraction( finishedPartsCount = finishedPartsCount, totalPartsCount = totalPartsCount, activePartsCount = activeCount, activeCountsTotalDownloaded = activeCountProgress, activeCountsTotalLength = activeCountTotalLength, ) val length = (downloadedBytes / percentFraction).toLong() return DurationBasedProcessingDownloadItemState( id = downloadJob.id, name = item.name, folder = item.folder, status = downloadJob.status.value, speed = props.speed, supportResume = true, contentLength = item.contentLength, parts = uiParts, downloadLink = item.link, saveLocation = item.name, dateAdded = item.dateAdded, startTime = item.startTime ?: 0, completeTime = item.completeTime ?: 0, duration = item.duration, progress = downloadedBytes, percent = (percentFraction * 100).toInt(), optimisticLength = length, isWaiting = props.isWaiting, ) } private fun getPercentFraction( finishedPartsCount: Int, totalPartsCount: Int, activePartsCount: Int, activeCountsTotalDownloaded: Long, activeCountsTotalLength: Long, ): Double { // how much all of active parts have progress (0.0-1.0) val activePartCountProgress = if (activeCountsTotalLength == 0L) { 0.0 } else { (activeCountsTotalDownloaded / activeCountsTotalLength.toDouble()) * activePartsCount } return (finishedPartsCount + activePartCountProgress) / totalPartsCount } } ================================================ FILE: shared/app/src/commonMain/kotlin/com/abdownloadmanager/shared/downloaderinui/hls/add/HLSDownloadUIChecker.kt ================================================ package com.abdownloadmanager.shared.downloaderinui.hls.add import com.abdownloadmanager.shared.downloaderinui.DownloadSize import com.abdownloadmanager.shared.downloaderinui.DownloadUiChecker import com.abdownloadmanager.shared.downloaderinui.LinkCheckerFactory import ir.amirab.downloader.downloaditem.hls.HLSDownloadCredentials import com.abdownloadmanager.shared.downloaderinui.hls.HLSLinkChecker import ir.amirab.downloader.downloaditem.hls.HLSResponseInfo import com.abdownloadmanager.shared.util.DownloadSystem import kotlinx.coroutines.CoroutineScope class HLSDownloadUIChecker( initCredentials: HLSDownloadCredentials, linkCheckerFactory: LinkCheckerFactory, initialFolder: String, initialName: String, downloadSystem: DownloadSystem, scope: CoroutineScope, ) : DownloadUiChecker( initialCredentials = initCredentials, linkCheckerFactory = linkCheckerFactory, initialFolder = initialFolder, initialName = initialName, downloadSystem = downloadSystem, scope = scope, ) { } ================================================ FILE: shared/app/src/commonMain/kotlin/com/abdownloadmanager/shared/downloaderinui/hls/add/HLSNewDownloadInputs.kt ================================================ package com.abdownloadmanager.shared.downloaderinui.hls.add import com.abdownloadmanager.resources.Res import com.abdownloadmanager.shared.downloaderinui.DownloadSize import com.abdownloadmanager.shared.downloaderinui.add.NewDownloadInputs import ir.amirab.downloader.downloaditem.hls.HLSDownloadCredentials import com.abdownloadmanager.shared.downloaderinui.hls.HLSLinkChecker import ir.amirab.downloader.downloaditem.hls.HLSResponseInfo import com.abdownloadmanager.shared.downloaderinui.http.applyToHttpDownload import com.abdownloadmanager.shared.ui.configurable.item.FileChecksumConfigurable import com.abdownloadmanager.shared.ui.configurable.item.IntConfigurable import com.abdownloadmanager.shared.ui.configurable.item.SpeedLimitConfigurable import com.abdownloadmanager.shared.ui.configurable.item.StringConfigurable import com.abdownloadmanager.shared.util.SizeAndSpeedUnitProvider import com.abdownloadmanager.shared.util.ThreadCountLimitation import com.abdownloadmanager.shared.util.FileChecksum import com.abdownloadmanager.shared.util.convertPositiveSpeedToHumanReadable import com.abdownloadmanager.shared.util.perhostsettings.PerHostSettingsItem import ir.amirab.downloader.downloaditem.DownloadJobExtraConfig import ir.amirab.downloader.downloaditem.DownloadStatus import ir.amirab.downloader.downloaditem.hls.HLSDownloadItem import ir.amirab.downloader.downloaditem.hls.HLSDownloadJobExtraConfig import ir.amirab.util.compose.StringSource import ir.amirab.util.compose.asStringSource import ir.amirab.util.compose.asStringSourceWithARgs import ir.amirab.util.flow.combineStateFlows import ir.amirab.util.flow.createMutableStateFlowFromStateFlow import ir.amirab.util.flow.mapStateFlow import ir.amirab.util.flow.mapTwoWayStateFlow import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow class HLSNewDownloadInputs( downloadUiChecker: HLSDownloadUIChecker, private val sizeAndSpeedUnitProvider: SizeAndSpeedUnitProvider, private val scope: CoroutineScope, ) : NewDownloadInputs< HLSDownloadItem, HLSDownloadCredentials, HLSResponseInfo, DownloadSize.Duration, HLSLinkChecker, >( downloadUiChecker = downloadUiChecker ) { //extra settings private var threadCount = MutableStateFlow(null as Int?) private var speedLimit = MutableStateFlow(0L) private var fileChecksum = MutableStateFlow(null as FileChecksum?) override val downloadItem: StateFlow = combineStateFlows( this.credentials, this.folder, this.name, this.downloadSize, this.speedLimit, this.threadCount, this.fileChecksum, ) { credentials, folder, name, duration, speedLimit, threadCount, fileChecksum -> HLSDownloadItem( id = -1, folder = folder, name = name, link = credentials.link, dateAdded = openedTime, startTime = null, completeTime = null, status = DownloadStatus.Added, preferredConnectionCount = threadCount, speedLimit = speedLimit, fileChecksum = fileChecksum?.toString(), duration = duration?.duration, ).withCredentials(credentials) } override val downloadJobConfig: StateFlow = downloadUiChecker.responseInfo.mapStateFlow { it?.let { HLSDownloadJobExtraConfig( hlsManifest = it.hlsManifest ) } } override fun applyHostSettingsToExtraConfig(extraConfig: PerHostSettingsItem) { extraConfig.applyToHttpDownload( setUsername = { setCredentials(credentials.value.copy(username = it)) }, setPassword = { setCredentials(credentials.value.copy(password = it)) }, setUserAgent = { setCredentials(credentials.value.copy(userAgent = it)) }, setThreadCount = { threadCount.value = it }, setSpeedLimit = { speedLimit.value = it } ) } override val configurableList = listOf( SpeedLimitConfigurable( Res.string.download_item_settings_speed_limit.asStringSource(), Res.string.download_item_settings_speed_limit_description.asStringSource(), backedBy = speedLimit, describe = { if (it == 0L) Res.string.unlimited.asStringSource() else convertPositiveSpeedToHumanReadable( it, sizeAndSpeedUnitProvider.speedUnit.value ).asStringSource() } ), FileChecksumConfigurable( Res.string.download_item_settings_file_checksum.asStringSource(), Res.string.download_item_settings_file_checksum_description.asStringSource(), backedBy = fileChecksum, describe = { "".asStringSource() } ), IntConfigurable( Res.string.settings_download_thread_count.asStringSource(), Res.string.settings_download_thread_count_description.asStringSource(), backedBy = threadCount.mapTwoWayStateFlow( map = { it ?: 0 }, unMap = { it.takeIf { it >= 1 } } ), range = 0..ThreadCountLimitation.MAX_ALLOWED_THREAD_COUNT, describe = { if (it == 0) Res.string.use_global_settings.asStringSource() else Res.string.download_item_settings_thread_count_describe .asStringSourceWithARgs( Res.string.download_item_settings_thread_count_describe_createArgs( count = it.toString() ) ) } ), StringConfigurable( Res.string.username.asStringSource(), Res.string.download_item_settings_username_description.asStringSource(), backedBy = createMutableStateFlowFromStateFlow( flow = credentials.mapStateFlow { it.username.orEmpty() }, updater = { setCredentials(credentials.value.copy(username = it.takeIf { it.isNotBlank() })) }, scope ), describe = { "".asStringSource() } ), StringConfigurable( Res.string.password.asStringSource(), Res.string.download_item_settings_password_description.asStringSource(), backedBy = createMutableStateFlowFromStateFlow( flow = credentials.mapStateFlow { it.password.orEmpty() }, updater = { setCredentials(credentials.value.copy(password = it.takeIf { it.isNotBlank() })) }, scope ), describe = { "".asStringSource() } ), StringConfigurable( Res.string.download_item_settings_user_agent.asStringSource(), Res.string.download_item_settings_user_agent_description.asStringSource(), backedBy = credentials.mapTwoWayStateFlow( map = { it.userAgent.orEmpty() }, unMap = { copy(userAgent = it.takeIf { it.isNotEmpty() }) } ), describe = { "".asStringSource() } ), StringConfigurable( Res.string.download_item_settings_download_page.asStringSource(), Res.string.download_item_settings_download_page_description.asStringSource(), backedBy = credentials.mapTwoWayStateFlow( map = { it.downloadPage.orEmpty() }, unMap = { copy(downloadPage = it.takeIf { it.isNotEmpty() }) } ), describe = { "".asStringSource() } ) ) override fun downloadSizeToStringSource(downloadSize: DownloadSize.Duration): StringSource { return downloadSize.asStringSource() } } ================================================ FILE: shared/app/src/commonMain/kotlin/com/abdownloadmanager/shared/downloaderinui/hls/edit/HLSEditDownloadChecker.kt ================================================ package com.abdownloadmanager.shared.downloaderinui.hls.edit import com.abdownloadmanager.shared.downloaderinui.DownloadSize import com.abdownloadmanager.shared.downloaderinui.edit.CanEditDownloadResult import com.abdownloadmanager.shared.downloaderinui.edit.CanEditWarnings import com.abdownloadmanager.shared.downloaderinui.edit.DownloadConflictDetector import ir.amirab.downloader.downloaditem.hls.HLSDownloadCredentials import com.abdownloadmanager.shared.downloaderinui.hls.HLSLinkChecker import ir.amirab.downloader.downloaditem.hls.HLSResponseInfo import com.abdownloadmanager.shared.downloaderinui.http.edit.EditDownloadChecker import ir.amirab.downloader.downloaditem.hls.HLSDownloadItem import ir.amirab.util.FileNameValidator import ir.amirab.util.HttpUrlUtils import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.launchIn import kotlinx.coroutines.flow.onEach class HLSEditDownloadChecker( currentDownloadItem: MutableStateFlow, editedDownloadItem: MutableStateFlow, linkChecker: HLSLinkChecker, conflictDetector: DownloadConflictDetector, scope: CoroutineScope ) : EditDownloadChecker( currentDownloadItem = currentDownloadItem, editedDownloadItem = editedDownloadItem, linkChecker = linkChecker, conflictDetector = conflictDetector, scope = scope, ) { init { editedDownloadItem .onEach { _canEditResult.value = CanEditDownloadResult.Waiting }.launchIn(scope) } override fun check() { _canEditResult.value = CanEditDownloadResult.Waiting _canEditResult.value = check( current = currentDownloadItem.value, edited = editedDownloadItem.value, newDuration = linkChecker.duration.value, ) } private fun check( current: HLSDownloadItem, edited: HLSDownloadItem, newDuration: Double?, ): CanEditDownloadResult { if (current == edited) { return CanEditDownloadResult.NothingChanged } if (!HttpUrlUtils.isValidUrl(edited.link)) { return CanEditDownloadResult.InvalidURL } if (edited.name != current.name) { if (!FileNameValidator.isValidFileName(edited.name)) { return CanEditDownloadResult.InvalidFileName } if (conflictDetector.checkAlreadyExists(current, edited)) { return CanEditDownloadResult.FileNameAlreadyExists } } val warnings = mutableListOf() if (current.duration != newDuration) { warnings.add( CanEditWarnings.DurationNotMatch( current.duration, newDuration, ) ) } return CanEditDownloadResult.CanEdit(warnings) } } ================================================ FILE: shared/app/src/commonMain/kotlin/com/abdownloadmanager/shared/downloaderinui/hls/edit/HLSEditDownloadInputs.kt ================================================ package com.abdownloadmanager.shared.downloaderinui.hls.edit import com.abdownloadmanager.resources.Res import com.abdownloadmanager.shared.downloaderinui.DownloadSize import com.abdownloadmanager.shared.downloaderinui.LinkCheckerFactory import com.abdownloadmanager.shared.downloaderinui.edit.DownloadConflictDetector import com.abdownloadmanager.shared.downloaderinui.edit.EditDownloadCheckerFactory import com.abdownloadmanager.shared.downloaderinui.edit.EditDownloadInputs import ir.amirab.downloader.downloaditem.hls.HLSDownloadCredentials import com.abdownloadmanager.shared.downloaderinui.hls.HLSLinkChecker import ir.amirab.downloader.downloaditem.hls.HLSResponseInfo import com.abdownloadmanager.shared.downloaderinui.hls.HlsItemToCredentialMapper import com.abdownloadmanager.shared.ui.configurable.item.FileChecksumConfigurable import com.abdownloadmanager.shared.ui.configurable.item.IntConfigurable import com.abdownloadmanager.shared.ui.configurable.item.SpeedLimitConfigurable import com.abdownloadmanager.shared.ui.configurable.item.StringConfigurable import com.abdownloadmanager.shared.util.SizeAndSpeedUnitProvider import com.abdownloadmanager.shared.util.ThreadCountLimitation import com.abdownloadmanager.shared.util.FileChecksum import com.abdownloadmanager.shared.util.convertPositiveSpeedToHumanReadable import ir.amirab.downloader.downloaditem.DownloadJobExtraConfig import ir.amirab.downloader.downloaditem.hls.HLSDownloadItem import ir.amirab.downloader.downloaditem.hls.HLSDownloadJobExtraConfig import ir.amirab.util.compose.StringSource import ir.amirab.util.compose.asStringSource import ir.amirab.util.compose.asStringSourceWithARgs import ir.amirab.util.flow.mapStateFlow import ir.amirab.util.flow.mapTwoWayStateFlow import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.launchIn import kotlinx.coroutines.flow.onEach class HLSEditDownloadInputs( currentDownloadItem: MutableStateFlow, editedDownloadItem: MutableStateFlow, mapper: HlsItemToCredentialMapper, conflictDetector: DownloadConflictDetector, linkCheckerFactory: LinkCheckerFactory, editDownloadCheckerFactory: EditDownloadCheckerFactory, scope: CoroutineScope, private val sizeAndSpeedUnitProvider: SizeAndSpeedUnitProvider, ) : EditDownloadInputs< HLSDownloadItem, HLSDownloadCredentials, HLSResponseInfo, DownloadSize.Duration, HLSLinkChecker, HlsItemToCredentialMapper >( currentDownloadItem, editedDownloadItem, mapper = mapper, scope = scope, conflictDetector = conflictDetector, linkCheckerFactory = linkCheckerFactory, editDownloadCheckerFactory = editDownloadCheckerFactory, ) { override val configurableList = listOf( SpeedLimitConfigurable( Res.string.download_item_settings_speed_limit.asStringSource(), Res.string.download_item_settings_speed_limit_description.asStringSource(), backedBy = editedDownloadItem.mapTwoWayStateFlow( map = { it.speedLimit }, unMap = { copy(speedLimit = it) } ), describe = { if (it == 0L) Res.string.unlimited.asStringSource() else convertPositiveSpeedToHumanReadable(it, sizeAndSpeedUnitProvider.speedUnit.value).asStringSource() } ), FileChecksumConfigurable( Res.string.download_item_settings_file_checksum.asStringSource(), Res.string.download_item_settings_file_checksum_description.asStringSource(), backedBy = editedDownloadItem.mapTwoWayStateFlow( map = { it.fileChecksum?.let { runCatching { FileChecksum.Companion.fromString(it) }.onFailure { println(it.printStackTrace()) }.getOrNull() } }, unMap = { copy(fileChecksum = it?.toString()) } ), describe = { "".asStringSource() } ), IntConfigurable( Res.string.settings_download_thread_count.asStringSource(), Res.string.settings_download_thread_count_description.asStringSource(), backedBy = editedDownloadItem.mapTwoWayStateFlow( map = { it.preferredConnectionCount ?: 0 }, unMap = { copy( preferredConnectionCount = it.takeIf { it >= 1 } ) } ), range = 0..ThreadCountLimitation.MAX_ALLOWED_THREAD_COUNT, describe = { if (it == 0) Res.string.use_global_settings.asStringSource() else Res.string.download_item_settings_thread_count_describe .asStringSourceWithARgs( Res.string.download_item_settings_thread_count_describe_createArgs( count = it.toString() ) ) } ), StringConfigurable( Res.string.username.asStringSource(), Res.string.download_item_settings_username_description.asStringSource(), backedBy = credentials.mapTwoWayStateFlow( map = { it.username.orEmpty() }, unMap = { copy(username = it.takeIf { it.isNotEmpty() }) } ), describe = { "".asStringSource() } ), StringConfigurable( Res.string.password.asStringSource(), Res.string.download_item_settings_password_description.asStringSource(), backedBy = credentials.mapTwoWayStateFlow( map = { it.password.orEmpty() }, unMap = { copy(password = it.takeIf { it.isNotEmpty() }) } ), describe = { "".asStringSource() } ), StringConfigurable( Res.string.download_item_settings_user_agent.asStringSource(), Res.string.download_item_settings_user_agent_description.asStringSource(), backedBy = credentials.mapTwoWayStateFlow( map = { it.userAgent.orEmpty() }, unMap = { copy(userAgent = it.takeIf { it.isNotEmpty() }) } ), describe = { "".asStringSource() } ), StringConfigurable( Res.string.download_item_settings_download_page.asStringSource(), Res.string.download_item_settings_download_page_description.asStringSource(), backedBy = credentials.mapTwoWayStateFlow( map = { it.downloadPage.orEmpty() }, unMap = { copy(downloadPage = it.takeIf { it.isNotEmpty() }) } ), describe = { "".asStringSource() } ), ) val duration = linkChecker.duration override val downloadJobConfig: StateFlow = linkChecker.responseInfo.mapStateFlow { it?.let { HLSDownloadJobExtraConfig(it.hlsManifest) } } private fun HLSDownloadItem.applyOurChanges(edited: HLSDownloadItem) { // we don't change some of these properties, so I commented them link = edited.link headers = edited.headers username = edited.username password = edited.password downloadPage = edited.downloadPage userAgent = edited.userAgent // id = edited.id folder = edited.folder name = edited.name contentLength = edited.contentLength // dateAdded = edited.dateAdded // startTime = edited.startTime // completeTime = edited.completeTime // status = edited.status preferredConnectionCount = edited.preferredConnectionCount speedLimit = edited.speedLimit fileChecksum = edited.fileChecksum duration = edited.duration } override fun applyEditedItemTo(item: HLSDownloadItem) { val edited = editedDownloadItem.value item.applyOurChanges(edited) } init { duration.onEach { scheduleRefresh(alsoRecheckLink = false) }.launchIn(scope) } override fun downloadSizeToStringSource(downloadSize: DownloadSize.Duration): StringSource { return downloadSize.asStringSource() } } ================================================ FILE: shared/app/src/commonMain/kotlin/com/abdownloadmanager/shared/downloaderinui/http/HttpCredentialsToItemMapper.kt ================================================ package com.abdownloadmanager.shared.downloaderinui.http import com.abdownloadmanager.shared.downloaderinui.CredentialAndItemMapper import ir.amirab.downloader.downloaditem.http.HttpDownloadCredentials import ir.amirab.downloader.downloaditem.http.HttpDownloadItem import ir.amirab.downloader.downloaditem.http.withHttpCredentials object HttpCredentialsToItemMapper : CredentialAndItemMapper { override fun itemToCredentials(item: HttpDownloadItem): HttpDownloadCredentials { return HttpDownloadCredentials.from(item) } override fun appliedCredentialsToItem( item: HttpDownloadItem, credentials: HttpDownloadCredentials ): HttpDownloadItem { return item.copy().withHttpCredentials(credentials) } override fun itemWithEditedName(item: HttpDownloadItem, name: String): HttpDownloadItem { return item.copy(name = name) } override fun credentialsWithEditedLink( credentials: HttpDownloadCredentials, link: String ): HttpDownloadCredentials { return credentials.copy(link = link) } } ================================================ FILE: shared/app/src/commonMain/kotlin/com/abdownloadmanager/shared/downloaderinui/http/HttpDownloaderInUi.kt ================================================ package com.abdownloadmanager.shared.downloaderinui.http import com.abdownloadmanager.shared.downloaderinui.BasicDownloadItem import com.abdownloadmanager.shared.downloaderinui.DownloaderInUi import com.abdownloadmanager.shared.downloaderinui.DownloadSize import com.abdownloadmanager.shared.downloaderinui.edit.DownloadConflictDetector import com.abdownloadmanager.shared.downloaderinui.http.add.HttpDownloadUiChecker import com.abdownloadmanager.shared.downloaderinui.http.add.HttpLinkChecker import com.abdownloadmanager.shared.downloaderinui.http.add.HttpNewDownloadInputs import com.abdownloadmanager.shared.downloaderinui.http.edit.HttpEditDownloadChecker import com.abdownloadmanager.shared.downloaderinui.http.edit.HttpEditDownloadInputs import com.abdownloadmanager.shared.util.SizeAndSpeedUnitProvider import com.abdownloadmanager.shared.util.DownloadSystem import ir.amirab.downloader.connection.response.HttpResponseInfo import ir.amirab.downloader.downloaditem.DownloadJob import ir.amirab.downloader.downloaditem.IDownloadCredentials import ir.amirab.downloader.downloaditem.http.HttpDownloadCredentials import ir.amirab.downloader.downloaditem.http.HttpDownloadItem import ir.amirab.downloader.downloaditem.http.HttpDownloadJob import ir.amirab.downloader.downloaditem.http.HttpDownloader import ir.amirab.downloader.downloaditem.http.IHttpDownloadCredentials import ir.amirab.downloader.monitor.CompletedDownloadItemState import ir.amirab.downloader.monitor.ProcessingDownloadItemFactoryInputs import ir.amirab.downloader.monitor.ProcessingDownloadItemState import ir.amirab.downloader.monitor.RangeBasedProcessingDownloadItemState import ir.amirab.downloader.monitor.UiRangedPart import ir.amirab.util.HttpUrlUtils import ir.amirab.util.compose.StringSource import ir.amirab.util.compose.asStringSource import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.flow.MutableStateFlow class HttpDownloaderInUi( httpDownloader: HttpDownloader, private val sizeAndSpeedUnitProvider: SizeAndSpeedUnitProvider, ) : DownloaderInUi( downloader = httpDownloader ) { override fun createLinkChecker(initialCredentials: HttpDownloadCredentials): HttpLinkChecker { return HttpLinkChecker( initialCredentials, downloader.httpDownloaderClient, ) } override fun newDownloadUiChecker( initialCredentials: HttpDownloadCredentials, initialFolder: String, initialName: String, downloadSystem: DownloadSystem, scope: CoroutineScope, ): HttpDownloadUiChecker { return HttpDownloadUiChecker( initialCredentials = initialCredentials, linkCheckerFactory = this, initialFolder = initialFolder, initialName = initialName, downloadSystem = downloadSystem, scope = scope, ) } override fun createNewDownloadInputs( initialCredentials: HttpDownloadCredentials, initialFolder: String, initialName: String, downloadSystem: DownloadSystem, scope: CoroutineScope ): HttpNewDownloadInputs { val downloadUiChecker = newDownloadUiChecker( initialCredentials, initialFolder, initialName, downloadSystem, scope, ) return HttpNewDownloadInputs( downloadUiChecker = downloadUiChecker, scope = scope, sizeAndSpeedUnitProvider = sizeAndSpeedUnitProvider ) } override fun createEditDownloadInputs( currentDownloadItem: MutableStateFlow, editedDownloadItem: MutableStateFlow, conflictDetector: DownloadConflictDetector, scope: CoroutineScope ): HttpEditDownloadInputs { return HttpEditDownloadInputs( currentDownloadItem = currentDownloadItem, editedDownloadItem = editedDownloadItem, sizeAndSpeedUnitProvider = sizeAndSpeedUnitProvider, mapper = HttpCredentialsToItemMapper, conflictDetector = conflictDetector, scope = scope, linkCheckerFactory = this, editDownloadCheckerFactory = this, ) } override fun acceptDownloadCredentials(item: IDownloadCredentials): Boolean { return item is IHttpDownloadCredentials } override fun supportsThisLink(link: String): Boolean { return HttpUrlUtils.isValidUrl(link) } override fun createMinimumCredentials(link: String): HttpDownloadCredentials { return HttpDownloadCredentials(link = link) } override fun createProcessingDownloadItemState( props: ProcessingDownloadItemFactoryInputs ): ProcessingDownloadItemState { val downloadJob = props.downloadJob val downloadItem = downloadJob.downloadItem val downloadJobStatus = downloadJob.status.value val parts = downloadJob.getParts() val contentLength = downloadItem.contentLength return RangeBasedProcessingDownloadItemState( id = downloadItem.id, folder = downloadItem.folder, name = downloadItem.name, contentLength = contentLength, dateAdded = downloadItem.dateAdded, startTime = downloadItem.startTime ?: -1, completeTime = downloadItem.completeTime ?: -1, status = downloadJobStatus, saveLocation = downloadItem.name, parts = parts.map { UiRangedPart.fromPart( part = it, totalLength = contentLength, ) }, speed = props.speed, supportResume = downloadJob.supportsConcurrent, downloadLink = downloadItem.link, isWaiting = props.isWaiting, ) } override fun createBareDownloadItem( credentials: HttpDownloadCredentials, basicDownloadItem: BasicDownloadItem ): HttpDownloadItem { return HttpDownloadItem.createWithCredentials( id = -1, credentials = credentials, folder = basicDownloadItem.folder, name = basicDownloadItem.name, contentLength = basicDownloadItem.contentLength, preferredConnectionCount = basicDownloadItem.preferredConnectionCount, speedLimit = basicDownloadItem.speedLimit, fileChecksum = basicDownloadItem.fileChecksum, ) } override val name: StringSource = "HTTP".asStringSource() override fun createEditDownloadChecker( currentDownloadItem: MutableStateFlow, editedDownloadItem: MutableStateFlow, linkChecker: HttpLinkChecker, conflictDetector: DownloadConflictDetector, scope: CoroutineScope ): HttpEditDownloadChecker { return HttpEditDownloadChecker( currentDownloadItem = currentDownloadItem, editedDownloadItem = editedDownloadItem, linkChecker = linkChecker, conflictDetector = conflictDetector, scope = scope, ) } } ================================================ FILE: shared/app/src/commonMain/kotlin/com/abdownloadmanager/shared/downloaderinui/http/add/HttpDownloadUiChecker.kt ================================================ package com.abdownloadmanager.shared.downloaderinui.http.add import com.abdownloadmanager.shared.downloaderinui.DownloadSize import com.abdownloadmanager.shared.util.DownloadSystem import com.abdownloadmanager.shared.downloaderinui.DownloadUiChecker import com.abdownloadmanager.shared.downloaderinui.LinkCheckerFactory import ir.amirab.downloader.connection.response.HttpResponseInfo import ir.amirab.downloader.downloaditem.http.HttpDownloadCredentials import kotlinx.coroutines.CoroutineScope class HttpDownloadUiChecker( initialCredentials: HttpDownloadCredentials = HttpDownloadCredentials.Companion.empty(), linkCheckerFactory: LinkCheckerFactory, initialFolder: String, initialName: String = "", downloadSystem: DownloadSystem, scope: CoroutineScope, ) : DownloadUiChecker( initialCredentials, linkCheckerFactory, initialFolder, initialName, downloadSystem, scope ) { } ================================================ FILE: shared/app/src/commonMain/kotlin/com/abdownloadmanager/shared/downloaderinui/http/add/HttpLinkChecker.kt ================================================ package com.abdownloadmanager.shared.downloaderinui.http.add import com.abdownloadmanager.shared.downloaderinui.DownloadSize import com.abdownloadmanager.shared.util.FilenameFixer import com.abdownloadmanager.shared.downloaderinui.LinkChecker import ir.amirab.downloader.connection.HttpDownloaderClient import ir.amirab.downloader.connection.response.HttpResponseInfo import ir.amirab.downloader.downloaditem.http.HttpDownloadCredentials import ir.amirab.util.flow.mapStateFlow import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.update class HttpLinkChecker( initialCredentials: HttpDownloadCredentials = HttpDownloadCredentials.Companion.empty(), private val client: HttpDownloaderClient, ) : LinkChecker(initialCredentials) { private val _suggestedName = MutableStateFlow(null as String?) override val suggestedName = _suggestedName.asStateFlow() private val _length = MutableStateFlow(null as Long?) override val downloadSize = _length.mapStateFlow { it?.let(DownloadSize::Bytes) } override fun infoUpdated(responseInfo: HttpResponseInfo?) { updateNameAndLength(responseInfo) } override suspend fun actualCheck(credentials: HttpDownloadCredentials): HttpResponseInfo { return client.test(credentials) } private fun updateNameAndLength(responseInfo: HttpResponseInfo?) { val suggestedName = responseInfo ?.fileName ?.let(FilenameFixer::fix) val length = responseInfo?.run { totalLength.takeIf { isSuccessFul } } _suggestedName.update { suggestedName } _length.update { length } } } ================================================ FILE: shared/app/src/commonMain/kotlin/com/abdownloadmanager/shared/downloaderinui/http/add/HttpNewDownloadInputs.kt ================================================ package com.abdownloadmanager.shared.downloaderinui.http.add import com.abdownloadmanager.resources.Res import com.abdownloadmanager.shared.downloaderinui.DownloadSize import com.abdownloadmanager.shared.downloaderinui.add.NewDownloadInputs import com.abdownloadmanager.shared.ui.configurable.item.FileChecksumConfigurable import com.abdownloadmanager.shared.ui.configurable.item.IntConfigurable import com.abdownloadmanager.shared.ui.configurable.item.SpeedLimitConfigurable import com.abdownloadmanager.shared.ui.configurable.item.StringConfigurable import com.abdownloadmanager.shared.util.SizeAndSpeedUnitProvider import com.abdownloadmanager.shared.util.ThreadCountLimitation import com.abdownloadmanager.shared.util.FileChecksum import com.abdownloadmanager.shared.util.convertPositiveSpeedToHumanReadable import com.abdownloadmanager.shared.util.perhostsettings.PerHostSettingsItem import com.abdownloadmanager.shared.downloaderinui.http.applyToHttpDownload import ir.amirab.downloader.connection.response.HttpResponseInfo import ir.amirab.downloader.downloaditem.DownloadJobExtraConfig import ir.amirab.downloader.downloaditem.DownloadStatus import ir.amirab.downloader.downloaditem.IDownloadItem import ir.amirab.downloader.downloaditem.http.HttpDownloadCredentials import ir.amirab.downloader.downloaditem.http.HttpDownloadItem import ir.amirab.util.compose.StringSource import ir.amirab.util.compose.asStringSource import ir.amirab.util.compose.asStringSourceWithARgs import ir.amirab.util.flow.combineStateFlows import ir.amirab.util.flow.createMutableStateFlowFromStateFlow import ir.amirab.util.flow.mapStateFlow import ir.amirab.util.flow.mapTwoWayStateFlow import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow class HttpNewDownloadInputs( downloadUiChecker: HttpDownloadUiChecker, scope: CoroutineScope, private val sizeAndSpeedUnitProvider: SizeAndSpeedUnitProvider ) : NewDownloadInputs< HttpDownloadItem, HttpDownloadCredentials, HttpResponseInfo, DownloadSize.Bytes, HttpLinkChecker, >( downloadUiChecker ) { //extra settings private var threadCount = MutableStateFlow(null as Int?) private var speedLimit = MutableStateFlow(0L) private var fileChecksum = MutableStateFlow(null as FileChecksum?) override val downloadItem: StateFlow = combineStateFlows( this.credentials, this.folder, this.name, this.downloadSize, this.speedLimit, this.threadCount, this.fileChecksum, ) { credentials, folder, name, length, speedLimit, threadCount, fileChecksum, -> HttpDownloadItem( id = -1, folder = folder, name = name, link = credentials.link, contentLength = length?.bytes ?: IDownloadItem.LENGTH_UNKNOWN, dateAdded = openedTime, startTime = null, completeTime = null, status = DownloadStatus.Added, preferredConnectionCount = threadCount, speedLimit = speedLimit, fileChecksum = fileChecksum?.toString() ).withCredentials(credentials) } override val downloadJobConfig: StateFlow = MutableStateFlow(null) override fun applyHostSettingsToExtraConfig(extraConfig: PerHostSettingsItem) { extraConfig.applyToHttpDownload( setUsername = { setCredentials(credentials.value.copy(username = it)) }, setPassword = { setCredentials(credentials.value.copy(password = it)) }, setUserAgent = { setCredentials(credentials.value.copy(userAgent = it)) }, setThreadCount = { threadCount.value = it }, setSpeedLimit = { speedLimit.value = it } ) } override val configurableList = listOf( SpeedLimitConfigurable( Res.string.download_item_settings_speed_limit.asStringSource(), Res.string.download_item_settings_speed_limit_description.asStringSource(), backedBy = speedLimit, describe = { if (it == 0L) Res.string.unlimited.asStringSource() else convertPositiveSpeedToHumanReadable( it, sizeAndSpeedUnitProvider.speedUnit.value ).asStringSource() } ), FileChecksumConfigurable( Res.string.download_item_settings_file_checksum.asStringSource(), Res.string.download_item_settings_file_checksum_description.asStringSource(), backedBy = fileChecksum, describe = { "".asStringSource() } ), IntConfigurable( Res.string.settings_download_thread_count.asStringSource(), Res.string.settings_download_thread_count_description.asStringSource(), backedBy = threadCount.mapTwoWayStateFlow( map = { it ?: 0 }, unMap = { it.takeIf { it >= 1 } } ), range = 0..ThreadCountLimitation.MAX_ALLOWED_THREAD_COUNT, describe = { if (it == 0) Res.string.use_global_settings.asStringSource() else Res.string.download_item_settings_thread_count_describe .asStringSourceWithARgs( Res.string.download_item_settings_thread_count_describe_createArgs( count = it.toString() ) ) } ), StringConfigurable( Res.string.username.asStringSource(), Res.string.download_item_settings_username_description.asStringSource(), backedBy = createMutableStateFlowFromStateFlow( flow = credentials.mapStateFlow { it.username.orEmpty() }, updater = { setCredentials(credentials.value.copy(username = it.takeIf { it.isNotBlank() })) }, scope ), describe = { "".asStringSource() } ), StringConfigurable( Res.string.password.asStringSource(), Res.string.download_item_settings_password_description.asStringSource(), backedBy = createMutableStateFlowFromStateFlow( flow = credentials.mapStateFlow { it.password.orEmpty() }, updater = { setCredentials(credentials.value.copy(password = it.takeIf { it.isNotBlank() })) }, scope ), describe = { "".asStringSource() } ), StringConfigurable( Res.string.download_item_settings_user_agent.asStringSource(), Res.string.download_item_settings_user_agent_description.asStringSource(), backedBy = credentials.mapTwoWayStateFlow( map = { it.userAgent.orEmpty() }, unMap = { copy(userAgent = it.takeIf { it.isNotEmpty() }) } ), describe = { "".asStringSource() } ), StringConfigurable( Res.string.download_item_settings_download_page.asStringSource(), Res.string.download_item_settings_download_page_description.asStringSource(), backedBy = credentials.mapTwoWayStateFlow( map = { it.downloadPage.orEmpty() }, unMap = { copy(downloadPage = it.takeIf { it.isNotEmpty() }) } ), describe = { "".asStringSource() } ) ) override fun downloadSizeToStringSource(downloadSize: DownloadSize.Bytes): StringSource { return downloadSize.asStringSource(sizeAndSpeedUnitProvider.sizeUnit.value) } } ================================================ FILE: shared/app/src/commonMain/kotlin/com/abdownloadmanager/shared/downloaderinui/http/applyHostSettingsToExtraConfig.kt ================================================ package com.abdownloadmanager.shared.downloaderinui.http import com.abdownloadmanager.shared.util.perhostsettings.PerHostSettingsItem import ir.amirab.downloader.downloaditem.http.HttpDownloadCredentials import ir.amirab.downloader.downloaditem.http.HttpDownloadItem fun PerHostSettingsItem.applyToHttpDownload( downloadCredentials: HttpDownloadCredentials ): HttpDownloadCredentials { var out = downloadCredentials applyToHttpDownload( setUsername = { out = out.copy(username = it) }, setPassword = { out = out.copy(password = it) }, setUserAgent = { out = out.copy(userAgent = it) }, setThreadCount = {}, setSpeedLimit = {} ) return out } fun PerHostSettingsItem.applyToHttpDownload( downloadCredentials: HttpDownloadItem ): HttpDownloadItem { var out = downloadCredentials applyToHttpDownload( setUsername = { out = out.copy(username = it) }, setPassword = { out = out.copy(password = it) }, setUserAgent = { out = out.copy(userAgent = it) }, setThreadCount = { out = out.copy(preferredConnectionCount = it) }, setSpeedLimit = { out = out.copy(speedLimit = it) } ) return out } fun PerHostSettingsItem.applyToHttpDownload( setUsername: (String) -> Unit, setPassword: (String) -> Unit, setUserAgent: (String) -> Unit, setThreadCount: (Int) -> Unit, setSpeedLimit: (Long) -> Unit, ) { username?.let(setUsername) password?.let(setPassword) userAgent?.let(setUserAgent) threadCount?.let(setThreadCount) speedLimit?.let(setSpeedLimit) } ================================================ FILE: shared/app/src/commonMain/kotlin/com/abdownloadmanager/shared/downloaderinui/http/edit/HttpEditDownloadChecker.kt ================================================ package com.abdownloadmanager.shared.downloaderinui.http.edit import com.abdownloadmanager.shared.downloaderinui.DownloadSize import com.abdownloadmanager.shared.downloaderinui.LinkChecker import com.abdownloadmanager.shared.downloaderinui.edit.CanEditDownloadResult import com.abdownloadmanager.shared.downloaderinui.edit.CanEditWarnings import com.abdownloadmanager.shared.downloaderinui.edit.DownloadConflictDetector import com.abdownloadmanager.shared.downloaderinui.http.add.HttpLinkChecker import ir.amirab.downloader.connection.IResponseInfo import ir.amirab.downloader.connection.response.HttpResponseInfo import ir.amirab.downloader.downloaditem.IDownloadCredentials import ir.amirab.downloader.downloaditem.IDownloadItem import ir.amirab.downloader.downloaditem.http.HttpDownloadCredentials import ir.amirab.downloader.downloaditem.http.HttpDownloadItem import ir.amirab.util.FileNameValidator import ir.amirab.util.HttpUrlUtils import ir.amirab.util.flow.mapStateFlow import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.launchIn import kotlinx.coroutines.flow.onEach abstract class EditDownloadChecker< TDownloadItem : IDownloadItem, TCredentials : IDownloadCredentials, TResponseInfo : IResponseInfo, TDownloadSize : DownloadSize, TLinkChecker : LinkChecker >( val currentDownloadItem: MutableStateFlow, val editedDownloadItem: MutableStateFlow, val linkChecker: TLinkChecker, val conflictDetector: DownloadConflictDetector, val scope: CoroutineScope, ) { abstract fun check() protected val _canEditResult = MutableStateFlow(CanEditDownloadResult.NothingChanged) val canEditResult = _canEditResult.asStateFlow() val canEdit = canEditResult.mapStateFlow { it is CanEditDownloadResult.CanEdit } } class HttpEditDownloadChecker( currentDownloadItem: MutableStateFlow, editedDownloadItem: MutableStateFlow, conflictDetector: DownloadConflictDetector, scope: CoroutineScope, linkChecker: HttpLinkChecker, ) : EditDownloadChecker( currentDownloadItem = currentDownloadItem, editedDownloadItem = editedDownloadItem, conflictDetector = conflictDetector, scope = scope, linkChecker = linkChecker ) { init { editedDownloadItem .onEach { _canEditResult.value = CanEditDownloadResult.Waiting }.launchIn(scope) } override fun check() { _canEditResult.value = CanEditDownloadResult.Waiting _canEditResult.value = check( current = currentDownloadItem.value, edited = editedDownloadItem.value, newLength = linkChecker.downloadSize.value?.bytes, ) } private fun check( current: HttpDownloadItem, edited: HttpDownloadItem, newLength: Long?, ): CanEditDownloadResult { if (current == edited) { return CanEditDownloadResult.NothingChanged } if (!HttpUrlUtils.isValidUrl(edited.link)) { return CanEditDownloadResult.InvalidURL } if (edited.name != current.name) { if (!FileNameValidator.isValidFileName(edited.name)) { return CanEditDownloadResult.InvalidFileName } if (conflictDetector.checkAlreadyExists(current, edited)) { return CanEditDownloadResult.FileNameAlreadyExists } } val warnings = mutableListOf() if (current.contentLength != newLength) { warnings.add( CanEditWarnings.FileSizeNotMatch( currentSize = current.contentLength, newSize = newLength ?: IDownloadItem.Companion.LENGTH_UNKNOWN, ) ) } return CanEditDownloadResult.CanEdit(warnings) } } ================================================ FILE: shared/app/src/commonMain/kotlin/com/abdownloadmanager/shared/downloaderinui/http/edit/HttpEditDownloadInputs.kt ================================================ package com.abdownloadmanager.shared.downloaderinui.http.edit import com.abdownloadmanager.resources.Res import com.abdownloadmanager.shared.downloaderinui.DownloadSize import com.abdownloadmanager.shared.downloaderinui.LinkCheckerFactory import com.abdownloadmanager.shared.downloaderinui.edit.DownloadConflictDetector import com.abdownloadmanager.shared.downloaderinui.edit.EditDownloadCheckerFactory import com.abdownloadmanager.shared.downloaderinui.edit.EditDownloadInputs import com.abdownloadmanager.shared.downloaderinui.http.HttpCredentialsToItemMapper import com.abdownloadmanager.shared.downloaderinui.http.add.HttpLinkChecker import com.abdownloadmanager.shared.ui.configurable.item.FileChecksumConfigurable import com.abdownloadmanager.shared.ui.configurable.item.IntConfigurable import com.abdownloadmanager.shared.ui.configurable.item.SpeedLimitConfigurable import com.abdownloadmanager.shared.ui.configurable.item.StringConfigurable import com.abdownloadmanager.shared.util.SizeAndSpeedUnitProvider import com.abdownloadmanager.shared.util.ThreadCountLimitation import com.abdownloadmanager.shared.util.FileChecksum import com.abdownloadmanager.shared.util.convertPositiveSpeedToHumanReadable import ir.amirab.downloader.connection.response.HttpResponseInfo import ir.amirab.downloader.downloaditem.DownloadJobExtraConfig import ir.amirab.downloader.downloaditem.http.HttpDownloadCredentials import ir.amirab.downloader.downloaditem.http.HttpDownloadItem import ir.amirab.util.compose.StringSource import ir.amirab.util.compose.asStringSource import ir.amirab.util.compose.asStringSourceWithARgs import ir.amirab.util.flow.mapTwoWayStateFlow import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.launchIn import kotlinx.coroutines.flow.onEach class HttpEditDownloadInputs( currentDownloadItem: MutableStateFlow, editedDownloadItem: MutableStateFlow, val sizeAndSpeedUnitProvider: SizeAndSpeedUnitProvider, mapper: HttpCredentialsToItemMapper, conflictDetector: DownloadConflictDetector, scope: CoroutineScope, linkCheckerFactory: LinkCheckerFactory, editDownloadCheckerFactory: EditDownloadCheckerFactory ) : EditDownloadInputs( currentDownloadItem = currentDownloadItem, editedDownloadItem = editedDownloadItem, mapper = mapper, scope = scope, conflictDetector = conflictDetector, linkCheckerFactory = linkCheckerFactory, editDownloadCheckerFactory = editDownloadCheckerFactory, ) { override val configurableList = listOf( SpeedLimitConfigurable( Res.string.download_item_settings_speed_limit.asStringSource(), Res.string.download_item_settings_speed_limit_description.asStringSource(), backedBy = editedDownloadItem.mapTwoWayStateFlow( map = { it.speedLimit }, unMap = { copy(speedLimit = it) } ), describe = { if (it == 0L) Res.string.unlimited.asStringSource() else convertPositiveSpeedToHumanReadable(it, sizeAndSpeedUnitProvider.speedUnit.value).asStringSource() } ), FileChecksumConfigurable( Res.string.download_item_settings_file_checksum.asStringSource(), Res.string.download_item_settings_file_checksum_description.asStringSource(), backedBy = editedDownloadItem.mapTwoWayStateFlow( map = { it.fileChecksum?.let { runCatching { FileChecksum.Companion.fromString(it) }.onFailure { println(it.printStackTrace()) }.getOrNull() } }, unMap = { copy(fileChecksum = it?.toString()) } ), describe = { "".asStringSource() } ), IntConfigurable( Res.string.settings_download_thread_count.asStringSource(), Res.string.settings_download_thread_count_description.asStringSource(), backedBy = editedDownloadItem.mapTwoWayStateFlow( map = { it.preferredConnectionCount ?: 0 }, unMap = { copy( preferredConnectionCount = it.takeIf { it >= 1 } ) } ), range = 0..ThreadCountLimitation.MAX_ALLOWED_THREAD_COUNT, describe = { if (it == 0) Res.string.use_global_settings.asStringSource() else Res.string.download_item_settings_thread_count_describe .asStringSourceWithARgs( Res.string.download_item_settings_thread_count_describe_createArgs( count = it.toString() ) ) } ), StringConfigurable( Res.string.username.asStringSource(), Res.string.download_item_settings_username_description.asStringSource(), backedBy = credentials.mapTwoWayStateFlow( map = { it.username.orEmpty() }, unMap = { copy(username = it.takeIf { it.isNotEmpty() }) } ), describe = { "".asStringSource() } ), StringConfigurable( Res.string.password.asStringSource(), Res.string.download_item_settings_password_description.asStringSource(), backedBy = credentials.mapTwoWayStateFlow( map = { it.password.orEmpty() }, unMap = { copy(password = it.takeIf { it.isNotEmpty() }) } ), describe = { "".asStringSource() } ), StringConfigurable( Res.string.download_item_settings_user_agent.asStringSource(), Res.string.download_item_settings_user_agent_description.asStringSource(), backedBy = credentials.mapTwoWayStateFlow( map = { it.userAgent.orEmpty() }, unMap = { copy(userAgent = it.takeIf { it.isNotEmpty() }) } ), describe = { "".asStringSource() } ), StringConfigurable( Res.string.download_item_settings_download_page.asStringSource(), Res.string.download_item_settings_download_page_description.asStringSource(), backedBy = credentials.mapTwoWayStateFlow( map = { it.downloadPage.orEmpty() }, unMap = { copy(downloadPage = it.takeIf { it.isNotEmpty() }) } ), describe = { "".asStringSource() } ), ) val length = linkChecker.downloadSize override val downloadJobConfig: MutableStateFlow = MutableStateFlow(null) private fun HttpDownloadItem.applyOurChanges(edited: HttpDownloadItem) { // we don't change some of these properties, so I commented them link = edited.link headers = edited.headers username = edited.username password = edited.password downloadPage = edited.downloadPage userAgent = edited.userAgent // id = edited.id folder = edited.folder name = edited.name contentLength = edited.contentLength serverETag = edited.serverETag // dateAdded = edited.dateAdded // startTime = edited.startTime // completeTime = edited.completeTime // status = edited.status preferredConnectionCount = edited.preferredConnectionCount speedLimit = edited.speedLimit fileChecksum = edited.fileChecksum } override fun applyEditedItemTo(item: HttpDownloadItem) { val edited = editedDownloadItem.value item.applyOurChanges(edited) } init { length.onEach { scheduleRefresh(alsoRecheckLink = false) }.launchIn(scope) } override fun downloadSizeToStringSource(downloadSize: DownloadSize.Bytes): StringSource { return downloadSize.asStringSource(sizeAndSpeedUnitProvider.sizeUnit.value) } } ================================================ FILE: shared/app/src/commonMain/kotlin/com/abdownloadmanager/shared/pagemanager/AboutPageManager.kt ================================================ package com.abdownloadmanager.shared.pagemanager interface AboutPageManager { fun openAboutPage() } ================================================ FILE: shared/app/src/commonMain/kotlin/com/abdownloadmanager/shared/pagemanager/AddDownloadDialogManager.kt ================================================ package com.abdownloadmanager.shared.pagemanager import com.abdownloadmanager.shared.pages.adddownload.AddDownloadCredentialsInUiProps import com.abdownloadmanager.shared.pages.adddownload.ImportOptions interface AddDownloadDialogManager { fun closeAddDownloadDialog() fun openAddDownloadDialog( links: List, importOptions: ImportOptions = ImportOptions(), ) } ================================================ FILE: shared/app/src/commonMain/kotlin/com/abdownloadmanager/shared/pagemanager/BatchDownloadPageManager.kt ================================================ package com.abdownloadmanager.shared.pagemanager interface BatchDownloadPageManager { fun openBatchDownloadPage() fun closeBatchDownload() } ================================================ FILE: shared/app/src/commonMain/kotlin/com/abdownloadmanager/shared/pagemanager/CategoryDialogManager.kt ================================================ package com.abdownloadmanager.shared.pagemanager interface CategoryDialogManager { fun openCategoryDialog(categoryId: Long) fun closeCategoryDialog() } ================================================ FILE: shared/app/src/commonMain/kotlin/com/abdownloadmanager/shared/pagemanager/DownloadDialogManager.kt ================================================ package com.abdownloadmanager.shared.pagemanager interface DownloadDialogManager { fun openDownloadDialog(id: Long) fun closeDownloadDialog() } ================================================ FILE: shared/app/src/commonMain/kotlin/com/abdownloadmanager/shared/pagemanager/EditDownloadDialogManager.kt ================================================ package com.abdownloadmanager.shared.pagemanager interface EditDownloadDialogManager { fun openEditDownloadDialog(id: Long) fun closeEditDownloadDialog() } ================================================ FILE: shared/app/src/commonMain/kotlin/com/abdownloadmanager/shared/pagemanager/EnterNewURLDialogManager.kt ================================================ package com.abdownloadmanager.shared.pagemanager interface EnterNewURLDialogManager { fun openEnterNewURLWindow() fun closeEnterNewURLWindow() } ================================================ FILE: shared/app/src/commonMain/kotlin/com/abdownloadmanager/shared/pagemanager/ExitApplicationRequestManager.kt ================================================ package com.abdownloadmanager.shared.pagemanager interface ExitApplicationRequestManager { suspend fun requestExitApp() } ================================================ FILE: shared/app/src/commonMain/kotlin/com/abdownloadmanager/shared/pagemanager/FileChecksumDialogManager.kt ================================================ package com.abdownloadmanager.shared.pagemanager interface FileChecksumDialogManager { fun openFileChecksumPage(ids: List) fun closeFileChecksumPage(dialogId: String) } ================================================ FILE: shared/app/src/commonMain/kotlin/com/abdownloadmanager/shared/pagemanager/NotificationSender.kt ================================================ package com.abdownloadmanager.shared.pagemanager import com.abdownloadmanager.shared.ui.widget.MessageDialogType import com.abdownloadmanager.shared.ui.widget.NotificationType import ir.amirab.util.compose.StringSource interface NotificationSender { fun sendDialogNotification(title: StringSource, description: StringSource, type: MessageDialogType) fun sendNotification(tag: Any, title: StringSource, description: StringSource, type: NotificationType) } ================================================ FILE: shared/app/src/commonMain/kotlin/com/abdownloadmanager/shared/pagemanager/OpenSourceLibrariesPageManager.kt ================================================ package com.abdownloadmanager.shared.pagemanager interface OpenSourceLibrariesPageManager { fun openOpenSourceLibrariesPage() } ================================================ FILE: shared/app/src/commonMain/kotlin/com/abdownloadmanager/shared/pagemanager/PerHostSettingsPageManager.kt ================================================ package com.abdownloadmanager.shared.pagemanager interface PerHostSettingsPageManager { fun openPerHostSettings(openedHost: String?) fun closePerHostSettings() } ================================================ FILE: shared/app/src/commonMain/kotlin/com/abdownloadmanager/shared/pagemanager/QueuePageManager.kt ================================================ package com.abdownloadmanager.shared.pagemanager interface QueuePageManager : QueueItemPageManager, NewQueuePageManager interface QueueItemPageManager { fun openQueues(openQueueId: Long? = null) fun closeQueues() } interface NewQueuePageManager { fun openNewQueueDialog() fun closeNewQueueDialog() } ================================================ FILE: shared/app/src/commonMain/kotlin/com/abdownloadmanager/shared/pagemanager/SettingsPageManager.kt ================================================ package com.abdownloadmanager.shared.pagemanager interface SettingsPageManager { fun openSettings() fun closeSettings() } ================================================ FILE: shared/app/src/commonMain/kotlin/com/abdownloadmanager/shared/pagemanager/TranslatorsPageManager.kt ================================================ package com.abdownloadmanager.shared.pagemanager interface TranslatorsPageManager { fun openTranslatorsPage() fun closeTranslatorsPage() } ================================================ FILE: shared/app/src/commonMain/kotlin/com/abdownloadmanager/shared/pages/adddownload/AddDownloadComponent.kt ================================================ package com.abdownloadmanager.shared.pages.adddownload import com.abdownloadmanager.shared.pagemanager.CategoryDialogManager import com.abdownloadmanager.shared.storage.ILastSavedLocationsStorage import com.abdownloadmanager.shared.util.BaseComponent import com.arkivanov.decompose.ComponentContext import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.update abstract class AddDownloadComponent( ctx: ComponentContext, val id: String, lastSavedLocationsStorage: ILastSavedLocationsStorage, ) : BaseComponent(ctx) { companion object { const val lastLocationsCacheSize = 4 } abstract fun getCategoryPageManager(): CategoryDialogManager fun onRequestAddCategory() { getCategoryPageManager().openCategoryDialog(-1) } private var dialogUsed = false protected fun consumeDialog(block: () -> Unit) { if (dialogUsed) { return } block() dialogUsed = true } private val _lastUsedLocations = lastSavedLocationsStorage.lastUsedSaveLocations val lastUsedLocations: StateFlow> = _lastUsedLocations.asStateFlow() fun addToLastUsedLocations(saveLocation: String) { _lastUsedLocations.update { buildList { add(saveLocation) addAll(it) } .distinct() .take(lastLocationsCacheSize) } } fun removeFromLastDownloadLocation(saveLocation: String) { _lastUsedLocations.update { it.filter { it != saveLocation } } } abstract val shouldShowWindow: StateFlow } ================================================ FILE: shared/app/src/commonMain/kotlin/com/abdownloadmanager/shared/pages/adddownload/AddDownloadConfig.kt ================================================ package com.abdownloadmanager.shared.pages.adddownload import kotlinx.serialization.Serializable import java.util.* @Serializable sealed interface AddDownloadConfig { val id: String val importOptions: ImportOptions @Serializable data class SingleAddConfig( val newDownload: AddDownloadCredentialsInUiProps, override val importOptions: ImportOptions = ImportOptions(), override val id: String = UUID.randomUUID().toString(), ) : AddDownloadConfig @Serializable data class MultipleAddConfig( val newDownloads: List = emptyList(), override val importOptions: ImportOptions = ImportOptions(), override val id: String = UUID.randomUUID().toString(), ) : AddDownloadConfig } ================================================ FILE: shared/app/src/commonMain/kotlin/com/abdownloadmanager/shared/pages/adddownload/AddDownloadCredentialsInUiProps.kt ================================================ package com.abdownloadmanager.shared.pages.adddownload import com.abdownloadmanager.shared.util.FilenameFixer import ir.amirab.downloader.downloaditem.IDownloadCredentials import kotlinx.serialization.Serializable @Serializable data class AddDownloadCredentialsInUiProps( val credentials: IDownloadCredentials, val extraConfig: Configs = Configs(), ) { @Serializable data class Configs( // don't consume it directly as it might not be a valid file name on user's current OS val suggestedName: String? = null, ) { fun getAndFixSuggestedName(): String? { return suggestedName?.let(FilenameFixer::fix) } } } ================================================ FILE: shared/app/src/commonMain/kotlin/com/abdownloadmanager/shared/pages/adddownload/ImportOptions.kt ================================================ package com.abdownloadmanager.shared.pages.adddownload import kotlinx.serialization.Serializable @Serializable data class SilentImportOptions( val silentDownload: Boolean, ) @Serializable data class ImportOptions( val silentImport: SilentImportOptions? = null, ) ================================================ FILE: shared/app/src/commonMain/kotlin/com/abdownloadmanager/shared/pages/adddownload/multiple/BaseAddMultiDownloadComponent.kt ================================================ package com.abdownloadmanager.shared.pages.adddownload.multiple import androidx.compose.runtime.derivedStateOf import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.setValue import androidx.compose.runtime.snapshotFlow import arrow.core.Some import com.abdownloadmanager.shared.downloaderinui.DownloadSize import com.abdownloadmanager.shared.downloaderinui.DownloaderInUiRegistry import com.abdownloadmanager.shared.downloaderinui.add.NewDownloadInputs import com.abdownloadmanager.shared.downloaderinui.add.NewDownloadInputsUniqueIdType import com.abdownloadmanager.shared.downloaderinui.add.TANewDownloadInputs import com.abdownloadmanager.shared.pages.adddownload.AddDownloadComponent import com.abdownloadmanager.shared.pages.adddownload.AddDownloadCredentialsInUiProps import com.abdownloadmanager.shared.repository.BaseAppRepository import com.abdownloadmanager.shared.storage.ILastSavedLocationsStorage import com.abdownloadmanager.shared.ui.configurable.Configurable import com.abdownloadmanager.shared.util.DownloadSystem import com.abdownloadmanager.shared.util.FileIconProvider import com.abdownloadmanager.shared.util.category.Category import com.abdownloadmanager.shared.util.category.CategoryItem import com.abdownloadmanager.shared.util.category.CategoryManager import com.abdownloadmanager.shared.util.category.CategorySelectionMode import com.abdownloadmanager.shared.util.perhostsettings.PerHostSettingsManager import com.abdownloadmanager.shared.util.perhostsettings.getSettingsForURL import com.arkivanov.decompose.ComponentContext import ir.amirab.SelectionUtil import ir.amirab.downloader.NewDownloadItemProps import ir.amirab.downloader.downloaditem.EmptyContext import ir.amirab.downloader.queue.QueueManager import ir.amirab.downloader.utils.OnDuplicateStrategy import ir.amirab.util.compose.StringSource import ir.amirab.util.ifThen import ir.amirab.util.wildcardMatch import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.flatMapLatest import kotlinx.coroutines.flow.flowOf import kotlinx.coroutines.flow.launchIn import kotlinx.coroutines.flow.onEach import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch abstract class BaseAddMultiDownloadComponent( ctx: ComponentContext, id: String, private val onRequestClose: () -> Unit, private val onRequestAdd: OnRequestAdd, private val appRepository: BaseAppRepository, private val perHostSettingsManager: PerHostSettingsManager, val downloadSystem: DownloadSystem, val fileIconProvider: FileIconProvider, private val categoryManager: CategoryManager, val downloaderInUiRegistry: DownloaderInUiRegistry, protected val queueManager: QueueManager, lastSavedLocationsStorage: ILastSavedLocationsStorage, ) : AddDownloadComponent(ctx, id, lastSavedLocationsStorage) { override val shouldShowWindow: StateFlow = MutableStateFlow(true) private val _folder = MutableStateFlow(appRepository.saveLocation.value) val folder = _folder.asStateFlow() fun setFolder(folder: String) { this._folder.update { folder } totalList.forEach { it.folder.update { folder } } } // when we select all files in one location let user option to auto categorize items private val _alsoAutoCategorize = MutableStateFlow(true) val alsoAutoCategorize = _alsoAutoCategorize.asStateFlow() fun setAlsoAutoCategorize(value: Boolean) { _alsoAutoCategorize.update { value } } val categories = categoryManager.categoriesFlow private val _selectedCategory = MutableStateFlow(null) val selectedCategory = _selectedCategory.asStateFlow() fun setSelectedCategory(category: Category?) { _selectedCategory.update { category } } private val _allInSameLocation = MutableStateFlow(false) val allInSameLocation = _allInSameLocation.asStateFlow() fun setAllItemsInSameLocation(sameLocation: Boolean) { _allInSameLocation.update { sameLocation } } private val _filterText = MutableStateFlow("") val filterText = _filterText.asStateFlow() fun setFilterText(text: String) { _filterText.update { text } } private fun newCheckerWithInputs( addDownloadCredentialsInUiProps: AddDownloadCredentialsInUiProps ): TANewDownloadInputs? { val iDownloadCredentials = addDownloadCredentialsInUiProps.credentials return downloaderInUiRegistry .getDownloaderOf(iDownloadCredentials) ?.createNewDownloadInputs( initialCredentials = iDownloadCredentials, initialName = addDownloadCredentialsInUiProps.extraConfig.getAndFixSuggestedName().orEmpty(), initialFolder = folder.value, downloadSystem = downloadSystem, scope = scope, ) } fun addItems(list: List) { val newItemsToAdd = list.filter { it.credentials !in this.totalList.map { it.credentials.value } }.mapNotNull { newCheckerWithInputs(it) ?.also { inputComponent -> val perHostSettingsItem = perHostSettingsManager .getSettingsForURL(it.credentials.link) perHostSettingsItem?.let { inputComponent .applyHostSettingsToExtraConfig(perHostSettingsItem) } } } enqueueCheck(newItemsToAdd) this.totalList = this.totalList.plus(newItemsToAdd) } var totalList: List by mutableStateOf(emptyList()) private val checkList = MutableSharedFlow() private fun enqueueCheck(links: List) { scope.launch { for (i in links) { checkList.emit(i) } } } init { checkList.onEach { it.downloadUiChecker.refresh() } .launchIn(scope) } var selectionList by mutableStateOf>(emptyList()) fun isSelected(itemId: NewDownloadInputsUniqueIdType): Boolean { return itemId in selectionList } val isTotalSelected by derivedStateOf { totalList.all { it.getUniqueId() in selectionList } } var lastSelectedId by mutableStateOf(null as NewDownloadInputsUniqueIdType?) fun setSelect(id: NewDownloadInputsUniqueIdType, selected: Boolean) { if (selected) { lastSelectedId = id if (!selectionList.contains(id)) { selectionList = selectionList.plus(id) } } else { selectionList = selectionList.minus(id) } } fun resetSelectionTo(ids: List, boolean: Boolean) { selectionList = ids.takeIf { boolean } .orEmpty() } fun selectAll(value: Boolean) { selectionList = if (value) { filteredList.value.map { it.id } } else { emptyList() } } fun toggleSelectInside() { val list = filteredList.value val listIds = list.map { it.id } val selection = selectionList.filter { it !in listIds } SelectionUtil.toggleSelectInside( selectionList = selection, fullSortedList = list, getId = { it.id } )?.let { selectionList = it } } fun inverseSelection() { val list = filteredList.value val listIds = list.map { it.id } val selection = selectionList.filter { it !in listIds } selectionList = SelectionUtil.invertSelection( selectionList = selection, all = list, getId = { it.id } ) } val canClickAdd by derivedStateOf { selectionList.isNotEmpty() } val queueList = queueManager.queues private fun getFolderForItem( categorySelectionMode: CategorySelectionMode?, allInSameLocation: Boolean, url: String, fleName: String, defaultFolder: String, ): String { if (allInSameLocation) return defaultFolder return when (categorySelectionMode) { CategorySelectionMode.Auto -> { downloadSystem.categoryManager .getCategoryOf( CategoryItem( url = url, fileName = fleName, ) )?.getDownloadPath() ?: defaultFolder } is CategorySelectionMode.Fixed -> { downloadSystem.categoryManager .getCategoryById(categorySelectionMode.categoryId)?.getDownloadPath() ?: defaultFolder } null -> defaultFolder } } fun requestAddDownloads( queueId: Long?, startQueue: Boolean, ) { val categorySelectionMode = when { alsoAutoCategorize.value -> CategorySelectionMode.Auto else -> selectedCategory.value?.let { CategorySelectionMode.Fixed(it.id) } } val itemsToAdd = totalList .filter { it.getUniqueId() in selectionList } .filter { val checker = it.downloadUiChecker checker.canAdd.value || checker.isDuplicate.value // we add numbered file strategy } .map { NewDownloadItemProps( downloadItem = it.downloadItem.value.copy( folder = Some( getFolderForItem( categorySelectionMode = categorySelectionMode, url = it.credentials.value.link, fleName = it.name.value, defaultFolder = it.folder.value, allInSameLocation = allInSameLocation.value ) ) ), extraConfig = it.downloadJobConfig.value, onDuplicateStrategy = OnDuplicateStrategy.AddNumbered, context = EmptyContext, ) } consumeDialog { onRequestAdd( items = itemsToAdd, queueId = queueId, categorySelectionMode = categorySelectionMode ).invokeOnCompletion { val folder = folder.value if (allInSameLocation.value) { addToLastUsedLocations(folder) } if (startQueue && queueId != null) { scope.launch { downloadSystem.startQueue(queueId) } } } requestClose() } } var showAddToQueue by mutableStateOf(false) private set fun getIdOf(item: TANewDownloadInputs): Int { return item.getUniqueId() } fun openConfigurableList( itemID: NewDownloadInputsUniqueIdType? ) { currentDownloadConfigurableList.value = itemID?.let { id -> totalList.find { getIdOf(it) == id } }?.configurableList } val currentDownloadConfigurableList: MutableStateFlow>?> = MutableStateFlow(null) fun openAddToQueueDialog() { showAddToQueue = true } fun closeAddToQueue() { showAddToQueue = false } fun requestClose() { onRequestClose() } val listStateFlow: Flow> = snapshotFlow { totalList } .flatMapLatest { downloadInputs -> if (downloadInputs.isEmpty()) flowOf(emptyList()) else combine( downloadInputs.map { it.asNewDownloadState() }, ) { it.toList() } } val filteredList = combine( listStateFlow, filterText, ) { list, filterText -> val filterText = filterText.trim() list.ifThen(filterText.isNotBlank()) { filter { wildcardMatch(filterText, it.name) } } }.stateIn(scope, SharingStarted.Eagerly, emptyList()) val selectedTotalSize = combine( snapshotFlow { selectionList }, filteredList, ) { selection, list -> list.filter { it.id in selection } .mapNotNull { it.size } .groupBy { it::class } .values .map { it.fold(it.first()) { acc, item -> acc.plus(item) } } }.stateIn(scope, SharingStarted.Eagerly, emptyList()) val isAllFilteredSelected = combine( snapshotFlow { selectionList }, filteredList, ) { selection, list -> val ids = list.map { it.id } ids.all { it in selection } }.stateIn(scope, SharingStarted.Eagerly, false) private fun NewDownloadInputs<*, *, *, *, *>.asNewDownloadState(): Flow { val id = this@asNewDownloadState.getUniqueId() return combine( name, credentials, downloadUiChecker.downloadSize, lengthStringFlow, ) { name, credentials, downloadSize, lengthString -> NewMultiDownloadState( id = id, name = name, size = downloadSize, sizeString = lengthString, link = credentials.link, ) } } } /** * this is used to represent multiple download list table */ data class NewMultiDownloadState( val id: NewDownloadInputsUniqueIdType, val name: String, val size: DownloadSize?, val sizeString: StringSource, val link: String, ) ================================================ FILE: shared/app/src/commonMain/kotlin/com/abdownloadmanager/shared/pages/adddownload/multiple/OnRequestAdd.kt ================================================ package com.abdownloadmanager.shared.pages.adddownload.multiple import com.abdownloadmanager.shared.util.category.CategorySelectionMode import ir.amirab.downloader.NewDownloadItemProps import kotlinx.coroutines.Deferred fun interface OnRequestAdd { operator fun invoke( items: List, queueId: Long?, categorySelectionMode: CategorySelectionMode?, ): Deferred> } ================================================ FILE: shared/app/src/commonMain/kotlin/com/abdownloadmanager/shared/pages/adddownload/single/BaseAddSingleDownloadComponent.kt ================================================ package com.abdownloadmanager.shared.pages.adddownload.single import com.abdownloadmanager.shared.pages.adddownload.AddDownloadComponent import androidx.compose.runtime.* import com.abdownloadmanager.shared.pages.adddownload.AddDownloadCredentialsInUiProps import com.abdownloadmanager.shared.pages.adddownload.ImportOptions import com.abdownloadmanager.shared.pages.adddownload.SilentImportOptions import com.abdownloadmanager.shared.util.mvi.ContainsEffects import com.abdownloadmanager.shared.util.mvi.supportEffects import com.abdownloadmanager.shared.util.* import com.abdownloadmanager.shared.util.FileIconProvider import com.arkivanov.decompose.ComponentContext import ir.amirab.downloader.downloaditem.DownloadStatus import ir.amirab.downloader.queue.QueueManager import ir.amirab.downloader.utils.OnDuplicateStrategy import ir.amirab.downloader.utils.orDefault import ir.amirab.util.flow.* import kotlinx.coroutines.flow.* import org.koin.core.component.KoinComponent import org.koin.core.component.inject import com.abdownloadmanager.shared.util.category.Category import com.abdownloadmanager.shared.util.category.CategoryItem import com.abdownloadmanager.shared.util.category.CategoryManager import com.abdownloadmanager.shared.downloaderinui.add.CanAddResult import com.abdownloadmanager.shared.downloaderinui.DownloaderInUi import com.abdownloadmanager.shared.pagemanager.CategoryDialogManager import com.abdownloadmanager.shared.repository.BaseAppRepository import com.abdownloadmanager.shared.storage.BaseAppSettingsStorage import com.abdownloadmanager.shared.storage.ILastSavedLocationsStorage import com.abdownloadmanager.shared.util.perhostsettings.PerHostSettingsManager import com.abdownloadmanager.shared.util.perhostsettings.getSettingsForURL import ir.amirab.downloader.NewDownloadItemProps import ir.amirab.downloader.downloaditem.DownloadJobExtraConfig import ir.amirab.downloader.downloaditem.EmptyContext import ir.amirab.downloader.downloaditem.IDownloadCredentials import ir.amirab.downloader.queue.DefaultQueueInfo import ir.amirab.util.compose.StringSource import kotlinx.coroutines.* import kotlinx.coroutines.selects.select import org.koin.core.component.inject import kotlin.getValue abstract class BaseAddSingleDownloadComponent( ctx: ComponentContext, val onRequestClose: () -> Unit, val onRequestDownload: OnRequestDownloadSingleItem, private val onRequestAddToQueue: OnRequestAddSingleItem, val openExistingDownload: (Long) -> Unit, val updateExistingDownloadCredentials: (Long, IDownloadCredentials, DownloadJobExtraConfig?) -> Unit, protected val downloadItemOpener: DownloadItemOpener, protected val lastSavedLocationsStorage: ILastSavedLocationsStorage, protected val appScope: CoroutineScope, protected val appSettings: BaseAppSettingsStorage, protected val appRepository: BaseAppRepository, protected val perHostSettingsManager: PerHostSettingsManager, protected val categoryManager: CategoryManager, val downloadSystem: DownloadSystem, val iconProvider: FileIconProvider, protected val queueManager: QueueManager, importOptions: ImportOptions, id: String, downloaderInUi: DownloaderInUi, initialCredentials: AddDownloadCredentialsInUiProps, ) : AddDownloadComponent(ctx, id, lastSavedLocationsStorage), ContainsEffects by supportEffects() { private val _shouldShowWindow = MutableStateFlow(importOptions.silentImport == null) override val shouldShowWindow: StateFlow = _shouldShowWindow.asStateFlow() val downloadInputsComponent = downloaderInUi.createNewDownloadInputs( initialFolder = appRepository.saveLocation.value, initialName = initialCredentials.extraConfig.getAndFixSuggestedName().orEmpty(), downloadSystem = downloadSystem, scope = scope, initialCredentials = initialCredentials.credentials, ) val downloadChecker = downloadInputsComponent.downloadUiChecker val categories = categoryManager.categoriesFlow private val _selectedCategory: MutableStateFlow = MutableStateFlow(categories.value.firstOrNull()) val selectedCategory = _selectedCategory.asStateFlow() private val _useCategory = MutableStateFlow(false) val useCategory = _useCategory.asStateFlow() fun setUseCategory(useCategory: Boolean) { _useCategory.update { useCategory } if (useCategory) { val usedCategoryFolder = useCategoryFolder(_useCategory.value) if (!usedCategoryFolder) { useDefaultFolder() } } else { useDefaultFolder() } } private fun useCategoryFolder( useCategory: Boolean, ): Boolean { val category = selectedCategory.value if (useCategory && category != null) { category.getDownloadPath()?.let { setFolder(it) return true } } return false } private fun useDefaultFolder() { setFolder(appRepository.saveLocation.value) } fun setSelectedCategory(category: Category) { _selectedCategory.update { category } val useCategory = useCategory.value if (useCategory) { val used = useCategoryFolder(useCategory) if (!used) { useDefaultFolder() } } } //inputs val credentials = downloadChecker.credentials.asStateFlow() val name = downloadChecker.name.asStateFlow() val folder = downloadChecker.folder.asStateFlow() val onDuplicateStrategy: MutableStateFlow = MutableStateFlow(null) fun setCredentials(downloadCredentials: IDownloadCredentials) { downloadChecker.credentials.update { downloadCredentials } } fun setFolder(folder: String) { downloadChecker.folder.update { folder } } fun setName(name: String) { downloadChecker.name.update { name } } fun setOnDuplicateStrategy(onDuplicateStrategy: OnDuplicateStrategy) { this.onDuplicateStrategy.update { onDuplicateStrategy } } fun getLengthString(): StringSource { return downloadInputsComponent.getLengthString() } init { credentials .map { it.link } .distinctUntilChanged() .debounce(250) .onEachLatest { link -> perHostSettingsManager .getSettingsForURL(link) ?.let(downloadInputsComponent::applyHostSettingsToExtraConfig) } .flowOn(Dispatchers.IO) .launchIn(scope) merge( credentials.mapStateFlow { it.link }, name, folder, ) .onEachLatest { onDuplicateStrategy.update { null } } .launchIn(scope) combine( name, credentials.map { it.link }, ) { name, link -> val category = categoryManager.getCategoryOf( CategoryItem( fileName = name, url = link, ) ) val globalUseCategoryByDefault = appSettings.useCategoryByDefault.value val suggestedUseCategory: Boolean if (category == null) { suggestedUseCategory = false } else { setSelectedCategory(category) suggestedUseCategory = true } if (globalUseCategoryByDefault) { setUseCategory(suggestedUseCategory) } }.launchIn(scope) } val canAddResult = downloadChecker.canAddToDownloadResult.asStateFlow() private val canAdd = downloadChecker.canAdd private val isDuplicate = downloadChecker.isDuplicate val isLinkLoading = downloadChecker.gettingResponseInfo val linkResponseInfo = downloadChecker.responseInfo val canAddToDownloads = combineStateFlows( canAdd, isDuplicate, onDuplicateStrategy, isLinkLoading ) { canAdd, isDuplicate, onDuplicateStrategy, isLinkLoading -> if (isLinkLoading) { // link is loading wait for it... return@combineStateFlows false } if (canAdd) { true } else if (isDuplicate && onDuplicateStrategy != null) { true } else { false } } val downloadItem = downloadInputsComponent.downloadItem val downloadJobConfig = downloadInputsComponent.downloadJobConfig var showMoreSettings by mutableStateOf(false) val configurables = downloadInputsComponent.configurableList val queues = queueManager.queues .stateIn( scope, SharingStarted.WhileSubscribed(), emptyList() ) fun refresh() { downloadChecker.refresh() } fun onRequestDownload() { val downloadItem = this@BaseAddSingleDownloadComponent.downloadItem.value val downloadJobExtraConfig = downloadJobConfig.value consumeDialog { saveLocationIfNecessary(downloadItem.folder) onRequestDownload( item = NewDownloadItemProps( downloadItem = downloadItem, extraConfig = downloadJobExtraConfig, onDuplicateStrategy = onDuplicateStrategy.value.orDefault(), context = EmptyContext ), categoryId = getCategoryIfUseCategoryIsOn()?.id ) onRequestClose() } } private fun getCategoryIfUseCategoryIsOn(): Category? { return if (useCategory.value) selectedCategory.value else null } private fun saveLocationIfNecessary(folder: String) { val category = getCategoryIfUseCategoryIsOn() val shouldAdd = if (category == null) { // always add if user don't use category true } else { // only add if category path is not the same as provided path category.getDownloadPath() != folder } if (shouldAdd) { addToLastUsedLocations(folder) } } fun onRequestAddToQueue( queueId: Long?, startQueue: Boolean, ) { val downloadItem = downloadItem.value val downloadJobConfig = downloadJobConfig.value consumeDialog { saveLocationIfNecessary(downloadItem.folder) onRequestAddToQueue( item = NewDownloadItemProps( downloadItem = downloadItem, extraConfig = downloadJobConfig, onDuplicateStrategy = onDuplicateStrategy.value.orDefault(), context = EmptyContext, ), queueId = queueId, categoryId = getCategoryIfUseCategoryIsOn()?.id, ).invokeOnCompletion { if (queueId != null && startQueue) { GlobalScope.launch { downloadSystem.startQueue(queueId) } } } onRequestClose() } } fun openDownloadFileForCurrentLink() { (canAddResult.value as? CanAddResult.DownloadAlreadyExists) ?.itemId ?.let { openExistingDownload(it) onRequestClose() } } fun updateDownloadCredentialsOfOriginalDownload() { (canAddResult.value as? CanAddResult.DownloadAlreadyExists) ?.itemId ?.let { updateExistingDownloadCredentials(it, downloadItem.value, downloadJobConfig.value) onRequestClose() } } var showSolutionsOnDuplicateDownloadUi by mutableStateOf(false) var shouldShowAddToQueue by mutableStateOf(false) val shouldShowOpenFile = combine( onDuplicateStrategy, canAddResult, ) { onDuplicateStrategy, result -> if (result is CanAddResult.DownloadAlreadyExists && onDuplicateStrategy == null) { val item = downloadSystem.getDownloadItemById(result.itemId) ?: return@combine false if (item.status != DownloadStatus.Completed) { return@combine false } downloadSystem.getDownloadFile(item).exists() } else false }.stateIn(scope, SharingStarted.WhileSubscribed(), false) fun openExistingFile() { val itemId = (canAddResult.value as? CanAddResult.DownloadAlreadyExists)?.itemId ?: return consumeDialog { appScope.launch { downloadItemOpener.openDownloadItem(itemId) } onRequestClose() } } fun addNewCategory() { onRequestAddCategory() } init { importOptions.silentImport?.let { handleSilentImport(it) } } fun handleSilentImport(silentImport: SilentImportOptions) { scope.launch { try { withTimeout(2_000) { // ensure all values are set! credentials.map { it.link }.first { it.isNotEmpty() } folder.first { it.isNotEmpty() } name.first { it.isNotEmpty() } } } catch (_: Exception) { onRequestClose() return@launch } val failAutoAdd = async { try { // although we don't need timeout, but I add this timeout here maybe there is a bug // and I don't want this coroutine to be halted infinitely withTimeout(10_000) { canAddToDownloads.first { it } if (silentImport.silentDownload) { onRequestDownload() } else { onRequestAddToQueue( DefaultQueueInfo.ID, false, ) } } false } catch (_: Exception) { true } } val errorDuringWait = async { val channel = canAddResult.produceIn(this) try { val startTime = System.currentTimeMillis() for (i in channel) { when (i) { is CanAddResult.DownloadAlreadyExists, CanAddResult.CantWriteInThisFolder -> { return@async true } CanAddResult.InvalidUrl, CanAddResult.InvalidFileName -> { // we may get invalid filename/invalid url at the beginning! because the name is empty if (System.currentTimeMillis() - startTime >= 1000) { return@async true } } CanAddResult.CanAdd -> { // we must not break here because it cancels [failAutoAdd] // instead we wait for [failAutoAdd] to be finished and we will be cancelled automatically after select is done! } null -> {} } } return@async true } finally { channel.cancel() } } val failedToAutoAdd = try { select { failAutoAdd.onAwait { failed -> failed } errorDuringWait.onAwait { errorDuringWait -> errorDuringWait } } } catch (_: Exception) { true } finally { runCatching { failAutoAdd.cancelAndJoin() errorDuringWait.cancelAndJoin() } } if (failedToAutoAdd) { // needs adjustments by user! _shouldShowWindow.value = true } } } sealed interface Effects { sealed interface Common : Effects { data class SuggestUrl(val link: String) : Common } interface Platform : Effects } } fun interface OnRequestAddSingleItem { operator fun invoke( item: NewDownloadItemProps, queueId: Long?, categoryId: Long?, ): Deferred } fun interface OnRequestDownloadSingleItem { operator fun invoke( item: NewDownloadItemProps, categoryId: Long?, ) } ================================================ FILE: shared/app/src/commonMain/kotlin/com/abdownloadmanager/shared/pages/batchdownload/BaseBatchDownloadComponent.kt ================================================ package com.abdownloadmanager.shared.pages.batchdownload import com.abdownloadmanager.shared.util.ClipboardUtil import com.abdownloadmanager.shared.util.BaseComponent import com.abdownloadmanager.shared.util.mvi.ContainsEffects import com.abdownloadmanager.shared.util.mvi.supportEffects import com.arkivanov.decompose.ComponentContext import ir.amirab.util.HttpUrlUtils import ir.amirab.util.flow.combineStateFlows import ir.amirab.util.flow.mapStateFlow import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.launch import kotlinx.coroutines.withContext import kotlin.math.max open class BaseBatchDownloadComponent( ctx: ComponentContext, val onClose: () -> Unit, val importLinks: (List) -> Unit, ) : BaseComponent(ctx), ContainsEffects by supportEffects() { private val _link = MutableStateFlow("") val link = _link.asStateFlow() fun setLink(link: String) { _link.value = link } private val _start = MutableStateFlow("") val start = _start.asStateFlow() fun setStart(start: String) { _start.value = start } private val _end = MutableStateFlow("") val end = _end.asStateFlow() fun setEnd(end: String) { _end.value = end } private val _wildcardLength = MutableStateFlow(WildcardLength.Auto) val wildcardLength = _wildcardLength fun setWildCardLength(wildcardLength: WildcardLength) { _wildcardLength.value = wildcardLength } init { fillLinkIfUrlIsInClipboard() } private fun fillLinkIfUrlIsInClipboard() { scope.launch { withContext(Dispatchers.Default) { val clipboard = ClipboardUtil.read() ?: return@withContext if (HttpUrlUtils.isValidUrl(clipboard)) { setLink(clipboard.trim()) } } } } @Suppress("NAME_SHADOWING") private val batch = combineStateFlows( link, start, end, wildcardLength, ) { link, start, end, wildcardLength -> val minimumSize = max(start.length, end.length) val start = start.toIntOrNull() ?: return@combineStateFlows null val end = end.toIntOrNull() ?: return@combineStateFlows null if (start < 0) return@combineStateFlows null if (end < 0 || end < start) return@combineStateFlows null WildcardString( string = link.trim(), range = start..end, wildcardLength = wildcardLength, minimumAllowed = minimumSize, ) } val startLinkResult: StateFlow = batch .mapStateFlow { it?.first() ?: "" } val endLinkResult: StateFlow = batch .mapStateFlow { it?.last() ?: "" } val validationResult = batch.mapStateFlow { when (it) { null -> BatchDownloadValidationResult.Others else -> { val listSize = it.size() when { listSize < 1 -> BatchDownloadValidationResult.Others listSize > MAX_ALLOWED_RANGE -> BatchDownloadValidationResult.MaxRangeExceed(MAX_ALLOWED_RANGE) !HttpUrlUtils.isValidUrl(it.first()) -> BatchDownloadValidationResult.URLInvalid else -> BatchDownloadValidationResult.Ok } } } } val canConfirm = validationResult.mapStateFlow { it is BatchDownloadValidationResult.Ok } fun confirm() { if (!canConfirm.value) { println(batch.value?.toList()) return } val items = batch.value?.toList()?.takeIf { it.isNotEmpty() } if (items != null) { importLinks(items) } onClose() } companion object { const val MAX_ALLOWED_RANGE = 1000 } sealed interface Effects { interface PlatformEffects : Effects } } sealed interface BatchDownloadValidationResult { data object Ok : BatchDownloadValidationResult data object Others : BatchDownloadValidationResult data class MaxRangeExceed(val allowed: Int) : BatchDownloadValidationResult data object URLInvalid : BatchDownloadValidationResult } sealed class WildcardLength { data object Auto : WildcardLength() data object Unspecified : WildcardLength() data class Custom(val v: Int) : WildcardLength() } data class WildcardString( val string: String, val range: IntRange, val wildcardLength: WildcardLength, val minimumAllowed: Int = range.last.toString().length, ) : Iterable { private fun transformIndex(index: Int): String { var str = index.toString() if (wildcardLength is WildcardLength.Unspecified) { return str } val length = when (wildcardLength) { is WildcardLength.Custom -> wildcardLength.v.coerceAtLeast(minimumAllowed) WildcardLength.Auto -> minimumAllowed WildcardLength.Unspecified -> null } if (length != null) { str = str.padStart(length, '0') } return str } fun get(index: Int): String { return string.replace("*", transformIndex(index)) } fun first(): String { return get(range.first) } fun last(): String { return get(range.last) } fun size() = range.last - range.first + 1 override fun iterator(): Iterator { return range .asSequence() .map(::get) .iterator() } } ================================================ FILE: shared/app/src/commonMain/kotlin/com/abdownloadmanager/shared/pages/category/CategoryComponent.kt ================================================ package com.abdownloadmanager.shared.pages.category import com.abdownloadmanager.shared.repository.BaseAppRepository import com.abdownloadmanager.shared.util.BaseComponent import com.abdownloadmanager.shared.util.category.Category import com.abdownloadmanager.shared.util.category.CategoryManager import com.abdownloadmanager.shared.util.category.iconSource import com.arkivanov.decompose.ComponentContext import ir.amirab.util.compose.IIconResolver import ir.amirab.util.compose.IconSource import ir.amirab.util.flow.combineStateFlows import ir.amirab.util.osfileutil.FileUtils import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.launch import org.koin.core.component.KoinComponent import org.koin.core.component.inject import java.io.File class CategoryComponent( ctx: ComponentContext, val id: Long, val close: () -> Unit, private val submit: (Category) -> Unit, ) : BaseComponent(ctx), KoinComponent { private val appRepository: BaseAppRepository by inject() val defaultDownloadLocation = appRepository.saveLocation private val categoryManager: CategoryManager by inject() private val iconResolver: IIconResolver by inject() init { if (id >= 0) { loadCategoryData() } } fun loadCategoryData() { scope.launch { val category = categoryManager.getCategoryById(id) ?: return@launch setIcon(category.iconSource(iconResolver)) setName(category.name) setTypesEnabled(category.acceptedFileTypes.isNotEmpty()) setTypes(category.acceptedFileTypes.joinToString(" ")) setUrlPatternsEnabled(category.acceptedUrlPatterns.isNotEmpty()) setUrlPatterns(category.acceptedUrlPatterns.joinToString(" ")) setPath(category.path) setUsePath(category.usePath) } } private val _icon = MutableStateFlow(null as IconSource?) val icon = _icon.asStateFlow() fun setIcon(iconSource: IconSource?) { _icon.value = iconSource } private val _name = MutableStateFlow("") val name = _name.asStateFlow() fun setName(name: String) { _name.value = name } private val _typesEnabled = MutableStateFlow(false) val typesEnabled = _typesEnabled.asStateFlow() fun setTypesEnabled(value: Boolean) { _typesEnabled.value = value } private val _types = MutableStateFlow("") val types = _types.asStateFlow() fun setTypes(types: String) { _types.value = types } private val _urlPatternsEnabled = MutableStateFlow(false) val urlPatternsEnabled = _urlPatternsEnabled.asStateFlow() fun setUrlPatternsEnabled(urlPatterns: Boolean) { _urlPatternsEnabled.value = urlPatterns } private val _urlPatterns = MutableStateFlow("") val urlPatterns = _urlPatterns.asStateFlow() fun setUrlPatterns(urlPatterns: String) { _urlPatterns.value = urlPatterns } private val _path = MutableStateFlow("") val path = _path.asStateFlow() fun setPath(path: String) { _path.value = path } private val _usePath = MutableStateFlow(false) val usePath = _usePath.asStateFlow() fun setUsePath(usePath: Boolean) { _usePath.value = usePath } val canSubmit = combineStateFlows( icon, name, types, path, usePath, ) { icon, name, types, path, usePath -> val iconOk = icon != null val nameOk = name.isNotBlank() val pathOk = FileUtils.Companion.canWriteInThisFolder(path) || !usePath iconOk && nameOk && pathOk } val isEditMode = id >= 0 fun submit() { if (!canSubmit.value) { return } val path = path.value runCatching { File(path).mkdirs() } submit( Category( id = id, name = name.value, acceptedFileTypes = if (typesEnabled.value) { types.value .split(" ") .filterNot { it.isBlank() } .distinct() } else { emptyList() }, icon = icon .value!! .uri!!, path = path, usePath = usePath.value, acceptedUrlPatterns = if (urlPatternsEnabled.value) { urlPatterns.value .split(" ") .filterNot { it.isBlank() } .distinct() } else { emptyList() }, items = emptyList() // ignored! ) ) } } ================================================ FILE: shared/app/src/commonMain/kotlin/com/abdownloadmanager/shared/pages/checksum/BaseFileChecksumComponent.kt ================================================ package com.abdownloadmanager.shared.pages.checksum import androidx.compose.runtime.Immutable import arrow.core.Some import com.abdownloadmanager.shared.util.BaseComponent import com.abdownloadmanager.shared.util.DownloadSystem import com.abdownloadmanager.shared.util.FileChecksum import com.abdownloadmanager.shared.util.FileChecksumAlgorithm import com.abdownloadmanager.shared.util.HashUtil import com.abdownloadmanager.shared.util.mvi.ContainsEffects import com.abdownloadmanager.shared.util.mvi.ContainsScreenState import com.abdownloadmanager.shared.util.mvi.SupportsScreenState import com.abdownloadmanager.shared.util.mvi.supportEffects import com.arkivanov.decompose.ComponentContext import ir.amirab.downloader.downloaditem.DownloadStatus import ir.amirab.downloader.downloaditem.IDownloadItem import ir.amirab.util.ifThen import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.launchIn import kotlinx.coroutines.flow.onEach import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch import kotlinx.coroutines.withContext import org.koin.core.component.KoinComponent import org.koin.core.component.inject import kotlin.properties.Delegates open class BaseFileChecksumComponent( ctx: ComponentContext, val id: String, val itemIds: List, private val closeComponent: () -> Unit, val downloadSystem: DownloadSystem ) : BaseComponent(ctx), ContainsScreenState by SupportsScreenState(FileChecksumUiState.default()), ContainsEffects by supportEffects() { private var downloadItems: List by Delegates.notNull() private val isChecking = MutableStateFlow(false) private val selectedDefaultAlgorithm: MutableStateFlow = MutableStateFlow(FileChecksumAlgorithm.Companion.default()) fun onAlgorithmChange(algorithm: FileChecksumAlgorithm) { this.selectedDefaultAlgorithm.update { algorithm } } fun isDefaultAlgorithmNeeded(): Boolean { return state.value.items.any { it.savedChecksum == null } } init { scope.launch { load(itemIds) setup() if (!isDefaultAlgorithmNeeded()) { // user don't need to manually set checksum algorithm // start checking immediately startCheck() } } isChecking.onEach { isChecking -> setState { fileChecksumUiState -> fileChecksumUiState.copy(isChecking = isChecking) } }.launchIn(scope) selectedDefaultAlgorithm.onEach { algorithm -> setState { fileChecksumUiState -> fileChecksumUiState.copy( // reset checksum algorithm items = fileChecksumUiState.items.map { itemWithChecksum -> itemWithChecksum.copy( algorithm = getChecksumAlgorithmForItem(itemWithChecksum.downloadItem) ) }, defaultAlgorithm = algorithm ) } }.launchIn(scope) } private fun setup() { setState { it.copy( items = downloadItems.map { downloadItem -> val savedChecksum = FileChecksum.Companion.fromNullableString(downloadItem.fileChecksum) DownloadItemWithChecksum( downloadItem = downloadItem, checksumStatus = ChecksumStatus.Waiting, algorithm = savedChecksum?.algorithm ?: selectedDefaultAlgorithm.value.algorithm, savedChecksum = savedChecksum?.value, calculatedChecksum = null, ) }, ) } } private suspend fun load(items: List) { downloadItems = items.mapNotNull { downloadSystem.getDownloadItemById(it) } } fun updateChecksum( downloadId: Long, fileChecksum: FileChecksum?, ) { scope.launch { val newChecksumString = fileChecksum?.toString() downloadSystem.downloadManager.updateDownloadItem( id = downloadId, updater = { it.fileChecksum = newChecksumString }, downloadJobExtraConfig = null, ) // update this class internal download items downloadItems = downloadItems.map { it.ifThen(it.id == downloadId) { copy(fileChecksum = Some(newChecksumString)) } } updateItem(downloadId) { var modified: DownloadItemWithChecksum = it // update the download item (in this component only) modified = modified.copy( downloadItem = it.downloadItem.copy( fileChecksum = Some(newChecksumString) ), savedChecksum = fileChecksum?.value ) // update hash compare if (fileChecksum != null) { val algorithm = fileChecksum.algorithm if (it.algorithm != fileChecksum.algorithm) { // reset calculated hash if the previous algorithm is different from the new one! modified = modified.copy( algorithm = algorithm, calculatedChecksum = null, checksumStatus = ChecksumStatus.Waiting, ) } else if (modified.calculatedChecksum != null) { // user previously started the check, and he calculated the hash // so we compare saved hash with calculated hash for him modified = modified.copy( algorithm = algorithm, checksumStatus = compareHashes( savedChecksum = fileChecksum, calculatedChecksum = FileChecksum(algorithm, modified.calculatedChecksum) ) ) } } else { // we don't have saved checksum, so we don't know if its matches or not! if (it.checksumStatus is ChecksumStatus.Finished) { modified = modified.copy( checksumStatus = ChecksumStatus.Finished.Done, ) } } modified } } } private suspend fun startCheck() { // clean old statuses setup() isChecking.update { true } try { withContext(Dispatchers.IO) { // some dude may change checksum when we are busy here // so always use the latest download items object! for (index in downloadItems.indices) { processItem(downloadItems[index]) } } } finally { isChecking.update { false } } } private fun processItem(item: IDownloadItem) { val file = downloadSystem.getDownloadFile(item) if (item.status != DownloadStatus.Completed) { scope.launch { updateItemStatus(item.id, ChecksumStatus.Error.DownloadNotFinished) } return } if (!file.isFile) { scope.launch { updateItemStatus(item.id, ChecksumStatus.Error.FileNotFound) } return } try { val algorithm = getChecksumAlgorithmForItem(item) val hash = HashUtil.fileHash( algorithm = algorithm, file = file, onNewPercent = { percent -> scope.launch { updateItemStatus(item.id, ChecksumStatus.Checking(percent)) } } ) val newStatus = compareHashes( FileChecksum.Companion.fromNullableString(item.fileChecksum), FileChecksum(algorithm, hash), ) scope.launch { updateItem(item.id) { it.copy( checksumStatus = newStatus, calculatedChecksum = hash, ) } } } catch (e: Exception) { scope.launch { updateItemStatus(item.id, ChecksumStatus.Error.Exception(e)) } } } private fun compareHashes( savedChecksum: FileChecksum?, calculatedChecksum: FileChecksum ): ChecksumStatus.Finished { return if (savedChecksum == null) { ChecksumStatus.Finished.Done } else { if (savedChecksum == calculatedChecksum) { ChecksumStatus.Finished.Matches } else { ChecksumStatus.Finished.NotMatches } } } private fun getChecksumAlgorithmForItem(downloadItem: IDownloadItem): String { return downloadItem.fileChecksum?.let { FileChecksum.Companion.fromString(it).algorithm } ?: selectedDefaultAlgorithm.value.algorithm } private fun updateItem(id: Long, updater: (DownloadItemWithChecksum) -> DownloadItemWithChecksum) { setState { it.copy( items = it.items.map { itemWithChecksum -> itemWithChecksum.ifThen(itemWithChecksum.downloadItem.id == id) { updater(itemWithChecksum) } } ) } } private fun updateItemStatus(id: Long, status: ChecksumStatus) { updateItem(id) { it.copy(checksumStatus = status) } } fun onRequestClose() { closeComponent() } fun onRequestStartCheck() { scope.launch { startCheck() } } interface Config { val itemIds: List } @Immutable sealed interface Effects { interface Platform : Effects // no common effects } } @Immutable sealed interface ChecksumStatus { sealed interface Finished : ChecksumStatus { data object Matches : Finished data object NotMatches : Finished // just finished there is no saved checksum to compare it data object Done : Finished } data class Checking(val percent: Int) : ChecksumStatus sealed interface Error : ChecksumStatus { data object FileNotFound : Error data object DownloadNotFinished : Error data class Exception(val t: Throwable) : Error } data object Waiting : ChecksumStatus } @Immutable data class DownloadItemWithChecksum( val downloadItem: IDownloadItem, val checksumStatus: ChecksumStatus, val algorithm: String, val savedChecksum: String?, val calculatedChecksum: String?, ) { val isProcessing = checksumStatus is ChecksumStatus.Checking val isError = checksumStatus is ChecksumStatus.Error } @Immutable data class FileChecksumUiState( val items: List, val isChecking: Boolean, val defaultAlgorithm: FileChecksumAlgorithm, ) { companion object { fun default() = FileChecksumUiState( items = emptyList(), isChecking = false, defaultAlgorithm = FileChecksumAlgorithm.default(), ) } } ================================================ FILE: shared/app/src/commonMain/kotlin/com/abdownloadmanager/shared/pages/credits/translators/LanguageTranslationInfo.kt ================================================ package com.abdownloadmanager.shared.pages.credits.translators import kotlinx.serialization.SerialName import kotlinx.serialization.Serializable data class LanguageTranslationInfo( val locale: String,// en,es_ES etc... val englishName: String,//Persian etc... val nativeName: String,//فارسی ... val translators: List, ) typealias TranslatorData = @Serializable Map> @Serializable data class Translator( @SerialName("name") val name: String, @SerialName("link") val link: String, ) ================================================ FILE: shared/app/src/commonMain/kotlin/com/abdownloadmanager/shared/pages/editdownload/BaseEditDownloadComponent.kt ================================================ package com.abdownloadmanager.shared.pages.editdownload import com.abdownloadmanager.shared.downloaderinui.DownloaderInUiRegistry import com.abdownloadmanager.shared.downloaderinui.edit.DownloadConflictDetector import com.abdownloadmanager.shared.downloaderinui.edit.EditDownloadInputs import com.abdownloadmanager.shared.util.BaseComponent import com.abdownloadmanager.shared.util.DownloadSystem import com.abdownloadmanager.shared.util.FileIconProvider import com.arkivanov.decompose.ComponentContext import ir.amirab.downloader.downloaditem.DownloadJobExtraConfig import ir.amirab.downloader.downloaditem.IDownloadCredentials import ir.amirab.downloader.downloaditem.IDownloadItem import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.launch open class BaseEditDownloadComponent( ctx: ComponentContext, private val downloaderInUiRegistry: DownloaderInUiRegistry, val iconProvider: FileIconProvider, val downloadSystem: DownloadSystem, val onRequestClose: () -> Unit, val downloadId: Long, val acceptEdit: StateFlow, private val onEdited: ((IDownloadItem) -> Unit, DownloadJobExtraConfig?) -> Unit, ) : BaseComponent(ctx) { val editDownloadUiChecker = MutableStateFlow(null as EditDownloadInputs?) init { scope.launch { load(downloadId) } } private var pendingCredential: IDownloadCredentials? = null private val _credentialsImportedFromExternal = MutableStateFlow(false) val credentialsImportedFromExternal = _credentialsImportedFromExternal.asStateFlow() fun importCredential(credentials: IDownloadCredentials) { editDownloadUiChecker.value?.let { it.importCredentials(credentials) } ?: run { pendingCredential = credentials } _credentialsImportedFromExternal.value = true } private suspend fun load(id: Long) { val downloadItem = downloadSystem.getDownloadItemById(id = id) if (downloadItem == null) { onRequestClose() println("item with id $id not found") return } val downloader = downloaderInUiRegistry.getDownloaderOf(downloadItem) if (downloader == null) { onRequestClose() println("downloader for id $id not found") return } val httpEditDownloadInputs = downloader.createEditDownloadInputs( currentDownloadItem = MutableStateFlow(downloadItem), editedDownloadItem = MutableStateFlow(downloadItem), conflictDetector = DownloadConflictDetector(downloadSystem), scope = scope, ) editDownloadUiChecker.value = httpEditDownloadInputs pendingCredential?.let { credentials -> httpEditDownloadInputs.importCredentials(credentials) pendingCredential = null } } fun onRequestEdit() { if (!acceptEdit.value) { return } editDownloadUiChecker.value?.let { editDownloadUiChecker -> onEdited(editDownloadUiChecker::applyEditedItemTo, editDownloadUiChecker.downloadJobConfig.value) } } } ================================================ FILE: shared/app/src/commonMain/kotlin/com/abdownloadmanager/shared/pages/enterurl/BaseEnterNewURLComponent.kt ================================================ package com.abdownloadmanager.shared.pages.enterurl import com.abdownloadmanager.shared.downloaderinui.DownloaderInUiRegistry import com.abdownloadmanager.shared.downloaderinui.TADownloaderInUI import com.abdownloadmanager.shared.util.ClipboardUtil import com.abdownloadmanager.shared.util.extractors.linkextractor.StringUrlExtractor import com.abdownloadmanager.shared.util.BaseComponent import com.abdownloadmanager.shared.util.mvi.ContainsEffects import com.abdownloadmanager.shared.util.mvi.supportEffects import com.arkivanov.decompose.ComponentContext import ir.amirab.downloader.downloaditem.IDownloadCredentials import ir.amirab.util.flow.combineStateFlows import ir.amirab.util.flow.mapStateFlow import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.launch open class BaseEnterNewURLComponent( val ctx: ComponentContext, val config: Config, val downloaderInUiRegistry: DownloaderInUiRegistry, private val onCloseRequest: () -> Unit, private val onRequestFinished: (IDownloadCredentials) -> Unit, ) : BaseComponent(ctx), ContainsEffects by supportEffects() { private val _url: MutableStateFlow = MutableStateFlow("") val url = _url.asStateFlow() val bestDownloader = url.mapStateFlow { downloaderInUiRegistry.bestMatchForThisLink(it) } private val _downloaderSelection = MutableStateFlow( DownloaderSelection.Auto ) val downloaderSelection = _downloaderSelection.asStateFlow() fun selectDownloader(downloaderSelection: DownloaderSelection) { _downloaderSelection.value = downloaderSelection } val downloaderToPickup: StateFlow = combineStateFlows( bestDownloader, downloaderSelection, ) { bestDownloader, userSelection -> when (userSelection) { DownloaderSelection.Auto -> bestDownloader is DownloaderSelection.Fixed -> userSelection.downloaderInUi } } val canAdd = combineStateFlows( downloaderToPickup, url, ) { downloader, url -> url.isNotBlank() && downloader != null } fun setURL(url: String) { _url.value = url } var firstTimeOpened = false open val shouldFillWithClipboard = true fun onPageOpen() { if (!firstTimeOpened) { if (shouldFillWithClipboard) { scope.launch { if (fillLinkIfThereIsALinkInClipboard()) { sendEffect(Effects.LinkSelectAll) } } } firstTimeOpened = true } } private fun fillLinkIfThereIsALinkInClipboard(): Boolean { val possibleLinks = ClipboardUtil.read() ?: return false val downloadLinks = StringUrlExtractor.extract(possibleLinks) if (downloadLinks.size == 1) { setURL(downloadLinks.first()) return true } return false } fun close() { scope.launch { onCloseRequest() } } fun newDownloadEntered() { val downloader = downloaderToPickup.value ?: return val link = url.value onRequestFinished( downloader.createMinimumCredentials(link) ) onCloseRequest() } val possibleValues = buildList { add(DownloaderSelection.Auto) addAll(downloaderInUiRegistry.getAll().map { DownloaderSelection.Fixed(it) }) } interface Config sealed interface Effects { data object LinkSelectAll : Effects interface PlatformEffects : Effects } } ================================================ FILE: shared/app/src/commonMain/kotlin/com/abdownloadmanager/shared/pages/enterurl/DownloaderSelection.kt ================================================ package com.abdownloadmanager.shared.pages.enterurl import com.abdownloadmanager.shared.downloaderinui.TADownloaderInUI sealed interface DownloaderSelection { data object Auto : DownloaderSelection data class Fixed( val downloaderInUi: TADownloaderInUI, ) : DownloaderSelection } ================================================ FILE: shared/app/src/commonMain/kotlin/com/abdownloadmanager/shared/pages/home/AbstractDownloadActions.kt ================================================ package com.abdownloadmanager.shared.pages.home import com.abdownloadmanager.resources.Res import com.abdownloadmanager.shared.action.createMoveToCategoryAction import com.abdownloadmanager.shared.action.createMoveToQueueAction import com.abdownloadmanager.shared.pagemanager.DownloadDialogManager import com.abdownloadmanager.shared.pagemanager.EditDownloadDialogManager import com.abdownloadmanager.shared.pagemanager.FileChecksumDialogManager import com.abdownloadmanager.shared.util.ClipboardUtil import com.abdownloadmanager.shared.util.DownloadSystem import com.abdownloadmanager.shared.util.category.Category import com.abdownloadmanager.shared.util.category.CategoryManager import com.abdownloadmanager.shared.util.extractors.linkextractor.DownloadCredentialsFromCurl import com.abdownloadmanager.shared.util.ui.icon.MyIcons import ir.amirab.downloader.downloaditem.DownloadJobStatus import ir.amirab.downloader.downloaditem.http.HttpDownloadCredentials import ir.amirab.downloader.downloaditem.http.HttpDownloadItem import ir.amirab.downloader.monitor.IDownloadItemState import ir.amirab.downloader.monitor.ProcessingDownloadItemState import ir.amirab.downloader.monitor.isFinished import ir.amirab.downloader.monitor.statusOrFinished import ir.amirab.downloader.queue.QueueManager import ir.amirab.util.compose.action.MenuItem import ir.amirab.util.compose.action.simpleAction import ir.amirab.util.compose.asStringSource import ir.amirab.util.flow.combineStateFlows import ir.amirab.util.flow.mapStateFlow import ir.amirab.util.isNotNull import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.launchIn import kotlinx.coroutines.flow.merge import kotlinx.coroutines.flow.onEach import kotlinx.coroutines.launch abstract class AbstractDownloadActions( private val scope: CoroutineScope, downloadSystem: DownloadSystem, downloadDialogManager: DownloadDialogManager, editDownloadDialogManager: EditDownloadDialogManager, fileChecksumDialogManager: FileChecksumDialogManager, val selections: StateFlow>, private val mainItem: StateFlow, private val queueManager: QueueManager, private val categoryManager: CategoryManager, private val openFile: (Long) -> Unit, private val requestDelete: (List) -> Unit, ) { val defaultItem = combineStateFlows( selections, mainItem, ) { selections, mainItem -> selections.let { it.find { it.id == mainItem } ?: it.firstOrNull() } } val resumableSelections = selections.mapStateFlow { it.filter { state -> if (state is ProcessingDownloadItemState) { state.canBeResumed() } else { false } } } val pausableSelections = selections.mapStateFlow { it.filter { state -> if (state is ProcessingDownloadItemState) { state.canBePaused() } else { false } } } val openFileAction = simpleAction( title = Res.string.open.asStringSource(), icon = MyIcons.fileOpen, checkEnable = defaultItem.mapStateFlow { it?.statusOrFinished() is DownloadJobStatus.Finished }, onActionPerformed = { scope.launch { val d = defaultItem.value ?: return@launch openFile(d.id) } } ) val deleteAction = simpleAction( title = Res.string.delete.asStringSource(), icon = MyIcons.remove, checkEnable = selections.mapStateFlow { it.isNotEmpty() }, onActionPerformed = { scope.launch { requestDelete(selections.value.map { it.id }) } }, ) val resumeAction = simpleAction( title = Res.string.resume.asStringSource(), icon = MyIcons.resume, checkEnable = resumableSelections.mapStateFlow { it.isNotEmpty() }, onActionPerformed = { scope.launch { resumableSelections.value.forEach { runCatching { downloadSystem.userManualResume(it.id) } } } } ) val reDownloadAction = simpleAction( Res.string.restart_download.asStringSource(), MyIcons.refresh ) { scope.launch { selections.value.forEach { scope.launch { runCatching { downloadSystem.reset(it.id) downloadSystem.userManualResume(it.id) } } } } } val pauseAction = simpleAction( title = Res.string.pause.asStringSource(), icon = MyIcons.pause, checkEnable = pausableSelections.mapStateFlow { it.isNotEmpty() }, onActionPerformed = { scope.launch { pausableSelections.value.forEach { runCatching { downloadSystem.manualPause(it.id) } } } } ) val editDownloadAction = simpleAction( title = Res.string.edit.asStringSource(), icon = MyIcons.edit, checkEnable = defaultItem.mapStateFlow { state -> state ?: return@mapStateFlow false // don't allow edit if download is active if (state is ProcessingDownloadItemState) { !state.canBePaused() } else { true } }, onActionPerformed = { scope.launch { val item = defaultItem.value ?: return@launch editDownloadDialogManager.openEditDownloadDialog(item.id) } } ) val copyDownloadLinkAction = simpleAction( title = Res.string.copy_link.asStringSource(), icon = MyIcons.copy, checkEnable = selections.mapStateFlow { it.isNotEmpty() }, onActionPerformed = { scope.launch { ClipboardUtil.copy( selections.value.joinToString(System.lineSeparator()) { it.downloadLink } ) } } ) val copyDownloadCredentialsAsCurlAction = simpleAction( title = Res.string.copy_as_curl.asStringSource(), icon = MyIcons.copy, checkEnable = selections.mapStateFlow { it.isNotEmpty() }, onActionPerformed = { scope.launch { val credentialsList = selections.value .mapNotNull { downloadSystem.getDownloadItemById(it.id) } .filterIsInstance() .map { HttpDownloadCredentials.from(it) } ClipboardUtil.copy(DownloadCredentialsFromCurl.generateCurlCommands(credentialsList).joinToString("\n")) } } ) val openDownloadDialogAction = simpleAction( Res.string.show_properties.asStringSource(), MyIcons.info, checkEnable = defaultItem.mapStateFlow(::isNotNull) ) { defaultItem.value?.let { itemState -> downloadDialogManager.openDownloadDialog(itemState.id) } } protected val fileChecksumAction = simpleAction( title = Res.string.file_checksum.asStringSource(), MyIcons.info, checkEnable = selections.mapStateFlow { list -> list.any { iiDownloadItemState -> iiDownloadItemState.isFinished() } } ) { fileChecksumDialogManager.openFileChecksumPage( selections.value.map { it.id } ) } protected val moveToQueueItems = MenuItem.SubMenu( title = Res.string.move_to_queue.asStringSource(), items = emptyList() ).apply { merge( queueManager.queues, selections ).onEach { val qs = queueManager.queues.value val list = qs.map { queue -> createMoveToQueueAction(scope, downloadSystem, queue, selections.value.map { it.id }) } setItems(list) }.launchIn(scope) } protected val moveToCategoryAction = MenuItem.SubMenu( title = Res.string.move_to_category.asStringSource(), items = emptyList() ).apply { merge( categoryManager.categoriesFlow.mapStateFlow { it.map(Category::id) }, selections ).onEach { val categories = categoryManager.categoriesFlow.value val list = categories.map { category -> createMoveToCategoryAction( scope = scope, category = category, downloadSystem = downloadSystem, itemIds = selections.value.map { iDownloadItemState -> iDownloadItemState.id } ) } setItems(list) }.launchIn(scope) } } ================================================ FILE: shared/app/src/commonMain/kotlin/com/abdownloadmanager/shared/pages/home/BaseHomeComponent.kt ================================================ package com.abdownloadmanager.shared.pages.home import androidx.compose.runtime.snapshotFlow import com.abdownloadmanager.resources.Res import com.abdownloadmanager.shared.pagemanager.AddDownloadDialogManager import com.abdownloadmanager.shared.pagemanager.CategoryDialogManager import com.abdownloadmanager.shared.pagemanager.DownloadDialogManager import com.abdownloadmanager.shared.pagemanager.EditDownloadDialogManager import com.abdownloadmanager.shared.pagemanager.EnterNewURLDialogManager import com.abdownloadmanager.shared.pagemanager.FileChecksumDialogManager import com.abdownloadmanager.shared.pagemanager.NotificationSender import com.abdownloadmanager.shared.pagemanager.QueuePageManager import com.abdownloadmanager.shared.pages.adddownload.AddDownloadCredentialsInUiProps import com.abdownloadmanager.shared.pages.home.category.DefinedStatusCategories import com.abdownloadmanager.shared.pages.home.category.DownloadStatusCategoryFilter import com.abdownloadmanager.shared.pages.home.queue.QueueActions import com.abdownloadmanager.shared.ui.widget.NotificationType import com.abdownloadmanager.shared.util.BaseComponent import com.abdownloadmanager.shared.util.DownloadItemOpener import com.abdownloadmanager.shared.util.DownloadSystem import com.abdownloadmanager.shared.util.FileIconProvider import com.abdownloadmanager.shared.util.category.Category import com.abdownloadmanager.shared.util.category.CategoryItemWithId import com.abdownloadmanager.shared.util.category.CategoryManager import com.abdownloadmanager.shared.util.category.DefaultCategories import com.abdownloadmanager.shared.util.mvi.ContainsEffects import com.abdownloadmanager.shared.util.mvi.supportEffects import com.arkivanov.decompose.ComponentContext import ir.amirab.downloader.DownloadManagerEvents import ir.amirab.downloader.db.QueueModel import ir.amirab.downloader.downloaditem.DownloadStatus import ir.amirab.downloader.downloaditem.contexts.RemovedBy import ir.amirab.downloader.downloaditem.contexts.User import ir.amirab.downloader.queue.DownloadQueue import ir.amirab.downloader.queue.QueueManager import ir.amirab.downloader.queue.queueModelsFlow import ir.amirab.util.compose.asStringSource import ir.amirab.util.coroutines.combine import ir.amirab.util.flow.combineStateFlows import ir.amirab.util.osfileutil.FileUtils import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.debounce import kotlinx.coroutines.flow.filterIsInstance import kotlinx.coroutines.flow.launchIn import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.onEach import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch import java.io.File abstract class BaseHomeComponent( componentContext: ComponentContext, protected val downloadItemOpener: DownloadItemOpener, protected val downloadDialogManager: DownloadDialogManager, protected val editDownloadDialogManager: EditDownloadDialogManager, protected val addDownloadDialogManager: AddDownloadDialogManager, protected val fileChecksumDialogManager: FileChecksumDialogManager, protected val queuePageManager: QueuePageManager, protected val categoryDialogManager: CategoryDialogManager, protected val notificationSender: NotificationSender, protected val downloadSystem: DownloadSystem, val categoryManager: CategoryManager, val queueManager: QueueManager, protected val defaultCategories: DefaultCategories, val fileIconProvider: FileIconProvider, ) : BaseComponent(componentContext), ContainsEffects by supportEffects() { protected abstract val enterNewURLDialogManager: EnterNewURLDialogManager val filterState = FilterState() protected fun requestDelete( downloadList: List, ) { if (downloadList.isEmpty()) { // nothing to delete! return } scope.launch { val unfinished = downloadSystem.getUnfinishedDownloadIds() .count { it in downloadList } val finished = downloadSystem.getFinishedDownloadIds() .count { it in downloadList } sendEffect( Effects.Common.DeleteItems( list = downloadList, unfinishedCount = unfinished, finishedCount = finished, ) ) } } fun onConfirmDeleteCategory(promptState: CategoryDeletePromptState) { scope.launch { categoryManager.deleteCategory(promptState.category) } } fun confirmDelete(promptState: DeletePromptState) { scope.launch { val selectionList = promptState.downloadList for (id in selectionList) { downloadSystem.removeDownload( id = id, alsoRemoveFile = promptState.alsoDeleteFile, context = RemovedBy(User), ) } } } fun onConfirmAutoCategorize() { val categorizedItems = categoryManager.getCategories() .flatMap { it.items } val allDownloads = activeDownloadList.value + completedList.value val unCategorizedItems = allDownloads.filterNot { it.id in categorizedItems } categoryManager .autoAddItemsToCategoriesBasedOnFileNames( unCategorizedItems.map { CategoryItemWithId( id = it.id, fileName = it.name, url = it.downloadLink, ) } ) } fun onConfirmResetCategories() { scope.launch { categoryManager.reset() } } fun moveItemsToCategory(category: Category, items: List) { scope.launch { categoryManager.addItemsToCategory(category.id, items) } } fun reorderCategory(index: Int, delta: Int) { scope.launch { categoryManager.reorderCategory(index, delta) } } fun moveItemsToQueue(queue: DownloadQueue, items: List) { scope.launch { queueManager.addToQueue(queue.id, items) } } fun requestAddNewDownload( link: List, ) { addDownloadDialogManager.openAddDownloadDialog(link) } private val _selectionList = MutableStateFlow>(emptyList()) val selectionList = _selectionList.asStateFlow() fun clearSelection() { _selectionList.update { emptyList() } } fun selectAll() { newSelection( ids = downloadList.value.map { it.id } ) } fun newSelection( ids: List, ) { _selectionList.update { ids } } fun onItemSelectionChange(id: Long, checked: Boolean) { _selectionList.update { lastSelection -> if (checked) { if (!lastSelection.contains(id)) { lastSelection + id } else { lastSelection } } else { lastSelection - id } } } fun onCategoryFilterChange( statusCategoryFilter: DownloadStatusCategoryFilter, typeCategoryFilter: Category?, ) { this.filterState.queueFilter = null this.filterState.statusFilter = statusCategoryFilter this.filterState.typeCategoryFilter = typeCategoryFilter } fun onQueueFilterChange( queueModel: QueueModel ) { this.filterState.statusFilter = DefinedStatusCategories.All this.filterState.typeCategoryFilter = null this.filterState.queueFilter = queueModel } val activeDownloadCountFlow = downloadSystem.downloadMonitor.activeDownloadCount val globalSpeedFlow = downloadSystem.downloadMonitor.activeDownloadListFlow.map { it.sumOf { it.speed } } val activeDownloadList = downloadSystem.downloadMonitor.activeDownloadListFlow val completedList = downloadSystem.downloadMonitor.completedDownloadListFlow init { categoryManager.categoriesFlow.onEach { categories -> val currentCategory = filterState.typeCategoryFilter ?: return@onEach filterState.typeCategoryFilter = categories.find { it.id == currentCategory.id } }.launchIn(scope) queueManager.queueModelsFlow().onEach { queueModels -> val currentQueueModel = filterState.queueFilter ?: return@onEach filterState.queueFilter = queueModels.find { it.id == currentQueueModel.id } }.launchIn(scope) } val downloadList = combine( snapshotFlow { filterState.textToSearch }, activeDownloadList, completedList, snapshotFlow { filterState.typeCategoryFilter }, snapshotFlow { filterState.statusFilter }, snapshotFlow { filterState.queueFilter }, ) { textToSearch, activeDownloads, completeDownloads, categoryFilter, statusFilter, queueFilter -> val isSearching = textToSearch.isNotBlank() val allowedList = categoryFilter?.items ?: queueFilter?.queueItems (activeDownloads + completeDownloads) .filter { val statusAccepted = filterState.statusFilter.accept(it) val itemIsInAllowedList = allowedList?.contains(it.id) ?: true val searchAccepted = if (isSearching) { it.name.contains(filterState.textToSearch, ignoreCase = true) } else true itemIsInAllowedList && statusAccepted && searchAccepted } // when restart a completed download item there is a duplication in list // so make sure to not pass bad data to download list table as it has item.id as key .distinctBy { it.id } } .withResumedLifecycle() .stateIn(scope, SharingStarted.Companion.Eagerly, emptyList()) init { downloadList.onEach { downloads -> _selectionList.value = selectionList.value.filter { previouslySelectedItem -> downloads.any { it.id == previouslySelectedItem } } }.launchIn(scope) downloadSystem.downloadManager.listOfJobsEvents .filterIsInstance() // wait until download list in table is also updated // it also prevents extra emits when multiple download added at the same time .debounce(100) .onEach { sendEffect(Effects.Common.ScrollToDownloadItem(it.downloadItem.id)) }.launchIn(scope) } protected val selectionListItems = combineStateFlows( selectionList, downloadList, ) { selectionList, downloadList -> val ids = selectionList ids.mapNotNull { id -> downloadList.find { it.id == id } } } fun openFileOrShowProperties(id: Long) { scope.launch { val dItem = downloadSystem.getDownloadItemById(id) ?: return@launch if (dItem.status != DownloadStatus.Completed) { downloadDialogManager.openDownloadDialog(id) return@launch } downloadItemOpener.openDownloadItem(dItem) } } fun openFile(id: Long) { scope.launch { val dItem = downloadSystem.getDownloadItemById(id) ?: return@launch if (dItem.status != DownloadStatus.Completed) { notificationSender.sendNotification( Res.string.open_file, Res.string.cant_open_file.asStringSource(), Res.string.not_finished.asStringSource(), NotificationType.Error, ) return@launch } downloadItemOpener.openDownloadItem(dItem) } } val queueActions = MutableStateFlow(null as QueueActions?) fun showCategoryOptions(queue: DownloadQueue?) { queueActions.value = QueueActions( scope = scope, queueManager = queueManager, mainQueueModel = queue?.queueModel?.value, requestDelete = { queueModel -> scope.launch { downloadSystem.deleteQueue(queueModel.id) } }, requestEdit = { queueModel -> runCatching { queueManager.getQueue(queueModel.id) } // it shouldn't be happened however I add this .getOrNull()?.let { q -> queuePageManager.openQueues(q.id) } }, requestClearItems = { scope.launch { runCatching { queueManager.clearQueue(it.id) } } }, onRequestNewQueue = { queuePageManager.openNewQueueDialog() } ) } fun closeQueueOptions() { queueActions.value = null } val categoryActions = MutableStateFlow(null as CategoryActions?) fun showCategoryOptions(categoryItem: Category?) { categoryActions.value = CategoryActions( scope = scope, categoryManager = categoryManager, defaultCategories = defaultCategories, categoryItem = categoryItem, openFolder = { runCatching { it.getDownloadPath()?.let { FileUtils.Companion.openFolder(File(it)) } } }, onRequestAddCategory = { categoryDialogManager.openCategoryDialog(-1) }, requestDelete = { sendEffect( Effects.Common.DeleteCategory(it) ) }, requestEdit = { categoryDialogManager.openCategoryDialog(it.id) }, onRequestCategorizeItems = { sendEffect(Effects.Common.AutoCategorize) }, onRequestResetToDefaults = { sendEffect(Effects.Common.ResetCategoriesToDefault) } ) } fun closeCategoryOptions() { categoryActions.value = null } fun requestEnterNewURL() { enterNewURLDialogManager.openEnterNewURLWindow() } sealed interface Effects { interface PlatformEffects : Effects sealed interface Common : Effects { data class DeleteItems( val list: List, val finishedCount: Int, val unfinishedCount: Int, ) : Common data class DeleteCategory( val category: Category, ) : Common data object ResetCategoriesToDefault : Common data object AutoCategorize : Common data class ScrollToDownloadItem( val downloadId: Long, val skipIfVisible: Boolean = false, ) : Common } } } ================================================ FILE: shared/app/src/commonMain/kotlin/com/abdownloadmanager/shared/pages/home/CategoryActions.kt ================================================ package com.abdownloadmanager.shared.pages.home import androidx.compose.runtime.Stable import com.abdownloadmanager.resources.Res import com.abdownloadmanager.shared.util.category.Category import com.abdownloadmanager.shared.util.category.CategoryManager import com.abdownloadmanager.shared.util.category.DefaultCategories import com.abdownloadmanager.shared.util.ui.icon.MyIcons import ir.amirab.util.compose.action.MenuItem import ir.amirab.util.compose.action.buildMenu import ir.amirab.util.compose.action.simpleAction import ir.amirab.util.compose.asStringSource import ir.amirab.util.flow.mapStateFlow import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.launch @Stable class CategoryActions( private val scope: CoroutineScope, private val categoryManager: CategoryManager, private val defaultCategories: DefaultCategories, val categoryItem: Category?, private val openFolder: (Category) -> Unit, private val requestDelete: (Category) -> Unit, private val requestEdit: (Category) -> Unit, private val onRequestResetToDefaults: () -> Unit, private val onRequestCategorizeItems: () -> Unit, private val onRequestAddCategory: () -> Unit, ) { private val mainItemExists = MutableStateFlow(categoryItem != null) private val canBeOpened = MutableStateFlow(categoryItem?.usePath ?: false) private inline fun useItem( block: (Category) -> Unit, ) { categoryItem?.let(block) } val openCategoryFolderAction = simpleAction( title = Res.string.open_folder.asStringSource(), icon = MyIcons.folderOpen, checkEnable = canBeOpened, onActionPerformed = { scope.launch { useItem { openFolder(it) } } } ) val deleteAction = simpleAction( title = Res.string.delete_category.asStringSource(), icon = MyIcons.remove, checkEnable = mainItemExists, onActionPerformed = { scope.launch { useItem { requestDelete(it) } } }, ) val editAction = simpleAction( title = Res.string.edit_category.asStringSource(), icon = MyIcons.settings, checkEnable = mainItemExists, onActionPerformed = { scope.launch { useItem { requestEdit(it) } } }, ) val addCategoryAction = simpleAction( title = Res.string.add_category.asStringSource(), icon = MyIcons.add, onActionPerformed = { scope.launch { onRequestAddCategory() } }, ) val categorizeItemsAction = simpleAction( title = Res.string.auto_categorize_downloads.asStringSource(), icon = MyIcons.refresh, onActionPerformed = { scope.launch { onRequestCategorizeItems() } }, ) val resetToDefaultAction = simpleAction( title = Res.string.restore_defaults.asStringSource(), icon = MyIcons.undo, checkEnable = categoryManager .categoriesFlow .mapStateFlow { !defaultCategories.isDefault(it) }, onActionPerformed = { scope.launch { onRequestResetToDefaults() } }, ) val menu: List = buildMenu { +editAction +openCategoryFolderAction +deleteAction separator() +addCategoryAction separator() +categorizeItemsAction +resetToDefaultAction } } ================================================ FILE: shared/app/src/commonMain/kotlin/com/abdownloadmanager/shared/pages/home/FilterState.kt ================================================ package com.abdownloadmanager.shared.pages.home import androidx.compose.runtime.Stable import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.setValue import com.abdownloadmanager.shared.pages.home.category.DefinedStatusCategories import com.abdownloadmanager.shared.pages.home.category.DownloadStatusCategoryFilter import com.abdownloadmanager.shared.util.category.Category import ir.amirab.downloader.db.QueueModel @Stable class FilterState { var textToSearch by mutableStateOf("") var typeCategoryFilter by mutableStateOf(null as Category?) var queueFilter by mutableStateOf(null as QueueModel?) var statusFilter by mutableStateOf(DefinedStatusCategories.All) } ================================================ FILE: shared/app/src/commonMain/kotlin/com/abdownloadmanager/shared/pages/home/PromptStates.kt ================================================ package com.abdownloadmanager.shared.pages.home import androidx.compose.runtime.Immutable import androidx.compose.runtime.Stable import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.setValue import com.abdownloadmanager.shared.util.category.Category import ir.amirab.util.compose.StringSource @Stable class DeletePromptState( val downloadList: List, val finishedCount: Int, val unfinishedCount: Int, ) { val hasFinishedDownloads = finishedCount > 0 var hasUnfinishedDownloads = unfinishedCount > 0 var alsoDeleteFile by mutableStateOf(false) fun hasBothFinishedAndUnfinished(): Boolean { return hasFinishedDownloads && hasUnfinishedDownloads } } @Immutable data class CategoryDeletePromptState( val category: Category, ) @Immutable data class ConfirmPromptState( val title: StringSource, val description: StringSource, val onConfirm: () -> Unit, ) ================================================ FILE: shared/app/src/commonMain/kotlin/com/abdownloadmanager/shared/pages/home/category/DefinedStatusCategories.kt ================================================ package com.abdownloadmanager.shared.pages.home.category import com.abdownloadmanager.resources.Res import com.abdownloadmanager.shared.util.ui.icon.MyIcons import ir.amirab.downloader.downloaditem.DownloadStatus import ir.amirab.downloader.monitor.IDownloadItemState import ir.amirab.util.compose.asStringSource object DefinedStatusCategories { fun values() = listOf(All, Finished, Unfinished) val All = object : DownloadStatusCategoryFilter( Res.string.all.asStringSource(), MyIcons.folder, ) { override fun accept(iDownloadStatus: IDownloadItemState): Boolean = true } val Finished = DownloadStatusCategoryFilterByList( Res.string.finished.asStringSource(), MyIcons.folder, listOf(DownloadStatus.Completed) ) val Unfinished = DownloadStatusCategoryFilterByList( Res.string.Unfinished.asStringSource(), MyIcons.folder, listOf( DownloadStatus.Error, DownloadStatus.Added, DownloadStatus.Paused, DownloadStatus.Downloading, ) ) } ================================================ FILE: shared/app/src/commonMain/kotlin/com/abdownloadmanager/shared/pages/home/category/DownloadStatusCategoryFilter.kt ================================================ package com.abdownloadmanager.shared.pages.home.category import ir.amirab.downloader.monitor.IDownloadItemState import ir.amirab.util.compose.IconSource import ir.amirab.util.compose.StringSource abstract class DownloadStatusCategoryFilter( val name: StringSource, val icon: IconSource, ) { abstract fun accept(iDownloadStatus: IDownloadItemState): Boolean } ================================================ FILE: shared/app/src/commonMain/kotlin/com/abdownloadmanager/shared/pages/home/category/DownloadStatusCategoryFilterByList.kt ================================================ package com.abdownloadmanager.shared.pages.home.category import ir.amirab.downloader.downloaditem.DownloadStatus import ir.amirab.downloader.monitor.IDownloadItemState import ir.amirab.downloader.monitor.statusOrFinished import ir.amirab.util.compose.IconSource import ir.amirab.util.compose.StringSource class DownloadStatusCategoryFilterByList( name: StringSource, icon: IconSource, val acceptedStatus: List, ) : DownloadStatusCategoryFilter(name, icon) { override fun accept(iDownloadStatus: IDownloadItemState): Boolean { return iDownloadStatus .statusOrFinished() .asDownloadStatus() in acceptedStatus } } ================================================ FILE: shared/app/src/commonMain/kotlin/com/abdownloadmanager/shared/pages/home/queue/QueueActions.kt ================================================ package com.abdownloadmanager.shared.pages.home.queue import androidx.compose.runtime.Stable import com.abdownloadmanager.resources.Res import com.abdownloadmanager.shared.util.ui.icon.MyIcons import ir.amirab.downloader.db.QueueModel import ir.amirab.downloader.queue.DefaultQueueInfo import ir.amirab.downloader.queue.DownloadQueue import ir.amirab.downloader.queue.QueueManager import ir.amirab.util.compose.action.MenuItem import ir.amirab.util.compose.action.buildMenu import ir.amirab.util.compose.action.simpleAction import ir.amirab.util.compose.asStringSource import ir.amirab.util.flow.mapStateFlow import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.launch @Stable class QueueActions( private val scope: CoroutineScope, private val queueManager: QueueManager, val mainQueueModel: QueueModel?, private val requestDelete: (QueueModel) -> Unit, private val requestEdit: (QueueModel) -> Unit, private val requestClearItems: (QueueModel) -> Unit, private val onRequestNewQueue: () -> Unit, ) { private val mainItemExists = MutableStateFlow(mainQueueModel != null) fun downloadQueueOrNull(): DownloadQueue? { val qId = mainQueueModel?.id ?: return null return runCatching { queueManager.getQueue(qId) }.getOrNull() } private inline fun useItem( block: (QueueModel) -> Unit, ) { mainQueueModel?.let(block) } val deleteAction = simpleAction( title = Res.string.delete.asStringSource(), icon = MyIcons.remove, checkEnable = MutableStateFlow(run { val item = mainQueueModel ?: return@run false item.id != DefaultQueueInfo.ID }), onActionPerformed = { scope.launch { useItem { requestDelete(it) } } }, ) val editAction = simpleAction( title = Res.string.edit.asStringSource(), icon = MyIcons.settings, checkEnable = mainItemExists, onActionPerformed = { scope.launch { useItem { requestEdit(it) } } }, ) val clearItems = simpleAction( title = Res.string.clear_queue_items.asStringSource(), icon = MyIcons.clear, checkEnable = mainItemExists, onActionPerformed = { scope.launch { useItem { requestClearItems(it) } } }, ) val addQueueAction = simpleAction( title = Res.string.add_new_queue.asStringSource(), icon = MyIcons.add, onActionPerformed = { scope.launch { onRequestNewQueue() } }, ) val start = simpleAction( title = Res.string.start_queue.asStringSource(), icon = MyIcons.queueStart, checkEnable = run { downloadQueueOrNull()?.activeFlow?.mapStateFlow { !it } ?: MutableStateFlow(false) }, onActionPerformed = { scope.launch { downloadQueueOrNull()?.start() } }, ) val stop = simpleAction( title = Res.string.stop_queue.asStringSource(), icon = MyIcons.queueStop, checkEnable = run { downloadQueueOrNull()?.activeFlow ?: MutableStateFlow(false) }, onActionPerformed = { scope.launch { downloadQueueOrNull()?.stop() } }, ) val menu: List = buildMenu { +start +stop separator() +editAction +deleteAction +clearItems separator() +addQueueAction } } ================================================ FILE: shared/app/src/commonMain/kotlin/com/abdownloadmanager/shared/pages/perhostsettings/PerHostSettingsComponent.kt ================================================ package com.abdownloadmanager.shared.pages.perhostsettings import arrow.core.prependTo import com.abdownloadmanager.shared.util.ThreadCountLimitation import com.abdownloadmanager.shared.ui.configurable.item.IntConfigurable import com.abdownloadmanager.shared.ui.configurable.item.SpeedLimitConfigurable import com.abdownloadmanager.shared.ui.configurable.item.StringConfigurable import com.abdownloadmanager.shared.ui.configurable.ConfigurableGroup import com.abdownloadmanager.resources.Res import com.abdownloadmanager.shared.repository.BaseAppRepository import com.abdownloadmanager.shared.util.BaseComponent import com.abdownloadmanager.shared.util.convertPositiveSpeedToHumanReadable import com.abdownloadmanager.shared.util.mvi.ContainsEffects import com.abdownloadmanager.shared.util.mvi.supportEffects import com.abdownloadmanager.shared.util.perhostsettings.PerHostSettingsItem import com.abdownloadmanager.shared.util.perhostsettings.PerHostSettingsManager import com.arkivanov.decompose.ComponentContext import ir.amirab.util.compose.asStringSource import ir.amirab.util.compose.asStringSourceWithARgs import ir.amirab.util.flow.combineStateFlows import ir.amirab.util.flow.mapTwoWayStateFlow import ir.amirab.util.flow.onEachLatest import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.coroutineScope import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.launchIn import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch import java.util.UUID data class PerHostSettingsItemWithId( val perHostSettingsItem: PerHostSettingsItem, val id: String = UUID.randomUUID().toString(), ) data class PerHostSettingsConfigurableWithId( val configurableGroups: List, val id: String, ) abstract class BasePerHostSettingsComponent( ctx: ComponentContext, private val perHostSettingsManager: PerHostSettingsManager, private val appRepository: BaseAppRepository, private val appScope: CoroutineScope, private val closeRequested: () -> Unit, ) : BaseComponent(ctx), ContainsEffects by supportEffects() { val editedPerHostSettings: MutableStateFlow> val savedPerHostSettings: MutableStateFlow> init { val data = getStorageDataWithUnitqueIds() editedPerHostSettings = MutableStateFlow(data) savedPerHostSettings = MutableStateFlow(data) } val selectedId = MutableStateFlow(null as String?) val canSave = combineStateFlows( savedPerHostSettings, editedPerHostSettings ) { a, b -> a != b } fun onHostSelected(host: String) { selectedId.value = editedPerHostSettings.value.find { it.perHostSettingsItem.host == host }?.id } fun onIdSelected(id: String?) { selectedId.value = id } fun load() { val data = getStorageDataWithUnitqueIds() editedPerHostSettings.value = data savedPerHostSettings.value = data } private fun getStorageDataWithUnitqueIds(): List { return perHostSettingsManager.getStorageData().map { PerHostSettingsItemWithId(perHostSettingsItem = it) } } fun save() { appScope.launch { perHostSettingsManager.setSettingsData(editedPerHostSettings.value.map { it.perHostSettingsItem }) } } val selectedItemConfigurableList: MutableStateFlow = MutableStateFlow(null) init { selectedId.onEachLatest { selectedId -> coroutineScope { selectedItemConfigurableList.value = run { if (selectedId != null) { val configWithId = editedPerHostSettings.value .find { it.id == selectedId } ?: return@run null val state = MutableStateFlow(configWithId.perHostSettingsItem) launch { performUpdatesFromStateFlow(state) } PerHostSettingsConfigurableWithId( id = selectedId, configurableGroups = createConfigurableGroupForItem(state) ) } else { null } } } }.launchIn(scope) } private suspend fun performUpdatesFromStateFlow( state: MutableStateFlow ) { state.collect { newUpdate -> editedPerHostSettings.value = editedPerHostSettings.value.map { if (it.id == selectedId.value) { it.copy(perHostSettingsItem = newUpdate) } else { it } } } } private fun createConfigurableGroupForItem( state: MutableStateFlow ): List { return listOf( ConfigurableGroup( nestedConfigurable = listOf( StringConfigurable( title = Res.string.settings_per_host_settings_host.asStringSource(), description = Res.string.settings_per_host_settings_host_description.asStringSource(), backedBy = state.mapTwoWayStateFlow( map = { it.host }, unMap = { copy(host = it) } ), describe = { "".asStringSource() } ), ) ), ConfigurableGroup( nestedConfigurable = listOf( SpeedLimitConfigurable( title = Res.string.download_item_settings_speed_limit.asStringSource(), description = Res.string.download_item_settings_speed_limit_description.asStringSource(), backedBy = state.mapTwoWayStateFlow( map = { it.speedLimit ?: 0 }, unMap = { copy(speedLimit = it.takeIf { it != 0L }) } ), describe = { if (it == 0L) Res.string.unlimited.asStringSource() else convertPositiveSpeedToHumanReadable(it, appRepository.speedUnit.value).asStringSource() } ), IntConfigurable( title = Res.string.download_item_settings_thread_count.asStringSource(), description = Res.string.download_item_settings_thread_count_description.asStringSource(), backedBy = state.mapTwoWayStateFlow( map = { it.threadCount ?: 0 }, unMap = { copy(threadCount = it.takeIf { it != 0 }) } ), range = 0..ThreadCountLimitation.MAX_ALLOWED_THREAD_COUNT, describe = { if (it == 0) Res.string.use_global_settings.asStringSource() else Res.string.download_item_settings_thread_count_describe .asStringSourceWithARgs( Res.string.download_item_settings_thread_count_describe_createArgs( count = it.toString() ) ) } ), ) ), ConfigurableGroup( nestedConfigurable = listOf( StringConfigurable( title = Res.string.username.asStringSource(), description = Res.string.download_item_settings_username_description.asStringSource(), backedBy = state.mapTwoWayStateFlow( map = { it.username.orEmpty() }, unMap = { copy(username = it.takeIf { it.isNotBlank() }) } ), describe = { "".asStringSource() } ), StringConfigurable( title = Res.string.password.asStringSource(), description = Res.string.download_item_settings_password_description.asStringSource(), backedBy = state.mapTwoWayStateFlow( map = { it.password.orEmpty() }, unMap = { copy(password = it.takeIf { it.isNotBlank() }) } ), describe = { "".asStringSource() } ), ) ), ConfigurableGroup( nestedConfigurable = listOf( StringConfigurable( title = Res.string.settings_default_user_agent.asStringSource(), description = Res.string.settings_default_user_agent_description.asStringSource(), backedBy = state.mapTwoWayStateFlow( map = { it.userAgent.orEmpty() }, unMap = { copy(userAgent = it.takeIf { it.isNotBlank() }) } ), describe = { "".asStringSource() } ), ) ) ) } fun onRequestAddNewHostSettingsItem() { val perHostSettingsItemWithId = PerHostSettingsItemWithId( PerHostSettingsItem("") ) editedPerHostSettings.update { perHostSettingsItemWithId.prependTo(it) } selectedId.value = perHostSettingsItemWithId.id } fun onRequestDeleteConfig(id: String) { val index = editedPerHostSettings.value.indexOfFirst { it.id == id } editedPerHostSettings.update { it.filterNot { item -> item.id == id } } selectedId.update { selectedId -> if (selectedId == id) { val editedConfigs = this.editedPerHostSettings.value runCatching { index.coerceIn(editedConfigs.indices) }.getOrNull()?.let { editedConfigs.getOrNull(it)?.id } } else { selectedId } } } fun close() { closeRequested() } fun saveAndClose() { save() close() } interface Config { val openedHost: String? } sealed interface Effects { interface Platform : Effects } } ================================================ FILE: shared/app/src/commonMain/kotlin/com/abdownloadmanager/shared/pages/updater/RenderUpdateNotifications.kt ================================================ package com.abdownloadmanager.shared.pages.updater import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.setValue import com.abdownloadmanager.resources.Res import com.abdownloadmanager.shared.ui.widget.NotificationType import com.abdownloadmanager.shared.ui.widget.ShowNotification import com.abdownloadmanager.shared.util.mvi.HandleEffects import ir.amirab.util.compose.StringSource import ir.amirab.util.compose.StringSource.* import ir.amirab.util.compose.asStringSource import kotlinx.coroutines.Job import kotlinx.coroutines.delay import kotlinx.coroutines.launch @Composable fun RenderUpdateNotifications(updateComponent: UpdateComponent) { var message by remember { mutableStateOf(null as StringSource?) } var notificationType by remember { mutableStateOf(null as NotificationType?) } val scope = rememberCoroutineScope() var clearMessageInJob by remember { mutableStateOf(null as Job?) } fun clearMessageAfter(delay: Long) { clearMessageInJob?.cancel() clearMessageInJob = scope.launch { delay(delay) message = null } } HandleEffects(updateComponent) { when (it) { UpdateComponent.Effects.CheckingForUpdate -> { message = Res.string.update_checking_for_update.asStringSource() notificationType = NotificationType.Loading(null) } is UpdateComponent.Effects.Error -> { clearMessageAfter(3000) message = CombinedStringSource( listOf( Res.string.update_check_error.asStringSource(), it.throwable.localizedMessage.orEmpty().asStringSource(), ), "\n", ) it.throwable.printStackTrace() notificationType = NotificationType.Error } UpdateComponent.Effects.NoUpdate -> { clearMessageAfter(3000) message = Res.string.update_no_update.asStringSource() notificationType = NotificationType.Info } UpdateComponent.Effects.NewUpdate -> { message = null notificationType = null } } } message?.let { message -> ShowNotification( title = Res.string.update_updater.asStringSource(), description = message, type = notificationType ?: NotificationType.Info, tag = "Updater" ) } } ================================================ FILE: shared/app/src/commonMain/kotlin/com/abdownloadmanager/shared/pages/updater/UpdateComponent.kt ================================================ package com.abdownloadmanager.shared.pages.updater import com.abdownloadmanager.UpdateCheckStatus import com.abdownloadmanager.shared.util.AppVersion import com.abdownloadmanager.shared.util.BaseComponent import com.abdownloadmanager.UpdateManager import com.abdownloadmanager.resources.Res import com.abdownloadmanager.shared.pagemanager.NotificationSender import com.abdownloadmanager.shared.ui.widget.MessageDialogType import com.abdownloadmanager.shared.util.mvi.ContainsEffects import com.abdownloadmanager.shared.util.mvi.supportEffects import com.arkivanov.decompose.ComponentContext import kotlinx.coroutines.* import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.update import org.koin.core.component.KoinComponent import ir.amirab.util.compose.asStringSource import kotlinx.coroutines.flow.drop import kotlinx.coroutines.flow.launchIn import kotlinx.coroutines.flow.onEach class UpdateComponent( ctx: ComponentContext, private val notificationSender: NotificationSender, private val updateManager: UpdateManager, ) : BaseComponent(ctx), ContainsEffects by supportEffects(), KoinComponent { fun isUpdateSupported(): Boolean { return updateManager.isUpdateSupported() } val currentVersion = AppVersion.get() val showNewUpdate = MutableStateFlow(false) val newVersionData = updateManager.newVersionData private var updateApplierJob: Job? = null val updateCheckStatus = updateManager.updateCheckStatus fun performUpdate() { updateApplierJob?.cancel() updateApplierJob = scope.launch { try { updateManager.update() } catch (e: Exception) { showMessage(e) } } } private fun showMessage(e: Exception) { e.printStackTrace() notificationSender.sendDialogNotification( Res.string.update_error.asStringSource(), e.localizedMessage.orEmpty().asStringSource(), type = MessageDialogType.Error, ) } fun showNewUpdate() { showNewUpdate.update { true } } fun requestCheckForUpdate() { scope.launch { updateManager.checkForUpdate() } } fun requestClose() { showNewUpdate.update { false } } init { updateCheckStatus .drop(1) // state flow .onEach { when (it) { UpdateCheckStatus.Checking -> { sendEffect(Effects.CheckingForUpdate) } is UpdateCheckStatus.Error -> { sendEffect(Effects.Error(it.e)) } UpdateCheckStatus.NewUpdate -> { sendEffect(Effects.NewUpdate) showNewUpdate() } UpdateCheckStatus.NoUpdate -> { sendEffect(Effects.NoUpdate) } UpdateCheckStatus.IDLE -> { } } }.launchIn(scope) } sealed interface Effects { data object CheckingForUpdate : Effects data object NoUpdate : Effects data object NewUpdate : Effects data class Error(val throwable: Throwable) : Effects } } ================================================ FILE: shared/app/src/commonMain/kotlin/com/abdownloadmanager/shared/repository/BaseAppRepository.kt ================================================ package com.abdownloadmanager.shared.repository import com.abdownloadmanager.shared.storage.BaseAppSettingsStorage import com.abdownloadmanager.shared.storage.SupportedSizeUnits import com.abdownloadmanager.shared.util.AutoStartManager import com.abdownloadmanager.shared.util.SizeAndSpeedUnitProvider import com.abdownloadmanager.shared.util.DownloadSystem import com.abdownloadmanager.shared.util.autoremove.RemovedDownloadsFromDiskTracker import com.abdownloadmanager.shared.util.category.CategoryManager import com.abdownloadmanager.shared.util.proxy.ProxyManager import ir.amirab.downloader.DownloadManager import ir.amirab.downloader.DownloadSettings import ir.amirab.downloader.monitor.IDownloadMonitor import ir.amirab.util.datasize.ConvertSizeConfig import ir.amirab.util.flow.mapStateFlow import ir.amirab.util.flow.withPrevious import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.flow.debounce import kotlinx.coroutines.flow.launchIn import kotlinx.coroutines.flow.onEach open class BaseAppRepository( protected val scope: CoroutineScope, protected val appSettings: BaseAppSettingsStorage, protected val proxyManager: ProxyManager, protected val downloadSystem: DownloadSystem, protected val downloadSettings: DownloadSettings, protected val removedDownloadsFromDiskTracker: RemovedDownloadsFromDiskTracker, protected val categoryManager: CategoryManager, ) : SizeAndSpeedUnitProvider { val theme = appSettings.theme val uiScale = appSettings.uiScale private val downloadManager: DownloadManager = downloadSystem.downloadManager private val downloadMonitor: IDownloadMonitor = downloadSystem.downloadMonitor val maxConcurrentDownloads = appSettings.maxConcurrentDownloads val speedLimiter = appSettings.speedLimit val threadCount = appSettings.threadCount val dynamicPartCreation = appSettings.dynamicPartCreation val useServerLastModifiedTime = appSettings.useServerLastModifiedTime val appendExtensionToIncompleteDownloads = appSettings.appendExtensionToIncompleteDownloads val useSparseFileAllocation = appSettings.useSparseFileAllocation val maxDownloadRetryCount = appSettings.maxDownloadRetryCount val useAverageSpeed = appSettings.useAverageSpeed val saveLocation = appSettings.defaultDownloadFolder val integrationEnabled = appSettings.browserIntegrationEnabled val integrationPort = appSettings.browserIntegrationPort val trackDeletedFilesOnDisk = appSettings.trackDeletedFilesOnDisk override val sizeUnit = appSettings.sizeUnit.mapStateFlow { it.toConfig() } override val speedUnit = appSettings.speedUnit.mapStateFlow { it.toConfig() } fun setSizeUnit(sizeUnit: ConvertSizeConfig) { SupportedSizeUnits.Companion.fromConfig(sizeUnit)?.let { appSettings.sizeUnit.value = it } } fun setSpeedUnit(speedUnit: ConvertSizeConfig) { SupportedSizeUnits.Companion.fromConfig(speedUnit)?.let { appSettings.speedUnit.value = it } } fun boot() { updateDownloadSettings() } private fun updateDownloadSettings() { downloadSettings.defaultThreadCount = threadCount.value downloadSettings.dynamicPartCreationMode = dynamicPartCreation.value downloadSettings.useServerLastModifiedTime = useServerLastModifiedTime.value downloadSettings.appendExtensionToIncompleteDownloads = appendExtensionToIncompleteDownloads.value downloadSettings.useSparseFileAllocation = useSparseFileAllocation.value downloadSettings.maxDownloadRetryCount = maxDownloadRetryCount.value downloadSettings.globalSpeedLimit = speedLimiter.value } init { saveLocation .debounce(500) .withPrevious() .onEach { (oldDownloadFolder, newDownloadFolder) -> if (oldDownloadFolder == null) { return@onEach } categoryManager.updateCategoryFoldersBasedOnDefaultDownloadFolder( previousDownloadFolder = oldDownloadFolder, currentDownloadFolder = newDownloadFolder, ) }.launchIn(scope) //maybe its better to move this to another place appSettings.autoStartOnBoot .debounce(500) .onEach { enabled -> AutoStartManager.startOnBoot(enabled) }.launchIn(scope) speedLimiter .debounce(500) .onEach { downloadSettings.globalSpeedLimit = it downloadManager.limitGlobalSpeed(it) }.launchIn(scope) useAverageSpeed .debounce(500) .onEach { downloadMonitor.useAverageSpeed = it }.launchIn(scope) threadCount .debounce(500) .onEach { downloadSettings.defaultThreadCount = it downloadManager.reloadSetting() }.launchIn(scope) dynamicPartCreation .debounce(500) .onEach { downloadSettings.dynamicPartCreationMode = it downloadManager.reloadSetting() }.launchIn(scope) useServerLastModifiedTime .debounce(500) .onEach { downloadSettings.useServerLastModifiedTime = it downloadManager.reloadSetting() }.launchIn(scope) appendExtensionToIncompleteDownloads .debounce(500) .onEach { downloadSettings.appendExtensionToIncompleteDownloads = it downloadManager.reloadSetting() }.launchIn(scope) useSparseFileAllocation .debounce(500) .onEach { downloadSettings.useSparseFileAllocation = it downloadManager.reloadSetting() }.launchIn(scope) maxDownloadRetryCount .debounce(500) .onEach { downloadSettings.maxDownloadRetryCount = it downloadManager.reloadSetting() }.launchIn(scope) trackDeletedFilesOnDisk .debounce(500) .onEach { enabled -> if (enabled) { removedDownloadsFromDiskTracker.removeDownloadsThatFilesAreMissing() removedDownloadsFromDiskTracker.start() } else { removedDownloadsFromDiskTracker.stop() } }.launchIn(scope) maxConcurrentDownloads .debounce(500) .onEach { downloadSystem.manualDownloadQueue.setMaxConcurrent(it) }.launchIn(scope) } } ================================================ FILE: shared/app/src/commonMain/kotlin/com/abdownloadmanager/shared/settings/BaseSettingsComponent.kt ================================================ package com.abdownloadmanager.shared.settings import com.abdownloadmanager.shared.ui.configurable.ConfigurableGroup import com.abdownloadmanager.shared.util.BaseComponent import com.abdownloadmanager.shared.util.mvi.ContainsEffects import com.abdownloadmanager.shared.util.mvi.supportEffects import com.arkivanov.decompose.ComponentContext import kotlinx.coroutines.flow.StateFlow abstract class BaseSettingsComponent( context: ComponentContext ) : BaseComponent( context ), ContainsEffects by supportEffects() { abstract val configurables: StateFlow> sealed interface Effects { interface Platform : Effects } } ================================================ FILE: shared/app/src/commonMain/kotlin/com/abdownloadmanager/shared/settings/CommonSettings.kt ================================================ package com.abdownloadmanager.shared.settings import com.abdownloadmanager.resources.Res import com.abdownloadmanager.shared.pagemanager.PerHostSettingsPageManager import com.abdownloadmanager.shared.repository.BaseAppRepository import com.abdownloadmanager.shared.storage.BaseAppSettingsStorage import com.abdownloadmanager.shared.ui.configurable.item.BooleanConfigurable import com.abdownloadmanager.shared.ui.configurable.item.EnumConfigurable import com.abdownloadmanager.shared.ui.configurable.item.FolderConfigurable import com.abdownloadmanager.shared.ui.configurable.item.IntConfigurable import com.abdownloadmanager.shared.ui.configurable.item.NavigatableConfigurable import com.abdownloadmanager.shared.ui.configurable.item.ProxyConfigurable import com.abdownloadmanager.shared.ui.configurable.item.SpeedLimitConfigurable import com.abdownloadmanager.shared.ui.configurable.item.StringConfigurable import com.abdownloadmanager.shared.ui.configurable.item.ThemeConfigurable import com.abdownloadmanager.shared.ui.theme.ThemeManager import com.abdownloadmanager.shared.util.MaximumDownloadRetriesLimitation import com.abdownloadmanager.shared.util.ThreadCountLimitation import com.abdownloadmanager.shared.util.convertPositiveSpeedToHumanReadable import com.abdownloadmanager.shared.util.proxy.ProxyManager import com.abdownloadmanager.shared.util.proxy.ProxyMode import com.abdownloadmanager.shared.util.ui.theme.DEFAULT_UI_SCALE import ir.amirab.util.compose.StringSource import ir.amirab.util.compose.asStringSource import ir.amirab.util.compose.asStringSourceWithARgs import ir.amirab.util.compose.combineStringSources import ir.amirab.util.compose.localizationmanager.LanguageInfo import ir.amirab.util.compose.localizationmanager.LanguageManager import ir.amirab.util.datasize.CommonSizeConvertConfigs import ir.amirab.util.datasize.ConvertSizeConfig import ir.amirab.util.datasize.SizeFactors import ir.amirab.util.datasize.SizeUnit import ir.amirab.util.flow.createMutableStateFlowFromStateFlow import ir.amirab.util.flow.mapStateFlow import ir.amirab.util.osfileutil.FileUtils import kotlinx.coroutines.CoroutineScope import kotlin.math.roundToInt object CommonSettings { fun threadCountConfig(appRepository: BaseAppRepository): IntConfigurable { return IntConfigurable( title = Res.string.settings_download_thread_count.asStringSource(), description = Res.string.settings_download_thread_count_description.asStringSource(), backedBy = appRepository.threadCount, range = 1..ThreadCountLimitation.MAX_ALLOWED_THREAD_COUNT, renderMode = IntConfigurable.RenderMode.TextField, describe = { buildList { add( Res.string.settings_download_thread_count_describe .asStringSourceWithARgs( Res.string.settings_download_thread_count_describe_createArgs( count = it.toString() ) ) ) if (it > ThreadCountLimitation.MAX_NORMAL_VALUE) { add( Res.string.settings_download_thread_count_with_large_value_describe.asStringSource() ) } }.combineStringSources("\n") }, ) } fun maxConcurrentDownloads(appRepository: BaseAppRepository): IntConfigurable { return IntConfigurable( title = Res.string.settings_download_max_concurrent_downloads.asStringSource(), description = Res.string.settings_download_max_concurrent_downloads_description.asStringSource(), backedBy = appRepository.maxConcurrentDownloads, range = 0..Int.MAX_VALUE, renderMode = IntConfigurable.RenderMode.TextField, describe = { if (it == 0) { Res.string.unlimited.asStringSource() } else { "$it".asStringSource() } }, ) } fun maxDownloadRetryCount(appRepository: BaseAppRepository): IntConfigurable { return IntConfigurable( title = Res.string.settings_download_max_retries_count.asStringSource(), description = Res.string.settings_download_max_retries_count_description.asStringSource(), backedBy = appRepository.maxDownloadRetryCount, range = 0..MaximumDownloadRetriesLimitation.MAX_ALLOWED_RETRIES, renderMode = IntConfigurable.RenderMode.TextField, describe = { if (it == 0) { Res.string.settings_download_max_retries_count_describe_no_retries.asStringSource() } else { Res.string.settings_download_max_retries_count_describe_n_retries .asStringSourceWithARgs( Res.string.settings_download_max_retries_count_describe_n_retries_createArgs( count = "$it" ) ) } }, ) } fun dynamicPartDownloadConfig(appRepository: BaseAppRepository): BooleanConfigurable { return BooleanConfigurable( title = Res.string.settings_dynamic_part_creation.asStringSource(), description = Res.string.settings_dynamic_part_creation_description.asStringSource(), backedBy = appRepository.dynamicPartCreation, describe = { if (it) { Res.string.enabled.asStringSource() } else { Res.string.disabled.asStringSource() } }, ) } fun useServerLastModified(appRepository: BaseAppRepository): BooleanConfigurable { return BooleanConfigurable( title = Res.string.settings_use_server_last_modified_time.asStringSource(), description = Res.string.settings_use_server_last_modified_time_description.asStringSource(), backedBy = appRepository.useServerLastModifiedTime, describe = { if (it) { Res.string.enabled.asStringSource() } else { Res.string.disabled.asStringSource() } }, ) } fun appendExtensionToIncompleteDownloads(appRepository: BaseAppRepository): BooleanConfigurable { return BooleanConfigurable( title = Res.string.settings_append_extension_to_incomplete_downloads.asStringSource(), description = Res.string.settings_append_extension_to_incomplete_downloads_description.asStringSource(), backedBy = appRepository.appendExtensionToIncompleteDownloads, describe = { if (it) { Res.string.enabled.asStringSource() } else { Res.string.disabled.asStringSource() } }, ) } fun useSparseFileAllocation(appRepository: BaseAppRepository): BooleanConfigurable { return BooleanConfigurable( title = Res.string.settings_use_sparse_file_allocation.asStringSource(), description = Res.string.settings_use_sparse_file_allocation_description.asStringSource(), backedBy = appRepository.useSparseFileAllocation, describe = { if (it) { Res.string.enabled.asStringSource() } else { Res.string.disabled.asStringSource() } }, ) } fun trackDeletedFilesOnDisk(appRepository: BaseAppRepository): BooleanConfigurable { return BooleanConfigurable( title = Res.string.settings_track_deleted_files_on_disk.asStringSource(), description = Res.string.settings_track_deleted_files_on_disk_description.asStringSource(), backedBy = appRepository.trackDeletedFilesOnDisk, describe = { if (it) { Res.string.enabled.asStringSource() } else { Res.string.disabled.asStringSource() } }, ) } fun deletePartialFileOnDownloadCancellation(appSettingsStorage: BaseAppSettingsStorage): BooleanConfigurable { return BooleanConfigurable( title = Res.string.settings_delete_partial_file_on_download_cancellation.asStringSource(), description = Res.string.settings_delete_partial_file_on_download_cancellation_description.asStringSource(), backedBy = appSettingsStorage.deletePartialFileOnDownloadCancellation, describe = { if (it) { Res.string.enabled.asStringSource() } else { Res.string.disabled.asStringSource() } }, ) } fun ignoreSSLCertificates(appSettingsStorage: BaseAppSettingsStorage): BooleanConfigurable { return BooleanConfigurable( title = Res.string.settings_ignore_ssl_certificates.asStringSource(), description = Res.string.settings_ignore_ssl_certificates_description.asStringSource(), backedBy = appSettingsStorage.ignoreSSLCertificates, describe = { if (it) { Res.string.enabled.asStringSource() } else { Res.string.disabled.asStringSource() } }, ) } fun userAgent(appSettingsStorage: BaseAppSettingsStorage): StringConfigurable { return StringConfigurable( title = Res.string.settings_default_user_agent.asStringSource(), description = Res.string.settings_default_user_agent_description.asStringSource(), backedBy = appSettingsStorage.userAgent, describe = { if (it.isBlank()) { Res.string.disabled.asStringSource() } else { "".asStringSource() } }, ) } fun useCategoryByDefault(appSettingsStorage: BaseAppSettingsStorage): BooleanConfigurable { return BooleanConfigurable( title = Res.string.settings_use_category_by_default.asStringSource(), description = Res.string.settings_use_category_by_default_description.asStringSource(), backedBy = appSettingsStorage.useCategoryByDefault, describe = { if (it) { Res.string.enabled.asStringSource() } else { Res.string.disabled.asStringSource() } }, ) } fun sizeUnit( appRepository: BaseAppRepository, scope: CoroutineScope ): EnumConfigurable { return EnumConfigurable( title = Res.string.settings_download_size_unit.asStringSource(), description = Res.string.settings_download_size_unit_description.asStringSource(), backedBy = createMutableStateFlowFromStateFlow( appRepository.sizeUnit, updater = { appRepository.setSizeUnit(it) }, scope = scope ), possibleValues = listOf( CommonSizeConvertConfigs.BinaryBytes, CommonSizeConvertConfigs.DecimalBytes, ), describe = { val sizeUnit = SizeUnit( SizeFactors.FactorValue.Kilo, it.baseSize, it.factors, ) "$sizeUnit".asStringSource() }, ) } fun speedUnit( appRepository: BaseAppRepository, scope: CoroutineScope ): EnumConfigurable { return EnumConfigurable( title = Res.string.settings_download_speed_unit.asStringSource(), description = Res.string.settings_download_speed_unit_description.asStringSource(), backedBy = createMutableStateFlowFromStateFlow( appRepository.speedUnit, updater = { appRepository.setSpeedUnit(it) }, scope = scope ), possibleValues = listOf( CommonSizeConvertConfigs.BinaryBytes, CommonSizeConvertConfigs.DecimalBytes, CommonSizeConvertConfigs.BinaryBits, CommonSizeConvertConfigs.DecimalBits, ), describe = { val sizeUnit = SizeUnit( SizeFactors.FactorValue.Kilo, it.baseSize, it.factors, ) val extraInfo = "${it.factors.baseValue} ${it.baseSize.longString()}/s" "${sizeUnit}/s ($extraInfo)".asStringSource() }, ) } fun showDownloadFinishWindow(settingsStorage: BaseAppSettingsStorage): BooleanConfigurable { return BooleanConfigurable( title = Res.string.settings_show_completion_dialog.asStringSource(), description = Res.string.settings_show_completion_dialog_description.asStringSource(), backedBy = settingsStorage.showDownloadCompletionDialog, describe = { (if (it) Res.string.enabled else Res.string.disabled).asStringSource() }, ) } fun autoShowDownloadProgressWindow(settingsStorage: BaseAppSettingsStorage): BooleanConfigurable { return BooleanConfigurable( title = Res.string.settings_show_download_progress_dialog.asStringSource(), description = Res.string.settings_show_download_progress_dialog_description.asStringSource(), backedBy = settingsStorage.showDownloadProgressDialog, describe = { (if (it) Res.string.enabled else Res.string.disabled).asStringSource() }, ) } fun perHostSettings(perHostSettingsPageManager: PerHostSettingsPageManager): NavigatableConfigurable { return NavigatableConfigurable( title = Res.string.settings_per_host_settings.asStringSource(), description = Res.string.settings_per_host_settings_descriptions.asStringSource(), onRequestNavigate = { perHostSettingsPageManager.openPerHostSettings(null) }, ) } fun speedLimitConfig(appRepository: BaseAppRepository): SpeedLimitConfigurable { return SpeedLimitConfigurable( title = Res.string.settings_global_speed_limiter.asStringSource(), description = Res.string.settings_global_speed_limiter_description.asStringSource(), backedBy = appRepository.speedLimiter, describe = { if (it == 0L) { Res.string.unlimited.asStringSource() } else { convertPositiveSpeedToHumanReadable( it, appRepository.speedUnit.value ).asStringSource() } } ) } fun useAverageSpeedConfig(appRepository: BaseAppRepository): BooleanConfigurable { return BooleanConfigurable( title = Res.string.settings_show_average_speed.asStringSource(), description = Res.string.settings_show_average_speed_description.asStringSource(), backedBy = appRepository.useAverageSpeed, describe = { if (it) Res.string.average_speed.asStringSource() else Res.string.exact_speed.asStringSource() } ) } fun defaultDownloadFolderConfig(appSettings: BaseAppSettingsStorage): FolderConfigurable { return FolderConfigurable( title = Res.string.settings_default_download_folder.asStringSource(), description = Res.string.settings_default_download_folder_description.asStringSource(), backedBy = appSettings.defaultDownloadFolder, validate = { FileUtils.Companion.canWriteInThisFolder(it) }, describe = { Res.string .settings_default_download_folder_describe .asStringSourceWithARgs( Res.string.settings_default_download_folder_describe_createArgs( folder = it ) ) } ) } fun uiScaleConfig(appSettings: BaseAppSettingsStorage): EnumConfigurable { return EnumConfigurable( title = Res.string.settings_ui_scale.asStringSource(), description = Res.string.settings_ui_scale_description.asStringSource(), backedBy = appSettings.uiScale, possibleValues = listOf( 0.8f, 0.9f, 1f, 1.1f, 1.25f, 1.5f, 1.75f, 2f, ), renderMode = EnumConfigurable.RenderMode.Spinner, describe = { val percent = (it * 100).roundToInt() if (it == DEFAULT_UI_SCALE) { StringSource.CombinedStringSource( listOf( Res.string.system.asStringSource(), "($percent%)".asStringSource() ), " " ) } else { "$percent%".asStringSource() } } ) } fun themeConfig( themeManager: ThemeManager, scope: CoroutineScope, ): ThemeConfigurable { val currentThemeInfo = themeManager.currentThemeInfo val themes = themeManager.selectableThemes return ThemeConfigurable( title = Res.string.settings_theme.asStringSource(), description = Res.string.settings_theme_description.asStringSource(), backedBy = createMutableStateFlowFromStateFlow( flow = currentThemeInfo, updater = { themeManager.setTheme(it.id) }, scope = scope, ), possibleValues = themes.value, describe = { it.name }, ) } fun defaultDarkThemeConfig( themeManager: ThemeManager, scope: CoroutineScope, ): ThemeConfigurable { val currentDefaultDarkThemeInfo = themeManager.selectedDarkThemeInfo val darkThemes = themeManager.selectableDarkThemes return ThemeConfigurable( title = Res.string.settings_default_dark_theme.asStringSource(), description = Res.string.settings_default_dark_theme_description.asStringSource(), backedBy = createMutableStateFlowFromStateFlow( flow = currentDefaultDarkThemeInfo, updater = { themeManager.setDarkTheme(it.id) }, scope = scope, ), possibleValues = darkThemes.value, describe = { it.name }, ) } fun defaultLightThemeConfig( themeManager: ThemeManager, scope: CoroutineScope, ): ThemeConfigurable { val currentDefaultLightThemeInfo = themeManager.selectedLightThemeInfo val lightThemes = themeManager.selectableLightThemes return ThemeConfigurable( title = Res.string.settings_default_light_theme.asStringSource(), description = Res.string.settings_default_light_theme_description.asStringSource(), backedBy = createMutableStateFlowFromStateFlow( flow = currentDefaultLightThemeInfo, updater = { themeManager.setLightTheme(it.id) }, scope = scope, ), possibleValues = lightThemes.value, describe = { it.name }, ) } fun languageConfig( languageManager: LanguageManager, scope: CoroutineScope, ): EnumConfigurable { val currentLanguageName = languageManager.selectedLanguageInStorage val allLanguages = languageManager.languageList.value return EnumConfigurable( title = Res.string.settings_language.asStringSource(), description = "".asStringSource(), backedBy = createMutableStateFlowFromStateFlow( flow = currentLanguageName.mapStateFlow { language -> language?.let { allLanguages.find { it.toLocaleString() == language } } }, updater = { languageInfo -> languageManager.selectLanguage(languageInfo) }, scope = scope, ), valueToString = { if (it == null) { emptyList() } else { listOfNotNull( it.nativeName, it.locale.languageCode, it.locale.countryCode, ) } }, possibleValues = listOf(null).plus(allLanguages), describe = { val isAuto = it == null val language = it ?: languageManager.systemLanguageOrDefault val languageName = language.nativeName if (isAuto) { // always use english here! "System ($languageName)".asStringSource() } else { languageName.asStringSource() } }, ) } fun showIconLabels(appSettings: BaseAppSettingsStorage): BooleanConfigurable { return BooleanConfigurable( title = Res.string.settings_show_icon_labels.asStringSource(), description = Res.string.settings_show_icon_labels_description.asStringSource(), backedBy = appSettings.showIconLabels, describe = { if (it) { Res.string.enabled.asStringSource() } else { Res.string.disabled.asStringSource() } }, ) } fun useRelativeDateTime(appSettings: BaseAppSettingsStorage): BooleanConfigurable { return BooleanConfigurable( title = Res.string.settings_use_relative_date_time.asStringSource(), description = Res.string.settings_use_relative_date_time_description.asStringSource(), backedBy = appSettings.useRelativeDateTime, describe = { if (it) { Res.string.enabled.asStringSource() } else { Res.string.disabled.asStringSource() } }, ) } fun autoStartConfig(appSettings: BaseAppSettingsStorage): BooleanConfigurable { return BooleanConfigurable( title = Res.string.settings_start_on_boot.asStringSource(), description = Res.string.settings_start_on_boot_description.asStringSource(), backedBy = appSettings.autoStartOnBoot, renderMode = BooleanConfigurable.RenderMode.Switch, describe = { if (it) { Res.string.enabled.asStringSource() } else { Res.string.disabled.asStringSource() } } ) } fun playSoundNotification(appSettings: BaseAppSettingsStorage): BooleanConfigurable { return BooleanConfigurable( title = Res.string.settings_notification_sound.asStringSource(), description = Res.string.settings_notification_sound_description.asStringSource(), backedBy = appSettings.notificationSound, renderMode = BooleanConfigurable.RenderMode.Switch, describe = { if (it) { Res.string.enabled.asStringSource() } else { Res.string.disabled.asStringSource() } } ) } fun browserIntegrationEnabled(appRepository: BaseAppRepository): BooleanConfigurable { return BooleanConfigurable( title = Res.string.settings_browser_integration.asStringSource(), description = Res.string.settings_browser_integration_description.asStringSource(), backedBy = appRepository.integrationEnabled, renderMode = BooleanConfigurable.RenderMode.Switch, describe = { if (it) { Res.string.enabled.asStringSource() } else { Res.string.disabled.asStringSource() } } ) } fun browserIntegrationPort(appRepository: BaseAppRepository): IntConfigurable { return IntConfigurable( title = Res.string.settings_browser_integration_server_port.asStringSource(), description = Res.string.settings_browser_integration_server_port_description.asStringSource(), backedBy = appRepository.integrationPort, describe = { Res.string.settings_browser_integration_server_port_describe .asStringSourceWithARgs( Res.string.settings_browser_integration_server_port_describe_createArgs( port = it.toString() ) ) }, range = 0..65000, ) } fun proxyConfig(proxyManager: ProxyManager): ProxyConfigurable { return ProxyConfigurable( title = Res.string.settings_use_proxy.asStringSource(), description = Res.string.settings_use_proxy_description.asStringSource(), backedBy = proxyManager.proxyData, validate = { true }, describe = { when (it.proxyMode) { ProxyMode.Direct -> Res.string.settings_use_proxy_describe_no_proxy.asStringSource() ProxyMode.UseSystem -> Res.string.settings_use_proxy_describe_system_proxy.asStringSource() ProxyMode.Manual -> Res.string.settings_use_proxy_describe_manual_proxy .asStringSourceWithARgs( Res.string.settings_use_proxy_describe_manual_proxy_createArgs( value = it.proxyWithRules.proxy.run { "$type $host:$port" } ) ) ProxyMode.Pac -> { Res.string.settings_use_proxy_describe_pac_proxy .asStringSourceWithARgs( Res.string.settings_use_proxy_describe_pac_proxy_createArgs( value = it.pac.uri ) ) } } } ) } } ================================================ FILE: shared/app/src/commonMain/kotlin/com/abdownloadmanager/shared/singledownloadpage/BaseSingleDownloadComponent.kt ================================================ package com.abdownloadmanager.shared.singledownloadpage import com.abdownloadmanager.resources.Res import com.abdownloadmanager.shared.repository.BaseAppRepository import com.abdownloadmanager.shared.storage.BaseAppSettingsStorage import com.abdownloadmanager.shared.storage.ExtraDownloadSettingsStorage import com.abdownloadmanager.shared.storage.IExtraDownloadItemSettings import com.abdownloadmanager.shared.ui.configurable.item.IntConfigurable import com.abdownloadmanager.shared.ui.configurable.item.SpeedLimitConfigurable import com.abdownloadmanager.shared.util.BaseComponent import com.abdownloadmanager.shared.util.DownloadItemOpener import com.abdownloadmanager.shared.util.DownloadSystem import com.abdownloadmanager.shared.util.FileIconProvider import com.abdownloadmanager.shared.util.ThreadCountLimitation import com.abdownloadmanager.shared.util.TimeNames import com.abdownloadmanager.shared.util.convertDurationToHumanReadable import com.abdownloadmanager.shared.util.convertPositiveSizeToHumanReadable import com.abdownloadmanager.shared.util.convertPositiveSpeedToHumanReadable import com.abdownloadmanager.shared.util.convertTimeRemainingToHumanReadable import com.abdownloadmanager.shared.util.mvi.ContainsEffects import com.abdownloadmanager.shared.util.mvi.supportEffects import com.arkivanov.decompose.ComponentContext import ir.amirab.downloader.DownloadManager import ir.amirab.downloader.DownloadManagerEvents import ir.amirab.downloader.downloaditem.DownloadJobStatus import ir.amirab.downloader.monitor.CompletedDownloadItemState import ir.amirab.downloader.monitor.DurationBasedProcessingDownloadItemState import ir.amirab.downloader.monitor.IDownloadItemState import ir.amirab.downloader.monitor.IDownloadMonitor import ir.amirab.downloader.monitor.ProcessingDownloadItemState import ir.amirab.util.compose.StringSource import ir.amirab.util.compose.asStringSource import ir.amirab.util.compose.asStringSourceWithARgs import ir.amirab.util.flow.combineStateFlows import ir.amirab.util.flow.createMutableStateFlowFromFlow import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.delay import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.debounce import kotlinx.coroutines.flow.distinctUntilChanged import kotlinx.coroutines.flow.drop import kotlinx.coroutines.flow.filter import kotlinx.coroutines.flow.filterIsInstance import kotlinx.coroutines.flow.launchIn import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.mapNotNull import kotlinx.coroutines.flow.onEach import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch import kotlinx.coroutines.runBlocking import org.koin.core.component.KoinComponent import kotlin.getValue abstract class BaseSingleDownloadComponent< TExtraDownloadItemSettings : IExtraDownloadItemSettings >( ctx: ComponentContext, val downloadItemOpener: DownloadItemOpener, private val extraDownloadSettingsStorage: ExtraDownloadSettingsStorage, private val onDismiss: () -> Unit, val downloadId: Long, private val downloadSystem: DownloadSystem, private val appSettings: BaseAppSettingsStorage, private val appRepository: BaseAppRepository, private val applicationScope: CoroutineScope, val fileIconProvider: FileIconProvider, ) : BaseComponent(ctx), ContainsEffects by supportEffects(), KoinComponent { open val defaultShowPartInfo: Boolean = true private val downloadMonitor: IDownloadMonitor = downloadSystem.downloadMonitor private val downloadManager: DownloadManager = downloadSystem.downloadManager val itemStateFlow = MutableStateFlow(null) protected val globalShowCompletionDialog: StateFlow = appSettings.showDownloadCompletionDialog protected val itemShouldShowCompletionDialog: MutableStateFlow = MutableStateFlow(null as Boolean?) private val shouldShowCompletionDialog = combineStateFlows( globalShowCompletionDialog, itemShouldShowCompletionDialog, ) { global, item -> item ?: global } val deletePartialFileOnDownloadCancellation = appSettings.deletePartialFileOnDownloadCancellation.asStateFlow() val extraDownloadItemSettingsFlow = createMutableStateFlowFromFlow( extraDownloadSettingsStorage .getExternalDownloadItemSettingsAsFlow(downloadId, initialEmit = false), extraDownloadSettingsStorage .getExtraDownloadItemSettings(downloadId), { scope.launch { extraDownloadSettingsStorage.setExtraDownloadItemSettings( it ) } }, scope, ) private fun shouldShowCompletionDialog(): Boolean { return shouldShowCompletionDialog.value } init { downloadMonitor .downloadListFlow // downloadListFlow (combinedStateFlow { active + completed } downloads) emits null sometimes when download item removed from active downloads and also not exists in completed downloads yet (exactly at the moment that download finishes) // however if the download removed by user (item == null) this component will be closed outside of this component we don't need to handle this case here // I explicitly filter nulls here to make onEach function predictable // if I fix downloadListFlow to not emit nulls I can remove this filter later .mapNotNull { it.firstOrNull { it.id == downloadId } } .distinctUntilChanged() .onEach { val item = it val previous = itemStateFlow.value if (previous is ProcessingDownloadItemState && item is CompletedDownloadItemState) { // if It was opened to show progress if (shouldShowCompletionDialog()) { itemStateFlow.value = item } else { itemStateFlow.value = null // app component tries to create this component if user want to auto open completion dialog and this component is not created yet // so we keep this component active a while to prevent create new component // this prevents opening this window if global [appSettings.showDownloadCompletionDialog] is true but user explicitly tells that he don't want to open completion dialog for this item delay(100) close() } } else { itemStateFlow.value = item } }.launchIn(scope) } private val _showPartInfo = MutableStateFlow(defaultShowPartInfo) val showPartInfo = _showPartInfo.asStateFlow() open fun setShowPartInfo(value: Boolean) { _showPartInfo.value = value } // TODO this can be moved to a nested component to reduce system resource usage val extraDownloadProgressInfo: StateFlow> = itemStateFlow .filterIsInstance() .map { buildList { add(SingleDownloadPagePropertyItem(Res.string.name.asStringSource(), it.name.asStringSource())) add(SingleDownloadPagePropertyItem(Res.string.status.asStringSource(), createStatusString(it))) if (it is DurationBasedProcessingDownloadItemState) { add( SingleDownloadPagePropertyItem( Res.string.size.asStringSource(), it.duration ?.let(::convertDurationToHumanReadable) ?: Res.string.unknown.asStringSource() ) ) } else { add( SingleDownloadPagePropertyItem( Res.string.size.asStringSource(), convertPositiveSizeToHumanReadable(it.contentLength, appRepository.sizeUnit.value) ) ) } add( SingleDownloadPagePropertyItem( Res.string.download_page_downloaded_size.asStringSource(), StringSource.CombinedStringSource( buildList { add(convertPositiveSizeToHumanReadable(it.progress, appRepository.sizeUnit.value)) if (it.percent != null) { add("(${it.percent}%)".asStringSource()) } }, " " ) ) ) add( SingleDownloadPagePropertyItem( Res.string.speed.asStringSource(), convertPositiveSpeedToHumanReadable(it.speed, appRepository.speedUnit.value).asStringSource() ) ) add( SingleDownloadPagePropertyItem( Res.string.time_left.asStringSource(), (it.remainingTime?.let { remainingTime -> convertTimeRemainingToHumanReadable(remainingTime, TimeNames.ShortNames) }.orEmpty()).asStringSource() ) ) add( SingleDownloadPagePropertyItem( Res.string.resume_support.asStringSource(), when (it.supportResume) { true -> Res.string.yes.asStringSource() false -> Res.string.no.asStringSource() null -> Res.string.unknown.asStringSource() }, when (it.supportResume) { true -> SingleDownloadPagePropertyItem.ValueType.Success false -> SingleDownloadPagePropertyItem.ValueType.Error null -> SingleDownloadPagePropertyItem.ValueType.Normal } ) ) } }.stateIn(scope, SharingStarted.Eagerly, emptyList()) fun openFolder() { val itemState = itemStateFlow.value applicationScope.launch { if (itemState is CompletedDownloadItemState) { downloadItemOpener.openDownloadItemFolder(downloadId) } } onDismiss() } fun openFile(alsoClose: Boolean = true) { val itemState = itemStateFlow.value applicationScope.launch { if (itemState is CompletedDownloadItemState) { runCatching { downloadItemOpener.openDownloadItem(downloadId) } } } if (alsoClose) { onDismiss() } } fun toggle() { val state = itemStateFlow.value as? ProcessingDownloadItemState ?: return scope.launch { when { state.canBePaused() -> downloadSystem.manualPause(downloadId) state.canBeResumed() -> downloadSystem.userManualResume(downloadId) } } } fun resume() { val state = itemStateFlow.value as? ProcessingDownloadItemState ?: return scope.launch { if (state.canBeResumed()) { downloadSystem.userManualResume(downloadId) } } } fun pause() { val state = itemStateFlow.value as? ProcessingDownloadItemState ?: return scope.launch { if (state.canBePaused()) { downloadSystem.manualPause(downloadId) } } } fun close() { scope.launch { onDismiss() } } fun cancel() { applicationScope.launch { val state = itemStateFlow.value as? ProcessingDownloadItemState if (deletePartialFileOnDownloadCancellation.value) { downloadSystem.reset(downloadId) } else { if (state?.canBePaused() ?: false) { downloadSystem.manualPause(downloadId) } } } scope.launch { onDismiss() } } private val threadCount: MutableStateFlow private val speedLimit: MutableStateFlow init { val dItem = runBlocking { downloadManager.dlListDb.getById(downloadId) } threadCount = MutableStateFlow( dItem?.preferredConnectionCount ?: 0 ) speedLimit = MutableStateFlow(dItem?.speedLimit ?: 0) downloadManager.listOfJobsEvents .filterIsInstance() .filter { it.downloadItem.id == dItem?.id } .onEach { event -> threadCount.update { event.downloadItem.preferredConnectionCount ?: 0 } speedLimit.update { event.downloadItem.speedLimit } }.launchIn(scope) threadCount .drop(1) .debounce(500) .onEach { count -> downloadManager.updateDownloadItem( id = downloadId, downloadJobExtraConfig = null ) { it.preferredConnectionCount = count.takeIf { it > 0 } } }.launchIn(scope) speedLimit .drop(1) .debounce(500) .onEach { limit -> downloadManager.updateDownloadItem( id = downloadId, downloadJobExtraConfig = null ) { it.speedLimit = limit } }.launchIn(scope) } val settings by lazy { listOf( IntConfigurable( title = Res.string.download_item_settings_thread_count.asStringSource(), description = Res.string.download_item_settings_thread_count_description.asStringSource(), backedBy = threadCount, describe = { if (it == 0) { Res.string.use_global_settings.asStringSource() } else { Res.string.download_item_settings_thread_count_describe .asStringSourceWithARgs( Res.string.download_item_settings_thread_count_describe_createArgs( count = it.toString() ) ) } }, range = 0..ThreadCountLimitation.MAX_ALLOWED_THREAD_COUNT, renderMode = IntConfigurable.RenderMode.TextField, ), SpeedLimitConfigurable( title = Res.string.download_item_settings_speed_limit.asStringSource(), description = Res.string.download_item_settings_speed_limit_description.asStringSource(), backedBy = speedLimit, describe = { if (it == 0L) { Res.string.unlimited.asStringSource() } else { convertPositiveSpeedToHumanReadable(it, appRepository.speedUnit.value).asStringSource() } }, ), ) } interface Config { val id: Long } sealed interface Effects { sealed interface Common : Effects interface Platform : Effects } } ================================================ FILE: shared/app/src/commonMain/kotlin/com/abdownloadmanager/shared/singledownloadpage/DownloadStatusStringSource.kt ================================================ package com.abdownloadmanager.shared.singledownloadpage import com.abdownloadmanager.resources.Res import ir.amirab.downloader.downloaditem.DownloadJobStatus import ir.amirab.downloader.monitor.IDownloadItemState import ir.amirab.downloader.monitor.ProcessingDownloadItemState import ir.amirab.downloader.monitor.statusOrFinished import ir.amirab.downloader.utils.ExceptionUtils import ir.amirab.util.compose.StringSource import ir.amirab.util.compose.asStringSource fun createStatusString(it: IDownloadItemState): StringSource { if (it is ProcessingDownloadItemState && it.isWaiting) { return Res.string.waiting.asStringSource() } return when (val status = it.statusOrFinished()) { is DownloadJobStatus.Canceled -> { if (ExceptionUtils.isNormalCancellation(status.e)) { Res.string.paused } else { Res.string.error } } DownloadJobStatus.Downloading -> Res.string.downloading DownloadJobStatus.Finished -> Res.string.finished DownloadJobStatus.IDLE -> Res.string.idle is DownloadJobStatus.PreparingFile -> Res.string.preparing_file DownloadJobStatus.Resuming -> Res.string.resuming is DownloadJobStatus.Retrying -> Res.string.retrying }.asStringSource() } ================================================ FILE: shared/app/src/commonMain/kotlin/com/abdownloadmanager/shared/singledownloadpage/SingleDownloadPagePropertyItem.kt ================================================ package com.abdownloadmanager.shared.singledownloadpage import androidx.compose.runtime.Immutable import ir.amirab.util.compose.StringSource @Immutable data class SingleDownloadPagePropertyItem( val name: StringSource, val value: StringSource, val valueState: ValueType = ValueType.Normal, ) { enum class ValueType { Normal, Error, Success } } ================================================ FILE: shared/app/src/commonMain/kotlin/com/abdownloadmanager/shared/storage/BaseAppSettingsStorage.kt ================================================ package com.abdownloadmanager.shared.storage import com.abdownloadmanager.shared.ui.theme.ThemeSettingsStorage import ir.amirab.util.compose.localizationmanager.LanguageStorage import kotlinx.coroutines.flow.MutableStateFlow interface IAppSettingsModel { val theme: String val defaultDarkTheme: String val defaultLightTheme: String val language: String? val font: String? val uiScale: Float? val showIconLabels: Boolean val useRelativeDateTime: Boolean val threadCount: Int val maxConcurrentDownloads: Int val maxDownloadRetryCount: Int val dynamicPartCreation: Boolean val useServerLastModifiedTime: Boolean val appendExtensionToIncompleteDownloads: Boolean val useSparseFileAllocation: Boolean val useAverageSpeed: Boolean val showDownloadProgressDialog: Boolean val showDownloadCompletionDialog: Boolean val speedLimit: Long val autoStartOnBoot: Boolean val notificationSound: Boolean val defaultDownloadFolder: String val browserIntegrationEnabled: Boolean val browserIntegrationPort: Int val trackDeletedFilesOnDisk: Boolean val deletePartialFileOnDownloadCancellation: Boolean val sizeUnit: SupportedSizeUnits val speedUnit: SupportedSizeUnits val ignoreSSLCertificates: Boolean val useCategoryByDefault: Boolean val userAgent: String } interface BaseAppSettingsStorage : LanguageStorage, ThemeSettingsStorage { override val theme: MutableStateFlow override val defaultDarkTheme: MutableStateFlow override val defaultLightTheme: MutableStateFlow override val selectedLanguage: MutableStateFlow val font: MutableStateFlow val uiScale: MutableStateFlow val showIconLabels: MutableStateFlow val useRelativeDateTime: MutableStateFlow val threadCount: MutableStateFlow val maxConcurrentDownloads: MutableStateFlow val dynamicPartCreation: MutableStateFlow val useServerLastModifiedTime: MutableStateFlow val appendExtensionToIncompleteDownloads: MutableStateFlow val useSparseFileAllocation: MutableStateFlow val useAverageSpeed: MutableStateFlow val maxDownloadRetryCount: MutableStateFlow val showDownloadProgressDialog: MutableStateFlow val showDownloadCompletionDialog: MutableStateFlow val speedLimit: MutableStateFlow val autoStartOnBoot: MutableStateFlow val notificationSound: MutableStateFlow val defaultDownloadFolder: MutableStateFlow val browserIntegrationEnabled: MutableStateFlow val browserIntegrationPort: MutableStateFlow val trackDeletedFilesOnDisk: MutableStateFlow val deletePartialFileOnDownloadCancellation: MutableStateFlow val sizeUnit: MutableStateFlow val speedUnit: MutableStateFlow val ignoreSSLCertificates: MutableStateFlow val useCategoryByDefault: MutableStateFlow val userAgent: MutableStateFlow } ================================================ FILE: shared/app/src/commonMain/kotlin/com/abdownloadmanager/shared/storage/ExtraDownloadSettingsStorage.kt ================================================ package com.abdownloadmanager.shared.storage import ir.amirab.downloader.db.TransactionalFileSaver import ir.amirab.downloader.utils.SuspendLockList import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.channels.BufferOverflow import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.emitAll import kotlinx.coroutines.flow.filter import kotlinx.coroutines.flow.flow import kotlinx.coroutines.withContext import java.io.File class ExtraDownloadSettingsStorage( private val folder: File, private val transactionalFileSaver: TransactionalFileSaver, private val dataClassDefinitions: IExtraDownloadItemSettings.DataClassDefinitions ) : IExtraDownloadSettingsStorage { private fun getFileOf(id: Long) = folder .resolve("${id}.json") private val updateLocks = SuspendLockList() private val lastEmits = MutableSharedFlow( extraBufferCapacity = 64,// too big! onBufferOverflow = BufferOverflow.DROP_OLDEST, ) override suspend fun deleteExtraDownloadItemSettings( downloadId: Long, ) { lastEmits.tryEmit( dataClassDefinitions.createDefault(downloadId) ) getFileOf(downloadId).delete() } override suspend fun setExtraDownloadItemSettings( extraDownloadItemSettings: T, ) { require(extraDownloadItemSettings.id >= 0) { "downloadId must be >= 0" } val file = getFileOf(extraDownloadItemSettings.id) lastEmits.tryEmit(extraDownloadItemSettings) return withContext(Dispatchers.IO) { updateLocks.withLock(extraDownloadItemSettings.id) { transactionalFileSaver.writeObject( file, extraDownloadItemSettings, dataClassDefinitions.serializer ) } } } override fun getExtraDownloadItemSettings(downloadId: Long): T { val file = getFileOf(downloadId) return transactionalFileSaver .readObject(file, dataClassDefinitions.serializer) ?: dataClassDefinitions.createDefault(downloadId) } override fun getExternalDownloadItemSettingsAsFlow( id: Long, initialEmit: Boolean, ): Flow { return flow { if (initialEmit) { emit(getExtraDownloadItemSettings(id)) } emitAll(lastEmits.filter { it.id == id }) } } } ================================================ FILE: shared/app/src/commonMain/kotlin/com/abdownloadmanager/shared/storage/ExtraQueueSettingsStorage.kt ================================================ package com.abdownloadmanager.shared.storage import ir.amirab.downloader.db.TransactionalFileSaver import ir.amirab.downloader.utils.SuspendLockList import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.channels.BufferOverflow import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.emitAll import kotlinx.coroutines.flow.filter import kotlinx.coroutines.flow.flow import kotlinx.coroutines.withContext import java.io.File class ExtraQueueSettingsStorage( private val folder: File, private val transactionalFileSaver: TransactionalFileSaver, private val dataClassDefinitions: IExtraQueueSettings.DataClassDefinitions, ) : IExtraQueueSettingsStorage { private fun getFileOf(id: Long) = folder .resolve("${id}.json") private val updateLocks = SuspendLockList() private val lastEmits = MutableSharedFlow( extraBufferCapacity = 64,// too big! onBufferOverflow = BufferOverflow.DROP_OLDEST, ) override suspend fun deleteExtraQueueSettings( queueId: Long, ) { lastEmits.tryEmit( dataClassDefinitions.createDefault(queueId) ) getFileOf(queueId).delete() } override suspend fun setExtraQueueSettings( extraQueueSettings: T, ) { require(extraQueueSettings.id >= 0) { "queueId must be >= 0. given ${extraQueueSettings.id}" } val file = getFileOf(extraQueueSettings.id) lastEmits.tryEmit(extraQueueSettings) return withContext(Dispatchers.IO) { updateLocks.withLock(extraQueueSettings.id) { transactionalFileSaver.writeObject( file, extraQueueSettings, dataClassDefinitions.serializer, ) } } } override fun getExtraQueueSettings(queueId: Long): T { val file = getFileOf(queueId) return transactionalFileSaver .readObject(file, dataClassDefinitions.serializer) ?: dataClassDefinitions.createDefault(queueId) } override fun getExternalQueueSettingsAsFlow( id: Long, initialEmit: Boolean, ): Flow { return flow { if (initialEmit) { emit(getExtraQueueSettings(id)) } emitAll(lastEmits.filter { it.id == id }) } } } ================================================ FILE: shared/app/src/commonMain/kotlin/com/abdownloadmanager/shared/storage/IExtraDownloadSettingsStorage.kt ================================================ package com.abdownloadmanager.shared.storage import kotlinx.coroutines.flow.Flow import kotlinx.serialization.KSerializer interface IExtraDownloadSettingsStorage { suspend fun deleteExtraDownloadItemSettings(downloadId: Long) suspend fun setExtraDownloadItemSettings(extraDownloadItemSettings: T) fun getExtraDownloadItemSettings(downloadId: Long): T fun getExternalDownloadItemSettingsAsFlow(id: Long, initialEmit: Boolean = false): Flow } interface IExtraDownloadItemSettings { val id: Long interface DataClassDefinitions { fun createDefault(id: Long): T val serializer: KSerializer } } ================================================ FILE: shared/app/src/commonMain/kotlin/com/abdownloadmanager/shared/storage/IExtraQueueSettingsStorage.kt ================================================ package com.abdownloadmanager.shared.storage import kotlinx.coroutines.flow.Flow import kotlinx.serialization.KSerializer interface IExtraQueueSettingsStorage { suspend fun deleteExtraQueueSettings(queueId: Long) suspend fun setExtraQueueSettings(extraQueueSettings: T) fun getExtraQueueSettings(queueId: Long): T fun getExternalQueueSettingsAsFlow(id: Long, initialEmit: Boolean = false): Flow } interface IExtraQueueSettings { val id: Long interface DataClassDefinitions { fun createDefault(id: Long): T val serializer: KSerializer } } ================================================ FILE: shared/app/src/commonMain/kotlin/com/abdownloadmanager/shared/storage/ILastSavedLocationsStorage.kt ================================================ package com.abdownloadmanager.shared.storage import kotlinx.coroutines.flow.MutableStateFlow interface ILastSavedLocationsStorage { val lastUsedSaveLocations: MutableStateFlow> } ================================================ FILE: shared/app/src/commonMain/kotlin/com/abdownloadmanager/shared/storage/PerHostSettingsDatastoreStorage.kt ================================================ package com.abdownloadmanager.shared.storage import androidx.datastore.core.DataStore import com.abdownloadmanager.shared.util.ConfigBaseSettingsByJson import com.abdownloadmanager.shared.util.perhostsettings.IPerHostSettingsStorage import com.abdownloadmanager.shared.util.perhostsettings.PerHostSettingsItem import kotlinx.coroutines.flow.MutableStateFlow class PerHostSettingsDatastoreStorage( dataStore: DataStore>, ) : IPerHostSettingsStorage, ConfigBaseSettingsByJson>(dataStore) { override val perHostSettingsFlow: MutableStateFlow> = data } ================================================ FILE: shared/app/src/commonMain/kotlin/com/abdownloadmanager/shared/storage/ProxyDatastoreStorage.kt ================================================ package com.abdownloadmanager.shared.storage import androidx.datastore.core.DataStore import com.abdownloadmanager.shared.util.ConfigBaseSettingsByJson import com.abdownloadmanager.shared.util.proxy.IProxyStorage import com.abdownloadmanager.shared.util.proxy.ProxyData class ProxyDatastoreStorage( dataStore: DataStore, ) : IProxyStorage, ConfigBaseSettingsByJson(dataStore) { override val proxyDataFlow = data } ================================================ FILE: shared/app/src/commonMain/kotlin/com/abdownloadmanager/shared/storage/SupportedSizeUnits.kt ================================================ package com.abdownloadmanager.shared.storage import ir.amirab.util.datasize.CommonSizeConvertConfigs import ir.amirab.util.datasize.ConvertSizeConfig enum class SupportedSizeUnits { BinaryBits, BinaryBytes, DecimalBits, DecimalBytes; fun toConfig(): ConvertSizeConfig { return when (this) { BinaryBits -> CommonSizeConvertConfigs.BinaryBits BinaryBytes -> CommonSizeConvertConfigs.BinaryBytes DecimalBits -> CommonSizeConvertConfigs.DecimalBits DecimalBytes -> CommonSizeConvertConfigs.DecimalBytes } } companion object { fun fromConfig(config: ConvertSizeConfig): SupportedSizeUnits? { return when (config) { CommonSizeConvertConfigs.BinaryBits -> BinaryBits CommonSizeConvertConfigs.BinaryBytes -> BinaryBytes CommonSizeConvertConfigs.DecimalBits -> DecimalBits CommonSizeConvertConfigs.DecimalBytes -> DecimalBytes else -> null } } } } ================================================ FILE: shared/app/src/commonMain/kotlin/com/abdownloadmanager/shared/storage/impl/LastSavedLocationStorage.kt ================================================ package com.abdownloadmanager.shared.storage.impl import androidx.datastore.core.DataStore import com.abdownloadmanager.shared.storage.ILastSavedLocationsStorage import com.abdownloadmanager.shared.util.ConfigBaseSettingsByJson import kotlinx.coroutines.flow.MutableStateFlow class LastSavedLocationStorage( dataStore: DataStore> ) : ConfigBaseSettingsByJson>(dataStore), ILastSavedLocationsStorage { override val lastUsedSaveLocations: MutableStateFlow> = data } ================================================ FILE: shared/app/src/commonMain/kotlin/com/abdownloadmanager/shared/ui/Bootstrap.kt ================================================ package com.abdownloadmanager.shared.ui import androidx.compose.runtime.Composable import androidx.compose.runtime.CompositionLocalProvider import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue import com.abdownloadmanager.shared.repository.BaseAppRepository import com.abdownloadmanager.shared.storage.BaseAppSettingsStorage import com.abdownloadmanager.shared.ui.configurable.ConfigurableRendererRegistry import com.abdownloadmanager.shared.ui.configurable.LocalConfigurationRendererRegistry import com.abdownloadmanager.shared.util.LocalUseRelativeDateTime import com.abdownloadmanager.shared.util.ProvideSizeAndSpeedUnit import ir.amirab.util.compose.IIconResolver import ir.amirab.util.compose.LocalIconFromUriResolver @Composable fun ProvideCommonSettings( appSettings: BaseAppSettingsStorage, iconProvider: IIconResolver, configurableRendererRegistry: ConfigurableRendererRegistry, content: @Composable () -> Unit, ) { val useNativeDateTime by appSettings.useRelativeDateTime.collectAsState() CompositionLocalProvider( LocalUseRelativeDateTime provides useNativeDateTime, LocalIconFromUriResolver provides iconProvider, LocalConfigurationRendererRegistry provides configurableRendererRegistry, ) { content() } } @Composable fun ProvideSizeUnits( appRepository: BaseAppRepository, content: @Composable () -> Unit, ) { ProvideSizeAndSpeedUnit( sizeUnitConfig = appRepository.sizeUnit.collectAsState().value, speedUnitConfig = appRepository.speedUnit.collectAsState().value, content = content ) } ================================================ FILE: shared/app/src/commonMain/kotlin/com/abdownloadmanager/shared/ui/configurable/BaseEnumConfigurable.kt ================================================ package com.abdownloadmanager.shared.ui.configurable import ir.amirab.util.compose.StringSource import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow abstract class BaseEnumConfigurable( title: StringSource, description: StringSource, backedBy: MutableStateFlow, describe: ((T) -> StringSource), val possibleValues: List, val valueToString: (T) -> List, enabled: StateFlow = DefaultEnabledValue, visible: StateFlow = DefaultVisibleValue, ) : Configurable( title = title, description = description, backedBy = backedBy, validate = { it in possibleValues }, describe = describe, enabled = enabled, visible = visible, ) ================================================ FILE: shared/app/src/commonMain/kotlin/com/abdownloadmanager/shared/ui/configurable/BaseLongConfigurable.kt ================================================ package com.abdownloadmanager.shared.ui.configurable import ir.amirab.util.compose.StringSource import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow abstract class BaseLongConfigurable( title: StringSource, description: StringSource, backedBy: MutableStateFlow, describe: ((Long) -> StringSource), val range: LongRange, enabled: StateFlow = DefaultEnabledValue, visible: StateFlow = DefaultVisibleValue, ) : Configurable( title = title, description = description, backedBy = backedBy, validate = { it in range }, describe = describe, enabled = enabled, visible = visible, ) ================================================ FILE: shared/app/src/commonMain/kotlin/com/abdownloadmanager/shared/ui/configurable/CommonConfigurableRenderers.kt ================================================ package com.abdownloadmanager.shared.ui.configurable import com.abdownloadmanager.shared.ui.configurable.item.BooleanConfigurable import com.abdownloadmanager.shared.ui.configurable.item.DayOfWeekConfigurable import com.abdownloadmanager.shared.ui.configurable.item.EnumConfigurable import com.abdownloadmanager.shared.ui.configurable.item.FileChecksumConfigurable import com.abdownloadmanager.shared.ui.configurable.item.FloatConfigurable import com.abdownloadmanager.shared.ui.configurable.item.FolderConfigurable import com.abdownloadmanager.shared.ui.configurable.item.IntConfigurable import com.abdownloadmanager.shared.ui.configurable.item.LongConfigurable import com.abdownloadmanager.shared.ui.configurable.item.NavigatableConfigurable import com.abdownloadmanager.shared.ui.configurable.item.ProxyConfigurable import com.abdownloadmanager.shared.ui.configurable.item.SpeedLimitConfigurable import com.abdownloadmanager.shared.ui.configurable.item.StringConfigurable import com.abdownloadmanager.shared.ui.configurable.item.ThemeConfigurable import com.abdownloadmanager.shared.ui.configurable.item.TimeConfigurable interface ContainsConfigurableRenderers { fun getAllRenderers(): Map> } data class CommonConfigurableRenderers( val booleanConfigurableRenderer: ConfigurableRenderer, val dayOfWeekConfigurableRenderer: ConfigurableRenderer, val fileChecksumConfigurableRenderer: ConfigurableRenderer, val floatConfigurableRenderer: ConfigurableRenderer, val folderConfigurableRenderer: ConfigurableRenderer, val intConfigurableRenderer: ConfigurableRenderer, val longConfigurableRenderer: ConfigurableRenderer, val perHostSettingsConfigurableRenderer: ConfigurableRenderer, val enumConfigurableRenderer: ConfigurableRenderer>, val speedConfigurableRenderer: ConfigurableRenderer, val stringConfigurableRenderer: ConfigurableRenderer, val themeConfigurableRenderer: ConfigurableRenderer, val timeConfigurableRenderer: ConfigurableRenderer, val proxyConfigurableRenderer: ConfigurableRenderer, ) : ContainsConfigurableRenderers { override fun getAllRenderers(): Map> { return mapOf( BooleanConfigurable.Key to booleanConfigurableRenderer, DayOfWeekConfigurable.Key to dayOfWeekConfigurableRenderer, FileChecksumConfigurable.Key to fileChecksumConfigurableRenderer, FloatConfigurable.Key to floatConfigurableRenderer, FolderConfigurable.Key to folderConfigurableRenderer, IntConfigurable.Key to intConfigurableRenderer, LongConfigurable.Key to longConfigurableRenderer, NavigatableConfigurable.Key to perHostSettingsConfigurableRenderer, EnumConfigurable.Key to enumConfigurableRenderer, SpeedLimitConfigurable.Key to speedConfigurableRenderer, StringConfigurable.Key to stringConfigurableRenderer, ThemeConfigurable.Key to themeConfigurableRenderer, TimeConfigurable.Key to timeConfigurableRenderer, ProxyConfigurable.Key to proxyConfigurableRenderer, ) } } ================================================ FILE: shared/app/src/commonMain/kotlin/com/abdownloadmanager/shared/ui/configurable/Configurable.kt ================================================ package com.abdownloadmanager.shared.ui.configurable import androidx.compose.foundation.layout.PaddingValues import androidx.compose.runtime.Composable import androidx.compose.runtime.Immutable import androidx.compose.ui.Modifier import ir.amirab.util.compose.StringSource import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow abstract class Configurable( val title: StringSource, val description: StringSource, val backedBy: MutableStateFlow, val validate: (T) -> Boolean = { true }, val describe: (T) -> StringSource, val enabled: StateFlow = DefaultEnabledValue, val visible: StateFlow = DefaultVisibleValue, ) { // each configurable should have a unique key // we use this to retrieve its renderer from registry interface Key abstract fun getKey(): Key val stateFlow = backedBy.asStateFlow() fun set(value: T): Boolean { if (validate(value)) { // don't use update function here maybe this is a mappedByTwoWayMutableStateFlow // IMPROVE backedBy.value = value return true } return false } companion object { val DefaultEnabledValue get() = MutableStateFlow(true) val DefaultVisibleValue get() = MutableStateFlow(true) } } @Immutable data class ConfigurableUiProps( val modifier: Modifier = Modifier, val itemPaddingValues: PaddingValues = PaddingValues.Zero, ) interface ConfigurableRenderer> { @Composable fun RenderConfigurable( configurable: TConfigurable, configurableUiProps: ConfigurableUiProps, ) } ================================================ FILE: shared/app/src/commonMain/kotlin/com/abdownloadmanager/shared/ui/configurable/ConfigurableGroup.kt ================================================ package com.abdownloadmanager.shared.ui.configurable import androidx.compose.runtime.Stable import ir.amirab.util.compose.StringSource import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow @Stable data class ConfigurableGroup( val groupTitle: StateFlow = MutableStateFlow(null), val mainConfigurable:Configurable<*>?=null, val nestedEnabled:StateFlow =MutableStateFlow(true), val nestedVisible:StateFlow =MutableStateFlow(true), val nestedConfigurable: List>, ) ================================================ FILE: shared/app/src/commonMain/kotlin/com/abdownloadmanager/shared/ui/configurable/ConfigurableRendererRegistry.kt ================================================ package com.abdownloadmanager.shared.ui.configurable import androidx.compose.runtime.Composable import androidx.compose.runtime.staticCompositionLocalOf import androidx.compose.ui.Modifier // >>>>>>>>> // only use these in public fun ConfigurableRendererRegistry( builder: ConfigurableRendererRegistryBuilder.() -> Unit ): ConfigurableRendererRegistry { return ConfigurableRendererRegistry( map = ConfigurableRendererRegistryBuilder() .apply(builder).map ) } @Composable fun Configurable.Render( configurableUiProps: ConfigurableUiProps, ) { LocalConfigurationRendererRegistry.current .get(this) .RenderConfigurable(this, configurableUiProps) } // <<<<<<<< // end of public api class ConfigurableRendererRegistryBuilder internal constructor() { @PublishedApi internal val map: MutableMap> = mutableMapOf() fun < TConfigurable : Configurable<*>, TConfigurableRenderer : ConfigurableRenderer > register( key: Configurable.Key, renderer: TConfigurableRenderer ) { map[key] = renderer } } class ConfigurableRendererRegistry internal constructor( private val map: MutableMap> ) { fun < TConfigurable : Configurable<*>, TConfigurableRenderer : ConfigurableRenderer > get( configurable: TConfigurable ): TConfigurableRenderer { val renderer = requireNotNull(map[configurable.getKey()]) { "renderer for $configurable not found" } @Suppress("UNCHECKED_CAST") return renderer as TConfigurableRenderer } } val LocalConfigurationRendererRegistry = staticCompositionLocalOf { error("LocalConfigurationRendererRegistry not provided") } ================================================ FILE: shared/app/src/commonMain/kotlin/com/abdownloadmanager/shared/ui/configurable/RenderConfigurable.kt ================================================ package com.abdownloadmanager.shared.ui.configurable import androidx.compose.runtime.Composable import androidx.compose.runtime.Immutable import androidx.compose.ui.Modifier @Immutable data class ConfigGroupInfo( val visible: Boolean, val enabled: Boolean, ) @Suppress("UNCHECKED_CAST") @Composable fun RenderConfigurable( cfg: Configurable<*>, configurableUiProps: ConfigurableUiProps, groupInfo: ConfigGroupInfo? = null, ) { ConfigurationWrapper( configurable = cfg, groupInfo = groupInfo ) { cfg.Render(configurableUiProps) } } ================================================ FILE: shared/app/src/commonMain/kotlin/com/abdownloadmanager/shared/ui/configurable/RenderConfigurableGroup.kt ================================================ package com.abdownloadmanager.shared.ui.configurable import com.abdownloadmanager.shared.util.ui.myColors import com.abdownloadmanager.shared.util.ui.theme.myTextSizes import com.abdownloadmanager.shared.util.div import androidx.compose.animation.AnimatedVisibility import androidx.compose.foundation.background import androidx.compose.foundation.layout.* import com.abdownloadmanager.shared.ui.widget.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp import com.abdownloadmanager.shared.util.ui.theme.myShapes import ir.amirab.util.ifThen @Composable fun RenderConfigurableGroup( group: ConfigurableGroup, modifier: Modifier, itemPadding: PaddingValues = PaddingValues(), spaceBy: Dp = 8.dp, ) { val enabled by group.nestedEnabled.collectAsState() val visible by group.nestedVisible.collectAsState() val title by group.groupTitle.collectAsState() Column( modifier .clip(myShapes.defaultRounded) .background(myColors.surface / 0.5f) ) { title?.rememberString()?.let { Text( text = it, fontSize = myTextSizes.base, fontWeight = FontWeight.Bold, modifier = Modifier .padding(start = 8.dp) .padding(vertical = 8.dp) ) Spacer( Modifier .fillMaxWidth() .height(1.dp) .background(myColors.onSurface / 0.1f) ) } group.mainConfigurable?.let { RenderConfigurable( it, configurableUiProps = ConfigurableUiProps( modifier = Modifier.fillMaxWidth() .ifThen(title != null) { padding(top = 4.dp) } .ifThen(visible) { padding(bottom = 4.dp) }, itemPaddingValues = itemPadding ) ) } AnimatedVisibility(visible) { Column( Modifier .ifThen(group.mainConfigurable != null) { padding(top = 4.dp) }, verticalArrangement = Arrangement.spacedBy(spaceBy) ) { group.nestedConfigurable.forEach { RenderConfigurable( cfg = it, configurableUiProps = ConfigurableUiProps( modifier = Modifier .fillMaxWidth(), itemPaddingValues = itemPadding ), groupInfo = ConfigGroupInfo( enabled = enabled, visible = visible, ), ) } } } } } ================================================ FILE: shared/app/src/commonMain/kotlin/com/abdownloadmanager/shared/ui/configurable/Shared.kt ================================================ package com.abdownloadmanager.shared.ui.configurable import com.abdownloadmanager.shared.util.ui.WithContentAlpha import com.abdownloadmanager.shared.util.ui.WithContentColor import com.abdownloadmanager.shared.util.ui.widget.MyIcon import com.abdownloadmanager.shared.util.ui.icon.MyIcons import com.abdownloadmanager.shared.util.ui.myColors import com.abdownloadmanager.shared.util.ui.theme.myTextSizes import ir.amirab.util.ifThen import com.abdownloadmanager.shared.ui.widget.Text import androidx.compose.animation.AnimatedVisibility import androidx.compose.animation.expandVertically import androidx.compose.animation.shrinkVertically import androidx.compose.foundation.background import androidx.compose.foundation.border import androidx.compose.foundation.clickable import androidx.compose.foundation.focusable import androidx.compose.foundation.layout.* import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.verticalScroll import androidx.compose.runtime.* import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.composed import androidx.compose.ui.draw.clip import androidx.compose.ui.draw.shadow import androidx.compose.ui.focus.FocusRequester import androidx.compose.ui.focus.focusRequester import androidx.compose.ui.graphics.Color import androidx.compose.ui.input.key.KeyEventType import androidx.compose.ui.input.key.onKeyEvent import androidx.compose.ui.input.key.type import androidx.compose.ui.input.key.utf16CodePoint import androidx.compose.ui.layout.onGloballyPositioned import androidx.compose.ui.layout.positionInParent import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.unit.DpOffset import androidx.compose.ui.unit.dp import androidx.compose.ui.window.Popup import androidx.compose.ui.window.PopupProperties import com.abdownloadmanager.shared.ui.widget.Help import com.abdownloadmanager.shared.ui.widget.rememberMyComponentRectPositionProvider import com.abdownloadmanager.shared.util.div import com.abdownloadmanager.shared.util.ui.MultiplatformVerticalScrollbar import com.abdownloadmanager.shared.util.ui.theme.myShapes import com.abdownloadmanager.shared.util.ui.theme.mySpacings import io.github.oikvpqya.compose.fastscroller.rememberScrollbarAdapter import kotlinx.coroutines.delay import kotlinx.coroutines.launch import kotlin.math.roundToInt fun defaultValueToString(item: T): List { return emptyList() } private const val SEARCH_RESET_TIMEOUT = 2_000L private fun Modifier.onSearch( searchDelayTimeout: Long = SEARCH_RESET_TIMEOUT, onSearchRequested: (String) -> Unit ): Modifier { return composed { var textToSearch by remember { mutableStateOf("") } LaunchedEffect(textToSearch) { if (textToSearch.isNotEmpty()) { onSearchRequested(textToSearch) delay(searchDelayTimeout) textToSearch = "" } } onKeyEvent { if (it.type == KeyEventType.KeyDown) { val char = it.utf16CodePoint.toChar() if (char.isLetterOrDigit()) { textToSearch += char true } else { false } } else { false } } } } @Composable fun RenderSpinner( possibleValues: List, value: T, onSelect: (T) -> Unit, modifier: Modifier, enabled: Boolean = true, valueToString: (T) -> List = ::defaultValueToString, // minWidth:Dp, render: @Composable (T) -> Unit, ) { val verticalPadding = 4.dp val horizontalPadding = 4.dp var isOpen by remember { mutableStateOf(false) } val shape = myShapes.defaultRounded val borderWidth = 1.dp val borderColor = myColors.onBackground / 10 var widthForPopup by remember { mutableStateOf(0.dp) } val density = LocalDensity.current Box { Row( modifier = modifier // .widthIn(min = minWidth) .clip(shape) .height(IntrinsicSize.Max) .onGloballyPositioned { widthForPopup = with(density) { it.size.width.toDp() } } .background(myColors.surface) .border(borderWidth, borderColor, shape) .heightIn(mySpacings.thumbSize) .clickable(enabled = enabled) { isOpen = true }, horizontalArrangement = Arrangement.SpaceBetween, verticalAlignment = Alignment.CenterVertically, ) { WithContentAlpha(1f) { Box( Modifier .padding(vertical = verticalPadding) .padding(horizontal = horizontalPadding) ) { render(value) } Row(verticalAlignment = Alignment.CenterVertically) { Spacer( Modifier.fillMaxHeight() .padding(vertical = borderWidth) .width(borderWidth).background(myColors.onBackground / 10) ) MyIcon(MyIcons.down, null, Modifier.padding(4.dp).size(mySpacings.iconSize)) } } } if (isOpen) { Popup( popupPositionProvider = rememberMyComponentRectPositionProvider( offset = DpOffset(y = 2.dp, x = 0.dp) ), onDismissRequest = { isOpen = false }, properties = PopupProperties( focusable = true ) ) { val coroutineScope = rememberCoroutineScope() val focusRequester = remember { FocusRequester() } LaunchedEffect(Unit) { focusRequester.requestFocus() } val possibleValuePositions = remember(possibleValues) { mutableStateMapOf() } var itemToBeIndicated: Int by remember { mutableStateOf(-1) } LaunchedEffect(itemToBeIndicated) { if (itemToBeIndicated != -1) { delay(SEARCH_RESET_TIMEOUT) itemToBeIndicated = -1 } } Box { val scrollState = rememberScrollState() Column( Modifier .clip(shape) .width(IntrinsicSize.Max) .widthIn(widthForPopup) .heightIn(max = 360.dp) .onSearch { searchText -> val itemIndex = possibleValues .indexOfFirst { value -> valueToString(value).any { string -> string.startsWith( searchText, ignoreCase = true, ) } } if (itemIndex == -1) { return@onSearch } val position = possibleValuePositions[itemIndex]?.roundToInt() coroutineScope.launch { position?.let { scrollState.scrollTo(it) itemToBeIndicated = itemIndex } } } .focusRequester(focusRequester) .focusable() .background(myColors.surface) .border(borderWidth, borderColor, shape) .padding(borderWidth) .clip(shape) .verticalScroll(scrollState) ) { WithContentColor(myColors.onSurface) { for ((index, p) in possibleValues.withIndex()) { key(p) { val isIndicating = itemToBeIndicated == index Row( modifier = Modifier .onGloballyPositioned { possibleValuePositions[index] = it.positionInParent().y } .ifThen(isIndicating) { background( myColors.onBackground / 0.05f ) } .heightIn(mySpacings.thumbSize) .clickable(onClick = { isOpen = false onSelect(p) }), verticalAlignment = Alignment.CenterVertically, ) { val selected = p == value WithContentAlpha(if (selected) 1f else 0.75f) { Box( Modifier .weight(1f) .padding(vertical = verticalPadding) .padding(horizontal = horizontalPadding) ) { render(p) } } Spacer( Modifier.width(borderWidth) ) if (selected) { MyIcon(MyIcons.check, null, Modifier.padding(4.dp).size(mySpacings.iconSize)) } } } } } } MultiplatformVerticalScrollbar( rememberScrollbarAdapter(scrollState), modifier = Modifier .padding(vertical = borderWidth) .matchParentSize().wrapContentWidth(Alignment.End) ) } } } } } private val LocalConfigurableIsEnabled = compositionLocalOf { error("LocalConfigurableIsEnabled not provided") } private val LocalConfigurableIsVisible = compositionLocalOf { error("LocalConfigurableIsVisible not provided") } @Composable fun isConfigVisible(): Boolean { return LocalConfigurableIsVisible.current } @Composable fun isConfigEnabled(): Boolean { return LocalConfigurableIsEnabled.current } @Composable fun ConfigurationWrapper( configurable: Configurable<*>, groupInfo: ConfigGroupInfo? = null, content: @Composable () -> Unit, ) { val enabled by configurable.enabled.collectAsState() val visible by configurable.visible.collectAsState() CompositionLocalProvider( LocalConfigurableIsEnabled provides (enabled && groupInfo?.enabled ?: true), LocalConfigurableIsVisible provides (visible && groupInfo?.visible ?: true), ) { AnimatedVisibility( visible = visible, exit = shrinkVertically(), enter = expandVertically(), ) { content() } } } @Composable fun Help( modifier: Modifier = Modifier, cfg: Configurable<*>, ) { Help(cfg.description.rememberString(), modifier) } ================================================ FILE: shared/app/src/commonMain/kotlin/com/abdownloadmanager/shared/ui/configurable/item/BooleanConfigurable.kt ================================================ package com.abdownloadmanager.shared.ui.configurable.item import com.abdownloadmanager.shared.ui.configurable.Configurable import ir.amirab.util.compose.StringSource import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow class BooleanConfigurable( title: StringSource, description: StringSource, backedBy: MutableStateFlow, describe: ((Boolean) -> StringSource), val renderMode: RenderMode = RenderMode.Switch, enabled: StateFlow = DefaultEnabledValue, visible: StateFlow = DefaultVisibleValue, ) : Configurable( title = title, description = description, backedBy = backedBy, validate = { true }, describe = describe, enabled = enabled, visible = visible, ) { object Key : Configurable.Key override fun getKey() = Key enum class RenderMode { Checkbox, Switch, } } ================================================ FILE: shared/app/src/commonMain/kotlin/com/abdownloadmanager/shared/ui/configurable/item/DayOfWeekConfigurable.kt ================================================ package com.abdownloadmanager.shared.ui.configurable.item import com.abdownloadmanager.shared.ui.configurable.Configurable import ir.amirab.util.compose.StringSource import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.datetime.DayOfWeek class DayOfWeekConfigurable( title: StringSource, description: StringSource, backedBy: MutableStateFlow>, describe: (Set) -> StringSource, validate: (Set) -> Boolean, enabled: StateFlow = DefaultEnabledValue, visible: StateFlow = DefaultVisibleValue, ) : Configurable>( title = title, description = description, backedBy = backedBy, describe = describe, validate = validate, enabled = enabled, visible = visible, ) { object Key : Configurable.Key override fun getKey() = Key } ================================================ FILE: shared/app/src/commonMain/kotlin/com/abdownloadmanager/shared/ui/configurable/item/EnumConfigurable.kt ================================================ package com.abdownloadmanager.shared.ui.configurable.item import com.abdownloadmanager.shared.ui.configurable.BaseEnumConfigurable import com.abdownloadmanager.shared.ui.configurable.Configurable import com.abdownloadmanager.shared.ui.configurable.defaultValueToString import ir.amirab.util.compose.StringSource import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow open class EnumConfigurable( title: StringSource, description: StringSource, backedBy: MutableStateFlow, describe: ((T) -> StringSource), possibleValues: List, valueToString: (T) -> List = ::defaultValueToString, val renderMode: RenderMode = RenderMode.Spinner, enabled: StateFlow = DefaultEnabledValue, visible: StateFlow = DefaultVisibleValue, ) : BaseEnumConfigurable( title = title, description = description, backedBy = backedBy, describe = describe, possibleValues = possibleValues, valueToString = valueToString, enabled = enabled, visible = visible, ) { object Key : Configurable.Key override fun getKey() = Key enum class RenderMode { Spinner, } } ================================================ FILE: shared/app/src/commonMain/kotlin/com/abdownloadmanager/shared/ui/configurable/item/FileChecksumConfigurable.kt ================================================ package com.abdownloadmanager.shared.ui.configurable.item import com.abdownloadmanager.shared.ui.configurable.Configurable import com.abdownloadmanager.shared.util.FileChecksum import ir.amirab.util.compose.StringSource import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow class FileChecksumConfigurable( title: StringSource, description: StringSource, backedBy: MutableStateFlow, describe: (FileChecksum?) -> StringSource, enabled: StateFlow = DefaultEnabledValue, visible: StateFlow = DefaultVisibleValue, ) : Configurable( title = title, description = description, backedBy = backedBy, describe = describe, enabled = enabled, visible = visible, ) { object Key : Configurable.Key override fun getKey() = Key } ================================================ FILE: shared/app/src/commonMain/kotlin/com/abdownloadmanager/shared/ui/configurable/item/FloatConfigurable.kt ================================================ package com.abdownloadmanager.shared.ui.configurable.item import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier import com.abdownloadmanager.shared.ui.configurable.Configurable import ir.amirab.util.compose.StringSource import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow class FloatConfigurable( title: StringSource, description: StringSource, backedBy: MutableStateFlow, val range: ClosedFloatingPointRange, val steps: Int = 0, val renderMode: RenderMode = RenderMode.TextField, describe: ((Float) -> StringSource), enabled: StateFlow = DefaultEnabledValue, visible: StateFlow = DefaultVisibleValue, ) : Configurable( title = title, description = description, backedBy = backedBy, validate = { it in range }, describe = describe, enabled = enabled, visible = visible, ) { object Key : Configurable.Key override fun getKey() = Key enum class RenderMode { TextField, } } ================================================ FILE: shared/app/src/commonMain/kotlin/com/abdownloadmanager/shared/ui/configurable/item/FolderConfigurable.kt ================================================ package com.abdownloadmanager.shared.ui.configurable.item import com.abdownloadmanager.shared.ui.configurable.Configurable import ir.amirab.util.compose.StringSource import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow class FolderConfigurable( title: StringSource, description: StringSource, backedBy: MutableStateFlow, describe: ((String) -> StringSource), validate: (String) -> Boolean, enabled: StateFlow = DefaultEnabledValue, visible: StateFlow = DefaultVisibleValue, ) : StringConfigurable( title = title, description = description, backedBy = backedBy, validate = validate, describe = describe, enabled = enabled, visible = visible, ) { object Key : Configurable.Key override fun getKey(): Configurable.Key = Key } ================================================ FILE: shared/app/src/commonMain/kotlin/com/abdownloadmanager/shared/ui/configurable/item/IntConfigurable.kt ================================================ package com.abdownloadmanager.shared.ui.configurable.item import com.abdownloadmanager.shared.ui.configurable.Configurable import ir.amirab.util.compose.StringSource import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow class IntConfigurable( title: StringSource, description: StringSource, backedBy: MutableStateFlow, describe: ((Int) -> StringSource), val range: IntRange, val renderMode: RenderMode = RenderMode.TextField, enabled: StateFlow = DefaultEnabledValue, visible: StateFlow = DefaultVisibleValue, ) : Configurable( title = title, description = description, backedBy = backedBy, validate = { it in range }, describe = describe, ) { object Key : Configurable.Key override fun getKey() = Key enum class RenderMode { TextField, } } ================================================ FILE: shared/app/src/commonMain/kotlin/com/abdownloadmanager/shared/ui/configurable/item/LongConfigurable.kt ================================================ package com.abdownloadmanager.shared.ui.configurable.item import com.abdownloadmanager.shared.ui.configurable.BaseLongConfigurable import com.abdownloadmanager.shared.ui.configurable.Configurable import ir.amirab.util.compose.StringSource import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow class LongConfigurable( title: StringSource, description: StringSource, backedBy: MutableStateFlow, describe: ((Long) -> StringSource), range: LongRange, val renderMode: RenderMode = RenderMode.TextField, enabled: StateFlow = DefaultEnabledValue, visible: StateFlow = DefaultVisibleValue, ) : BaseLongConfigurable( title = title, description = description, backedBy = backedBy, describe = describe, range = range, enabled = enabled, visible = visible, ) { object Key : Configurable.Key override fun getKey() = Key enum class RenderMode { TextField, } } ================================================ FILE: shared/app/src/commonMain/kotlin/com/abdownloadmanager/shared/ui/configurable/item/NavigatableConfigurable.kt ================================================ package com.abdownloadmanager.shared.ui.configurable.item import com.abdownloadmanager.shared.ui.configurable.Configurable import ir.amirab.util.compose.StringSource import ir.amirab.util.compose.asStringSource import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow class NavigatableConfigurable( title: StringSource, description: StringSource, val onRequestNavigate: () -> Unit, enabled: StateFlow = DefaultEnabledValue, visible: StateFlow = DefaultVisibleValue, ) : Configurable( title = title, description = description, backedBy = MutableStateFlow(Unit), describe = { "".asStringSource() }, enabled = enabled, visible = visible, ) { object Key : Configurable.Key override fun getKey() = Key } ================================================ FILE: shared/app/src/commonMain/kotlin/com/abdownloadmanager/shared/ui/configurable/item/ProxyConfigurable.kt ================================================ package com.abdownloadmanager.shared.ui.configurable.item import com.abdownloadmanager.shared.ui.configurable.Configurable import com.abdownloadmanager.shared.util.proxy.ProxyData import ir.amirab.util.compose.StringSource import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow class ProxyConfigurable( title: StringSource, description: StringSource, backedBy: MutableStateFlow, describe: (ProxyData) -> StringSource, validate: (ProxyData) -> Boolean, enabled: StateFlow = DefaultEnabledValue, visible: StateFlow = DefaultVisibleValue, ) : Configurable( title = title, description = description, backedBy = backedBy, describe = describe, validate = validate, enabled = enabled, visible = visible, ) { object Key : Configurable.Key override fun getKey() = Key } ================================================ FILE: shared/app/src/commonMain/kotlin/com/abdownloadmanager/shared/ui/configurable/item/SpeedLimitConfigurable.kt ================================================ package com.abdownloadmanager.shared.ui.configurable.item import com.abdownloadmanager.shared.ui.configurable.BaseLongConfigurable import com.abdownloadmanager.shared.ui.configurable.Configurable import ir.amirab.util.compose.StringSource import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow class SpeedLimitConfigurable( title: StringSource, description: StringSource, backedBy: MutableStateFlow, describe: (Long) -> StringSource, enabled: StateFlow = DefaultEnabledValue, visible: StateFlow = DefaultVisibleValue, ) : BaseLongConfigurable( title = title, description = description, backedBy = backedBy, describe = describe, range = 0..Long.MAX_VALUE, enabled = enabled, visible = visible, ) { object Key : Configurable.Key override fun getKey() = Key } ================================================ FILE: shared/app/src/commonMain/kotlin/com/abdownloadmanager/shared/ui/configurable/item/StringConfigurable.kt ================================================ package com.abdownloadmanager.shared.ui.configurable.item import com.abdownloadmanager.shared.ui.configurable.Configurable import ir.amirab.util.compose.StringSource import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow open class StringConfigurable( title: StringSource, description: StringSource, backedBy: MutableStateFlow, describe: ((String) -> StringSource), validate: (String) -> Boolean = { true }, enabled: StateFlow = DefaultEnabledValue, visible: StateFlow = DefaultVisibleValue, ) : Configurable( title = title, description = description, backedBy = backedBy, validate = validate, describe = describe, enabled = enabled, visible = visible, ) { object Key : Configurable.Key override fun getKey(): Configurable.Key = Key } ================================================ FILE: shared/app/src/commonMain/kotlin/com/abdownloadmanager/shared/ui/configurable/item/ThemeConfigurable.kt ================================================ package com.abdownloadmanager.shared.ui.configurable.item import com.abdownloadmanager.shared.ui.configurable.BaseEnumConfigurable import com.abdownloadmanager.shared.ui.configurable.Configurable import com.abdownloadmanager.shared.ui.theme.ThemeInfo import ir.amirab.util.compose.StringSource import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow class ThemeConfigurable( title: StringSource, description: StringSource, backedBy: MutableStateFlow, describe: (ThemeInfo) -> StringSource, possibleValues: List, valueToString: (ThemeInfo) -> List = { listOf(it.name.getString()) }, enabled: StateFlow = DefaultEnabledValue, visible: StateFlow = DefaultVisibleValue, ) : BaseEnumConfigurable( title = title, description = description, backedBy = backedBy, describe = describe, possibleValues = possibleValues, valueToString = valueToString, enabled = enabled, visible = visible, ) { object Key : Configurable.Key override fun getKey() = Key } ================================================ FILE: shared/app/src/commonMain/kotlin/com/abdownloadmanager/shared/ui/configurable/item/TimeConfigurable.kt ================================================ package com.abdownloadmanager.shared.ui.configurable.item import com.abdownloadmanager.shared.ui.configurable.Configurable import ir.amirab.util.compose.StringSource import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.datetime.LocalTime class TimeConfigurable( title: StringSource, description: StringSource, backedBy: MutableStateFlow, describe: (LocalTime) -> StringSource, enabled: StateFlow = DefaultEnabledValue, visible: StateFlow = DefaultVisibleValue, ) : Configurable( title = title, description = description, backedBy = backedBy, describe = describe, enabled = enabled, visible = visible, ) { object Key : Configurable.Key override fun getKey() = Key } ================================================ FILE: shared/app/src/commonMain/kotlin/com/abdownloadmanager/shared/ui/modifier/PointerHoverIcon.kt ================================================ package com.abdownloadmanager.shared.ui.modifier import androidx.compose.ui.Modifier sealed interface MyPointerHoverIcon { data object Default : MyPointerHoverIcon data object Crosshair : MyPointerHoverIcon data object Text : MyPointerHoverIcon data object Hand : MyPointerHoverIcon data object VerticalResize : MyPointerHoverIcon data object HorizontalResize : MyPointerHoverIcon } expect fun Modifier.myPointerHoverIcon( pointerHoverIcon: MyPointerHoverIcon, overrideDescendants: Boolean, ): Modifier ================================================ FILE: shared/app/src/commonMain/kotlin/com/abdownloadmanager/shared/ui/theme/ABDownloaderTheme.kt ================================================ package com.abdownloadmanager.shared.ui.theme import androidx.compose.animation.core.tween import androidx.compose.foundation.LocalIndication import androidx.compose.runtime.* import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.text.font.FontFamily import androidx.compose.ui.unit.TextUnit import com.abdownloadmanager.shared.util.ui.* import com.abdownloadmanager.shared.util.ui.theme.* import com.abdownloadmanager.shared.util.ui.theme.UiScaledContent @Composable fun ABDownloaderTheme( myColors: MyColors, fontFamily: FontFamily? = null, uiScale: Float = DEFAULT_UI_SCALE, content: @Composable () -> Unit, ) { val systemDensity = LocalDensity.current val textSizes = myPlatformTextSizes() CompositionLocalProvider( LocalMyColors provides animatedColors(myColors), LocalUiScale provides uiScale, LocalSystemDensity provides systemDensity, LocalMyShapes provides myPlatformShapes(), LocalSpacing provides myPlatformSpacing(), ) { CompositionLocalProvider( LocalMultiplatformScrollbarStyle provides myPlatformScrollbarStyle(), LocalIndication provides ripple(), LocalContentColor provides myColors.onBackground, LocalContentAlpha provides 1f, LocalTextSizes provides textSizes, LocalTextStyle provides LocalTextStyle.current.copy( lineHeight = TextUnit.Unspecified, fontSize = textSizes.base, fontFamily = fontFamily, ), ) { PlatformDependentProviders { // it is overridden by [Window] Composable, // but I put this here. maybe I need this outside of window scope! UiScaledContent { content() } } } } } ================================================ FILE: shared/app/src/commonMain/kotlin/com/abdownloadmanager/shared/ui/theme/DefaultThemes.kt ================================================ package com.abdownloadmanager.shared.ui.theme import androidx.compose.ui.graphics.Color import com.abdownloadmanager.shared.util.ui.MyColors object DefaultThemes { val dark = MyColors( id = "dark", name = "Dark", primary = Color(0xFF4791BF), primaryVariant = Color(0xFF60A6D9), onPrimary = Color(0xFFEFF2F6), secondary = Color(0xFFB85DFF), secondaryVariant = Color(0xFFD1A6FF), onSecondary = Color(0xFFEFF2F6), background = Color(0xFF1E1F22), onBackground = Color(0xFFD6D6D6), surface = Color(0xFF2A2B2F), onSurface = Color(0xFFE0E0E0), error = Color(0xFFEA4C3C), onError = Color(0xFFEFEFEF), success = Color(0xFF45B36B), onSuccess = Color(0xFFE5E5E5), warning = Color(0xFFF6C244), onWarning = Color(0xFF1E1E1E), info = Color(0xFF40A9F3), onInfo = Color(0xFF1E1E1E), isLight = false ) val light = MyColors( id = "light", name = "Light", primary = Color(0xFF4791BF), primaryVariant = Color(0xFF3576A1), onPrimary = Color(0xFFFFFFFF), secondary = Color(0xFFB85DFF), secondaryVariant = Color(0xFF9700FF), onSecondary = Color(0xFFFFFFFF), background = Color(0xFFFFFFFF), onBackground = Color(0xFF232323), surface = Color(0xFFF2F2F2), onSurface = Color(0xFF232323), error = Color(0xFFEA4C3C), onError = Color(0xFFFFFFFF), success = Color(0xFF45B36B), onSuccess = Color(0xFFFFFFFF), warning = Color(0xFFF6C244), onWarning = Color(0xFF232323), info = Color(0xFF40A9F3), onInfo = Color(0xFF232323), isLight = true ) val obsidian = MyColors( id = "obsidian", name = "Obsidian", primary = Color(0xFF4791BF), onPrimary = Color.White, secondary = Color(0xFFB85DFF), onSecondary = Color.White, background = Color(0xFF16161E), onBackground = Color(0xFFBBBBBB), onSurface = Color(0xFFBBBBBB), surface = Color(0xFF22222A), error = Color(0xffff5757), onError = Color.White, success = Color(0xff69BA5A), onSuccess = Color.White, warning = Color(0xFFffbe56), onWarning = Color.White, info = Color(0xFF2f77d4), onInfo = Color.White, isLight = false, ) val deepOcean = MyColors( id = "deep_ocean", name = "Deep Ocean", primary = Color(0xFF4791BF), primaryVariant = Color(0xFF60A6D9), onPrimary = Color(0xFFEFF2F6), secondary = Color(0xFFB85DFF), secondaryVariant = Color(0xFFD1A6FF), onSecondary = Color(0xFFEFF2F6), background = Color(0xFF17212B), onBackground = Color(0xFFE5EAF2), surface = Color(0xFF242F3D), onSurface = Color(0xFFE5EAF2), error = Color(0xFFEA4C3C), onError = Color(0xFFE5E5E5), success = Color(0xFF45B36B), onSuccess = Color(0xFFE5E5E5), warning = Color(0xFFF6C244), onWarning = Color(0xFF232323), info = Color(0xFF40A9F3), onInfo = Color(0xFF232323), isLight = false ) val black = MyColors( id = "black", name = "Black", primary = Color(0xFF4791BF), primaryVariant = Color(0xFF60A6D9), onPrimary = Color(0xFFEFF2F6), secondary = Color(0xFFB85DFF), secondaryVariant = Color(0xFFD1A6FF), onSecondary = Color(0xFFEFF2F6), background = Color(0xFF000000), onBackground = Color(0xFFEFEFEF), surface = Color(0xFF1A1F26), onSurface = Color(0xFFEFEFEF), error = Color(0xFFEA4C3C), onError = Color(0xFFFFFFFF), success = Color(0xFF45B36B), onSuccess = Color(0xFFFFFFFF), warning = Color(0xFFF6C244), onWarning = Color(0xFF000000), info = Color(0xFF40A9F3), onInfo = Color(0xFF000000), isLight = false ) val lightGray = MyColors( id = "light_gray", name = "Light Gray", primary = Color(0xFF4791BF), primaryVariant = Color(0xFF60A6D9), onPrimary = Color(0xFF20303A), secondary = Color(0xFFB85DFF), secondaryVariant = Color(0xFFD1A6FF), onSecondary = Color(0xFF20303A), background = Color(0xFFF0F0F0), onBackground = Color(0xFF232323), surface = Color(0xFFE0E0E0), onSurface = Color(0xFF232323), error = Color(0xFFEA4C3C), onError = Color(0xFFFFFFFF), success = Color(0xFF45B36B), onSuccess = Color(0xFFFFFFFF), warning = Color(0xFFF6C244), onWarning = Color(0xFF232323), info = Color(0xFF40A9F3), onInfo = Color(0xFF232323), isLight = true ) fun getAll(): List { return listOf( dark, light, obsidian, deepOcean, black, lightGray, ) } fun getDefaultDark() = dark fun getDefaultLight() = light } ================================================ FILE: shared/app/src/commonMain/kotlin/com/abdownloadmanager/shared/ui/theme/Markdown.kt ================================================ package com.abdownloadmanager.shared.ui.theme import androidx.compose.runtime.Composable import androidx.compose.ui.graphics.Color import androidx.compose.ui.text.TextLinkStyles import androidx.compose.ui.text.font.FontFamily import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextDecoration import com.abdownloadmanager.shared.util.ui.LocalContentColor import com.abdownloadmanager.shared.util.ui.LocalTextStyle import com.abdownloadmanager.shared.util.ui.myColors import com.abdownloadmanager.shared.util.ui.theme.myTextSizes import com.mikepenz.markdown.model.DefaultMarkdownColors import com.mikepenz.markdown.model.DefaultMarkdownTypography @Composable fun myMarkdownColors(): DefaultMarkdownColors { val currentColor = LocalContentColor.current return DefaultMarkdownColors( text = currentColor, codeBackground = myColors.surface, dividerColor = currentColor.copy(alpha = 0.1f), inlineCodeBackground = myColors.surface, tableBackground = Color.Transparent, ) } @Composable fun myMarkdownTypography(): DefaultMarkdownTypography { val defaultTextStyle = LocalTextStyle.current val textSizes = myTextSizes val colors = myColors return DefaultMarkdownTypography( h1 = defaultTextStyle.copy( fontSize = textSizes.xl * 1.1f, fontWeight = FontWeight.Bold, ), h2 = defaultTextStyle.copy( fontSize = textSizes.xl, fontWeight = FontWeight.Bold, ), h3 = defaultTextStyle.copy( fontSize = textSizes.lg, fontWeight = FontWeight.Bold, ), h4 = defaultTextStyle.copy( fontSize = textSizes.base, fontWeight = FontWeight.Bold, ), h5 = defaultTextStyle.copy( fontSize = textSizes.sm, fontWeight = FontWeight.Bold, ), h6 = defaultTextStyle.copy( fontSize = textSizes.xs, fontWeight = FontWeight.Bold, ), text = defaultTextStyle.copy( fontSize = textSizes.base, fontWeight = FontWeight.Bold, ), code = defaultTextStyle.copy( fontSize = textSizes.base, fontWeight = FontWeight.Normal, fontFamily = FontFamily.Monospace, ), inlineCode = defaultTextStyle.copy( fontSize = textSizes.base, fontWeight = FontWeight.Normal, fontFamily = FontFamily.Monospace, ), quote = defaultTextStyle.copy( fontSize = textSizes.base, ), paragraph = defaultTextStyle.copy( fontSize = textSizes.base, ), ordered = defaultTextStyle.copy( fontSize = textSizes.base, ), bullet = defaultTextStyle.copy( fontSize = textSizes.base, ), list = defaultTextStyle.copy( fontSize = textSizes.base, fontWeight = FontWeight.Normal, ), textLink = TextLinkStyles( style = defaultTextStyle.copy( fontSize = textSizes.base, color = colors.info, ).toSpanStyle(), hoveredStyle = defaultTextStyle.copy( fontSize = textSizes.base, color = colors.info, textDecoration = TextDecoration.Underline ).toSpanStyle() ), table = defaultTextStyle, ) } ================================================ FILE: shared/app/src/commonMain/kotlin/com/abdownloadmanager/shared/ui/theme/PlatformThemeDefinitions.kt ================================================ package com.abdownloadmanager.shared.ui.theme import androidx.compose.runtime.Composable import com.abdownloadmanager.shared.util.ui.theme.MyShapes import com.abdownloadmanager.shared.util.ui.theme.MySpacings import com.abdownloadmanager.shared.util.ui.theme.TextSizes import io.github.oikvpqya.compose.fastscroller.ScrollbarStyle @Composable expect fun myPlatformScrollbarStyle(): ScrollbarStyle @Composable expect fun myPlatformTextSizes(): TextSizes @Composable expect fun myPlatformShapes(): MyShapes @Composable expect fun myPlatformSpacing(): MySpacings @Composable expect fun PlatformDependentProviders(content: @Composable () -> Unit) ================================================ FILE: shared/app/src/commonMain/kotlin/com/abdownloadmanager/shared/ui/theme/ThemeManager.kt ================================================ package com.abdownloadmanager.shared.ui.theme import androidx.compose.runtime.Stable import androidx.compose.ui.graphics.Color import com.abdownloadmanager.shared.util.ui.theme.ISystemThemeDetector import com.abdownloadmanager.shared.util.ui.MyColors import com.abdownloadmanager.resources.Res import ir.amirab.util.compose.StringSource import ir.amirab.util.compose.asStringSource import ir.amirab.util.flow.combineStateFlows import ir.amirab.util.flow.mapStateFlow import ir.amirab.util.guardedEntry import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Job import kotlinx.coroutines.flow.* import kotlin.collections.filter import kotlin.collections.map class ThemeManager( private val scope: CoroutineScope, private val appSettings: ThemeSettingsStorage, private val osThemeDetector: ISystemThemeDetector, ) { companion object { val defaultThemes = DefaultThemes.getAll() val DefaultDarkTheme = DefaultThemes.getDefaultDark() val DefaultLightTheme = DefaultThemes.getDefaultLight() val DefaultTheme = DefaultDarkTheme val DEFAULT_THEME_ID = DefaultTheme.id val systemThemeInfo = ThemeInfo( id = "system", name = Res.string.system.asStringSource(), color = Color.Gray, ) } private val _availableThemes = MutableStateFlow(emptyList()) val availableThemes = _availableThemes.asStateFlow() private fun getThemeById(themeId: String): MyColors? { return availableThemes.value.find { it.id == themeId } } val selectableThemes = availableThemes.mapStateFlow { buildList { if (osThemeDetector.isSupported) { add(systemThemeInfo) } addAll(it.map { it.toThemeInfo() }) } } val selectableDarkThemes = availableThemes.mapStateFlow { it.filter { !it.isLight }.map { it.toThemeInfo() } } val selectableLightThemes = availableThemes.mapStateFlow { it.filter { it.isLight }.map { it.toThemeInfo() } } private val themeIds = selectableThemes.mapStateFlow { it.map { it.id } } val currentThemeInfo = combineStateFlows( appSettings.theme, selectableThemes ) { themeId, possibleThemes -> possibleThemes.find { it.id == themeId } ?: possibleThemes.find { it.id == DEFAULT_THEME_ID }!! } val selectedDarkThemeInfo = combineStateFlows( appSettings.defaultDarkTheme, selectableThemes ) { themeId, possibleThemes -> possibleThemes.find { it.id == themeId } ?: possibleThemes.find { it.id == DefaultDarkTheme.id }!! } val selectedLightThemeInfo = combineStateFlows( appSettings.defaultLightTheme, selectableThemes ) { themeId, possibleThemes -> possibleThemes.find { it.id == themeId } ?: possibleThemes.find { it.id == DefaultLightTheme.id }!! } private var osDarkModeFlow = MutableStateFlow(true) val currentThemeColor = combineStateFlows( themeIds, appSettings.theme, appSettings.defaultDarkTheme, appSettings.defaultLightTheme, osDarkModeFlow, ) { themes, themeId, userDefaultDarkThemeId, userDefaultLightThemeId, osThemeIsDark -> val id = if (themeId == systemThemeInfo.id) { if (osThemeIsDark) { userDefaultDarkThemeId } else { userDefaultLightThemeId } } else { themeId } if (themes.contains(id)) { getThemeById(id)!! } else { DefaultTheme } } fun setTheme(themeId: String) { synchronized(this) { if (themeId == systemThemeInfo.id) { registerSystemThemeDetector() } else { unRegisterSystemThemeDetector() } if (themeIds.value.contains(themeId)) { appSettings.theme.value = themeId } else { // theme id in setting is invalid update it appSettings.theme.value = DEFAULT_THEME_ID } } } fun setDarkTheme(themeId: String) { synchronized(this) { appSettings.defaultDarkTheme.value = if (themeIds.value.contains(themeId)) { themeId } else { // theme id in setting is invalid update it DefaultDarkTheme.id } } } fun setLightTheme(themeId: String) { synchronized(this) { appSettings.defaultLightTheme.value = if (themeIds.value.contains(themeId)) { themeId } else { // theme id in setting is invalid update it DefaultLightTheme.id } } } private var booted = guardedEntry() fun boot() { booted.action { // now we can load custom themes here // loadCustomThemes() // _availableThemes.update { it.plus(defaultThemes) } setTheme(appSettings.theme.value) } } private var osUpdateFlowJob: Job? = null private fun registerSystemThemeDetector() { osUpdateFlowJob?.cancel() if (osThemeDetector.isSupported) { // update immediately osDarkModeFlow.value = osThemeDetector.isDark() osUpdateFlowJob = osThemeDetector.systemThemeFlow.onEach { isDark -> osDarkModeFlow.value = isDark }.launchIn(scope) } } private fun unRegisterSystemThemeDetector() { osUpdateFlowJob?.cancel() osUpdateFlowJob = null } } /** * This is for demonstration purposes of a theme */ @Stable data class ThemeInfo( val id: String, val name: StringSource, val color: Color, ) private fun MyColors.toThemeInfo(): ThemeInfo { return ThemeInfo( id = id, name = name.asStringSource(), color = surface, ) } ================================================ FILE: shared/app/src/commonMain/kotlin/com/abdownloadmanager/shared/ui/theme/ThemeSettingsStorage.kt ================================================ package com.abdownloadmanager.shared.ui.theme import kotlinx.coroutines.flow.MutableStateFlow interface ThemeSettingsStorage { val theme: MutableStateFlow val defaultDarkTheme: MutableStateFlow val defaultLightTheme: MutableStateFlow } ================================================ FILE: shared/app/src/commonMain/kotlin/com/abdownloadmanager/shared/ui/widget/ActionButton.kt ================================================ package com.abdownloadmanager.shared.ui.widget import androidx.compose.foundation.LocalIndication import com.abdownloadmanager.shared.util.ui.LocalContentColor import com.abdownloadmanager.shared.util.ui.myColors import com.abdownloadmanager.shared.util.ui.theme.myTextSizes import com.abdownloadmanager.shared.util.ui.WithContentAlpha import com.abdownloadmanager.shared.util.ui.WithContentColor import com.abdownloadmanager.shared.util.div import androidx.compose.foundation.background import androidx.compose.foundation.border import androidx.compose.foundation.clickable import androidx.compose.foundation.interaction.MutableInteractionSource import androidx.compose.foundation.interaction.collectIsFocusedAsState import androidx.compose.foundation.layout.* import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue import androidx.compose.runtime.remember import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip import androidx.compose.ui.graphics.Brush import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.SolidColor import androidx.compose.ui.semantics.Role import androidx.compose.ui.unit.dp import com.abdownloadmanager.shared.util.ui.theme.myShapes import com.abdownloadmanager.shared.util.ui.theme.mySpacings @Composable fun ActionButton( text: String, modifier: Modifier = Modifier, enabled: Boolean = true, onClick: () -> Unit, backgroundColor: Brush = SolidColor(myColors.surface), disabledBackgroundColor: Brush = SolidColor(myColors.surface / 0.5f), borderColor: Brush = SolidColor(myColors.onBackground / 10), focusedBorderColor: Brush = SolidColor(myColors.focusedBorderColor), disabledBorderColor: Brush = SolidColor(myColors.onBackground / 10), contentColor: Color = LocalContentColor.current, contentPadding: PaddingValues = PaddingValues(vertical = 6.dp, horizontal = 24.dp), interactionSource: MutableInteractionSource = remember { MutableInteractionSource() }, start: (@Composable RowScope.() -> Unit)? = null, end: (@Composable RowScope.() -> Unit)? = null, ) { val isFocused by interactionSource.collectIsFocusedAsState() val shape = myShapes.defaultRounded val borderColor = if (isFocused) focusedBorderColor else borderColor Row( modifier .heightIn(mySpacings.thumbSize) .border(1.dp, if (enabled) borderColor else disabledBorderColor, shape) .clip(shape) .background(if (enabled) backgroundColor else disabledBackgroundColor) .clickable( enabled = enabled, interactionSource = interactionSource, indication = LocalIndication.current, role = Role.Button, onClick = onClick, ) .padding(contentPadding), horizontalArrangement = Arrangement.Center, verticalAlignment = Alignment.CenterVertically, ) { WithContentColor(contentColor) { WithContentAlpha(if (enabled) 1f else 0.5f) { start?.let { it() } Text( text = text, modifier = Modifier, fontSize = myTextSizes.base, maxLines = 1, softWrap = false, ) end?.let { it() } } } } } ================================================ FILE: shared/app/src/commonMain/kotlin/com/abdownloadmanager/shared/ui/widget/ActionContainer.kt ================================================ package com.abdownloadmanager.shared.ui.widget import androidx.compose.foundation.background import androidx.compose.foundation.layout.* import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier import androidx.compose.ui.unit.dp import com.abdownloadmanager.shared.util.ui.myColors import com.abdownloadmanager.shared.util.div @Composable fun ActionContainer( modifier: Modifier, contentPadding: PaddingValues = PaddingValues( horizontal = 16.dp, vertical = 8.dp, ), content: @Composable () -> Unit, ) { Column(modifier) { Spacer( Modifier .fillMaxWidth() .height(1.dp) .background(myColors.onBackground / 0.15f) ) Box( Modifier .fillMaxWidth() .background(myColors.surface / 0.5f) .padding(contentPadding), ) { content() } } } ================================================ FILE: shared/app/src/commonMain/kotlin/com/abdownloadmanager/shared/ui/widget/AddUrlButton.kt ================================================ package com.abdownloadmanager.shared.ui.widget import com.abdownloadmanager.shared.util.ui.widget.MyIcon import com.abdownloadmanager.shared.util.ui.icon.MyIcons import com.abdownloadmanager.shared.util.ui.myColors import com.abdownloadmanager.shared.util.ui.theme.myTextSizes import com.abdownloadmanager.shared.util.ui.WithContentAlpha import androidx.compose.foundation.background import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.* import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.runtime.Composable import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip import androidx.compose.ui.unit.dp import com.abdownloadmanager.resources.Res import com.abdownloadmanager.shared.util.ui.theme.myShapes import ir.amirab.util.compose.resources.myStringResource @Composable fun AddUrlButton( modifier: Modifier=Modifier, onClick:()->Unit ) { val shape = myShapes.defaultRounded val addUrlIcon = MyIcons.link val downloadIcon = MyIcons.download Row( modifier .clip(shape) .background(myColors.surface) .clickable(onClick = onClick) .height(32.dp) // .width(120.dp) .padding(horizontal = 8.dp), verticalAlignment = Alignment.CenterVertically, ) { WithContentAlpha(1f) { MyIcon(addUrlIcon, null, Modifier.size(16.dp)) Spacer(Modifier.width(10.dp)) Text( myStringResource(Res.string.new_download), Modifier, maxLines = 1, fontSize = myTextSizes.sm, ) } Spacer(Modifier.width(10.dp)) Box( Modifier .clip(myShapes.defaultRounded) .background( myColors.primaryGradient ).padding(4.dp) ) { MyIcon( downloadIcon, null, Modifier.size(12.dp), tint = myColors.onPrimaryGradient, ) } } } ================================================ FILE: shared/app/src/commonMain/kotlin/com/abdownloadmanager/shared/ui/widget/CheckBox.kt ================================================ package com.abdownloadmanager.shared.ui.widget import com.abdownloadmanager.shared.util.ui.LocalContentColor import com.abdownloadmanager.shared.util.ui.widget.MyIcon import com.abdownloadmanager.shared.util.ui.icon.MyIcons import com.abdownloadmanager.shared.util.ui.myColors import ir.amirab.util.ifThen import com.abdownloadmanager.shared.util.div import androidx.compose.animation.AnimatedContent import androidx.compose.animation.core.animateFloatAsState import androidx.compose.animation.core.tween import androidx.compose.animation.fadeIn import androidx.compose.animation.fadeOut import androidx.compose.animation.togetherWith import androidx.compose.foundation.background import androidx.compose.foundation.border import androidx.compose.foundation.interaction.MutableInteractionSource import androidx.compose.foundation.interaction.collectIsFocusedAsState import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.size import androidx.compose.foundation.selection.triStateToggleable import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue import androidx.compose.runtime.remember import androidx.compose.ui.Modifier import androidx.compose.ui.draw.alpha import androidx.compose.ui.draw.clip import androidx.compose.ui.graphics.Shape import androidx.compose.ui.semantics.Role import androidx.compose.ui.state.ToggleableState import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp @Composable fun CheckBox( value: Boolean, onValueChange: (Boolean) -> Unit, enabled: Boolean = true, modifier: Modifier = Modifier, size: Dp = 18.dp, interactionSource: MutableInteractionSource = remember { MutableInteractionSource() }, uncheckedAlpha: Float = 0.25f, shape: Shape = RoundedCornerShape(25) ) { val isFocused by interactionSource.collectIsFocusedAsState() Box( modifier .ifThen(!enabled) { alpha(0.5f) } .size(size) .clip(shape) .triStateToggleable( state = ToggleableState(value), enabled = enabled, role = Role.Checkbox, interactionSource = interactionSource, indication = null, onClick = { onValueChange(!value) }, ) ) { val borderColor = if (isFocused) { myColors.focusedBorderColor } else { LocalContentColor.current / uncheckedAlpha } Spacer( Modifier.matchParentSize() .border(1.dp, borderColor, shape) ) AnimatedContent( value, transitionSpec = { val tween = tween(220) fadeIn(tween) togetherWith fadeOut(tween) } ) { val m = Modifier .fillMaxSize() .alpha(animateFloatAsState(if (value) 1f else 0f).value) .background(myColors.primaryGradient) if (it) { MyIcon( MyIcons.check, contentDescription = null, modifier = m, tint = myColors.onPrimaryGradient, ) } else { Spacer(m) } } } } ================================================ FILE: shared/app/src/commonMain/kotlin/com/abdownloadmanager/shared/ui/widget/DashedBorder.kt ================================================ package com.abdownloadmanager.shared.ui.widget import androidx.compose.foundation.BorderStroke import androidx.compose.ui.Modifier import androidx.compose.ui.composed import androidx.compose.ui.draw.drawWithCache import androidx.compose.ui.geometry.Offset import androidx.compose.ui.geometry.Size import androidx.compose.ui.geometry.isSimple import androidx.compose.ui.graphics.* import androidx.compose.ui.graphics.drawscope.Stroke import androidx.compose.ui.graphics.drawscope.clipRect import androidx.compose.ui.graphics.drawscope.withTransform import androidx.compose.ui.platform.debugInspectorInfo import androidx.compose.ui.unit.Dp /** * Modify element to add border with appearance specified with a [border] and a [shape], pad the * content by the [BorderStroke.width] and clip it. * * @sample androidx.compose.foundation.samples.BorderSample() * * @param border [BorderStroke] class that specifies border appearance, such as size and color * @param shape shape of the border */ fun Modifier.dashedBorder(border: BorderStroke, shape: Shape = RectangleShape, on: Dp, off: Dp) = dashedBorder(width = border.width, brush = border.brush, shape = shape, on, off) /** * Returns a [Modifier] that adds border with appearance specified with [width], [color] and a * [shape], pads the content by the [width] and clips it. * * @sample androidx.compose.foundation.samples.BorderSampleWithDataClass() * * @param width width of the border. Use [Dp.Hairline] for a hairline border. * @param color color to paint the border with * @param shape shape of the border * @param on the size of the solid part of the dashes * @param off the size of the space between dashes */ fun Modifier.dashedBorder(width: Dp, color: Color, shape: Shape = RectangleShape, on: Dp, off: Dp) = dashedBorder(width, SolidColor(color), shape, on, off) /** * Returns a [Modifier] that adds border with appearance specified with [width], [brush] and a * [shape], pads the content by the [width] and clips it. * * @sample androidx.compose.foundation.samples.BorderSampleWithBrush() * * @param width width of the border. Use [Dp.Hairline] for a hairline border. * @param brush brush to paint the border with * @param shape shape of the border */ fun Modifier.dashedBorder(width: Dp, brush: Brush, shape: Shape, on: Dp, off: Dp): Modifier = composed( factory = { this.then( Modifier.drawWithCache { val outline: Outline = shape.createOutline(size, layoutDirection, this) val borderSize = if (width == Dp.Hairline) 1f else width.toPx() var insetOutline: Outline? = null // outline used for roundrect/generic shapes var stroke: Stroke? = null // stroke to draw border for all outline types var pathClip: Path? = null // path to clip roundrect/generic shapes var inset = 0f // inset to translate before drawing the inset outline // path to draw generic shapes or roundrects with different corner radii var insetPath: Path? = null if (borderSize > 0 && size.minDimension > 0f) { if (outline is Outline.Rectangle) { stroke = Stroke( borderSize, pathEffect = PathEffect.dashPathEffect( floatArrayOf(on.toPx(), off.toPx()) ) ) } else { // Multiplier to apply to the border size to get a stroke width that is // large enough to cover the corners while not being too large to overly // square off the internal shape. The resultant shape will be // clipped to the desired shape. Any value lower will show artifacts in // the corners of shapes. A value too large will always square off // the internal shape corners. For example, for a rounded rect border // a large multiplier will always have squared off edges within the // inner section of the stroke, however, having a smaller multiplier // will still keep the rounded effect for the inner section of the // border val strokeWidth = 1.2f * borderSize inset = borderSize - strokeWidth / 2 val insetSize = Size( size.width - inset * 2, size.height - inset * 2 ) insetOutline = shape.createOutline(insetSize, layoutDirection, this) stroke = Stroke( strokeWidth, pathEffect = PathEffect.dashPathEffect( floatArrayOf(on.toPx(), off.toPx()) ) ) pathClip = if (outline is Outline.Rounded) { Path().apply { addRoundRect(outline.roundRect) } } else if (outline is Outline.Generic) { outline.path } else { // should not get here because we check for Outline.Rectangle // above null } insetPath = if (insetOutline is Outline.Rounded && !insetOutline.roundRect.isSimple ) { // Rounded rect with non equal corner radii needs a path // to be pre-translated Path().apply { addRoundRect(insetOutline.roundRect) translate(Offset(inset, inset)) } } else if (insetOutline is Outline.Generic) { // Generic paths must be created and pre-translated Path().apply { addPath(insetOutline.path, Offset(inset, inset)) } } else { // Drawing a round rect with equal corner radii without // usage of a path null } } } onDrawWithContent { drawContent() // Only draw the border if a have a valid stroke parameter. If we have // an invalid border size we will just draw the content if (stroke != null) { if (insetOutline != null && pathClip != null) { val isSimpleRoundRect = insetOutline is Outline.Rounded && insetOutline.roundRect.isSimple withTransform({ clipPath(pathClip) // we are drawing the round rect not as a path so we must // translate ourselves othe if (isSimpleRoundRect) { translate(inset, inset) } }) { if (isSimpleRoundRect) { // If we don't have an insetPath then we are drawing // a simple round rect with the corner radii all identical val rrect = (insetOutline as Outline.Rounded).roundRect drawRoundRect( brush = brush, topLeft = Offset(rrect.left, rrect.top), size = Size(rrect.width, rrect.height), cornerRadius = rrect.topLeftCornerRadius, style = stroke, alpha = 0f, ) } else if (insetPath != null) { drawPath( path = insetPath, brush = brush, style = stroke, alpha = 0f, ) } } // Clip rect to ensure the stroke does not extend the bounds // of the composable. clipRect { // Draw a hairline stroke to cover up non-anti-aliased pixels // generated from the clip if (isSimpleRoundRect) { val rrect = (outline as Outline.Rounded).roundRect drawRoundRect( brush = brush, topLeft = Offset(rrect.left, rrect.top), size = Size(rrect.width, rrect.height), cornerRadius = rrect.topLeftCornerRadius, style = Stroke( Stroke.HairlineWidth, pathEffect = PathEffect.dashPathEffect( floatArrayOf(on.toPx(), off.toPx()) ) ) ) } else { drawPath( pathClip, brush = brush, style = Stroke( Stroke.HairlineWidth, pathEffect = PathEffect.dashPathEffect( floatArrayOf(on.toPx(), off.toPx()) ) ) ) } } } else { // Rectangular border fast path val strokeWidth = stroke.width val halfStrokeWidth = strokeWidth / 2 drawRect( brush = brush, topLeft = Offset(halfStrokeWidth, halfStrokeWidth), size = Size( size.width - strokeWidth, size.height - strokeWidth ), style = stroke ) } } } } ) }, inspectorInfo = debugInspectorInfo { name = "border" properties["width"] = width if (brush is SolidColor) { properties["color"] = brush.value value = brush.value } else { properties["brush"] = brush } properties["shape"] = shape } ) ================================================ FILE: shared/app/src/commonMain/kotlin/com/abdownloadmanager/shared/ui/widget/ExpandableItem.kt ================================================ package com.abdownloadmanager.shared.ui.widget import androidx.compose.animation.AnimatedVisibility import androidx.compose.foundation.layout.Column import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier @Composable fun ExpandableItem( isExpanded:Boolean, header:@Composable ()->Unit, body: @Composable () -> Unit, modifier: Modifier = Modifier, ){ Column(modifier) { header() AnimatedVisibility(isExpanded){ body() } } } ================================================ FILE: shared/app/src/commonMain/kotlin/com/abdownloadmanager/shared/ui/widget/Handle.kt ================================================ package com.abdownloadmanager.shared.ui.widget import com.abdownloadmanager.shared.util.ui.myColors import com.abdownloadmanager.shared.util.div import androidx.compose.animation.animateColorAsState import androidx.compose.foundation.background import androidx.compose.foundation.gestures.Orientation import androidx.compose.foundation.gestures.draggable import androidx.compose.foundation.gestures.rememberDraggableState import androidx.compose.foundation.hoverable import androidx.compose.foundation.interaction.MutableInteractionSource import androidx.compose.foundation.interaction.collectIsDraggedAsState import androidx.compose.foundation.interaction.collectIsHoveredAsState import androidx.compose.foundation.layout.Spacer import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue import androidx.compose.runtime.remember import androidx.compose.runtime.rememberUpdatedState import androidx.compose.ui.Modifier import androidx.compose.ui.composed import androidx.compose.ui.graphics.Color import androidx.compose.ui.input.pointer.pointerHoverIcon import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.platform.LocalLayoutDirection import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.LayoutDirection import com.abdownloadmanager.shared.ui.modifier.MyPointerHoverIcon import com.abdownloadmanager.shared.ui.modifier.myPointerHoverIcon @Composable fun Handle( modifier: Modifier, orientation: Orientation = Orientation.Horizontal, interactionSource: MutableInteractionSource = remember { MutableInteractionSource() }, color: Color = myColors.surface, inactiveColor: Color = myColors.surface / 50, onDrag: (Dp) -> Unit, ) { val isHovered by interactionSource.collectIsHoveredAsState() val isDragging by interactionSource.collectIsDraggedAsState() val hoverIcon = when (orientation) { Orientation.Vertical -> MyPointerHoverIcon.VerticalResize Orientation.Horizontal -> MyPointerHoverIcon.HorizontalResize } Spacer( modifier .myPointerHoverIcon(hoverIcon, true) .hoverable(interactionSource) .resizeHandle( orientation = orientation, interactionSource = interactionSource, onDrag = onDrag, ) .background( animateColorAsState( if (isHovered || isDragging) color else inactiveColor ).value ) ) } fun Modifier.resizeHandle( orientation: Orientation = Orientation.Horizontal, interactionSource: MutableInteractionSource? = null, onDrag: (Dp) -> Unit, ) = composed { val latestOnDrag by rememberUpdatedState(onDrag) val density = LocalDensity.current val draggableState = rememberDraggableState { density.run { latestOnDrag(it.toDp()) } } val isRtl = LocalLayoutDirection.current == LayoutDirection.Rtl val reverseDirection = orientation == Orientation.Horizontal && isRtl draggable( state = draggableState, orientation = orientation, interactionSource = interactionSource, reverseDirection = reverseDirection ) } ================================================ FILE: shared/app/src/commonMain/kotlin/com/abdownloadmanager/shared/ui/widget/Help.kt ================================================ package com.abdownloadmanager.shared.ui.widget import androidx.compose.foundation.background import androidx.compose.foundation.border import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.* import androidx.compose.foundation.shape.CircleShape import androidx.compose.runtime.* import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip import androidx.compose.ui.graphics.Color import androidx.compose.ui.unit.dp import com.abdownloadmanager.shared.util.ui.icon.MyIcons import com.abdownloadmanager.shared.util.ui.myColors import com.abdownloadmanager.shared.util.ui.widget.MyIcon @Composable fun Help( content: String, modifier: Modifier = Modifier, ) { var showHelpContent by remember { mutableStateOf(false) } val onRequestCloseShowHelpContent = { showHelpContent = false } Column(modifier) { MyIcon( MyIcons.question, "Hint", Modifier .clip(CircleShape) .clickable { showHelpContent = !showHelpContent } .border( 1.dp, if (showHelpContent) myColors.primary else Color.Transparent, CircleShape ) .background(myColors.surface) .padding(4.dp) .size(12.dp), tint = myColors.onSurface, ) if (showHelpContent) { TooltipPopup( onRequestCloseShowHelpContent = onRequestCloseShowHelpContent, content = content, ) } } } ================================================ FILE: shared/app/src/commonMain/kotlin/com/abdownloadmanager/shared/ui/widget/IconPick.kt ================================================ package com.abdownloadmanager.shared.ui.widget import androidx.compose.animation.AnimatedVisibility import androidx.compose.animation.fadeIn import androidx.compose.animation.fadeOut import androidx.compose.foundation.background import androidx.compose.foundation.border import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.* import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.items import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip import androidx.compose.ui.draw.shadow import androidx.compose.ui.graphics.Brush import androidx.compose.ui.graphics.Color import androidx.compose.ui.unit.DpOffset import androidx.compose.ui.unit.dp import com.abdownloadmanager.shared.util.ui.myColors import ir.amirab.util.ifThen import com.abdownloadmanager.shared.ui.widget.menu.custom.MyDropDown import com.abdownloadmanager.shared.util.div import com.abdownloadmanager.shared.util.ui.theme.myShapes import com.abdownloadmanager.shared.util.ui.widget.MyIcon import ir.amirab.util.compose.IconSource @Composable fun IconPick( selectedIcon: IconSource?, icons: List, onSelected: (IconSource) -> Unit, onCancel: () -> Unit, ) { MyDropDown( onDismissRequest = onCancel, offset = DpOffset(y = 2.dp, x = 0.dp), content = { val shape = myShapes.defaultRounded Box( Modifier .shadow(24.dp) // .verticalScroll(rememberScrollState()) .clip(shape) // .width(IntrinsicSize.Max) .widthIn(120.dp) .height(220.dp) .border(1.dp, myColors.surface, shape) .background(myColors.menuGradientBackground) ) { Content( modifier = Modifier.padding(horizontal = 8.dp, vertical = 0.dp), selectedIcon = selectedIcon, icons = icons, onSelected = onSelected, ) } } ) } @Composable private fun Content( modifier: Modifier, selectedIcon: IconSource?, icons: List, onSelected: (IconSource) -> Unit, ) { val state = rememberLazyListState() val shape = myShapes.defaultRounded Box { LazyColumn( modifier = modifier, state = state, contentPadding = PaddingValues(vertical = 8.dp), content = { items(icons.chunked(6)) { rowItems -> Row { for (iconSource in rowItems) { val isSelected = selectedIcon == iconSource MyIcon( iconSource, null, Modifier .clip(shape) .ifThen(isSelected) { background(myColors.primary / 0.25f) } .border( 1.dp, if (isSelected) myColors.primary / 0.25f else Color.Transparent, shape ) .clickable { onSelected(iconSource) } .padding(8.dp) .size(24.dp), ) } } } // LazyVerticalGrid( // columns = GridCells.Fixed(6), // content = { // val shape = myShapes.defaultRounded // items(icons) { // MyIcon( // it, // null, // Modifier // .clip(shape) // .ifThen(selectedIcon == it) { // background(myColors.primary / 0.25f) // } // .clickable { // onSelected(it) // } // .padding(8.dp) // .size(24.dp), // ) // } // } // ) } ) AnimatedVisibility( state.canScrollForward, modifier = Modifier.matchParentSize(), enter = fadeIn(), exit = fadeOut(), ) { Spacer( Modifier .fillMaxSize() .background( Brush.verticalGradient( colorStops = arrayOf( 0f to Color.Transparent, 0.8f to Color.Transparent, 1f to myColors.background, ) ) ) ) } } } ================================================ FILE: shared/app/src/commonMain/kotlin/com/abdownloadmanager/shared/ui/widget/Language.kt ================================================ package com.abdownloadmanager.shared.ui.widget import androidx.compose.runtime.Composable import androidx.compose.runtime.CompositionLocalProvider import androidx.compose.runtime.collectAsState import androidx.compose.ui.platform.LocalLayoutDirection import androidx.compose.ui.unit.LayoutDirection import com.abdownloadmanager.shared.util.ui.LocalTitleBarDirection import ir.amirab.util.compose.localizationmanager.LanguageManager import ir.amirab.util.compose.localizationmanager.LocalLanguageManager import ir.amirab.util.compose.localizationmanager.LocaleLanguageDirection @Composable fun ProvideLanguageManager( languageManager: LanguageManager, content: @Composable () -> Unit, ) { val isRtl = languageManager.isRtl.collectAsState().value val languageDirection = if (isRtl) { LayoutDirection.Rtl } else { LayoutDirection.Ltr } CompositionLocalProvider( LocalLanguageManager provides languageManager, LocalLayoutDirection provides languageDirection, LocalTitleBarDirection provides LayoutDirection.Ltr, LocaleLanguageDirection provides languageDirection ) { content() } } ================================================ FILE: shared/app/src/commonMain/kotlin/com/abdownloadmanager/shared/ui/widget/LinkText.kt ================================================ package com.abdownloadmanager.shared.ui.widget import androidx.compose.foundation.clickable import androidx.compose.foundation.hoverable import androidx.compose.foundation.interaction.MutableInteractionSource import androidx.compose.foundation.interaction.collectIsHoveredAsState import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.size import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue import androidx.compose.runtime.remember import androidx.compose.ui.Modifier import androidx.compose.ui.draw.alpha import androidx.compose.ui.input.pointer.PointerIcon import androidx.compose.ui.input.pointer.pointerHoverIcon import androidx.compose.ui.platform.LocalUriHandler import androidx.compose.ui.text.TextStyle import androidx.compose.ui.text.style.TextDecoration import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp import com.abdownloadmanager.shared.util.ui.LocalTextStyle import com.abdownloadmanager.shared.util.ui.icon.MyIcons import com.abdownloadmanager.shared.util.ui.myColors import com.abdownloadmanager.shared.util.ui.widget.MyIcon import ir.amirab.util.HttpUrlUtils import ir.amirab.util.ifThen @Composable fun LinkText( text: String, link: String, modifier: Modifier = Modifier, maxLines: Int = Int.MAX_VALUE, showExternalIndicator: Boolean = true, overflow: TextOverflow = TextOverflow.Clip, ) { val handler = LocalUriHandler.current val interactionSource = remember { MutableInteractionSource() } val isHovered by interactionSource.collectIsHoveredAsState() Row( modifier .pointerHoverIcon(PointerIcon.Hand) .hoverable(interactionSource) .clickable( interactionSource = interactionSource, indication = null ) { handler.openUri(link) } ) { Text( text = text, style = LocalTextStyle.current .merge(LinkStyle).ifThen(isHovered) { copy( textDecoration = TextDecoration.Underline ) }, overflow = overflow, maxLines = maxLines, ) if (showExternalIndicator) { MyIcon( MyIcons.externalLink, null, Modifier.size(10.dp).alpha( if (isHovered) 0.75f else 0.5f ) ) } } } @Composable fun MaybeLinkText( text: String, link: String?, modifier: Modifier = Modifier, overflow: TextOverflow = TextOverflow.Clip, maxLines: Int = Int.MAX_VALUE, ) { val link = remember(link) { link?.takeIf(HttpUrlUtils::isValidUrl) } if (link == null) { Text( modifier = modifier, text = text, maxLines = maxLines, overflow = overflow, ) } else { LinkText( modifier = modifier, text = text, link = link, maxLines = maxLines, overflow = overflow ) } } private val LinkStyle: TextStyle @Composable get() = TextStyle( color = myColors.info, ) ================================================ FILE: shared/app/src/commonMain/kotlin/com/abdownloadmanager/shared/ui/widget/LoadingIndicator.kt ================================================ package com.abdownloadmanager.shared.ui.widget import com.abdownloadmanager.shared.util.ui.myColors import androidx.annotation.FloatRange import androidx.compose.animation.core.* import androidx.compose.foundation.Canvas import androidx.compose.foundation.layout.padding import androidx.compose.foundation.progressSemantics import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Brush import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.SolidColor import androidx.compose.ui.graphics.StrokeCap import androidx.compose.ui.graphics.drawscope.Stroke import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp @Composable fun LoadingIndicator( modifier: Modifier, sweepAngle: Float = 90f, // angle (length) of indicator arc color: Color = myColors.primary, // color of indicator arc line strokeWidth: Dp = 4.dp, ) { val transition = rememberInfiniteTransition() // define the changing value from 0 to 360. // This is the angle of the beginning of indicator arc // this value will change over time from 0 to 360 and repeat indefinitely. // it changes starting position of the indicator arc and the animation is obtained val currentArcStartAngle by transition.animateValue( 0, 360, Int.VectorConverter, infiniteRepeatable( animation = tween( durationMillis = 1100, easing = LinearEasing ) ) ) IndicatorCanvas( modifier = modifier, currentArcStartAngle = currentArcStartAngle, strokeWidth = strokeWidth, color = SolidColor(color), sweepAngle = sweepAngle, ) } @Composable fun LoadingIndicator( modifier: Modifier, color: Color = myColors.primary, // color of indicator arc line strokeWidth: Dp = 4.dp, @FloatRange(0.0,1.0) progress:Float ) { IndicatorCanvas( modifier = modifier, currentArcStartAngle = 0, sweepAngle = (progress * 360).coerceIn(0f, 360f), strokeWidth = strokeWidth, color = SolidColor(color), ) } @Composable fun LoadingIndicatorWithBrush( modifier: Modifier, brush: Brush = SolidColor(myColors.primary), // color of indicator arc line strokeWidth: Dp = 4.dp, @FloatRange(0.0, 1.0) progress: Float ) { IndicatorCanvas( modifier = modifier, currentArcStartAngle = 0, sweepAngle = (progress*360).coerceIn(0f,360f), strokeWidth = strokeWidth, color = brush, ) } @Composable fun IndicatorCanvas( modifier: Modifier, currentArcStartAngle: Int, sweepAngle:Float, strokeWidth: Dp, color: Brush, ) { // define stroke with given width and arc ends type considering device DPI val stroke = with(LocalDensity.current) { Stroke(width = strokeWidth.toPx(), cap = StrokeCap.Round) } // draw on canvas Canvas( modifier .progressSemantics() // (optional) for Accessibility services .padding(strokeWidth / 2) //padding. otherwise, not the whole circle will fit in the canvas ) { // draw arc with the same stroke drawArc( color, // arc start angle // -90 shifts the start position towards the y-axis startAngle = currentArcStartAngle.toFloat() - 90, sweepAngle = sweepAngle, useCenter = false, style = stroke ) } } ================================================ FILE: shared/app/src/commonMain/kotlin/com/abdownloadmanager/shared/ui/widget/MainActionButton.kt ================================================ package com.abdownloadmanager.shared.ui.widget import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Brush import com.abdownloadmanager.shared.util.div import com.abdownloadmanager.shared.util.ui.myColors @Composable fun PrimaryMainActionButton( text: String, modifier: Modifier, enabled: Boolean = true, onClick: () -> Unit, ) { val backgroundColor = Brush.horizontalGradient( myColors.primaryGradientColors.map { it / 30 } ) val borderColor = Brush.horizontalGradient( myColors.primaryGradientColors ) val disabledBorderColor = Brush.horizontalGradient( myColors.primaryGradientColors.map { it / 50 } ) ActionButton( text = text, modifier = modifier, enabled = enabled, onClick = onClick, backgroundColor = backgroundColor, disabledBackgroundColor = backgroundColor, borderColor = borderColor, disabledBorderColor = disabledBorderColor, ) } ================================================ FILE: shared/app/src/commonMain/kotlin/com/abdownloadmanager/shared/ui/widget/MessageDialogType.kt ================================================ package com.abdownloadmanager.shared.ui.widget @Suppress("unused") sealed class MessageDialogType { data object Success : MessageDialogType() data object Info : MessageDialogType() data object Error : MessageDialogType() data object Warning : MessageDialogType() } ================================================ FILE: shared/app/src/commonMain/kotlin/com/abdownloadmanager/shared/ui/widget/Multiselect.kt ================================================ package com.abdownloadmanager.shared.ui.widget import androidx.compose.foundation.background import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.heightIn import androidx.compose.foundation.layout.padding import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.runtime.Composable import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Shape import androidx.compose.ui.unit.dp import com.abdownloadmanager.shared.util.ui.myColors import ir.amirab.util.ifThen import com.abdownloadmanager.shared.util.div import com.abdownloadmanager.shared.util.ui.LocalContentColor import com.abdownloadmanager.shared.util.ui.WithContentAlpha import com.abdownloadmanager.shared.util.ui.theme.myShapes import com.abdownloadmanager.shared.util.ui.theme.mySpacings @Composable fun Multiselect( selections: List, selectedItem: T, onSelectionChange: (T) -> Unit, modifier: Modifier = Modifier, shape: Shape = myShapes.defaultRounded, backgroundColour: Color = myColors.surface, selectedColor: Color = LocalContentColor.current / 10, unselectedAlpha: Float = 0.5f, render: @Composable (T) -> Unit, ) { Row( verticalAlignment = Alignment.CenterVertically, modifier = modifier .clip(shape) .background(backgroundColour) ) { for (item in selections) { val isSelected = item == selectedItem Box( Modifier .padding(vertical = 4.dp, horizontal = 4.dp) .heightIn(mySpacings.thumbSize) .clip(shape) .ifThen(isSelected) { background(selectedColor) } .clickable { onSelectionChange(item) } .padding(vertical = 2.dp, horizontal = 4.dp), contentAlignment = Alignment.Center, ) { WithContentAlpha( if (isSelected) { 1f } else { unselectedAlpha } ) { render(item) } } } } } ================================================ FILE: shared/app/src/commonMain/kotlin/com/abdownloadmanager/shared/ui/widget/MyIconButton.kt ================================================ package com.abdownloadmanager.shared.ui.widget import ir.amirab.util.compose.IconSource import com.abdownloadmanager.shared.util.ui.widget.MyIcon import com.abdownloadmanager.shared.util.ui.myColors import ir.amirab.util.ifThen import com.abdownloadmanager.shared.util.div import androidx.compose.animation.core.* import androidx.compose.foundation.LocalIndication import androidx.compose.foundation.background import androidx.compose.foundation.border import androidx.compose.foundation.clickable import androidx.compose.foundation.interaction.MutableInteractionSource import androidx.compose.foundation.interaction.collectIsFocusedAsState import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.sizeIn import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue import androidx.compose.runtime.remember import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.alpha import androidx.compose.ui.draw.clip import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Shape import androidx.compose.ui.semantics.Role import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp import com.abdownloadmanager.shared.util.ui.LocalContentColor import com.abdownloadmanager.shared.util.ui.WithContentColor import com.abdownloadmanager.shared.util.ui.theme.myShapes import com.abdownloadmanager.shared.util.ui.theme.mySpacings import ir.amirab.util.compose.StringSource import ir.amirab.util.compose.modifiers.autoMirror @Composable fun alphaFlicker(): Float { val t = rememberInfiniteTransition() return t.animateFloat(1f, 0f, infiniteRepeatable(tween(1000), repeatMode = RepeatMode.Reverse)).value } @Composable fun IconActionButton( icon: IconSource, contentDescription: StringSource, modifier: Modifier = Modifier, indicateActive: Boolean = false, requiresAttention: Boolean = false, enabled: Boolean = true, shape: Shape = myShapes.defaultRounded, backgroundColor: Color = myColors.surface, contentColor: Color = LocalContentColor.current, borderColor: Color = myColors.onBackground / 10, interactionSource: MutableInteractionSource = remember { MutableInteractionSource() }, automaticMirrorIcon: Boolean = true, iconSize: Dp = mySpacings.iconSize, onClick: () -> Unit, ) { Tooltip(contentDescription) { WithContentColor(contentColor) { val isFocused by interactionSource.collectIsFocusedAsState() val isActiveOrFocused = indicateActive || isFocused Box( modifier .sizeIn(mySpacings.thumbSize, mySpacings.thumbSize) .ifThen(!enabled) { alpha(0.5f) } .border( 1.dp, borderColor, shape ) .ifThen(isActiveOrFocused || requiresAttention) { border( 1.dp, myColors.focusedBorderColor / if (isActiveOrFocused) 1f else alphaFlicker(), shape ) } .clip(shape) .background(backgroundColor) .clickable( enabled = enabled, indication = LocalIndication.current, interactionSource = interactionSource, role = Role.Button, onClick = onClick, ) .padding(6.dp), contentAlignment = Alignment.Center, ) { MyIcon( icon, contentDescription.rememberString(), Modifier .ifThen(automaticMirrorIcon) { autoMirror() } .size(iconSize), ) } } } } @Composable fun TransparentIconActionButton( icon: IconSource, contentDescription: StringSource, modifier: Modifier = Modifier, indicateActive: Boolean = false, requiresAttention: Boolean = false, enabled: Boolean = true, shape: Shape = myShapes.defaultRounded, contentColor: Color = LocalContentColor.current, interactionSource: MutableInteractionSource = remember { MutableInteractionSource() }, automaticMirrorIcon: Boolean = true, iconSize: Dp = mySpacings.iconSize, onClick: () -> Unit, ) { IconActionButton( icon = icon, contentDescription = contentDescription, modifier = modifier, indicateActive = indicateActive, requiresAttention = requiresAttention, enabled = enabled, shape = shape, backgroundColor = Color.Transparent, contentColor = contentColor, borderColor = Color.Transparent, interactionSource = interactionSource, automaticMirrorIcon = automaticMirrorIcon, iconSize = iconSize, onClick = onClick, ) } ================================================ FILE: shared/app/src/commonMain/kotlin/com/abdownloadmanager/shared/ui/widget/MyTextField.kt ================================================ package com.abdownloadmanager.shared.ui.widget import com.abdownloadmanager.shared.util.ui.LocalContentColor import com.abdownloadmanager.shared.util.ui.LocalTextStyle import com.abdownloadmanager.shared.util.ui.myColors import ir.amirab.util.ifThen import com.abdownloadmanager.shared.util.div import androidx.compose.animation.* import androidx.compose.foundation.background import androidx.compose.foundation.border import androidx.compose.foundation.clickable import androidx.compose.foundation.interaction.MutableInteractionSource import androidx.compose.foundation.interaction.collectIsFocusedAsState import androidx.compose.foundation.layout.* import androidx.compose.foundation.text.BasicTextField import androidx.compose.foundation.text.KeyboardActions import androidx.compose.foundation.text.KeyboardOptions import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue import androidx.compose.runtime.remember import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.alpha import androidx.compose.ui.draw.clip import androidx.compose.ui.focus.FocusRequester import androidx.compose.ui.focus.focusRequester import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Shape import androidx.compose.ui.graphics.SolidColor import androidx.compose.ui.graphics.isSpecified import androidx.compose.ui.input.key.Key import androidx.compose.ui.input.key.key import androidx.compose.ui.input.key.onKeyEvent import androidx.compose.ui.input.pointer.PointerIcon import androidx.compose.ui.input.pointer.pointerHoverIcon import androidx.compose.ui.platform.LocalFocusManager import androidx.compose.ui.text.TextStyle import androidx.compose.ui.unit.TextUnit import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.takeOrElse import com.abdownloadmanager.shared.util.ui.theme.myShapes import com.abdownloadmanager.shared.util.ui.theme.mySpacings @Composable fun MyTextField( text: String, onTextChange: (String) -> Unit, placeholder: String, modifier: Modifier, background: Color = myColors.surface, contentColor: Color = myColors.getContentColorFor(background).takeIf { it.isSpecified } ?: LocalContentColor.current, focusedBorderColor: Color = myColors.primary, borderColor: Color = myColors.onBackground / 0.1f, shape: Shape = myShapes.defaultRounded, textPadding: PaddingValues = PaddingValues(8.dp), interactionSource: MutableInteractionSource = remember { MutableInteractionSource() }, fontSize: TextUnit = TextUnit.Unspecified, enabled: Boolean = true, keyboardOptions: KeyboardOptions = KeyboardOptions.Default, keyboardActions: KeyboardActions = KeyboardActions.Default, singleLine: Boolean = true, maxLines: Int = if (singleLine) 1 else Int.MAX_VALUE, minLines: Int = 1, start: @Composable (RowScope.() -> Unit)? = null, end: @Composable (RowScope.() -> Unit)? = null, ) { val focusRequester = remember { FocusRequester() } val fm = LocalFocusManager.current val isFocused by interactionSource.collectIsFocusedAsState() val textSize = fontSize.takeOrElse { LocalTextStyle.current.fontSize } Row( modifier .ifThen(!enabled) { alpha(0.5f) } .clip(shape) .heightIn(mySpacings.thumbSize) .height(IntrinsicSize.Max) // .height(32.dp) .pointerHoverIcon( if (enabled) PointerIcon.Text else PointerIcon.Default ) .onKeyEvent { if (it.key == Key.Escape) { fm.clearFocus() true } else false } .clickable( indication = null, interactionSource = null, ) { focusRequester.requestFocus() } .border( 1.dp, animateColorAsState( if (isFocused) focusedBorderColor else borderColor ).value, shape ) .ifThen(background.isSpecified) { background(background) }, verticalAlignment = Alignment.CenterVertically, ) { start?.let { it() } BasicTextField( value = text, singleLine = singleLine, maxLines = maxLines, minLines = minLines, onValueChange = onTextChange, interactionSource = interactionSource, enabled = enabled, modifier = Modifier .weight(1f) .padding(textPadding) .focusRequester(focusRequester), textStyle = LocalTextStyle.current.merge( TextStyle( color = LocalContentColor.current.ifThen(!enabled) { copy(0.5f) }, fontSize = fontSize ) ), decorationBox = { Box { androidx.compose.animation.AnimatedVisibility( text.isEmpty(), // modifier = Modifier.matchParentSize(), enter = fadeIn(), exit = fadeOut(), ) { Text( text = placeholder, maxLines = 1, color = contentColor / 50, fontSize = textSize ) } it() } }, cursorBrush = SolidColor(myColors.primary), keyboardActions = keyboardActions, keyboardOptions = keyboardOptions, ) end?.let { it() } } } ================================================ FILE: shared/app/src/commonMain/kotlin/com/abdownloadmanager/shared/ui/widget/MyTextFieldWithIcons.kt ================================================ package com.abdownloadmanager.shared.ui.widget import androidx.compose.animation.AnimatedVisibility import androidx.compose.foundation.background import androidx.compose.foundation.clickable import androidx.compose.foundation.interaction.MutableInteractionSource import androidx.compose.foundation.interaction.collectIsFocusedAsState import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxHeight import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.width import androidx.compose.foundation.layout.wrapContentHeight import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue import androidx.compose.runtime.remember import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color import androidx.compose.ui.input.pointer.PointerIcon import androidx.compose.ui.input.pointer.pointerHoverIcon import androidx.compose.ui.unit.dp import com.abdownloadmanager.shared.util.div import com.abdownloadmanager.shared.util.ui.WithContentAlpha import com.abdownloadmanager.shared.util.ui.myColors import com.abdownloadmanager.shared.util.ui.theme.myShapes import com.abdownloadmanager.shared.util.ui.theme.mySpacings import com.abdownloadmanager.shared.util.ui.theme.myTextSizes import com.abdownloadmanager.shared.util.ui.widget.MyIcon import ir.amirab.util.compose.IconSource import ir.amirab.util.ifThen @Composable fun MyTextFieldWithIcons( text: String, onTextChange: (String) -> Unit, placeholder: String, modifier: Modifier, errorText: String? = null, enabled: Boolean = true, singleLine: Boolean = true, start: @Composable (() -> Unit)? = null, end: @Composable (() -> Unit)? = null, ) { val interactionSource = remember { MutableInteractionSource() } val isFocused by interactionSource.collectIsFocusedAsState() val dividerModifier = Modifier .fillMaxHeight() .padding(vertical = 1.dp) //to not conflict with text-field border .width(1.dp) .background(if (isFocused) myColors.onBackground / 10 else Color.Transparent) Column(modifier) { MyTextField( text = text, onTextChange = onTextChange, placeholder = placeholder, modifier = Modifier.fillMaxWidth(), background = myColors.surface / 50, interactionSource = interactionSource, shape = myShapes.defaultRounded, singleLine = singleLine, enabled = enabled, start = start?.let { { WithContentAlpha(0.5f) { it() } Spacer(dividerModifier) } }, end = end?.let { { Spacer(dividerModifier) it() } } ) AnimatedVisibility(errorText != null) { if (errorText != null) { Text( errorText, Modifier.padding(bottom = 4.dp, start = 4.dp), fontSize = myTextSizes.sm, color = myColors.error, ) } } } } @Composable fun MyTextFieldIcon( icon: IconSource, enabled: Boolean = true, contentDescription: String? = null, onClick: (() -> Unit)? = null, ) { MyIcon( icon = icon, contentDescription = contentDescription, modifier = Modifier .fillMaxHeight() .ifThen(onClick != null) { pointerHoverIcon(PointerIcon.Default) .clickable(enabled = enabled, onClick = onClick) } .wrapContentHeight() .padding(horizontal = 8.dp) .size(mySpacings.iconSize) ) } ================================================ FILE: shared/app/src/commonMain/kotlin/com/abdownloadmanager/shared/ui/widget/NavigateableItem.kt ================================================ package com.abdownloadmanager.shared.ui.widget import com.abdownloadmanager.shared.util.ui.myColors import com.abdownloadmanager.shared.util.ui.WithContentAlpha import androidx.compose.foundation.background import androidx.compose.foundation.border import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.* import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.runtime.Composable import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip import androidx.compose.ui.unit.dp @Composable fun NavigateableItem( isSelected:Boolean, onClick:()->Unit, content:@Composable ()->Unit, ){ val shape = RoundedCornerShape(12.dp) WithContentAlpha(if (isSelected)1f else 0.75f){ Row( Modifier .fillMaxWidth() .clip(shape) .let { if (isSelected) { val selectionColor = myColors.onBackground it .border( 1.dp, myColors.selectionGradient(0.10f, 0.05f, selectionColor), shape ) .background(myColors.selectionGradient(0.15f, 0f, selectionColor)) } else it } .clickable { onClick() } .padding(8.dp), verticalAlignment = Alignment.CenterVertically, ) { content() } } } ================================================ FILE: shared/app/src/commonMain/kotlin/com/abdownloadmanager/shared/ui/widget/Notification.kt ================================================ package com.abdownloadmanager.shared.ui.widget import com.abdownloadmanager.shared.util.ui.widget.MyIcon import com.abdownloadmanager.shared.util.ui.icon.MyIcons import com.abdownloadmanager.shared.util.ui.myColors import com.abdownloadmanager.shared.util.ui.theme.myTextSizes import com.abdownloadmanager.shared.util.ui.WithContentAlpha import com.abdownloadmanager.shared.util.div import androidx.compose.animation.animateContentSize import androidx.compose.foundation.background import androidx.compose.foundation.border import androidx.compose.foundation.layout.* import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.itemsIndexed import androidx.compose.runtime.* import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip import androidx.compose.ui.draw.shadow import androidx.compose.ui.graphics.Color import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.dp import com.abdownloadmanager.shared.util.ui.theme.myShapes import ir.amirab.util.compose.StringSource import ir.amirab.util.compose.asStringSource import kotlinx.coroutines.* import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.update import java.util.UUID private val LocalNotification = compositionLocalOf { error("LocalNotification not provided yet") } @Composable fun useNotification(): NotificationManager { return LocalNotification.current } sealed interface NotificationType { data class Loading(val percent: Int? = null) : NotificationType data object Success : NotificationType data object Info : NotificationType data object Error : NotificationType data object Warning : NotificationType } @Stable class NotificationModel( val tag: Any, initialTitle: StringSource = "".asStringSource(), initialDescription: StringSource = "".asStringSource(), initialNotificationType: NotificationType = NotificationType.Info, ) { var notificationType: NotificationType by mutableStateOf(initialNotificationType) var title: StringSource by mutableStateOf(initialTitle) var description: StringSource by mutableStateOf(initialDescription) } @Composable fun ProvideNotificationManager( notificationManager: NotificationManager, content: @Composable () -> Unit ) { CompositionLocalProvider( LocalNotification provides notificationManager ) { content() } } @Composable fun ShowNotification( title: StringSource, description: StringSource, type: NotificationType, tag: Any = currentCompositeKeyHash, ) { val notification = remember(tag) { NotificationModel( tag = tag, initialTitle = title, initialDescription = description, ) } LaunchedEffect(type){ notification.notificationType=type } LaunchedEffect(title) { notification.title = title } LaunchedEffect(description) { notification.description = description } val notificationManager = useNotification() LaunchedEffect(Unit) { notificationManager.showNotification(notification) } } @Composable fun NotificationArea( modifier: Modifier ) { val notificationManager = useNotification() // val list = notificationManager.activeNotificationList val activeNotificationList by notificationManager.activeNotificationList.collectAsState() val notificationListToShow by remember { derivedStateOf { activeNotificationList.distinctBy { it.tag } } } LazyColumn (modifier) { itemsIndexed(notificationListToShow) { index, item -> Spacer(Modifier.size(12.dp)) RenderNotification( Modifier.animateItem(), item ) Spacer(Modifier.size(12.dp)) } } } @Composable private fun RenderNotification( modifier: Modifier, notificationModel: NotificationModel, alignment: Alignment = Alignment.BottomCenter, ) { val shape = myShapes.defaultRounded Row(modifier .shadow(4.dp, shape) .animateContentSize(alignment = alignment) .height(IntrinsicSize.Max) .fillMaxWidth() .clip(shape) .border(1.dp, myColors.menuBorderColor, shape) .background(myColors.menuGradientBackground, shape) .padding(10.dp) ) { NotificationIcon( Modifier .padding(horizontal = 2.dp) .align(Alignment.CenterVertically), notificationModel ) Spacer(Modifier .fillMaxHeight() .padding(horizontal = 8.dp) .padding(vertical = 2.dp) .width(1.dp) .background(myColors.onSurface/20) ) Column { NotificationTitle(notificationModel) Spacer(Modifier.size(4.dp)) NotificationDescription(notificationModel) } } } @Composable private fun NotificationDescription(notificationModel: NotificationModel) { WithContentAlpha(0.75f) { Text( text = notificationModel.description.rememberString(), fontSize = myTextSizes.base ) } } @Composable private fun NotificationTitle(notificationModel: NotificationModel) { WithContentAlpha(1f) { Text( text = notificationModel.title.rememberString(), fontSize = myTextSizes.base, fontWeight = FontWeight.Bold, ) } } @Composable fun LoadingIcon(modifier: Modifier, percent: Int?) { if (percent == null) { LoadingIndicator( modifier = modifier ) } else { LoadingIndicator( modifier = modifier, progress = (percent / 100f).coerceIn(0f, 1f) ) } } @Composable private fun InfoIcon(modifier: Modifier, color: Color) { MyIcon( icon = MyIcons.info, contentDescription = null, modifier = modifier, tint = color, ) } @Composable fun NotificationIcon( modifier: Modifier=Modifier, notificationModel: NotificationModel ) { val notificationType = notificationModel.notificationType val modifier = modifier.size(24.dp) when (notificationType) { NotificationType.Error -> { InfoIcon(modifier, myColors.error) } NotificationType.Info -> { InfoIcon(modifier, myColors.info) } NotificationType.Success -> { InfoIcon(modifier, myColors.success) } NotificationType.Warning -> { InfoIcon(modifier, myColors.warning) } is NotificationType.Loading -> { LoadingIcon(modifier, notificationType.percent) } } } @Stable class NotificationManager { private val _activeNotificationList = MutableStateFlow>(emptyList()) val activeNotificationList = _activeNotificationList.asStateFlow() suspend fun showNotification( notification: NotificationModel, ) { try { _activeNotificationList.update { it.plus(notification) } awaitCancellation() } finally { _activeNotificationList.update { it.minus(notification) } } } suspend fun showNotification( title: StringSource, description: StringSource, delay: Long = -1, type: NotificationType = NotificationType.Info, tag: Any = UUID.randomUUID(), ) { val notification = NotificationModel( tag = tag, initialTitle = title, initialDescription = description, initialNotificationType = type, ) coroutineScope { if (delay == -1L) { showNotification(notification) } else { withTimeoutOrNull(delay) { showNotification(notification) } } } } } /* fun main() { application { ABDownloaderTheme("dark") { ProvideNotificationManager { CustomWindow( rememberWindowState( size = DpSize(400.dp, 400.dp) ), onCloseRequest = this::exitApplication, ) { val useNotification = useNotification() LaunchedEffect(Unit) { delay(1000) launch { useNotification.showNotification( title = "A title", description = "A brief description", delay = 5000, ) } delay(1000) launch { useNotification.showNotification( title = "A second title", description = "A brief description", delay = 5000, ) } } Box(Modifier.fillMaxSize()) { NotificationArea( Modifier .width(200.dp) .padding(8.dp) .align(Alignment.BottomEnd) ) } } } } } }*/ ================================================ FILE: shared/app/src/commonMain/kotlin/com/abdownloadmanager/shared/ui/widget/NumberTextField.kt ================================================ package com.abdownloadmanager.shared.ui.widget import com.abdownloadmanager.shared.util.ui.widget.MyIcon import com.abdownloadmanager.shared.util.ui.icon.MyIcons import ir.amirab.util.ifThen import com.abdownloadmanager.shared.util.ui.WithContentAlpha import androidx.compose.animation.core.animateFloatAsState import androidx.compose.foundation.clickable import androidx.compose.foundation.gestures.Orientation import androidx.compose.foundation.hoverable import androidx.compose.foundation.interaction.MutableInteractionSource import androidx.compose.foundation.interaction.collectIsDraggedAsState import androidx.compose.foundation.interaction.collectIsFocusedAsState import androidx.compose.foundation.interaction.collectIsHoveredAsState import androidx.compose.foundation.layout.* import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.text.KeyboardActions import androidx.compose.foundation.text.KeyboardOptions import androidx.compose.runtime.* import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip import androidx.compose.ui.graphics.Shape import androidx.compose.ui.input.key.Key import androidx.compose.ui.input.key.key import androidx.compose.ui.input.key.onKeyEvent import androidx.compose.ui.input.pointer.PointerIcon import androidx.compose.ui.input.pointer.pointerHoverIcon import androidx.compose.ui.text.input.KeyboardType import androidx.compose.ui.unit.dp import com.abdownloadmanager.shared.util.ui.theme.myShapes import com.abdownloadmanager.shared.util.ui.theme.mySpacings import ir.amirab.util.compose.IconSource private val DefaultShape @Composable get() = myShapes.defaultRounded @Composable fun IntTextField( value: Int, onValueChange: (Int) -> Unit, interactionSource: MutableInteractionSource = remember { MutableInteractionSource() }, range: ClosedRange, modifier: Modifier, enabled: Boolean = true, keyboardOptions: KeyboardOptions, keyboardActions: KeyboardActions = KeyboardActions.Default, prettify: (Int) -> String = { it.toString() }, placeholder: String = "", textPadding: PaddingValues = PaddingValues(4.dp), shape: Shape = DefaultShape, ) { NumberTextField( value = value, onValueChange = onValueChange, enc = { value + it }, toValue = { it.toIntOrNull() }, prettify = prettify, fromValue = { it.toString() }, range = range, modifier = modifier, enabled = enabled, keyboardOptions = keyboardOptions, keyboardActions = keyboardActions, interactionSource = interactionSource, placeholder = placeholder, textPadding = textPadding, shape = shape, ) } @Composable fun LongTextField( value: Long, onValueChange: (Long) -> Unit, interactionSource: MutableInteractionSource = remember { MutableInteractionSource() }, range: ClosedRange, modifier: Modifier, enabled: Boolean = true, keyboardOptions: KeyboardOptions = KeyboardOptions.Default, keyboardActions: KeyboardActions = KeyboardActions.Default, textPadding: PaddingValues = PaddingValues(4.dp), placeholder: String = "", ) { NumberTextField( value = value, onValueChange = onValueChange, enc = { value + it }, toValue = { it.toLongOrNull() }, fromValue = { it.toString() }, range = range, modifier = modifier, enabled = enabled, keyboardOptions = keyboardOptions.copy( keyboardType = KeyboardType.Decimal ), keyboardActions = keyboardActions, interactionSource = interactionSource, placeholder = placeholder, textPadding = textPadding, ) } @Composable fun DoubleTextField( value: Double, onValueChange: (Double) -> Unit, interactionSource: MutableInteractionSource = remember { MutableInteractionSource() }, range: ClosedRange, modifier: Modifier, unit: Double = 0.5, prettify: (Double) -> String = { it.toString() }, enabled: Boolean = true, keyboardOptions: KeyboardOptions = KeyboardOptions.Default, keyboardActions: KeyboardActions = KeyboardActions.Default, placeholder: String = "", ) { NumberTextField( value = value, onValueChange = onValueChange, enc = { value + it * unit }, toValue = { it.toDoubleOrNull() }, fromValue = prettify, range = range, modifier = modifier, enabled = enabled, keyboardOptions = keyboardOptions.copy( keyboardType = KeyboardType.Decimal ), keyboardActions = keyboardActions, interactionSource = interactionSource, placeholder = placeholder ) } @Composable fun FloatTextField( value: Float, onValueChange: (Float) -> Unit, interactionSource: MutableInteractionSource = remember { MutableInteractionSource() }, range: ClosedRange, modifier: Modifier, unit: Float = 0.5f, enabled: Boolean = true, prettify: (Float) -> String = { it.toString() }, keyboardOptions: KeyboardOptions = KeyboardOptions.Default, keyboardActions: KeyboardActions = KeyboardActions.Default, placeholder: String = "", textPadding: PaddingValues = PaddingValues(8.dp), ) { NumberTextField( value = value, onValueChange = onValueChange, enc = { value + it * unit }, toValue = { it.toFloat() }, fromValue = prettify, range = range, modifier = modifier, enabled = enabled, keyboardOptions = keyboardOptions.copy( keyboardType = KeyboardType.Decimal ), keyboardActions = keyboardActions, interactionSource = interactionSource, placeholder = placeholder, textPadding = textPadding, ) } //a null symbol used by NumberTextField private val NULL = Any() @Composable fun > NumberTextField( value: T, onValueChange: (T) -> Unit, enc: (unit: Int) -> T, toValue: (String) -> T?, fromValue: (T) -> String, prettify: (T) -> String = fromValue, range: ClosedRange, modifier: Modifier, enabled: Boolean, keyboardOptions: KeyboardOptions, keyboardActions: KeyboardActions, interactionSource: MutableInteractionSource = remember { MutableInteractionSource() }, placeholder: String = "", textPadding: PaddingValues = PaddingValues(4.dp), shape: Shape = DefaultShape, ) { val value by rememberUpdatedState(value) val isFocused by interactionSource.collectIsFocusedAsState() var haveWrongValue by remember(value) { mutableStateOf(false) } var myText by remember { mutableStateOf("") } var lastEmittedValueByMe by remember { mutableStateOf(NULL as Any?) } // we observe new values here // we want to check who is changing that value // if lastEmittedValueByMe == value then we do that, // so we can stop prettifying that value until focus gone // this logic depends on [set] function [prettify] parameter // if it set lastEmittedByMe to null then it will recall prettify // another purpose of this logic is to handle new value that are coming in the composable LaunchedEffect(value) { if (lastEmittedValueByMe != value) { myText = prettify(value) } } fun set(v: T, prettify: Boolean): Boolean { val isInRange = v in range val valueInRange = if (isInRange) v else v.coerceIn(range) lastEmittedValueByMe = if (prettify || !isInRange) { NULL } else { valueInRange } onValueChange(valueInRange) return isInRange } LaunchedEffect(isFocused, haveWrongValue) { if (!isFocused) { if (haveWrongValue) { set(range.start, true) } else { myText = prettify(value) } } } MyTextField( textPadding = textPadding, shape = shape, modifier = modifier.onKeyEvent { when (it.key) { Key.DirectionUp -> { set(enc(1), true) true } Key.DirectionDown -> { set(enc(-1), true) true } else -> { false } } }, placeholder = placeholder, text = myText, onTextChange = { if (it.isBlank()) { myText = "" haveWrongValue = true return@MyTextField } val v = toValue(it) if (v != null) { if (v == value) { //only update text (not prettify until focus lost myText = it } else { val wasInRange = set(v, false) if (wasInRange) { myText = it } } } }, enabled = enabled, keyboardOptions = keyboardOptions, keyboardActions = keyboardActions, interactionSource = interactionSource, end = { VerticalDirectionHandle( modifier = Modifier, onValueChange = { set(it, true) }, enc = enc, enabled = enabled, ) } ) } @Composable private fun > VerticalDirectionHandle( modifier: Modifier, onValueChange: (T) -> Unit, enc: (unit: Int) -> T, enabled: Boolean, ) { val interactionSource = remember { MutableInteractionSource() } val isDragging by interactionSource.collectIsDraggedAsState() val isHovered by interactionSource.collectIsHoveredAsState() WithContentAlpha( animateFloatAsState(if (isDragging || isHovered) 1f else 0.5f).value ) { Row( modifier .ifThen(enabled) { hoverable(interactionSource) .resizeHandle( Orientation.Vertical, interactionSource ) { val times = it.value.toInt() //we reverse this as Y is top to down onValueChange(enc(-times)) } } .pointerHoverIcon(PointerIcon.Default), ) { DirectionIcon( MyIcons.down, enabled = enabled, onClick = { onValueChange(enc(-1)) }, ) DirectionIcon( MyIcons.up, enabled = enabled, onClick = { onValueChange(enc(1)) }, ) } } } @Composable private fun DirectionIcon( icon: IconSource, enabled: Boolean = true, onClick: () -> Unit, ) { MyIcon( icon, null, Modifier .pointerHoverIcon(PointerIcon.Default) .fillMaxHeight() .clickable(enabled = enabled, onClick = onClick) .wrapContentHeight() .padding(horizontal = 4.dp) .size(mySpacings.iconSize) ) } ================================================ FILE: shared/app/src/commonMain/kotlin/com/abdownloadmanager/shared/ui/widget/Popup.kt ================================================ package com.abdownloadmanager.shared.ui.widget import androidx.compose.runtime.Composable import androidx.compose.runtime.remember import androidx.compose.ui.Alignment import androidx.compose.ui.ExperimentalComposeUiApi import androidx.compose.ui.geometry.Offset import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.DpOffset import androidx.compose.ui.unit.IntOffset import androidx.compose.ui.unit.IntRect import androidx.compose.ui.unit.IntSize import androidx.compose.ui.unit.LayoutDirection import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.round import androidx.compose.ui.window.PopupPositionProvider import kotlin.math.roundToInt /** * A [PopupPositionProvider] that positions the popup at the given position relative to the anchor. * * @param positionPx the offset, in pixels, relative to the anchor, to position the popup at. * @param offset [DpOffset] to be added to the position of the popup. * @param alignment The alignment of the popup relative to desired position. * @param windowMargin Defines the area within the window that limits the placement of the popup. */ @ExperimentalComposeUiApi @Composable fun rememberMyPopupPositionProviderAtPosition( positionPx: Offset, offset: DpOffset = DpOffset.Zero, alignment: Alignment = Alignment.BottomEnd, windowMargin: Dp = 4.dp ): PopupPositionProvider = with(LocalDensity.current) { val offsetPx = Offset(offset.x.toPx(), offset.y.toPx()) val windowMarginPx = windowMargin.roundToPx() remember(positionPx, offsetPx, alignment, windowMarginPx) { PopupPositionProviderAtPosition( positionPx = positionPx, isRelativeToAnchor = true, offsetPx = offsetPx, alignment = alignment, windowMarginPx = windowMarginPx ) } } /** * A [PopupPositionProvider] that positions the popup at the given offsets and alignment. * * @param positionPx The offset of the popup's location, in pixels. * @param isRelativeToAnchor Whether [positionPx] is relative to the anchor bounds passed to * [calculatePosition]. If `false`, it is relative to the window. * @param offsetPx Extra offset to be added to the position of the popup, in pixels. * @param alignment The alignment of the popup relative to desired position. * @param windowMarginPx Defines the area within the window that limits the placement of the popup, * in pixels. */ @ExperimentalComposeUiApi class PopupPositionProviderAtPosition( val positionPx: Offset, val isRelativeToAnchor: Boolean, val offsetPx: Offset, val alignment: Alignment = Alignment.BottomEnd, val windowMarginPx: Int, ) : PopupPositionProvider { override fun calculatePosition( anchorBounds: IntRect, windowSize: IntSize, layoutDirection: LayoutDirection, popupContentSize: IntSize ): IntOffset { val anchor = IntRect( offset = positionPx.round() + (if (isRelativeToAnchor) anchorBounds.topLeft else IntOffset.Zero), size = IntSize.Zero ) val tooltipArea = IntRect( IntOffset( anchor.left - popupContentSize.width, anchor.top - popupContentSize.height, ), IntSize( popupContentSize.width * 2, popupContentSize.height * 2 ) ) val position = alignment.align(popupContentSize, tooltipArea.size, layoutDirection) var x = tooltipArea.left + position.x + offsetPx.x var y = tooltipArea.top + position.y + offsetPx.y if (x + popupContentSize.width > windowSize.width - windowMarginPx) { x -= popupContentSize.width } if (y + popupContentSize.height > windowSize.height - windowMarginPx) { y -= popupContentSize.height + anchor.height } x = x.coerceAtLeast(windowMarginPx.toFloat()) y = y.coerceAtLeast(windowMarginPx.toFloat()) return IntOffset(x.roundToInt(), y.roundToInt()) } } /** * Provides [PopupPositionProvider] relative to the current component bounds. * * @param anchor The anchor point relative to the current component bounds. * @param alignment The alignment of the popup relative to the [anchor] point. * @param offset [DpOffset] to be added to the position of the popup. */ @Composable fun rememberMyComponentRectPositionProvider( anchor: Alignment = Alignment.BottomCenter, alignment: Alignment = Alignment.BottomCenter, offset: DpOffset = DpOffset.Zero ): PopupPositionProvider { val offsetPx = with(LocalDensity.current) { IntOffset(offset.x.roundToPx(), offset.y.roundToPx()) } return remember(anchor, alignment, offsetPx) { object : PopupPositionProvider { override fun calculatePosition( anchorBounds: IntRect, windowSize: IntSize, layoutDirection: LayoutDirection, popupContentSize: IntSize ): IntOffset { val anchorPoint = anchor.align(IntSize.Zero, anchorBounds.size, layoutDirection) val tooltipArea = IntRect( IntOffset( anchorBounds.left + anchorPoint.x - popupContentSize.width, anchorBounds.top + anchorPoint.y - popupContentSize.height, ), IntSize( popupContentSize.width * 2, popupContentSize.height * 2 ) ) val position = alignment.align(popupContentSize, tooltipArea.size, layoutDirection) return tooltipArea.topLeft + position + offsetPx } } } } @Composable fun rememberMyComponentCustomRectPositionProvider( providedAnchorBounds: IntRect, anchor: Alignment = Alignment.BottomCenter, alignment: Alignment = Alignment.BottomCenter, offset: DpOffset = DpOffset.Zero ): PopupPositionProvider { val offsetPx = with(LocalDensity.current) { IntOffset(offset.x.roundToPx(), offset.y.roundToPx()) } return remember(providedAnchorBounds, anchor, alignment, offsetPx) { object : PopupPositionProvider { override fun calculatePosition( anchorBounds: IntRect, windowSize: IntSize, layoutDirection: LayoutDirection, popupContentSize: IntSize ): IntOffset { val anchorPoint = anchor.align(IntSize.Zero, providedAnchorBounds.size, layoutDirection) val tooltipArea = IntRect( IntOffset( providedAnchorBounds.left + anchorPoint.x - popupContentSize.width, providedAnchorBounds.top + anchorPoint.y - popupContentSize.height, ), IntSize( popupContentSize.width * 2, popupContentSize.height * 2 ) ) val position = alignment.align(popupContentSize, tooltipArea.size, layoutDirection) return tooltipArea.topLeft + position + offsetPx } } } } ================================================ FILE: shared/app/src/commonMain/kotlin/com/abdownloadmanager/shared/ui/widget/RadioButton.kt ================================================ package com.abdownloadmanager.shared.ui.widget import com.abdownloadmanager.shared.util.ui.LocalContentColor import com.abdownloadmanager.shared.util.ui.widget.MyIcon import com.abdownloadmanager.shared.util.ui.icon.MyIcons import com.abdownloadmanager.shared.util.ui.myColors import ir.amirab.util.ifThen import com.abdownloadmanager.shared.util.div import androidx.compose.animation.AnimatedContent import androidx.compose.animation.core.animateFloatAsState import androidx.compose.animation.core.tween import androidx.compose.animation.fadeIn import androidx.compose.animation.fadeOut import androidx.compose.animation.togetherWith import androidx.compose.foundation.background import androidx.compose.foundation.border import androidx.compose.foundation.interaction.MutableInteractionSource import androidx.compose.foundation.layout.* import androidx.compose.foundation.selection.triStateToggleable import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.runtime.Composable import androidx.compose.runtime.remember import androidx.compose.ui.Modifier import androidx.compose.ui.draw.alpha import androidx.compose.ui.draw.clip import androidx.compose.ui.graphics.Brush import androidx.compose.ui.graphics.SolidColor import androidx.compose.ui.semantics.Role import androidx.compose.ui.state.ToggleableState import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp @Composable fun RadioButton( value: Boolean, onValueChange: (Boolean) -> Unit, enabled: Boolean = true, modifier: Modifier = Modifier, size: Dp = 18.dp, interactionSource: MutableInteractionSource = remember { MutableInteractionSource() }, uncheckedAlpha: Float = 0.25f, ) { val shape = CircleShape Box( modifier .ifThen(!enabled) { alpha(0.5f) } .size(size) .clip(shape) .triStateToggleable( state = ToggleableState(value), enabled = enabled, role = Role.RadioButton, interactionSource = interactionSource, indication = null, onClick = { onValueChange(!value) }, ) ) { Spacer( Modifier.matchParentSize() .border( 1.dp, if (value) { myColors.primaryGradient } else { SolidColor(LocalContentColor.current / uncheckedAlpha) }, shape ) ) AnimatedContent( value, transitionSpec = { val tween = tween(220) fadeIn(tween) togetherWith fadeOut(tween) } ) { val m = Modifier .fillMaxSize() .alpha(animateFloatAsState(if (value) 1f else 0f).value) .padding(4.dp) .clip(shape) .background(myColors.primaryGradient) Spacer(m) } } } ================================================ FILE: shared/app/src/commonMain/kotlin/com/abdownloadmanager/shared/ui/widget/Switch.kt ================================================ package com.abdownloadmanager.shared.ui.widget import com.abdownloadmanager.shared.util.ui.myColors import ir.amirab.util.ifThen import com.abdownloadmanager.shared.util.div import androidx.compose.animation.core.animateFloatAsState import androidx.compose.foundation.background import androidx.compose.foundation.interaction.MutableInteractionSource import androidx.compose.foundation.layout.* import androidx.compose.foundation.selection.toggleable import androidx.compose.foundation.shape.CircleShape import androidx.compose.runtime.Composable import androidx.compose.runtime.remember import androidx.compose.ui.BiasAlignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.alpha import androidx.compose.ui.draw.clip import androidx.compose.ui.graphics.Brush import androidx.compose.ui.graphics.Color import androidx.compose.ui.semantics.Role import androidx.compose.ui.unit.dp @Composable fun Switch( checked: Boolean, onCheckedChange: (Boolean) -> Unit, enabled: Boolean = true, modifier: Modifier = Modifier.width(42.dp).height(24.dp), interactionSource: MutableInteractionSource = remember { MutableInteractionSource() }, ) { Box( modifier .clip(CircleShape) .ifThen(!enabled) { alpha(0.5f) } .background( if (checked) { myColors.primaryGradient }else { Brush.linearGradient(listOf(Color.Gray,Color.Gray)) } ) .toggleable( value = checked, onValueChange = onCheckedChange, enabled = enabled, role = Role.Switch, interactionSource = interactionSource, indication = null ) .padding(4.dp) .fillMaxSize() ) { Box( Modifier .fillMaxHeight() .aspectRatio(1f, true) .align( BiasAlignment( animateFloatAsState( if (checked) 1f else -1f ).value, 0f, ) ) .clip(CircleShape) .background(myColors.onPrimaryGradient / animateFloatAsState(if (checked) 1f else 0.5f).value) ) } } ================================================ FILE: shared/app/src/commonMain/kotlin/com/abdownloadmanager/shared/ui/widget/Tabs.kt ================================================ package com.abdownloadmanager.shared.ui.widget import ir.amirab.util.compose.IconSource import com.abdownloadmanager.shared.util.ui.widget.MyIcon import com.abdownloadmanager.shared.util.ui.myColors import com.abdownloadmanager.shared.util.ui.theme.myTextSizes import com.abdownloadmanager.shared.util.ui.WithContentAlpha import androidx.compose.foundation.background import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.* import androidx.compose.runtime.Composable import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.dp import com.abdownloadmanager.shared.util.ui.theme.mySpacings import ir.amirab.util.compose.StringSource import ir.amirab.util.ifThen @Composable fun MyTabRow(content: @Composable RowScope.() -> Unit) { Row( verticalAlignment = Alignment.CenterVertically, modifier = Modifier ) { content() } } @Composable fun MyTab( selected: Boolean, onClick: () -> Unit, icon: IconSource, title: StringSource, selectionBackground: Color = myColors.surface, ) { WithContentAlpha( if (selected) 1f else 0.75f ) { Row( verticalAlignment = Alignment.CenterVertically, modifier = Modifier .ifThen(selected) { background(selectionBackground) } .clickable { onClick() } .heightIn(mySpacings.thumbSize) .padding(horizontal = 12.dp) .padding(vertical = 6.dp) ) { MyIcon(icon, null, Modifier.size(16.dp)) Spacer(Modifier.width(4.dp)) Text( title.rememberString(), maxLines = 1, fontSize = myTextSizes.base, fontWeight = if (selected) { FontWeight.Bold } else { FontWeight.Medium } ) } } } ================================================ FILE: shared/app/src/commonMain/kotlin/com/abdownloadmanager/shared/ui/widget/Text.kt ================================================ package com.abdownloadmanager.shared.ui.widget import com.abdownloadmanager.shared.util.ui.LocalContentAlpha import com.abdownloadmanager.shared.util.ui.LocalContentColor import com.abdownloadmanager.shared.util.ui.LocalTextStyle import androidx.compose.foundation.text.BasicText import androidx.compose.foundation.text.InlineTextContent import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.isSpecified import androidx.compose.ui.text.AnnotatedString import androidx.compose.ui.text.TextLayoutResult import androidx.compose.ui.text.TextStyle import androidx.compose.ui.text.font.FontFamily import androidx.compose.ui.text.font.FontStyle import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.text.style.TextDecoration import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.TextUnit @Composable fun Text( text: String, modifier: Modifier = Modifier, color: Color = Color.Unspecified, fontSize: TextUnit = TextUnit.Unspecified, fontStyle: FontStyle? = null, fontWeight: FontWeight? = null, fontFamily: FontFamily? = null, letterSpacing: TextUnit = TextUnit.Unspecified, textDecoration: TextDecoration? = null, textAlign: TextAlign = TextAlign.Unspecified, lineHeight: TextUnit = TextUnit.Unspecified, overflow: TextOverflow = TextOverflow.Clip, softWrap: Boolean = true, maxLines: Int = Int.MAX_VALUE, minLines: Int = 1, onTextLayout: ((TextLayoutResult) -> Unit)? = null, style: TextStyle = LocalTextStyle.current ) { val localContentColor = LocalContentColor.current val localContentAlpha = LocalContentAlpha.current val overrideColorOrUnspecified: Color = if (color.isSpecified) { color } else if (style.color.isSpecified) { style.color } else { localContentColor.copy(localContentAlpha) } BasicText( text = text, modifier = modifier, style = style.merge( fontSize = fontSize, fontWeight = fontWeight, textAlign = textAlign, lineHeight = lineHeight, fontFamily = fontFamily, textDecoration = textDecoration, fontStyle = fontStyle, letterSpacing = letterSpacing ), onTextLayout = onTextLayout, overflow = overflow, softWrap = softWrap, maxLines = maxLines, minLines = minLines, color = { overrideColorOrUnspecified } ) } @Composable fun Text( text: AnnotatedString, modifier: Modifier = Modifier, color: Color = Color.Unspecified, fontSize: TextUnit = TextUnit.Unspecified, fontStyle: FontStyle? = null, fontWeight: FontWeight? = null, fontFamily: FontFamily? = null, letterSpacing: TextUnit = TextUnit.Unspecified, textDecoration: TextDecoration? = null, textAlign: TextAlign = TextAlign.Unspecified, lineHeight: TextUnit = TextUnit.Unspecified, overflow: TextOverflow = TextOverflow.Clip, softWrap: Boolean = true, maxLines: Int = Int.MAX_VALUE, minLines: Int = 1, inlineContent: Map = mapOf(), onTextLayout: (TextLayoutResult) -> Unit = {}, style: TextStyle = LocalTextStyle.current ) { val localContentColor = LocalContentColor.current val localContentAlpha = LocalContentAlpha.current val overrideColorOrUnspecified = if (color.isSpecified) { color } else if (style.color.isSpecified) { style.color } else { localContentColor.copy(localContentAlpha) } BasicText( text = text, modifier = modifier, style = style.merge( fontSize = fontSize, fontWeight = fontWeight, textAlign = textAlign, lineHeight = lineHeight, fontFamily = fontFamily, textDecoration = textDecoration, fontStyle = fontStyle, letterSpacing = letterSpacing ), onTextLayout = onTextLayout, overflow = overflow, softWrap = softWrap, maxLines = maxLines, minLines = minLines, inlineContent = inlineContent, color = { overrideColorOrUnspecified } ) } ================================================ FILE: shared/app/src/commonMain/kotlin/com/abdownloadmanager/shared/ui/widget/Tooltip.kt ================================================ package com.abdownloadmanager.shared.ui.widget import androidx.compose.foundation.BasicTooltipState import androidx.compose.foundation.ExperimentalFoundationApi import androidx.compose.foundation.MutatePriority import androidx.compose.foundation.background import androidx.compose.foundation.border import androidx.compose.foundation.gestures.awaitEachGesture import androidx.compose.foundation.gestures.awaitFirstDown import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.widthIn import androidx.compose.runtime.* import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip import androidx.compose.ui.draw.shadow import androidx.compose.ui.unit.dp import androidx.compose.ui.window.Popup import com.abdownloadmanager.shared.util.div import com.abdownloadmanager.shared.util.ui.myColors import com.abdownloadmanager.shared.util.ui.theme.myTextSizes import com.abdownloadmanager.shared.util.ui.WithContentColor import com.abdownloadmanager.shared.util.ui.theme.myShapes import ir.amirab.util.compose.StringSource import kotlinx.coroutines.delay private const val TooltipDelay = 500L @Composable fun Tooltip( tooltip: StringSource, delayUntilShow: Long = TooltipDelay, anchor: Alignment = Alignment.TopCenter, alignment: Alignment = Alignment.TopCenter, content: @Composable () -> Unit, ) { val showHint = remember { mutableStateOf(false) } Column( modifier = Modifier .detectTooltip(showHint) ) { if (showHint.value) { DelayedTooltipPopup( onRequestCloseShowHelpContent = { showHint.value = false }, content = tooltip.rememberString(), delay = delayUntilShow, anchor = anchor, alignment = alignment ) } content() } } @Composable fun TooltipPopup( onRequestCloseShowHelpContent: () -> Unit, content: String, anchor: Alignment = Alignment.TopCenter, alignment: Alignment = Alignment.TopCenter ) { Popup( popupPositionProvider = rememberMyComponentRectPositionProvider( anchor = anchor, alignment = alignment, ), onDismissRequest = onRequestCloseShowHelpContent ) { val shape = myShapes.defaultRounded Box( Modifier .padding(vertical = 4.dp) .widthIn(max = 240.dp) .shadow(4.dp, shape) .clip(shape) .border(1.dp, myColors.onSurface / 0.1f, shape) .background(myColors.surface) .padding(8.dp) ) { WithContentColor(myColors.onSurface) { Text( content, fontSize = myTextSizes.base, ) } } } } @Composable fun DelayedTooltipPopup( onRequestCloseShowHelpContent: () -> Unit, content: String, delay: Long = TooltipDelay, anchor: Alignment = Alignment.TopCenter, alignment: Alignment = Alignment.TopCenter, ) { var showPopup by remember { mutableStateOf(false) } LaunchedEffect(Unit) { delay(delay) showPopup = true } if (showPopup) { TooltipPopup( onRequestCloseShowHelpContent = onRequestCloseShowHelpContent, content = content, anchor = anchor, alignment = alignment, ) } } expect fun Modifier.detectTooltip( state: MutableState ): Modifier ================================================ FILE: shared/app/src/commonMain/kotlin/com/abdownloadmanager/shared/ui/widget/menu/custom/DropDown.kt ================================================ package com.abdownloadmanager.shared.ui.widget.menu.custom import androidx.compose.runtime.Composable import androidx.compose.ui.Alignment import androidx.compose.ui.unit.DpOffset import androidx.compose.ui.window.Popup import androidx.compose.ui.window.PopupProperties import com.abdownloadmanager.shared.ui.widget.rememberMyComponentRectPositionProvider @Composable fun MyDropDown( onDismissRequest: () -> Unit, offset: DpOffset = DpOffset.Zero, anchor: Alignment = Alignment.BottomStart, alignment: Alignment = Alignment.BottomEnd, focusable: Boolean = true, content: @Composable () -> Unit, ) { val positionProvider = rememberMyComponentRectPositionProvider( offset = offset, anchor = anchor, alignment = alignment, ) Popup( popupPositionProvider = positionProvider, onDismissRequest = onDismissRequest, properties = PopupProperties(focusable = focusable), content = { content() }) } @Composable fun SiblingDropDown( onDismissRequest: () -> Unit, offset: DpOffset = DpOffset.Zero, content: @Composable () -> Unit, ) { val positionProvider = rememberMyComponentRectPositionProvider( anchor = Alignment.TopEnd, alignment = Alignment.BottomEnd, offset = offset, ) Popup( popupPositionProvider = positionProvider, onDismissRequest = onDismissRequest, ) { content() } } ================================================ FILE: shared/app/src/commonMain/kotlin/com/abdownloadmanager/shared/ui/widget/menu/custom/MenuColumn.kt ================================================ package com.abdownloadmanager.shared.ui.widget.menu.custom import androidx.compose.foundation.background import androidx.compose.foundation.border import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.ColumnScope import androidx.compose.foundation.layout.IntrinsicSize import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.width import androidx.compose.foundation.layout.widthIn import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip import androidx.compose.ui.draw.shadow import androidx.compose.ui.unit.dp import com.abdownloadmanager.shared.util.ui.myColors @Composable fun MenuColumn( content: @Composable ColumnScope.() -> Unit, ) { val shape = LocalMenuBoxClip.current Column( Modifier.Companion .shadow(24.dp) // .verticalScroll(rememberScrollState()) .clip(shape) .width(IntrinsicSize.Max) .widthIn(120.dp) .border(1.dp, myColors.surface, shape) .background(myColors.menuGradientBackground) .padding(horizontal = 0.dp, vertical = 0.dp) ) { content() } } ================================================ FILE: shared/app/src/commonMain/kotlin/com/abdownloadmanager/shared/ui/widget/menu/custom/Option.kt ================================================ package com.abdownloadmanager.shared.ui.widget.menu.custom import com.abdownloadmanager.shared.util.ui.myColors import com.abdownloadmanager.shared.util.ui.theme.myTextSizes import ir.amirab.util.compose.action.MenuItem import com.abdownloadmanager.shared.util.div import androidx.compose.foundation.background import androidx.compose.foundation.basicMarquee import androidx.compose.foundation.layout.* import com.abdownloadmanager.shared.ui.widget.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue import androidx.compose.ui.Modifier import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp @Composable expect fun ShowOptionsInPopup( menu: MenuItem.SubMenu, onDismissRequest: () -> Unit, ) /** * this is only used by expect actual ShowOptionsInPopup if their actual implementations need other style remove it here */ @Composable internal fun RenderOptions( menu: MenuItem.SubMenu, onDismissRequest: () -> Unit ) { SubMenu(menu,onDismissRequest) { Column( Modifier .width(200.dp) ) { val itemPadding = Modifier.padding(horizontal = 12.dp, vertical = 8.dp) val title by menu.title.collectAsState() Text( title.rememberString(), Modifier .then(itemPadding) .basicMarquee( iterations = Int.MAX_VALUE, initialDelayMillis = 0 ), fontSize = myTextSizes.base, maxLines = 1, overflow = TextOverflow.Clip, ) Spacer(Modifier .fillMaxWidth() .height(1.dp) .background(myColors.onSurface/5)) } } } ================================================ FILE: shared/app/src/commonMain/kotlin/com/abdownloadmanager/shared/ui/widget/menu/custom/SiblingMenuPositionProvider.kt ================================================ package com.abdownloadmanager.shared.ui.widget.menu.custom import androidx.compose.runtime.Immutable import androidx.compose.ui.unit.* import androidx.compose.ui.window.PopupPositionProvider @Immutable internal class SiblingMenuPositionProvider : PopupPositionProvider { override fun calculatePosition( anchorBounds: IntRect, windowSize: IntSize, layoutDirection: LayoutDirection, popupContentSize: IntSize, ): IntOffset { return anchorBounds.topRight } } ================================================ FILE: shared/app/src/commonMain/kotlin/com/abdownloadmanager/shared/ui/widget/menu/custom/SubMenu.kt ================================================ package com.abdownloadmanager.shared.ui.widget.menu.custom import androidx.compose.foundation.background import androidx.compose.foundation.border import androidx.compose.foundation.clickable import androidx.compose.foundation.hoverable import androidx.compose.foundation.interaction.MutableInteractionSource import androidx.compose.foundation.interaction.collectIsHoveredAsState import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.IntrinsicSize import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.width import androidx.compose.foundation.layout.widthIn import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.collectAsState import androidx.compose.runtime.compositionLocalOf import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.alpha import androidx.compose.ui.draw.clip import androidx.compose.ui.draw.shadow import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Shape import androidx.compose.ui.text.TextStyle import androidx.compose.ui.unit.dp import com.abdownloadmanager.shared.ui.widget.Text import com.abdownloadmanager.shared.util.LocalShortCutManager import com.abdownloadmanager.shared.util.PlatformKeyStroke import com.abdownloadmanager.shared.util.div import com.abdownloadmanager.shared.util.ui.ProvideTextStyle import com.abdownloadmanager.shared.util.ui.WithContentColor import com.abdownloadmanager.shared.util.ui.icon.MyIcons import com.abdownloadmanager.shared.util.ui.myColors import com.abdownloadmanager.shared.util.ui.theme.myTextSizes import com.abdownloadmanager.shared.util.ui.widget.MyIcon import ir.amirab.util.compose.action.MenuItem import ir.amirab.util.compose.modifiers.autoMirror import ir.amirab.util.ifThen enum class MenuDisabledItemBehavior { Filter, LowerOpacity, } val LocalMenuDisabledItemBehavior = compositionLocalOf { MenuDisabledItemBehavior.LowerOpacity } val LocalMenuBoxClip = compositionLocalOf { RoundedCornerShape(6.dp) } /** * render a menu */ @Composable fun SubMenu( subMenu: MenuItem.SubMenu, onRequestClose: () -> Unit, header: (@Composable () -> Unit)? = null, ) { SubMenu( subMenu = subMenu.items.collectAsState().value, header = header, onRequestClose = onRequestClose, ) } @Composable fun SubMenu( subMenu: List, onRequestClose: () -> Unit, header: (@Composable () -> Unit)? = null, ) { var openedItem: MenuItem.SubMenu? by remember { mutableStateOf(null) } var lastHoveredItem by remember { mutableStateOf(null as MenuItem?) } WithContentColor(myColors.onMenuColor) { val shape = LocalMenuBoxClip.current Column( Modifier .shadow(24.dp) // .verticalScroll(rememberScrollState()) .clip(shape) .width(IntrinsicSize.Max) .widthIn(120.dp) .border(1.dp, myColors.surface, shape) .background(myColors.menuGradientBackground) .padding(horizontal = 0.dp, vertical = 0.dp) ) { header?.invoke() for (menuItem in subMenu) { val interactionSource = remember { MutableInteractionSource() } val isHovered by interactionSource.collectIsHoveredAsState() LaunchedEffect(isHovered) { if (isHovered) { // println("last overed item is ${menuItem.hashCode()}") // println("last overed item is ${(menuItem as? MenuItem.SubMenu)?.title?.value}") lastHoveredItem = menuItem } } RenderMenuItem( menuItem = menuItem, openedItem = openedItem, onRequestCLose = onRequestClose, isSelected = openedItem == menuItem, onRequestOpenItem = { openedItem = it }, isHovered = lastHoveredItem == menuItem, modifier = Modifier.hoverable(interactionSource) ) } } } } @Composable private fun ReactableItem( item: MenuItem.ReadableItem, onClick: () -> Unit, isSelected: Boolean, modifier: Modifier = Modifier, interactionSource: MutableInteractionSource = remember { MutableInteractionSource() }, extraContent: @Composable () -> Unit = {}, ) { val iconModifier = Modifier.size(16.dp) val title by item.title.collectAsState() val icon by item.icon.collectAsState() val itemPadding = Modifier.padding(horizontal = 8.dp, vertical = 8.dp) val isHovered by interactionSource.collectIsHoveredAsState() val isEnabled = (item as? MenuItem.HasEnable) ?.isEnabled ?.collectAsState() ?.value ?: true Row( modifier .ifThen(!isEnabled) { alpha(0.5f) } .hoverable(interactionSource) .background( when { (isHovered && isEnabled) || isSelected -> { myColors.surface } else -> { Color.Transparent } } ) .clickable(enabled = isEnabled) { onClick() } .then(itemPadding) .fillMaxWidth(), verticalAlignment = Alignment.CenterVertically ) { icon.let { icon -> if (icon != null) { Spacer(Modifier.width(4.dp)) MyIcon(icon, null, iconModifier) Spacer(Modifier.width(8.dp)) } else { Spacer(iconModifier) } } Text( title.rememberString(), Modifier.weight(1f), fontSize = myTextSizes.base, softWrap = false, maxLines = 1, ) Spacer(Modifier.width(16.dp)) extraContent() } } @Composable private fun RenderMenuItem( menuItem: MenuItem, openedItem: MenuItem.SubMenu?, onRequestCLose: () -> Unit, isSelected: Boolean, isHovered: Boolean, modifier: Modifier = Modifier, onRequestOpenItem: (MenuItem.SubMenu?) -> Unit, ) { // val isEnabled by menuItem.isEnabled.collectAsState() LaunchedEffect(isHovered, menuItem) { if (isHovered) { if (menuItem is MenuItem.SubMenu) { onRequestOpenItem(menuItem) } else { onRequestOpenItem(null) } } } Row( modifier .fillMaxWidth() ) { when (menuItem) { MenuItem.Separator -> { Spacer( Modifier .fillMaxWidth() .height(1.dp) .background(myColors.onSurface / 5) ) } is MenuItem.SingleItem -> { RenderSingleItem( item = menuItem, isSelected = isSelected, onRequestClose = onRequestCLose, ) } is MenuItem.SubMenu -> { RenderSubMenuItem( menuItem = menuItem, isSelected = isSelected, onRequestCLose = onRequestCLose, openedItem = openedItem, onRequestOpenItem = onRequestOpenItem, ) } } } } @Composable private fun RenderSubMenuItem( menuItem: MenuItem.SubMenu, isSelected: Boolean, openedItem: MenuItem.SubMenu?, onRequestOpenItem: (MenuItem.SubMenu?) -> Unit, onRequestCLose: () -> Unit, ) { ReactableItem( item = menuItem, onClick = { onRequestOpenItem(menuItem) }, isSelected = isSelected, extraContent = { MyIcon( MyIcons.next, null, Modifier .size(16.dp) .autoMirror(), ) }) if (openedItem == menuItem) { SiblingDropDown( onDismissRequest = { onRequestOpenItem(null) } ) { SubMenu(menuItem, onRequestCLose) } } } @Composable private fun RenderSingleItem( onRequestClose: () -> Unit, isSelected: Boolean, item: MenuItem.SingleItem, ) { val isEnabled by item.isEnabled.collectAsState() if (!isEnabled && LocalMenuDisabledItemBehavior.current == MenuDisabledItemBehavior.Filter) { return } val shortcutManager = LocalShortCutManager.current val shortcutStroke = remember(shortcutManager, item) { shortcutManager?.getShortCutOf(item) } val onClick = { if (item.shouldDismissOnClick) { onRequestClose() } item.onClick() } ReactableItem( item = item, onClick = onClick, isSelected = isSelected, extraContent = { if (shortcutStroke != null) { RenderShortcutStroke(shortcutStroke) } } ) } @Composable private fun RenderShortcutStroke(shortcutStroke: PlatformKeyStroke) { val modifiers = remember(shortcutStroke) { buildList { addAll(shortcutStroke.getModifiers()) add(shortcutStroke.getKeyText()) } } ProvideTextStyle( TextStyle( fontSize = myTextSizes.xs, ) ) { Row( verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(1.dp) ) { val shape = RoundedCornerShape(10) WithContentColor(myColors.onBackground) { Text( modifiers.joinToString("+"), Modifier .clip(shape) .background(myColors.onBackground / 5) .padding(2.dp) ) } } } } ================================================ FILE: shared/app/src/commonMain/kotlin/com/abdownloadmanager/shared/ui/widget/menu/custom/WithContextMenu.kt ================================================ package com.abdownloadmanager.shared.ui.widget.menu.custom import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier import ir.amirab.util.compose.action.MenuItem @Composable expect fun WithContextMenu( menuProvider: () -> List, modifier: Modifier = Modifier, content: @Composable () -> Unit ) ================================================ FILE: shared/app/src/commonMain/kotlin/com/abdownloadmanager/shared/ui/widget/sort/ComparatorProvider.kt ================================================ package com.abdownloadmanager.shared.ui.widget.sort interface ComparatorProvider { fun comparator(): Comparator } ================================================ FILE: shared/app/src/commonMain/kotlin/com/abdownloadmanager/shared/ui/widget/sort/Sort.kt ================================================ package com.abdownloadmanager.shared.ui.widget.sort import kotlinx.serialization.Serializable @Serializable data class Sort>( val cell: Cell, private val isDescending: Boolean, ) { fun isAscending() = !isDescending fun isDescending() = isDescending fun reverse(): Sort { return copy(isDescending = !isDescending) } companion object { const val DEFAULT_IS_DESCENDING: Boolean = true } } ================================================ FILE: shared/app/src/commonMain/kotlin/com/abdownloadmanager/shared/ui/widget/sort/SortIndicatorMode.kt ================================================ package com.abdownloadmanager.shared.ui.widget.sort enum class SortIndicatorMode { None, Ascending, Descending, } fun SortIndicatorMode.isAscending(): Boolean { return when (this) { SortIndicatorMode.Ascending -> true else -> false } } fun SortIndicatorMode.isDescending(): Boolean { return when (this) { SortIndicatorMode.Descending -> true else -> false } } fun SortIndicatorMode.next(): SortIndicatorMode { return when (this) { SortIndicatorMode.None -> SortIndicatorMode.Descending SortIndicatorMode.Ascending -> SortIndicatorMode.Descending SortIndicatorMode.Descending -> SortIndicatorMode.Ascending } } ================================================ FILE: shared/app/src/commonMain/kotlin/com/abdownloadmanager/shared/ui/widget/sort/sorted.kt ================================================ package com.abdownloadmanager.shared.ui.widget.sort import ir.amirab.util.ifThen fun > Sort.sorted( list: List ): List { return list .sortedWith( cell .comparator() .ifThen(isDescending()) { reversed() } ) } fun > T.toSortIndicatorMode(): SortIndicatorMode { return if (isDescending()) { SortIndicatorMode.Descending } else { SortIndicatorMode.Ascending } } ================================================ FILE: shared/app/src/commonMain/kotlin/com/abdownloadmanager/shared/updater/UpdateDownloaderViaDownloadSystem.kt ================================================ package com.abdownloadmanager.shared.updater import com.abdownloadmanager.UpdateDownloadLocationProvider import com.abdownloadmanager.shared.util.DownloadSystem import com.abdownloadmanager.updateapplier.UpdateDownloader import com.abdownloadmanager.updatechecker.UpdateSource import ir.amirab.downloader.NewDownloadItemProps import ir.amirab.downloader.downloaditem.EmptyContext import ir.amirab.downloader.downloaditem.http.HttpDownloadItem import ir.amirab.downloader.utils.OnDuplicateStrategy import kotlinx.coroutines.async import kotlinx.coroutines.coroutineScope import java.io.File class UpdateDownloaderViaDownloadSystem( private val downloadSystem: DownloadSystem, private val updateDownloadLocationProvider: UpdateDownloadLocationProvider, ) : UpdateDownloader() { override suspend fun downloadUpdateFile(updateDirectDownloadLink: UpdateSource.DirectDownloadLink): File { val updateDownloadsFolder = updateDownloadLocationProvider.getSaveLocation().path val updateDownloads = downloadSystem.getDownloadItemsByFolder(updateDownloadsFolder) val pausedDownload = updateDownloads.find { it.name == updateDirectDownloadLink.name } // at the moment if the download was finished but removed from the filesystem // download will not be restarted automatically val requireRestartDownload = pausedDownload?.getFullPath()?.exists()?.not() ?: false val id = pausedDownload?.id ?: downloadSystem.addDownload( newDownload = NewDownloadItemProps( downloadItem = HttpDownloadItem( id = -1, link = updateDirectDownloadLink.link, folder = updateDownloadsFolder, name = updateDirectDownloadLink.name, ), onDuplicateStrategy = OnDuplicateStrategy.AddNumbered, extraConfig = null, context = EmptyContext, ), queueId = null, categoryId = null, ) coroutineScope { if (requireRestartDownload) { downloadSystem.reset(id) } val waiter = async { downloadSystem.downloadMonitor.waitForDownloadToFinishOrCancel(id) } downloadSystem.manualResume(id, EmptyContext) waiter.await() } // we recheck download info maybe some dude change the file name! val downloadedItem = downloadSystem.getDownloadItemById(id) requireNotNull(downloadedItem) { "Download is removed!" } return downloadSystem.getDownloadFile(downloadedItem) } override suspend fun removeUpdateFiles(updateDirectDownloadLink: UpdateSource.DirectDownloadLink) { val id = downloadSystem .getDownloadItemsByFolder(updateDownloadLocationProvider.getSaveLocation().path) .find { it.name == updateDirectDownloadLink.name }?.id id?.let { downloadSystem.removeDownload(id, true, EmptyContext) } } override suspend fun removeAllUpdateFiles() { val ids = downloadSystem .getDownloadItemsByFolder(updateDownloadLocationProvider.getSaveLocation().path) .map { it.id } for (id in ids) { downloadSystem.removeDownload( id = id, alsoRemoveFile = true, EmptyContext ) } } } ================================================ FILE: shared/app/src/commonMain/kotlin/com/abdownloadmanager/shared/util/AppHostNameVerifier.kt ================================================ package com.abdownloadmanager.shared.util import kotlinx.coroutines.flow.StateFlow import javax.net.ssl.HostnameVerifier import javax.net.ssl.SSLSession class AppHostNameVerifier( private val delegateHostnameVerifier: HostnameVerifier, private val ignoreHostNameVerification: StateFlow, ) : HostnameVerifier { override fun verify(hostname: String?, session: SSLSession?): Boolean { if (ignoreHostNameVerification.value) { return true } return delegateHostnameVerifier.verify(hostname, session) } } ================================================ FILE: shared/app/src/commonMain/kotlin/com/abdownloadmanager/shared/util/AppSSLFactoryProvider.kt ================================================ package com.abdownloadmanager.shared.util import kotlinx.coroutines.flow.StateFlow import okhttp3.internal.platform.Platform import java.security.cert.X509Certificate import javax.net.ssl.SSLSocketFactory import javax.net.ssl.X509TrustManager /** * at the moment we simply use okhttp ssl factory provider with a toggleable trust manager to ignore ssl certificates */ class AppSSLFactoryProvider( private val ignoreSSLCertificates: StateFlow, ) { val trustManager: X509TrustManager by lazy { ToggleableTrustManager( trustManager = Platform.get().platformTrustManager(), shouldCheck = { !ignoreSSLCertificates.value } ) } fun createSSLSocketFactory(): SSLSocketFactory { return Platform.get().newSslSocketFactory( trustManager = trustManager, ) } } private class ToggleableTrustManager( private val trustManager: X509TrustManager, private val shouldCheck: () -> Boolean, ) : X509TrustManager { override fun checkClientTrusted(chain: Array?, authType: String?) { if (shouldCheck()) { trustManager.checkClientTrusted(chain, authType) } } override fun checkServerTrusted(chain: Array?, authType: String?) { if (shouldCheck()) { trustManager.checkServerTrusted(chain, authType) } } override fun getAcceptedIssuers(): Array { return trustManager.acceptedIssuers } } ================================================ FILE: shared/app/src/commonMain/kotlin/com/abdownloadmanager/shared/util/AppVersion.kt ================================================ package com.abdownloadmanager.shared.util import com.abdownloadmanager.shared.BuildConfig import io.github.z4kn4fein.semver.Version object AppVersion { private val currentVersion = Version.parse(BuildConfig.APP_VERSION) fun get() = currentVersion } ================================================ FILE: shared/app/src/commonMain/kotlin/com/abdownloadmanager/shared/util/AutoStartManager.kt ================================================ package com.abdownloadmanager.shared.util import org.koin.core.component.KoinComponent import org.koin.core.component.inject import ir.amirab.util.startup.AbstractStartupManager object AutoStartManager : KoinComponent { private val startManager by inject() fun startOnBoot(boolean: Boolean) { // println("start Manager is ${startManager}") if (boolean) { startManager.install() } else { startManager.uninstall() } } } ================================================ FILE: shared/app/src/commonMain/kotlin/com/abdownloadmanager/shared/util/BaseComponent.kt ================================================ package com.abdownloadmanager.shared.util import com.arkivanov.decompose.ComponentContext import com.arkivanov.essenty.lifecycle.Lifecycle import com.arkivanov.essenty.lifecycle.coroutines.coroutineScope import com.arkivanov.essenty.lifecycle.coroutines.withLifecycle import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.SupervisorJob import kotlinx.coroutines.flow.Flow abstract class BaseComponent( componentContext: ComponentContext ) : ComponentContext by componentContext { val scope = coroutineScope( SupervisorJob() + Dispatchers.Main ) fun Flow.withResumedLifecycle(): Flow { return withLifecycle( lifecycle = lifecycle, minActiveState = Lifecycle.State.STARTED, ) } } ================================================ FILE: shared/app/src/commonMain/kotlin/com/abdownloadmanager/shared/util/BaseConstants.kt ================================================ package com.abdownloadmanager.shared.util interface BaseConstants { val appName: String val appDisplayName: String val packageName: String val dataDirName: String val projectWebsite: String val projectSourceCode: String val donateLink: String val projectTranslations: String val projectGithubOwner: String val projectGithubRepo: String val browserIntegrations: List val telegramGroupUrl: String val telegramChannelUrl: String } ================================================ FILE: shared/app/src/commonMain/kotlin/com/abdownloadmanager/shared/util/BaseSettings.kt ================================================ package com.abdownloadmanager.shared.util import androidx.datastore.core.DataStore import arrow.optics.Lens import ir.amirab.util.flow.mapTwoWayStateFlow import ir.amirab.util.config.MapConfig import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.flow.* import kotlinx.coroutines.runBlocking import org.koin.core.component.KoinComponent import org.koin.core.component.inject abstract class BaseStorage : KoinComponent { val scope: CoroutineScope by inject() protected abstract val inMemoryState: MutableStateFlow protected abstract suspend fun saveData(data: T) val data get() = inMemoryState fun from(lens: Lens): MutableStateFlow { return inMemoryState.mapTwoWayStateFlow(lens) } /** * call this on upper implementations where [inMemoryState] and [saveData] are implemented */ protected fun startPersistData() { inMemoryState //first .drop(1) .debounce(500) .onEach { s -> saveData(s) }.launchIn(scope) } } abstract class ConfigBaseSettingsByMapConfig( private val dataStore: DataStore, private val lens: Lens, ) : BaseStorage(), KoinComponent { private val lastFileState = dataStore.data.let { runBlocking { it.stateIn(scope) } } override val inMemoryState = MutableStateFlow( lens.get(lastFileState.value) ) override suspend fun saveData(data: T) { dataStore.updateData { val newData = lens.set(MapConfig(), data) newData } } init { startPersistData() } } abstract class ConfigBaseSettingsByJson( private val dataStore: DataStore, ) : BaseStorage(), KoinComponent { private val lastFileState = dataStore.data.let { runBlocking { it.stateIn(scope) } } override val inMemoryState = MutableStateFlow(lastFileState.value) override suspend fun saveData(data: T) { dataStore.updateData { data } } init { startPersistData() } } ================================================ FILE: shared/app/src/commonMain/kotlin/com/abdownloadmanager/shared/util/BottomSheet.kt ================================================ package com.abdownloadmanager.shared.util import androidx.compose.animation.* import androidx.compose.animation.core.updateTransition import androidx.compose.foundation.background import androidx.compose.foundation.layout.* import androidx.compose.runtime.* import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color import androidx.compose.ui.platform.LocalFocusManager import com.abdownloadmanager.shared.util.ui.widget.MPBackHandler import ir.amirab.util.compose.modifiers.hijackClick import ir.amirab.util.compose.modifiers.silentClickable import kotlinx.coroutines.flow.drop import kotlinx.coroutines.flow.filter import kotlinx.coroutines.flow.map @Suppress("NAME_SHADOWING") @Composable fun ResponsiveDialog( state: ResponsiveDialogState, onDismiss: () -> Unit, modifier: Modifier = Modifier, enter: EnterTransition = slideInVertically { it }, exit: ExitTransition = slideOutVertically { it }, content: @Composable ResponsiveDialogScope.() -> Unit ) { // I don't know why but if I don't wrap these to rememberUpdateState // sometimes recomposition not works for lambda that placed in host val modifier by rememberUpdatedState(modifier) val onDismiss by rememberUpdatedState(onDismiss) val content by rememberUpdatedState(content) val enter by rememberUpdatedState(enter) val exit by rememberUpdatedState(exit) if (state.targetIsOpened || state.currentIsOpened) { PlaceInHost { val focusManager = LocalFocusManager.current LaunchedEffect(Unit) { focusManager.clearFocus() } CustomSheet(modifier, state, onDismiss, enter, exit, content) } } } @Stable class ResponsiveDialogState( isOpened: Boolean ) { var targetIsOpened by mutableStateOf(isOpened) internal set var currentIsOpened by mutableStateOf(isOpened) internal set val isFullyVisible by derivedStateOf { val targetIsOpened = targetIsOpened val currentIsOpened = currentIsOpened targetIsOpened == currentIsOpened && targetIsOpened } val isFullyInvisible by derivedStateOf { val targetIsOpened = targetIsOpened val currentIsOpened = currentIsOpened targetIsOpened == currentIsOpened && !targetIsOpened } val onFullyInvisibleFlow = snapshotFlow { isFullyInvisible } .filter { it }.map { } val isIdle by derivedStateOf { targetIsOpened == currentIsOpened } fun show() { targetIsOpened = true } fun hide() { targetIsOpened = false } } @Composable fun ResponsiveDialogState.OnFullyDismissed( onDismiss: () -> Unit, ) { val state = this val onDismiss by rememberUpdatedState(onDismiss) LaunchedEffect(state) { val isFullyInvisibleAtFirst = state.isFullyInvisible val dropValue = if (isFullyInvisibleAtFirst) 1 else 0 state.onFullyInvisibleFlow .drop(dropValue) .collect { onDismiss() } } } @Composable fun rememberResponsiveDialogState(isOpened: Boolean): ResponsiveDialogState { return remember { ResponsiveDialogState(isOpened) } } interface ResponsiveDialogScope { val isTopEndFree: Boolean val isTopStartFree: Boolean val isBottomStartFree: Boolean val isBottomEndFree: Boolean } @Immutable private data class ResponsiveDialogScopeImpl( override val isTopStartFree: Boolean, override val isTopEndFree: Boolean, override val isBottomStartFree: Boolean, override val isBottomEndFree: Boolean, ) : ResponsiveDialogScope @Composable private fun CustomSheet( modifier: Modifier, state: ResponsiveDialogState, onDismiss: () -> Unit, enter: EnterTransition = slideInVertically { it }, exit: ExitTransition = slideOutVertically { it }, content: @Composable (ResponsiveDialogScope.() -> Unit) ) { val originalTransition = updateTransition(state.targetIsOpened, "originalTransition") // it should be animated for the first time! var isVisible by remember { mutableStateOf(false) } val transition = updateTransition(isVisible, "transition") // the reason I use || is to prevent state from closing too early state.currentIsOpened = originalTransition.currentState || transition.currentState LaunchedEffect(originalTransition.targetState) { isVisible = originalTransition.targetState } val responsiveSize = rememberResponsiveWidth() Box( modifier.fillMaxSize(), ) { transition.AnimatedVisibility( visible = { it }, enter = fadeIn(), exit = fadeOut() ) { Box( Modifier .fillMaxSize() .background(Color.Black.copy(alpha = 0.5f)) .silentClickable(onClick = onDismiss) ) } val widthFraction: Float val alignment: Alignment when (responsiveSize) { ResponsiveTarget.Phone -> { widthFraction = 1f alignment = Alignment.BottomCenter } ResponsiveTarget.Tablet -> { widthFraction = 0.75f alignment = Alignment.Center } ResponsiveTarget.Desktop -> { widthFraction = 0.5f alignment = Alignment.Center } } val responsiveDialogScope = ResponsiveDialogScopeImpl( isTopStartFree = true, isTopEndFree = true, isBottomStartFree = alignment == Alignment.Center, isBottomEndFree = alignment == Alignment.Center, ) transition.AnimatedVisibility( { it }, enter = enter, exit = exit, modifier = Modifier .fillMaxWidth(widthFraction) .align(alignment) .hijackClick() ) { MPBackHandler(onBack = onDismiss) Box(Modifier) { content(responsiveDialogScope) } } } } ================================================ FILE: shared/app/src/commonMain/kotlin/com/abdownloadmanager/shared/util/BrowserIntegrationModel.kt ================================================ package com.abdownloadmanager.shared.util import androidx.compose.runtime.Immutable import ir.amirab.util.compose.IconSource import com.abdownloadmanager.shared.util.ui.icon.MyIcons sealed class BrowserType( val code:String ){ data object Firefox : BrowserType("firefox") data object Chrome : BrowserType("chrome") data object Opera : BrowserType("opera") data object Edge : BrowserType("edge") } fun BrowserType.getName():String{ return when(this){ BrowserType.Chrome -> "Google Chrome" BrowserType.Edge -> "Microsoft Edge" BrowserType.Firefox -> "Mozilla Firefox" BrowserType.Opera -> "Opera" } } fun BrowserType.getIcon(): IconSource { return when(this){ BrowserType.Chrome -> MyIcons.browserGoogleChrome BrowserType.Edge -> MyIcons.browserMicrosoftEdge BrowserType.Firefox -> MyIcons.browserMozillaFirefox BrowserType.Opera -> MyIcons.browserOpera } } @Immutable data class BrowserIntegrationModel( val type: BrowserType, val url:String, ) ================================================ FILE: shared/app/src/commonMain/kotlin/com/abdownloadmanager/shared/util/ClipboardUtil.kt ================================================ package com.abdownloadmanager.shared.util expect object ClipboardUtil { fun read(): String? fun copy(text: String) } ================================================ FILE: shared/app/src/commonMain/kotlin/com/abdownloadmanager/shared/util/ColorUtils.kt ================================================ package com.abdownloadmanager.shared.util import androidx.compose.ui.graphics.Color operator fun Color.div(percent: Int): Color = run { require(percent in 0..100) div(percent.toFloat() / 100) } operator fun Color.div(percent: Float): Color = run { require(percent in 0f..1f) copy(alpha = percent) } fun Color.lighter(amount: Float = 0.1f): Color { return toHsl().apply { laminate += amount }.toColor() } fun Color.darker(amount: Float = 0.1f): Color { return toHsl().apply { laminate -= amount }.toColor() } @JvmInline value class HSLColor(val hsl: FloatArray) { constructor(hue: Float, saturation: Float, laminate: Float) : this( floatArrayOf(hue, saturation, laminate) ) constructor(hue: Int, saturation: Float, laminate: Float) : this( floatArrayOf(hue / 360f, saturation, laminate) ) fun copy(): HSLColor { return HSLColor(hsl.copyOf()) } override fun toString(): String { return """HSLColor( hue=$hue , saturation=$saturation , laminate=$laminate )""" } fun getHueInt(): Int { return (hue * 360).toInt() } fun setHue(value: Int) { val h = value.coerceIn(0..360) hue = h / 360f } var hue get() = hsl[0] set(value) { hsl[0] = value.coerceIn(0f, 1f) } var saturation get() = hsl[1] set(value) { hsl[1] = value.coerceIn(0f, 1f) } var laminate get() = hsl[2] set(value) { hsl[2] = value.coerceIn(0f, 1f) } } fun Color.toHsl(): HSLColor { val hsl = FloatArray(3) val color = this val r = color.red val g = color.green val b = color.blue val max = maxOf(r, g, b) val min = minOf(r, g, b) hsl[2] = (max + min) / 2 if (max == min) { hsl[1] = 0f hsl[0] = hsl[1] } else { val d = max - min hsl[1] = if (hsl[2] > 0.5f) d / (2f - max - min) else d / (max + min) when (max) { r -> hsl[0] = (g - b) / d + (if (g < b) 6 else 0) g -> hsl[0] = (b - r) / d + 2 b -> hsl[0] = (r - g) / d + 4 } hsl[0] /= 6f } return HSLColor(hsl) } fun HSLColor.toColor(): Color { val hsl: FloatArray = this.hsl val r: Float val g: Float val b: Float val h = hsl[0] val s = hsl[1] val l = hsl[2] if (s == 0f) { b = l g = b r = g } else { val q = if (l < 0.5f) l * (1 + s) else l + s - l * s val p = 2 * l - q r = hue2rgb(p, q, h + 1f / 3) g = hue2rgb(p, q, h) b = hue2rgb(p, q, h - 1f / 3) } return Color((r * 255).toInt(), (g * 255).toInt(), (b * 255).toInt()) } private fun hue2rgb(p: Float, q: Float, t: Float): Float { var valueT = t if (valueT < 0) valueT += 1f if (valueT > 1) valueT -= 1f if (valueT < 1f / 6) return p + (q - p) * 6f * valueT if (valueT < 1f / 2) return q return if (valueT < 2f / 3) p + (q - p) * (2f / 3 - valueT) * 6f else p } ================================================ FILE: shared/app/src/commonMain/kotlin/com/abdownloadmanager/shared/util/ContainsShortcuts.kt ================================================ package com.abdownloadmanager.shared.util interface ContainsShortcuts { val shortcutManager: ShortcutManager } ================================================ FILE: shared/app/src/commonMain/kotlin/com/abdownloadmanager/shared/util/CoroutineUtils.kt ================================================ package com.abdownloadmanager.shared.util import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.SupervisorJob import kotlinx.coroutines.job import kotlin.coroutines.CoroutineContext import kotlin.coroutines.EmptyCoroutineContext fun newScopeBasedOn( scope: CoroutineScope, extraContext: CoroutineContext = EmptyCoroutineContext ): CoroutineScope { return CoroutineScope(scope.coroutineContext + SupervisorJob(scope.coroutineContext.job) + extraContext) } ================================================ FILE: shared/app/src/commonMain/kotlin/com/abdownloadmanager/shared/util/DefinedPaths.kt ================================================ package com.abdownloadmanager.shared.util import okio.Path import java.io.File import kotlin.io.resolve abstract class DefinedPaths( val dataDir: Path, ) { val configDir: Path = dataDir.resolve("config") val systemDir: Path = dataDir.resolve("system") val updateDir: Path = systemDir.resolve("update") val logDir: Path = systemDir.resolve("log") val pagesStateDir: Path = configDir.resolve("pages") val optionsDir: Path = configDir.resolve("options") val downloadDbDir: Path = configDir.resolve("download_db") val downloadListDir = downloadDbDir.resolve("downloadlist") val extraDownloadSettings: Path = downloadDbDir.resolve("extra_download_settings") val extraQueueSettings: Path = downloadDbDir.resolve("extra_queue_settings") val categoriesDir: Path = downloadDbDir.resolve("categories") val categoriesFile: Path = categoriesDir.resolve("categories.json") val partsDir: Path = downloadDbDir.resolve("parts") val updateDownloadLocation: Path = updateDir.resolve("downloads") val downloadDataDir: Path = systemDir.resolve("downloadData") val queuesDir: Path = downloadDbDir.resolve("queues") val proxySettingsFile: Path = optionsDir.resolve("proxySettings.json") val appSettingsFile: Path = configDir.resolve("appSettings.json") val perHostSettingsFile: Path = optionsDir.resolve("perHostSettings.json") } ================================================ FILE: shared/app/src/commonMain/kotlin/com/abdownloadmanager/shared/util/DesktopDiskStat.kt ================================================ package com.abdownloadmanager.shared.util import ir.amirab.downloader.utils.IDiskStat expect class PlatformDiskStat : IDiskStat ================================================ FILE: shared/app/src/commonMain/kotlin/com/abdownloadmanager/shared/util/DownloadFoldersRegistry.kt ================================================ package com.abdownloadmanager.shared.util import okio.Path import java.io.File /** * this is used to boot when file permission are granted! */ class DownloadFoldersRegistry { private val foldersToCreate = mutableListOf() fun boot() { // println("folder registery is $this") foldersToCreate.forEach { it.mkdirs() } } override fun toString(): String { return foldersToCreate.map { it.absolutePath }.joinToString("\n").let { "DownloadFoldersRegistry(\nlist=$it\n)" } } fun registerAndGet(folder: Path): File { val file = folder.toFile() foldersToCreate.add(file) return file } } ================================================ FILE: shared/app/src/commonMain/kotlin/com/abdownloadmanager/shared/util/DownloadItemOpener.kt ================================================ package com.abdownloadmanager.shared.util import ir.amirab.downloader.downloaditem.IDownloadItem interface DownloadItemOpener { suspend fun openDownloadItem(id:Long) suspend fun openDownloadItem(downloadItem: IDownloadItem) suspend fun openDownloadItemFolder(id:Long) suspend fun openDownloadItemFolder(downloadItem: IDownloadItem) } ================================================ FILE: shared/app/src/commonMain/kotlin/com/abdownloadmanager/shared/util/DownloadSystem.kt ================================================ package com.abdownloadmanager.shared.util import com.abdownloadmanager.shared.storage.IExtraDownloadSettingsStorage import com.abdownloadmanager.shared.storage.IExtraQueueSettingsStorage import com.abdownloadmanager.shared.util.category.CategoryItemWithId import com.abdownloadmanager.shared.util.category.CategoryManager import com.abdownloadmanager.shared.util.category.CategorySelectionMode import com.abdownloadmanager.shared.util.ondownloadcompletion.OnDownloadCompletionActionRunner import com.abdownloadmanager.shared.util.onqueuecompletion.OnQueueEventActionRunner import ir.amirab.downloader.DownloadManager import ir.amirab.downloader.NewDownloadItemProps import ir.amirab.downloader.db.IDownloadListDb import ir.amirab.downloader.downloaditem.* import ir.amirab.downloader.downloaditem.contexts.ResumedBy import ir.amirab.downloader.downloaditem.contexts.StoppedBy import ir.amirab.downloader.downloaditem.contexts.User import ir.amirab.downloader.downloaditem.DownloadStatus import ir.amirab.downloader.monitor.IDownloadItemState import ir.amirab.downloader.monitor.IDownloadMonitor import ir.amirab.downloader.monitor.ProcessingDownloadItemState import ir.amirab.downloader.monitor.isDownloadActiveFlow import ir.amirab.downloader.queue.ManualDownloadQueue import ir.amirab.downloader.queue.QueueManager import ir.amirab.downloader.utils.OnDuplicateStrategy import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch import java.io.File /** * a facade for download manager library * all the download manager features should be accessed and controlled here */ class DownloadSystem( val downloadManager: DownloadManager, val queueManager: QueueManager, val manualDownloadQueue: ManualDownloadQueue, val categoryManager: CategoryManager, val downloadMonitor: IDownloadMonitor, val onDownloadCompletionActionRunner: OnDownloadCompletionActionRunner, val onQueueEventActionRunner: OnQueueEventActionRunner, private val scope: CoroutineScope, private val downloadListDB: IDownloadListDb, private val extraQueueSettingsStorage: IExtraQueueSettingsStorage<*>, private val extraDownloadSettingsStorage: IExtraDownloadSettingsStorage<*>, private val foldersRegistry: DownloadFoldersRegistry, ) { private val booted = MutableStateFlow(false) val downloadEvents = downloadManager.listOfJobsEvents suspend fun boot() { if (booted.value) return foldersRegistry.boot() queueManager.boot() downloadManager.boot() categoryManager.boot() manualDownloadQueue.boot() onDownloadCompletionActionRunner.startListening() onQueueEventActionRunner.startListening() booted.update { true } } suspend fun addDownload( newItemsToAdd: List, queueId: Long? = null, categorySelectionMode: CategorySelectionMode? = null, ): List { val createdIds = newItemsToAdd.map { downloadManager.addDownload(it) } createdIds.also { ids -> queueId?.let { queueManager.addToQueue( it, ids ) } } categorySelectionMode?.let { when (it) { CategorySelectionMode.Auto -> { categoryManager.autoAddItemsToCategoriesBasedOnFileNames( createdIds.mapIndexed { index: Int, id: Long -> val downloadItem = newItemsToAdd[index].downloadItem CategoryItemWithId( id = id, fileName = downloadItem.name, url = downloadItem.link, ) } ) } is CategorySelectionMode.Fixed -> { categoryManager.addItemsToCategory( it.categoryId, createdIds, ) } } } return createdIds } suspend fun addDownload( newDownload: NewDownloadItemProps, queueId: Long?, categoryId: Long?, ): Long { val downloadId = downloadManager.addDownload(newDownload) queueId?.let { queueManager.addToQueue(queueId, downloadId) } categoryId?.let { categoryManager.addItemsToCategory( categoryId = categoryId, itemIds = listOf(downloadId) ) } return downloadId } suspend fun removeDownload( id: Long, alsoRemoveFile: Boolean, context: DownloadItemContext, ) { downloadManager.deleteDownload( id = id, alsoRemoveFile = { alsoRemoveFile }, context = context ) categoryManager.removeItemInCategories(listOf(id)) extraDownloadSettingsStorage.deleteExtraDownloadItemSettings(id) } suspend fun userManualResume(id: Long): Boolean { manualDownloadQueue.resume(id) return true } suspend fun manualResume(id: Long, context: DownloadItemContext): Boolean { // it won't go though headless queue // to respect the max concurrent limits downloadManager.resume(id, context) return true } suspend fun reset(id: Long): Boolean { downloadManager.reset(id) return true } suspend fun manualPause(id: Long): Boolean { manualDownloadQueue.pause(id) return true } suspend fun startQueue( queueId: Long, ) { val queue = queueManager.getQueue(queueId) if (queue.isQueueActive) { return } // going to start queue.start() } suspend fun stopAnything() { queueManager.getAll().forEach { it.stop() } manualDownloadQueue.clearQueue() downloadManager.stopAll() } suspend fun stopQueue( queueId: Long, ) { queueManager.getQueue(queueId) .stop() } suspend fun getDownloadItemById(id: Long): IDownloadItem? { return downloadListDB.getById(id) ?: return null } suspend fun getDownloadItemByLink(link: String): List { return downloadListDB.getAll().filter { it.link == link } } suspend fun getDownloadItemsBy(selector: (IDownloadItem) -> Boolean): List { return downloadListDB.getAll().filter(selector) } suspend fun getOrCreateDownloadByLink( downloadItem: IDownloadItem, ): Long { val items = getDownloadItemByLink(downloadItem.link) if (items.isNotEmpty()) { val completedFound = items.find { it.status == DownloadStatus.Completed } if (completedFound != null) { return completedFound.id } val id = items.sortedByDescending { it.dateAdded }.first().id return id } val id = addDownload( newDownload = NewDownloadItemProps( downloadItem = downloadItem, onDuplicateStrategy = OnDuplicateStrategy.AddNumbered, extraConfig = null, context = EmptyContext, ), queueId = null, categoryId = null, ) return id } fun getDownloadFile(downloadItem: IDownloadItem): File { return downloadManager.calculateOutputFile(downloadItem) } fun getDownloadItemByPath(path: String): IDownloadItemState? { return downloadMonitor.downloadListFlow.value.find { it.getFullPath().path == path } } fun getDownloadItemsByFolder(folder: String): List { return downloadMonitor.downloadListFlow.value.filter { it.folder == folder } } suspend fun getFilePathById(id: Long): File? { val item = getDownloadItemById(id) ?: return null return downloadManager.calculateOutputFile(item) } fun addQueue(name: String) { scope.launch { queueManager.addQueue(name) } } fun getAllDownloadIds(): List { return getUnfinishedDownloadIds() + getFinishedDownloadIds() } fun getFinishedDownloadIds(): List { return downloadMonitor.completedDownloadListFlow.value.map { it.id } } fun getUnfinishedDownloadIds(): List { return downloadMonitor.activeDownloadListFlow.value.map { it.id } } fun isDownloadMissingFileOrHaveNotProgress(downloadItem: IDownloadItemState): Boolean { val missingFileBypass = if (downloadItem is ProcessingDownloadItemState) { // some downloads not started yet so there is no file belong to them, so we shouldn't remove them downloadItem.hasProgress } else { // finished downloads can be removed true } return missingFileBypass && !downloadItem.getFullPath().exists() } fun getListOfDownloadThatMissingFileOrHaveNotProgress(): List { val downloads = downloadMonitor.downloadListFlow.value return downloads.filter { isDownloadMissingFileOrHaveNotProgress(it) } } fun getAllRegisteredDownloadFiles(): List { return downloadMonitor.run { activeDownloadListFlow.value + completedDownloadListFlow.value }.map { File(it.folder, it.name) } } suspend fun isDownloadActive(id: Long): Boolean { return downloadMonitor.isDownloadActiveFlow(id).value } suspend fun editDownload( id: Long, applyUpdate: (IDownloadItem) -> Unit, downloadJobExtraConfig: DownloadJobExtraConfig? ) { val wasActive = isDownloadActive(id) if (wasActive) { manualPause(id) } downloadManager.updateDownloadItem( id = id, downloadJobExtraConfig = downloadJobExtraConfig, updater = applyUpdate, ) if (wasActive) { userManualResume(id) } } suspend fun deleteQueue(queueId: Long) { queueManager.deleteQueue(queueId) extraQueueSettingsStorage.deleteExtraQueueSettings(queueId) } } ================================================ FILE: shared/app/src/commonMain/kotlin/com/abdownloadmanager/shared/util/DurationUtil.kt ================================================ package com.abdownloadmanager.shared.util import ir.amirab.util.compose.StringSource import ir.amirab.util.compose.asStringSource import kotlin.math.roundToInt /** * @param duration duration in seconds */ fun convertDurationToHumanReadable(duration: Double): StringSource { // omit fractional section val duration = duration.roundToInt() val seconds = duration % 60 val minutes = (duration / 60) % 60 val hours = duration / 3600 return if (hours > 0) { String.format("%02d:%02d:%02d", hours, minutes, seconds) } else { String.format("%02d:%02d", minutes, seconds) }.asStringSource() } ================================================ FILE: shared/app/src/commonMain/kotlin/com/abdownloadmanager/shared/util/ExceptionToString.kt ================================================ package com.abdownloadmanager.shared.util fun exceptionToString(exception: Exception): String { return exception.message?:exception::class.qualifiedName?:"Unknown Error" } ================================================ FILE: shared/app/src/commonMain/kotlin/com/abdownloadmanager/shared/util/FileIconProvider.kt ================================================ package com.abdownloadmanager.shared.util import androidx.compose.runtime.Composable import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue import androidx.compose.runtime.remember import com.abdownloadmanager.shared.util.category.CategoryManager import com.abdownloadmanager.shared.util.category.DefaultCategories import com.abdownloadmanager.shared.util.category.iconSource import com.abdownloadmanager.shared.util.ui.IMyIcons import ir.amirab.util.compose.IIconResolver import ir.amirab.util.compose.IconSource interface FileIconProvider { fun getIcon(fileName: String): IconSource /** * Automatically update icon if other dependencies changed */ @Composable fun rememberIcon(fileName: String): IconSource } class FileIconProviderUsingCategoryIcons( private val defaultCategories: DefaultCategories, private val categoryManager: CategoryManager, private val icons: IMyIcons, private val iconResolver: IIconResolver, ) : FileIconProvider { override fun getIcon(fileName: String): IconSource { return fromDefaultCategories(fileName) ?: fromUserDefinedCategories(fileName) ?: icons.file } @Composable override fun rememberIcon(fileName: String): IconSource { val fromDefault = remember(fileName) { fromDefaultCategories(fileName) } if (fromDefault != null) { return fromDefault } val categories by categoryManager.categoriesFlow.collectAsState() val fromCategories = remember(fileName, categories) { fromUserDefinedCategories(fileName) } if (fromCategories != null) { return fromCategories } return icons.file } private fun fromDefaultCategories(fileName: String): IconSource? { return defaultCategories .getCategoryOfFileName(fileName)?.iconSource(iconResolver) } private fun fromUserDefinedCategories(fileName: String): IconSource? { return categoryManager .getCategoryOfFileName(fileName)?.iconSource(iconResolver) } } ================================================ FILE: shared/app/src/commonMain/kotlin/com/abdownloadmanager/shared/util/FilenameFixer.kt ================================================ package com.abdownloadmanager.shared.util import ir.amirab.util.ifThen import ir.amirab.util.platform.Platform import ir.amirab.util.platform.isWindows /** * This utility class removes characters that are not supported by the current OS. * * If additional modifications are required, it may be better to create a separate class for each platform. */ object FilenameFixer { private const val DEFAULT_REPLACEMENT_CHAR = "_" private val illegalChars by lazy { when (Platform.getCurrentPlatform()) { Platform.Desktop.Windows -> setOf('<', '>', ':', '"', '/', '\\', '|', '?', '*') Platform.Desktop.MacOS -> setOf(':') Platform.Desktop.Linux, Platform.Android, -> setOf('/') } } fun fix(name: String): String { return buildString { name.forEach { char -> append( if (char in illegalChars) { DEFAULT_REPLACEMENT_CHAR } else { char } ) } } .ifThen(Platform.isWindows()) { trimEnd(' ', '.') } } } ================================================ FILE: shared/app/src/commonMain/kotlin/com/abdownloadmanager/shared/util/HashUtil.kt ================================================ package com.abdownloadmanager.shared.util import ir.amirab.downloader.utils.calcPercent import java.io.File import java.io.InputStream import java.security.MessageDigest sealed class FileChecksumAlgorithm( val algorithm: String, ) { data object MD5 : FileChecksumAlgorithm("MD5") data object SHA1 : FileChecksumAlgorithm("SHA-1") data object SHA256 : FileChecksumAlgorithm("SHA-256") data object SHA512 : FileChecksumAlgorithm("SHA-512") companion object { fun default() = SHA256 fun all() = listOf( MD5, SHA1, SHA256, SHA512, ) } } data class FileChecksum( val algorithm: String, val value: String, ) { override fun toString(): String { return "$algorithm:$value" } companion object { fun fromString(string: String): FileChecksum { val segments = string.split(":") require(segments.size == 2) { "Invalid checksum string: $string it should be in format algorithm:value" } return FileChecksum( algorithm = segments[0], value = segments[1], ) } fun fromNullableString(string: String?): FileChecksum? { return string?.let { fromString(it) } } } override fun equals(other: Any?): Boolean { if (this === other) return true if (javaClass != other?.javaClass) return false other as FileChecksum return algorithm.equals(other.algorithm, true) && value.equals(other.value, true) } override fun hashCode(): Int { var result = algorithm.hashCode() result = 31 * result + value.hashCode() return result } } object HashUtil { fun hash( algorithm: String, inputStream: InputStream, size: Long, onNewPercent: (Int) -> Unit, ): String { val messageDigest = MessageDigest.getInstance(algorithm) val buffer = ByteArray(DEFAULT_BUFFER_SIZE) var processedBytes = 0L var lastPercent = 0 while (true) { val readCount = inputStream.read(buffer) if (readCount == -1) { break } messageDigest.update(buffer, 0, readCount) processedBytes += readCount val newPercent = calcPercent(processedBytes, size) if (newPercent != lastPercent) { onNewPercent(newPercent) lastPercent = newPercent } } return messageDigest .digest() .joinToString("") { "%02x".format(it) } } fun fileHash( algorithm: String, file: File, onNewPercent: (Int) -> Unit ): String { val fileSize = file.length() return file.inputStream().use { hash( algorithm = algorithm, inputStream = it, size = fileSize, onNewPercent = onNewPercent ) } } } ================================================ FILE: shared/app/src/commonMain/kotlin/com/abdownloadmanager/shared/util/IPUtils.kt ================================================ @file:OptIn(ExperimentalUnsignedTypes::class) @file:Suppress("unused", "MemberVisibilityCanBePrivate") package com.abdownloadmanager.shared.util import java.net.NetworkInterface fun getSubnet(): List { return runCatching { NetworkInterface.getNetworkInterfaces() .toList() .flatMap { networkInterface -> networkInterface.interfaceAddresses }.filter { !it.address.isLinkLocalAddress && !it.address.isLoopbackAddress && it.networkPrefixLength > 0 }.mapNotNull { val ip = ByteIp(it.address.address) SubnetAddress( ip, it.networkPrefixLength ) } }.getOrElse { emptyList() } } abstract class Ip { abstract val intIp: UInt abstract val byteIp: UByteArray fun toByteIp() = ByteIp(byteIp) fun toIntIp() = IntIp(intIp) override fun toString(): String { return byteIp.map { it }.joinToString(".") } override fun equals(other: Any?): Boolean { if (other === this) return true if (other is Ip) { return other.intIp == intIp } return false } override fun hashCode(): Int { return intIp.hashCode() } } class IntIp( ip: UInt ) : Ip() { override val intIp by lazy { ip } override val byteIp by lazy { UByteArray(4) { val bitCount = (3 - it) * 8 (ip shr bitCount).toUByte() } } } @JvmName("ByteIpFromByteArrayVararg") fun ByteIp(ip: ByteArray): ByteIp { return ByteIp( ip.map { it.toUByte() }.toUByteArray() ) } @JvmName("ByteIpFromByteVararg") fun ByteIp(vararg ip: Byte): ByteIp { return ByteIp(ip) } @JvmName("ByteIpFromUByteVararg") fun ByteIp(vararg ip: UByte): ByteIp { return ByteIp(ip) } @JvmName("ByteIpFromIntVararg") fun ByteIp(vararg ip: Int): ByteIp { return ByteIp( ip.map { it.toUByte() }.toUByteArray() ) } class ByteIp( ip: UByteArray ) : Ip() { init { require(ip.size == 4) } override val intIp: UInt by lazy { ip.foldIndexed(0u) { index, acc, byte -> val int = byte.toUInt() val bitCount = ((3 - index) * 8) val shifted = int shl bitCount acc.or(shifted) } } override val byteIp by lazy { ip } } class SubnetAddress( val ip: Ip, val prefix: Short ) { override fun toString(): String { return "$network/$prefix" } val mask: UInt by lazy { val full = UInt.MAX_VALUE full shl (32 - prefix) } val network: Ip by lazy { IntIp(ip.intIp and mask) } val broadcast: Ip by lazy { IntIp(network.intIp or mask.inv()) } private val subnetIntRange = ((network.intIp + 1u) until broadcast.intIp) val subnetRangeSize by lazy { (subnetIntRange.last - subnetIntRange.first) } val subnetIpRange by lazy { subnetIntRange.asSequence().map { IntIp(it) } } } ================================================ FILE: shared/app/src/commonMain/kotlin/com/abdownloadmanager/shared/util/InstanceCheckUtils.kt ================================================ package com.abdownloadmanager.shared.util fun T.isAnyOf(vararg conditions: (T) -> Boolean): Boolean { return conditions.any { it(this) } } fun T.isAllOf(vararg conditions: (T) -> Boolean): Boolean { return conditions.all { it(this) } } ================================================ FILE: shared/app/src/commonMain/kotlin/com/abdownloadmanager/shared/util/LimitationsInUI.kt ================================================ package com.abdownloadmanager.shared.util object ThreadCountLimitation { const val MAX_ALLOWED_THREAD_COUNT = 256 const val MAX_NORMAL_VALUE = 32 } object MaximumDownloadRetriesLimitation { const val MAX_ALLOWED_RETRIES = 1024 } ================================================ FILE: shared/app/src/commonMain/kotlin/com/abdownloadmanager/shared/util/Platform.kt ================================================ package com.abdownloadmanager.shared.util //expect object Platform { // val type: OSInfo.OSType //} ================================================ FILE: shared/app/src/commonMain/kotlin/com/abdownloadmanager/shared/util/PlatformKeyStroke.kt ================================================ package com.abdownloadmanager.shared.util interface PlatformKeyStroke { val keyCode: Int fun getModifiers(): List fun getKeyText(): String } ================================================ FILE: shared/app/src/commonMain/kotlin/com/abdownloadmanager/shared/util/PlatformThemeDetector.kt ================================================ package com.abdownloadmanager.shared.util import com.abdownloadmanager.shared.util.ui.theme.ISystemThemeDetector expect class PlatformThemeDetector : ISystemThemeDetector ================================================ FILE: shared/app/src/commonMain/kotlin/com/abdownloadmanager/shared/util/PopUpContainer.kt ================================================ package com.abdownloadmanager.shared.util import androidx.compose.runtime.* import java.util.* private val LocalBottomSheetContainer = staticCompositionLocalOf Unit>>> { error("not initialized yet") } @Composable fun PlaceInHost( item: @Composable () -> Unit ) { val key = remember { UUID.randomUUID() } val container = LocalBottomSheetContainer.current DisposableEffect(key) { val item = key to item container.add(item) onDispose { container.remove(item) } } } @Composable fun PopUpContainer( content: @Composable () -> Unit ) { val items = remember { mutableStateListOf Unit>>() } CompositionLocalProvider( LocalBottomSheetContainer provides items, content = { content() items.forEach { (key, content) -> key(key) { content() } } } ) } ================================================ FILE: shared/app/src/commonMain/kotlin/com/abdownloadmanager/shared/util/RememberDotLoading.kt ================================================ package com.abdownloadmanager.shared.util import androidx.compose.animation.core.* import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue @Composable fun rememberDotLoading(): String { val transition = rememberInfiniteTransition() val count by transition.animateValue( 1, 4, Int.VectorConverter, infiniteRepeatable( tween(2500, easing = LinearEasing), repeatMode = RepeatMode.Restart ) ) return buildString { for (i in 1..3) { if (i <= count) { append(".") } else { append(" ") } } } } ================================================ FILE: shared/app/src/commonMain/kotlin/com/abdownloadmanager/shared/util/Responsive.kt ================================================ package com.abdownloadmanager.shared.util import androidx.compose.foundation.layout.BoxWithConstraints import androidx.compose.foundation.layout.BoxWithConstraintsScope import androidx.compose.runtime.* import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp interface ResponsiveSize { val maxHeight: Dp val maxWidth: Dp } @Immutable data class WindowSize( override val maxHeight: Dp, override val maxWidth: Dp, ) : ResponsiveSize @Immutable data class ContainerSize( override val maxHeight: Dp, override val maxWidth: Dp, private val containerName: String ) : ResponsiveSize @Composable fun provideContainerSize( maxHeight: Dp, maxWidth: Dp, name: String? = null ): ProvidedValue { return LocalContainerSize provides ContainerSize( maxHeight, maxWidth, name ?: "not specified container" ) } @Composable fun provideWindowSize( maxHeight: Dp, maxWidth: Dp, ): ProvidedValue { return LocalWindowSize provides WindowSize(maxHeight, maxWidth) } @Composable fun ResponsiveBox(content: @Composable BoxWithConstraintsScope.() -> Unit) { BoxWithConstraints { CompositionLocalProvider( provideContainerSize(maxHeight, maxWidth), ) { content() } } } enum class ResponsiveTarget { Phone, Tablet, Desktop, } @Composable fun rememberResponsiveWidth(): ResponsiveTarget { val width = LocalContainerSize.current.run { minOf(maxWidth, maxHeight) } return when (width) { in 0.dp..599.dp -> ResponsiveTarget.Phone in 600.dp..1199.dp -> ResponsiveTarget.Tablet else -> ResponsiveTarget.Desktop } } val LocalWindowSize = compositionLocalOf { error("not initialized") } val LocalContainerSize = compositionLocalOf { error("not initialized") } ================================================ FILE: shared/app/src/commonMain/kotlin/com/abdownloadmanager/shared/util/SharedConstants.kt ================================================ package com.abdownloadmanager.shared.util import com.abdownloadmanager.shared.BuildConfig import com.abdownloadmanager.shared.util.BaseConstants import com.abdownloadmanager.shared.util.BrowserIntegrationModel import com.abdownloadmanager.shared.util.BrowserType object SharedConstants : BaseConstants { override val appName: String = BuildConfig.APP_NAME override val appDisplayName: String = BuildConfig.APP_DISPLAY_NAME override val packageName: String = BuildConfig.PACKAGE_NAME override val dataDirName: String = BuildConfig.DATA_DIR_NAME override val projectWebsite: String = BuildConfig.PROJECT_WEBSITE override val projectTranslations: String = BuildConfig.PROJECT_TRANSLATIONS override val projectSourceCode: String = BuildConfig.PROJECT_SOURCE_CODE override val donateLink: String = BuildConfig.DONATE_LINK override val projectGithubOwner: String = BuildConfig.PROJECT_GITHUB_OWNER override val projectGithubRepo: String = BuildConfig.PROJECT_GITHUB_REPO override val browserIntegrations: List = listOf( BrowserIntegrationModel( BrowserType.Chrome, BuildConfig.INTEGRATION_CHROME_LINK ), BrowserIntegrationModel( BrowserType.Firefox, BuildConfig.INTEGRATION_FIREFOX_LINK ) ) override val telegramChannelUrl: String = BuildConfig.TELEGRAM_CHANNEL override val telegramGroupUrl: String = BuildConfig.TELEGRAM_GROUP } ================================================ FILE: shared/app/src/commonMain/kotlin/com/abdownloadmanager/shared/util/ShortcutManager.kt ================================================ package com.abdownloadmanager.shared.util import androidx.compose.runtime.compositionLocalOf import androidx.compose.ui.input.key.KeyEvent import androidx.compose.ui.input.key.key import javax.swing.KeyStroke abstract class ShortcutManager { private val shortcuts = mutableMapOf Unit>() fun register(keyStroke: PlatformKeyStroke, action: () -> Unit) { shortcuts[keyStroke] = action } abstract fun stringToKeyStroke(keyStrokeString: String): PlatformKeyStroke abstract fun getKeyStrokeFromEvent(s: KeyEvent): PlatformKeyStroke? abstract fun getKeyStrokeFromKeyCode(keyCode: Int): PlatformKeyStroke? infix fun String.to(action: () -> Unit) { register(stringToKeyStroke(this), action) } infix fun Int.to(action: () -> Unit) { getKeyStrokeFromKeyCode(this)?.let { register(it, action) } } fun executeShortcut( keyStroke: PlatformKeyStroke, ): Boolean { val action = shortcuts[keyStroke] ?: return false runCatching { action() } return true } fun getShortCutOf(action: () -> Unit): PlatformKeyStroke? { return shortcuts.firstNotNullOfOrNull { if (it.value == action) { it.key } else null } } fun handle(event: KeyEvent): Boolean { val keyStroke = getKeyStrokeFromEvent(event) ?: return false return executeShortcut(keyStroke) } } val LocalShortCutManager = compositionLocalOf { null as ShortcutManager? } ================================================ FILE: shared/app/src/commonMain/kotlin/com/abdownloadmanager/shared/util/ShouldValidate.kt ================================================ package com.abdownloadmanager.shared.util import kotlinx.coroutines.flow.StateFlow interface ShouldValidate { val valid: StateFlow } ================================================ FILE: shared/app/src/commonMain/kotlin/com/abdownloadmanager/shared/util/SizeAndSpeedUnitProvider.kt ================================================ package com.abdownloadmanager.shared.util import ir.amirab.util.datasize.ConvertSizeConfig import kotlinx.coroutines.flow.StateFlow interface SizeAndSpeedUnitProvider { val sizeUnit: StateFlow val speedUnit: StateFlow } ================================================ FILE: shared/app/src/commonMain/kotlin/com/abdownloadmanager/shared/util/SizeUtil.kt ================================================ package com.abdownloadmanager.shared.util import ir.amirab.util.datasize.CommonSizeConvertConfigs import ir.amirab.util.datasize.ConvertSizeConfig import ir.amirab.util.datasize.SizeWithUnit import androidx.compose.runtime.Composable import androidx.compose.runtime.CompositionLocalProvider import androidx.compose.runtime.compositionLocalOf import com.abdownloadmanager.resources.Res import ir.amirab.util.compose.StringSource import ir.amirab.util.compose.asStringSource import ir.amirab.util.datasize.* import ir.amirab.util.datasize.SizeWithUnit.Companion.DefaultFormat import ir.amirab.util.datasize.SizeWithUnit.Companion.SmallFormat import java.text.NumberFormat val LocalSpeedUnit = compositionLocalOf { CommonSizeConvertConfigs.BinaryBytes } val LocalSizeUnit = compositionLocalOf { CommonSizeConvertConfigs.BinaryBytes } @Composable fun ProvideSizeAndSpeedUnit( sizeUnitConfig: ConvertSizeConfig, speedUnitConfig: ConvertSizeConfig, content: @Composable () -> Unit, ) { CompositionLocalProvider( LocalSpeedUnit provides speedUnitConfig, LocalSizeUnit provides sizeUnitConfig, content = content ) } // they are used for ui // size == -1 means that its unknown fun convertPositiveBytesToSizeUnit( size: Long, target: ConvertSizeConfig, ): SizeWithUnit? { if (size < 0) return null return SizeConverter.bytesToSize( bytes = size, target = target, ) } fun convertPositiveBytesToHumanReadable( size: Long, target: ConvertSizeConfig, asCompactAsPossible: Boolean = false, ): String? { val format = if (asCompactAsPossible) SmallFormat else DefaultFormat return convertPositiveBytesToSizeUnit(size, target) ?.let { buildString { append(it.formatedValue(format)) if (!asCompactAsPossible) { append(" ") } append(it.unit.toString()) } } } fun convertPositiveSizeToHumanReadable( size: Long, target: ConvertSizeConfig, asCompactAsPossible: Boolean = false, ): StringSource { return convertPositiveBytesToHumanReadable(size, target, asCompactAsPossible) ?.asStringSource() ?: Res.string.unknown.asStringSource() } fun convertPositiveSpeedToHumanReadable(size: Long, target: ConvertSizeConfig, perUnit: String = "s"): String { return convertPositiveBytesToHumanReadable(size, target) ?.let { "$it/$perUnit" } ?: "-" } ================================================ FILE: shared/app/src/commonMain/kotlin/com/abdownloadmanager/shared/util/StateUtils.kt ================================================ package com.abdownloadmanager.shared.util import androidx.compose.runtime.* import ir.amirab.util.flow.DerivedStateFlow import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.flow.* @Composable fun MutableStateFlow.collectAsModifiableState(): MutableState { val upStream = this val state = remember(this) { mutableStateOf(upStream.value) } LaunchedEffect(this) { upStream.onEach { state.value = it }.launchIn(this) snapshotFlow { state.value } .onEach { each -> upStream.update { each } }.launchIn(this) } return state } fun MutableStateFlow.asMutableState( scope: CoroutineScope, ): MutableState { val upStream = this val state = mutableStateOf(upStream.value) upStream.onEach { state.value = it }.launchIn(scope) snapshotFlow { state.value } .onEach { each -> upStream.update { each } }.launchIn(scope) return state } fun StateFlow.asState( scope: CoroutineScope, ): State { val upStream = this val state = mutableStateOf(upStream.value) upStream.onEach { state.value = it }.launchIn(scope) return state } fun Flow.asState( scope: CoroutineScope, initialValue:T, ): MutableState { val upStream = this val state = mutableStateOf(initialValue) upStream.onEach { state.value = it }.launchIn(scope) return state } inline fun MutableState.asState(): State { return this as State } fun State.asStateFlow(): StateFlow { val getValue = { value } return DerivedStateFlow( getValue = getValue, flow = snapshotFlow(getValue) ) } ================================================ FILE: shared/app/src/commonMain/kotlin/com/abdownloadmanager/shared/util/StringUtils.kt ================================================ package com.abdownloadmanager.shared.util fun String.takeOrAppendDots(takeCount: Int): String { val take = take(takeCount) if (length<=takeCount){ return take }else{ return "$take…" } } ================================================ FILE: shared/app/src/commonMain/kotlin/com/abdownloadmanager/shared/util/SystemDownloadLocationProvider.kt ================================================ package com.abdownloadmanager.shared.util import java.io.File abstract class SystemDownloadLocationProvider { fun getDownloadLocation(): File { return runCatching { getCurrentDownloadLocation() } .onFailure { it.printStackTrace() } .getOrNull() ?: getCommonDownloadLocation() } /** * it should be a fixed path! * this meant to be used as fallback * - if the OS doesn't provide api to get download location dynamically * - or the [getCurrentDownloadLocation] fails for some reason * So, do your best to not throw exception here otherwise the [getDownloadLocation] will crash too! */ protected abstract fun getCommonDownloadLocation(): File protected abstract fun getCurrentDownloadLocation(): File? } ================================================ FILE: shared/app/src/commonMain/kotlin/com/abdownloadmanager/shared/util/TimeUtil.kt ================================================ package com.abdownloadmanager.shared.util import androidx.compose.runtime.* import com.abdownloadmanager.resources.Res import ir.amirab.util.compose.StringSource import ir.amirab.util.compose.asStringSourceWithARgs import kotlinx.coroutines.delay import kotlinx.coroutines.isActive import kotlinx.datetime.DateTimePeriod import kotlinx.datetime.LocalDateTime import kotlin.math.absoluteValue fun formatTime( value: Long, forceUseHour: Boolean = false ): String? { if (value < 0) { return null } //10_000_000 var remaining = value //10_000 val _SEC = 1000 val _MIN = 60 * _SEC val _HOUR = 60 * _MIN val hour = remaining / _HOUR remaining %= _HOUR val min = remaining / _MIN remaining %= _MIN val sec = remaining / _SEC val padded: (Long) -> String = { "$it".padStart(2, '0') } return buildString { if (hour == 0L || forceUseHour) { append(hour) append(":") } append(padded(min)) append(":") append(padded(sec)) } } fun prettifyRelativeTime( duration: DateTimePeriod, count: Int = 1, names: TimeNames = DefaultTimeNames, ): String { val years = duration.years.absoluteValue val months = duration.months.absoluteValue val days = duration.days.absoluteValue val hours = duration.hours.absoluteValue val minutes = duration.minutes.absoluteValue val seconds = duration.seconds.absoluteValue val isLater = arrayOf( years, months, days, hours, minutes, seconds, ).any { it < 0 } return prettifyRelativeTime( years = years, months = months, days = days, hours = hours, minutes = minutes, seconds = seconds, isLater = isLater, count = count, names = names ) } fun prettifyRelativeTime( years: Int = 0, months: Int = 0, days: Int = 0, hours: Int = 0, minutes: Int = 0, seconds: Int = 0, count: Int = 1, names: TimeNames = DefaultTimeNames, isLater: Boolean, ): String { val relativeTime = relativeTime( years = years, months = months, days = days, hours = hours, minutes = minutes, seconds = seconds, count = count, names = names, ) return (if (isLater) { names.left(relativeTime) } else { names.ago(relativeTime) }).getString() } private fun relativeTime( years: Int, months: Int, days: Int, hours: Int, minutes: Int, seconds: Int, count: Int = 6, names: TimeNames = DefaultTimeNames, ): String { require(count > 0) var used = 0 val relativeTime = buildString { if (years > 0) { used++ append(names.years(years).getString()) } if (used == count) return@buildString if (months > 0) { if (used > 0) { append(" ") } used++ append(names.months(months).getString()) } if (used == count) return@buildString if (days > 0) { if (used > 0) { append(" ") } used++ append(names.days(days).getString()) } if (used == count) return@buildString if (hours > 0) { if (used > 0) { append(" ") } used++ append(names.hours(hours).getString()) } if (used == count) return@buildString if (minutes > 0) { if (used > 0) { append(" ") } used++ append(names.minutes(minutes).getString()) } if (used == count) return@buildString if (seconds > 0) { if (used > 0) { append(" ") } used++ append(names.seconds(seconds).getString()) } if (used == count) return@buildString if (used == 0) { append(names.seconds(0).getString()) } } return relativeTime } @Composable fun rememberTimeFormatedValue(value: Long): String? { return remember(value) { formatTime(value) } } @Composable fun rememberTimeEllapsed(value: Long): Long { var result by remember { mutableStateOf(0L) } LaunchedEffect(value) { while (isActive) { result = if (value < 0) { -1 } else { System.currentTimeMillis() - value } delay(1_000) } } return result } fun convertTimeRemainingToHumanReadable( totalSecs: Long, timeNames: TimeNames = TimeNames.SimpleNames, ): String { val hours = totalSecs / 3600; val minutes = (totalSecs % 3600) / 60; val seconds = totalSecs % 60; return prettifyRelativeTime( hours = hours.toInt(), minutes = minutes.toInt(), seconds = seconds.toInt(), isLater = true, count = 3, names = timeNames, ) } @Stable interface TimeNames { fun years(years: Int): StringSource fun months(months: Int): StringSource fun days(days: Int): StringSource fun hours(hours: Int): StringSource fun minutes(minutes: Int): StringSource fun seconds(seconds: Int): StringSource fun ago(time: String): StringSource fun left(time: String): StringSource @Stable object SimpleNames : TimeNames { override fun years(years: Int): StringSource = Res.string.relative_time_long_years.asStringSourceWithARgs( Res.string.relative_time_long_years_createArgs(years = years.toString()) ) override fun months(months: Int): StringSource = Res.string.relative_time_long_months.asStringSourceWithARgs( Res.string.relative_time_long_months_createArgs(months = months.toString()) ) override fun days(days: Int): StringSource = Res.string.relative_time_long_days.asStringSourceWithARgs(Res.string.relative_time_long_days_createArgs(days = days.toString())) override fun hours(hours: Int): StringSource = Res.string.relative_time_long_hours.asStringSourceWithARgs( Res.string.relative_time_long_hours_createArgs(hours = hours.toString()) ) override fun minutes(minutes: Int): StringSource = Res.string.relative_time_long_minutes.asStringSourceWithARgs( Res.string.relative_time_long_minutes_createArgs(minutes = minutes.toString()) ) override fun seconds(seconds: Int): StringSource = Res.string.relative_time_long_seconds.asStringSourceWithARgs( Res.string.relative_time_long_seconds_createArgs(seconds = seconds.toString()) ) override fun left(time: String): StringSource = Res.string.relative_time_left.asStringSourceWithARgs(Res.string.relative_time_left_createArgs(time = time)) override fun ago(time: String): StringSource = Res.string.relative_time_ago.asStringSourceWithARgs(Res.string.relative_time_ago_createArgs(time = time)) } object ShortNames : TimeNames { override fun years(years: Int): StringSource = Res.string.relative_time_short_years.asStringSourceWithARgs( Res.string.relative_time_short_years_createArgs(years = years.toString()) ) override fun months(months: Int): StringSource = Res.string.relative_time_short_months.asStringSourceWithARgs( Res.string.relative_time_short_months_createArgs(months = months.toString()) ) override fun days(days: Int): StringSource = Res.string.relative_time_short_days.asStringSourceWithARgs( Res.string.relative_time_short_days_createArgs(days = days.toString()) ) override fun hours(hours: Int): StringSource = Res.string.relative_time_short_hours.asStringSourceWithARgs( Res.string.relative_time_short_hours_createArgs(hours = hours.toString()) ) override fun minutes(minutes: Int): StringSource = Res.string.relative_time_short_minutes.asStringSourceWithARgs( Res.string.relative_time_short_minutes_createArgs(minutes = minutes.toString()) ) override fun seconds(seconds: Int): StringSource = Res.string.relative_time_short_seconds.asStringSourceWithARgs( Res.string.relative_time_short_seconds_createArgs(seconds = seconds.toString()) ) override fun left(time: String): StringSource = Res.string.relative_time_left.asStringSourceWithARgs(Res.string.relative_time_left_createArgs(time = time)) override fun ago(time: String): StringSource = Res.string.relative_time_ago.asStringSourceWithARgs(Res.string.relative_time_ago_createArgs(time = time)) } } private val DefaultTimeNames = TimeNames.SimpleNames object MyDateAndTimeFormats { val fullDateTime = LocalDateTime.Format { year() chars("/") monthNumber() chars("/") day() chars(" ") hour() chars(":") minute() chars(":") second() } val fullDateTimeWithoutYearAndSeconds = LocalDateTime.Format { monthNumber() chars("/") day() chars(" ") hour() chars(":") minute() } } val LocalUseRelativeDateTime = staticCompositionLocalOf { true } ================================================ FILE: shared/app/src/commonMain/kotlin/com/abdownloadmanager/shared/util/UiConstants.kt ================================================ package com.abdownloadmanager.shared.util const val DOUBLE_CLICK_DELAY = 500L ================================================ FILE: shared/app/src/commonMain/kotlin/com/abdownloadmanager/shared/util/UserAgentProviderFromSettings.kt ================================================ package com.abdownloadmanager.shared.util import com.abdownloadmanager.shared.storage.BaseAppSettingsStorage import ir.amirab.downloader.connection.UserAgentProvider class UserAgentProviderFromSettings( private val appSettingsStorage: BaseAppSettingsStorage ) : UserAgentProvider { override fun getUserAgent(): String? { return appSettingsStorage.userAgent.value.takeIf { it.isNotBlank() } } } ================================================ FILE: shared/app/src/commonMain/kotlin/com/abdownloadmanager/shared/util/ValueUtils.kt ================================================ package com.abdownloadmanager.shared.util import androidx.compose.runtime.Composable import androidx.compose.runtime.collectAsState import com.arkivanov.decompose.router.slot.ChildSlot import com.arkivanov.decompose.value.Value import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow fun Value.subscribeAsStateFlow(): StateFlow { val stateFlow = MutableStateFlow(this.value) subscribe { stateFlow.value = it } return stateFlow } @Composable fun StateFlow>.rememberChild(): T? { return collectAsState().value.child?.instance } ================================================ FILE: shared/app/src/commonMain/kotlin/com/abdownloadmanager/shared/util/appinfo/PreviousVersion.kt ================================================ package com.abdownloadmanager.shared.util.appinfo import io.github.z4kn4fein.semver.Version import java.io.File class PreviousVersion( systemPath: File, private val currentVersion: Version, ) { private val versionFile = File(systemPath, ".version") private var previousVersion: Version? = null fun get(): Version? { return previousVersion } fun boot() { previousVersion = kotlin.runCatching { // maybe versionFile is null but we catch it val versionString = versionFile.readText() Version.parse(versionString) }.getOrNull() kotlin.runCatching { versionFile.parentFile.mkdirs() versionFile.writeText(currentVersion.toString()) }.onFailure { it.printStackTrace() } } } ================================================ FILE: shared/app/src/commonMain/kotlin/com/abdownloadmanager/shared/util/autoremove/RemovedDownloadsFromDiskTracker.kt ================================================ package com.abdownloadmanager.shared.util.autoremove import com.abdownloadmanager.shared.util.DownloadSystem import io.github.irgaly.kfswatch.KfsDirectoryWatcher import io.github.irgaly.kfswatch.KfsEvent import ir.amirab.downloader.downloaditem.contexts.CanPerformRemove import ir.amirab.downloader.downloaditem.contexts.RemovedBy import ir.amirab.downloader.monitor.* import ir.amirab.util.flow.withPrevious import ir.amirab.util.ifThen import ir.amirab.util.osfileutil.FileUtils import ir.amirab.util.platform.Platform import ir.amirab.util.platform.isWindows import kotlinx.coroutines.* import kotlinx.coroutines.flow.* import java.io.File class RemovedDownloadsFromDiskTracker( private val downloadMonitor: IDownloadMonitor, private val scope: CoroutineScope, private val downloadSystem: DownloadSystem, ) { private fun createWatcher( scope: CoroutineScope, ): KfsDirectoryWatcher { return KfsDirectoryWatcher( scope = scope, ) } @Volatile private var stopped = true //download item ids that should be checked for existence after a delay private var itemsToCheck = MutableStateFlow(emptySet()) private var activeJob: Job? = null fun start() { stopped = false // it seems that if we watch a folder in a removable storage // then it can't be ejected! so we have to ignore it // we know that it happens in Windows! val shouldFilterRemovableStorages = Platform.isWindows() activeJob = scope.launch { val watcher = createWatcher(this) watcher .onEventFlow .filter { it.event == KfsEvent.Delete } .onEach { val fullPath = File(it.targetDirectory, it.path).path onPathRemoved(fullPath) } .launchIn(this) downloadMonitor.downloadListFlow .map { it.map { it.folder }.distinct() } .distinctUntilChanged() .changes() .onEach { changes -> val groups = changes .groupBy { it.second } groups[Change.Removed] ?.takeIf { it.isNotEmpty() } ?.map { it.first } ?.toTypedArray() ?.let { watcher.remove(*it) } groups[Change.Added] ?.takeIf { it.isNotEmpty() } ?.map { it.first } ?.ifThen(shouldFilterRemovableStorages) { filter { !FileUtils.isRemovableStorage(it) } } ?.toTypedArray() ?.let { watcher.add(*it) } } .launchIn(this) itemsToCheck .debounce(500) .filter { it.isNotEmpty() } .onEach { downloadItems -> checkAndRemoveThisItems(downloadItems) itemsToCheck.update { it.subtract(downloadItems) } }.launchIn(this) } } suspend fun stop() { activeJob?.cancelAndJoin() activeJob = null itemsToCheck.update { emptySet() } stopped = true } suspend fun removeDownloadsThatFilesAreMissing() { checkAndRemoveThisItems( downloadSystem.getListOfDownloadThatMissingFileOrHaveNotProgress() .map { it.id } .toSet() ) } private suspend fun checkAndRemoveThisItems(ids: Set) { for (id in ids) { val downloadItem = downloadSystem.getDownloadItemById(id) ?: continue val file = downloadSystem.getDownloadFile(downloadItem) if (!file.exists()) { downloadSystem.removeDownload( id = downloadItem.id, alsoRemoveFile = false, // it is already deleted! context = RemovedBy(AutoRemoveOption) ) } } } /** * find the corespounding download and schedule for remove that * I will add a delay for that (maybe it's a temporary file remove for example when renaming download item) */ private fun onPathRemoved(path: String) { if (stopped) return val item = downloadSystem.getDownloadItemByPath(path) ?: return itemsToCheck.update { it.plus(item.id) } } } private sealed interface Change { data object Added : Change data object Removed : Change data object NotChange : Change } private fun Flow>.changes(): Flow>> { return withPrevious { previous, current -> if (previous == null) { current.map { it to Change.Added } } else { diffOf(previous, current) } } } private fun diffOf( a: Collection, b: Collection, ): List> { val output = ArrayList>(maxOf(a.size, b.size)) val aSet = a.toSet() val remainingBItems = b.toMutableSet() // find removed items in b for (i in aSet) { if (i in remainingBItems) { output.add(i to Change.NotChange) remainingBItems.remove(i) } else { output.add(i to Change.Removed) } } // remaining b's are added! output.addAll(remainingBItems.map { it to Change.Added }) return output } data object AutoRemoveOption : CanPerformRemove ================================================ FILE: shared/app/src/commonMain/kotlin/com/abdownloadmanager/shared/util/category/Category.kt ================================================ package com.abdownloadmanager.shared.util.category import androidx.compose.runtime.Composable import androidx.compose.runtime.Immutable import androidx.compose.runtime.remember import ir.amirab.util.compose.IIconResolver import ir.amirab.util.compose.IconSource import ir.amirab.util.compose.LocalIconFromUriResolver import ir.amirab.util.wildcardMatch import kotlinx.serialization.SerialName import kotlinx.serialization.Serializable /** * @param path * this is a default download path for this category * @param icon * can be used by [IconSource] */ @Immutable @Serializable data class Category( @SerialName("id") val id: Long, @SerialName("name") val name: String, @SerialName("icon") val icon: String, @SerialName("path") // don't directly use this check for usePath first! see [getDownloadPath()] val path: String, @SerialName("usePath") val usePath: Boolean = true, @SerialName("acceptedFileTypes") val acceptedFileTypes: List = emptyList(), // this is optional if nothing provided it means that every url is acceptable @SerialName("acceptedUrlPatterns") val acceptedUrlPatterns: List = emptyList(), @SerialName("items") val items: List = emptyList(), ) { val hasFileTypes = acceptedFileTypes.isNotEmpty() val hasUrlPattern = acceptedUrlPatterns.isNotEmpty() private val filterCount = run { var count = 0 if (hasFileTypes) count++ if (hasUrlPattern) count++ count } val hasFilters = filterCount > 0 fun acceptFileName(fileName: String): Boolean { if (!hasFileTypes) { return true } return acceptedFileTypes.any { ext -> fileName.endsWith( suffix = ".$ext", ignoreCase = true ) } } fun withExtraItems(newItems: List): Category { return copy( items = items.plus(newItems).distinct() ) } fun getDownloadPath(): String? { return if (usePath) path else null } fun acceptUrl(url: String): Boolean { if (!hasUrlPattern) { return true } return acceptedUrlPatterns.any { wildcardMatch( pattern = it, input = url ) } } } fun Category.iconSource( iconResolver: IIconResolver, ): IconSource? { return iconResolver.resolve(icon) } @Composable fun Category.rememberIconPainter(): IconSource? { val iconResolver = LocalIconFromUriResolver.current return remember(icon) { iconResolver.resolve(icon) } } ================================================ FILE: shared/app/src/commonMain/kotlin/com/abdownloadmanager/shared/util/category/CategoryFileStorage.kt ================================================ package com.abdownloadmanager.shared.util.category import ir.amirab.downloader.db.TransactionalFileSaver import kotlinx.coroutines.sync.Mutex import kotlinx.coroutines.sync.withLock import java.io.File class CategoryFileStorage( val file: File, val fileSaver: TransactionalFileSaver, ) : CategoryStorage { val lock = Mutex() override suspend fun setCategories(categories: List) { lock.withLock { fileSaver.writeObject(file, categories) } } override suspend fun getCategories(): List { return fileSaver.readObject(file) ?: emptyList() } override suspend fun isCategoriesSet(): Boolean { return file.exists() } } ================================================ FILE: shared/app/src/commonMain/kotlin/com/abdownloadmanager/shared/util/category/CategoryItem.kt ================================================ package com.abdownloadmanager.shared.util.category import androidx.compose.runtime.Immutable interface ICategoryItem { val fileName: String val url: String } @Immutable data class CategoryItem( override val fileName: String, override val url: String, ) : ICategoryItem @Immutable data class CategoryItemWithId( val id: Long, override val fileName: String, override val url: String, ) : ICategoryItem ================================================ FILE: shared/app/src/commonMain/kotlin/com/abdownloadmanager/shared/util/category/CategoryManager.kt ================================================ package com.abdownloadmanager.shared.util.category import ir.amirab.util.ifThen import ir.amirab.util.shifted import ir.amirab.util.suspendGuardedEntry import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.FlowPreview import kotlinx.coroutines.flow.* import kotlinx.coroutines.withContext import java.io.File class CategoryManager( private val categoryStorage: CategoryStorage, private val scope: CoroutineScope, private val defaultCategoriesFactory: DefaultCategories, private val categoryItemProvider: ICategoryItemProvider, ) { private val _categories = MutableStateFlow>(emptyList()) val categoriesFlow = _categories.asStateFlow() private var booted = suspendGuardedEntry() @OptIn(FlowPreview::class) suspend fun boot() { booted.action { if (categoryStorage.isCategoriesSet()) { _categories.value = categoryStorage .getCategories() } else { reset() } _categories .sample(500) .onEach { categoryStorage.setCategories(it) } .launchIn(scope) } } suspend fun reset() { val newCategories = defaultCategoriesFactory.getDefaultCategories() setCategories(newCategories) withContext(Dispatchers.IO) { newCategories.forEach { prepareCategory(it) } autoAddItemsToCategoriesBasedOnFileNames( categoryItemProvider .getAll() ) } } fun getCategories(): List { return _categories.value } fun setCategories(categories: List) { _categories.update { categories } } fun getCategoryById(id: Long): Category? { return getCategories() .firstOrNull { it.id == id } } fun getCategoryOfType(extension: String): Category? { return getCategories().firstOrNull { c -> c.acceptedFileTypes.any { it.equals(extension, true) } } } fun getCategoryOfFileName(fileName: String): Category? { return getCategories() .firstOrNull { it.acceptFileName(fileName) } } fun getCategoryOf(categoryItem: ICategoryItem): Category? { val url = categoryItem.url val fileName = categoryItem.fileName return getCategories() .filter { it.hasFilters } .filter { it.acceptFileName(fileName) && it.acceptUrl(url) } .sortedWith( // first prioritize categories which has url pattern filters compareByDescending { it.hasUrlPattern } // then prioritize categories with fewer url patterns count .thenBy { it.acceptedUrlPatterns.size } // second prioritize categories which has file types .thenByDescending { it.hasFileTypes } // then prioritize categories with fewer file type count .thenBy { it.acceptedFileTypes.size } ) .firstOrNull() } fun getCategoryOfItem(id: Long): Category? { return getCategories() .firstOrNull { it.items.contains(id) } } fun deleteCategory(category: Category) { deleteCategory(category.id) } fun deleteCategory(categoryId: Long) { _categories.update { it.filter { it.id != categoryId } } } fun addCustomCategory(category: Category) { require(category.id == -1L) val categories = getCategories() val newId = ( categories .maxOfOrNull { it.id } ?.coerceAtLeast(DEFAULT_CATEGORY_END_ID) ?: DEFAULT_CATEGORY_END_ID ) + 1 val newCategory = category.copy( id = newId ) setCategories( categories.plus( newCategory ) ) prepareCategory(newCategory) } private fun createDirectoryIfNecessary(category: Category) { kotlin.runCatching { val folder = category .getDownloadPath() ?.let(::File) ?.canonicalFile ?: return if (!folder.exists()) { folder.mkdirs() } } } private fun prepareCategory(newCategory: Category) { createDirectoryIfNecessary(newCategory) } fun updateCategory(categoryToUpdate: Category) { _categories.update { it.updatedItem( categoryId = categoryToUpdate.id, update = { categoryToUpdate } ) } } fun updateCategory(id: Long, categoryToUpdate: (Category) -> Category) { _categories.update { it.updatedItem(id, categoryToUpdate) } } fun addItemsToCategory(categoryId: Long, itemIds: List) { _categories.update { previousCategories -> previousCategories .removedItemIds(itemIds) .updatedItem(categoryId) { it.withExtraItems(itemIds) } } } fun removeItemInCategories(idsToRemove: List) { _categories.update { it.removedItemIds(idsToRemove) } } fun isDefaultCategory(category: Category): Boolean { return category.id in 0..DEFAULT_CATEGORY_END_ID } fun autoAddItemsToCategoriesBasedOnFileNames( unCategorizedItems: List, ) { val newItemsMap = mutableMapOf>() var count = 0 for (item in unCategorizedItems) { val categoryToUpdate = getCategoryOf(item) ?: continue newItemsMap .getOrPut(categoryToUpdate.id) { mutableListOf() } .add(item.id) count++ } for ((categoryId, itemsToAdd) in newItemsMap) { updateCategory(categoryId) { it.withExtraItems(itemsToAdd) } } } fun isThisPathBelongsToACategory(folder: String): Boolean { return getCategories() .mapNotNull { it.getDownloadPath() }.contains(folder) } @Suppress("NAME_SHADOWING") fun updateCategoryFoldersBasedOnDefaultDownloadFolder( previousDownloadFolder: String, currentDownloadFolder: String, ) { val previousDownloadFolder = File(previousDownloadFolder).absoluteFile val currentDownloadFolder = File(currentDownloadFolder).absoluteFile for (category in getCategories()) { val categoryPath = File(category.path).absoluteFile if (categoryPath.startsWith(previousDownloadFolder)) { val relativePath = categoryPath.relativeTo(previousDownloadFolder) updateCategory(category.id) { it.copy( path = currentDownloadFolder.resolve(relativePath).absolutePath ) } } } } fun reorderCategory( index: Int, delta: Int, ) { _categories.update { categories -> categories.ifThen(index in categories.indices && (index + delta) in categories.indices) { shifted(index, delta) } } } companion object { /** * Reserved ids for default categories * this is too big BTW as we only use 5 for now * maybe we need more or extra hidden categories that users can enable (maybe ?) */ const val DEFAULT_CATEGORY_END_ID = 100L } } private fun List.removedItemIds(itemIds: List): List { return map { it.copy( items = it.items.filter { itemId -> itemId !in itemIds } ) } } private inline fun List.updatedItem(categoryId: Long, update: (Category) -> Category): List { return map { if (it.id == categoryId) { update(it) } else it } } ================================================ FILE: shared/app/src/commonMain/kotlin/com/abdownloadmanager/shared/util/category/CategoryManagerExtensions.kt ================================================ package com.abdownloadmanager.shared.util.category import androidx.compose.runtime.Composable import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue import androidx.compose.runtime.remember @Composable fun CategoryManager.rememberCategoryOf( itemId: Long, ): Category? { val categories by categoriesFlow.collectAsState() return remember(itemId, categories) { categories.firstOrNull { it.items.contains(itemId) } } } ================================================ FILE: shared/app/src/commonMain/kotlin/com/abdownloadmanager/shared/util/category/CategorySelectionMode.kt ================================================ package com.abdownloadmanager.shared.util.category import androidx.compose.runtime.Immutable @Immutable sealed interface CategorySelectionMode { data class Fixed(val categoryId: Long) : CategorySelectionMode data object Auto : CategorySelectionMode } ================================================ FILE: shared/app/src/commonMain/kotlin/com/abdownloadmanager/shared/util/category/CategoryStorage.kt ================================================ package com.abdownloadmanager.shared.util.category interface CategoryStorage { suspend fun setCategories(categories: List) suspend fun getCategories(): List suspend fun isCategoriesSet(): Boolean } ================================================ FILE: shared/app/src/commonMain/kotlin/com/abdownloadmanager/shared/util/category/DefaultCategories.kt ================================================ package com.abdownloadmanager.shared.util.category import com.abdownloadmanager.shared.util.ui.IMyIcons import ir.amirab.util.compose.IconSource import java.io.File class DefaultCategories( private val icons: IMyIcons, private val getDefaultDownloadFolder: () -> String, ) { fun getCategoryOfFileName(name: String): Category? { return getDefaultCategories() .firstOrNull { it.acceptFileName(name) } } fun getDefaultCategories(): List { fun IconSource.toUri(): String { return requireNotNull(uri) { "It seems that we use an icon that does not have uri" } } fun relative(path: String): String { return File(getDefaultDownloadFolder(), path).path } val compressed = Category( id = 0, name = "Compressed", path = relative("Compressed"), icon = icons.zipFile.toUri(), acceptedFileTypes = listOf( "zip", "rar", "7z", "tar", "gz", "bz2", "xz", "iso", "dmg", "tgz", ), ) val programs = Category( id = 1, name = "Programs", path = relative("Programs"), icon = icons.applicationFile.toUri(), acceptedFileTypes = listOf( "apk", "exe", "msi", "bat", "sh", "jar", "app", "deb", "rpm", "bin", ), ) val videos = Category( id = 2, name = "Videos", path = relative("Videos"), icon = icons.videoFile.toUri(), acceptedFileTypes = listOf( "mp4", "avi", "mkv", "mov", "wmv", "flv", "webm", "m4v", "3gp", "mpeg", "ts", ), ) val music = Category( id = 3, name = "Music", path = relative("Music"), icon = icons.musicFile.toUri(), acceptedFileTypes = listOf( "mp3", "wav", "aac", "flac", "ogg", "aiff", "wma", "m4a", ), ) val pictures = Category( id = 4, name = "Pictures", path = relative("Pictures"), icon = icons.pictureFile.toUri(), acceptedFileTypes = listOf( "jpg", "jpeg", "png", "gif", "bmp", "tiff", "tif", "svg", "webp", "heic", "ico", "raw", "psd", ), ) val documents = Category( id = 5, name = "Documents", path = relative("Documents"), icon = icons.documentFile.toUri(), acceptedFileTypes = listOf( "doc", "docx", "pdf", "txt", "rtf", "odt", "xls", "xlsx", "ppt", "pptx", "csv", "epub", "pages", ), ) return listOf( compressed, programs, videos, music, pictures, documents, ) } fun isDefault(categories: List): Boolean { return getDefaultCategories() == categories.map { it.copy(items = emptyList()) } } } ================================================ FILE: shared/app/src/commonMain/kotlin/com/abdownloadmanager/shared/util/category/DownloadManagerCategoryItemProvider.kt ================================================ package com.abdownloadmanager.shared.util.category import ir.amirab.downloader.DownloadManager class DownloadManagerCategoryItemProvider( private val dowManager: DownloadManager, ) : ICategoryItemProvider { override suspend fun getAll(): List { return dowManager.getDownloadList().map { CategoryItemWithId( id = it.id, fileName = it.name, url = it.link ) } } } ================================================ FILE: shared/app/src/commonMain/kotlin/com/abdownloadmanager/shared/util/category/ICategoryItemProvider.kt ================================================ package com.abdownloadmanager.shared.util.category interface ICategoryItemProvider { suspend fun getAll(): List } ================================================ FILE: shared/app/src/commonMain/kotlin/com/abdownloadmanager/shared/util/category/InMemoryCategoryStorage.kt ================================================ package com.abdownloadmanager.shared.util.category class InMemoryCategoryStorage : CategoryStorage { private var categories = emptyList() override suspend fun setCategories(categories: List) { this.categories = categories } override suspend fun getCategories(): List { return categories } override suspend fun isCategoriesSet(): Boolean { return true } } ================================================ FILE: shared/app/src/commonMain/kotlin/com/abdownloadmanager/shared/util/downloadlocation/PlatformDownloadLocationProvider.kt ================================================ package com.abdownloadmanager.shared.util.downloadlocation import com.abdownloadmanager.shared.util.SystemDownloadLocationProvider object PlatformDownloadLocationProvider { val instance: SystemDownloadLocationProvider by lazy { getPlatformDownloadLocationProvider() } } expect fun getPlatformDownloadLocationProvider(): SystemDownloadLocationProvider ================================================ FILE: shared/app/src/commonMain/kotlin/com/abdownloadmanager/shared/util/extractors/Extractor.kt ================================================ package com.abdownloadmanager.shared.util.extractors interface Extractor{ fun extract(input:Input):Output } ================================================ FILE: shared/app/src/commonMain/kotlin/com/abdownloadmanager/shared/util/extractors/linkextractor/DownloadCredentialExtractor.kt ================================================ package com.abdownloadmanager.shared.util.extractors.linkextractor import com.abdownloadmanager.shared.downloaderinui.DownloaderInUiRegistry import com.abdownloadmanager.shared.util.extractors.Extractor import ir.amirab.downloader.downloaditem.IDownloadCredentials import org.koin.core.component.KoinComponent import org.koin.core.component.inject interface DownloadCredentialExtractor : Extractor> { override fun extract(input: T): List } object DownloadCredentialFromStringExtractor : DownloadCredentialExtractor, KoinComponent { val downloaderInUiRegistry: DownloaderInUiRegistry by inject() override fun extract(input: String): List { return StringUrlExtractor.extract(input) .mapNotNull { downloaderInUiRegistry .bestMatchForThisLink(it) ?.createMinimumCredentials(it) } } } ================================================ FILE: shared/app/src/commonMain/kotlin/com/abdownloadmanager/shared/util/extractors/linkextractor/DownloadCredentialsFromCurl.kt ================================================ package com.abdownloadmanager.shared.util.extractors.linkextractor import ir.amirab.downloader.downloaditem.http.HttpDownloadCredentials object DownloadCredentialsFromCurl : DownloadCredentialExtractor { override fun extract(input: String): List { val curlCommands = input.split("\n").filter { it.trim().startsWith("curl") } return curlCommands.map { command -> val urlRegex = """curl\s+"([^"]+)"""".toRegex() val headerRegex = """-H\s+"([^"]+)"""".toRegex() val urlMatch = urlRegex.find(command) val headerMatches = headerRegex.findAll(command) val url = urlMatch?.groupValues?.get(1) ?: "" val headers = headerMatches.mapNotNull { match -> val header = match.groupValues[1] val (key, value) = header.split(":", limit = 2) key.trim() to value.trim() }.toMap() val usernamePasswordRegex = """(?:-u|--user)\s+([^:]+):(.*)""".toRegex() val usernamePasswordMatch = usernamePasswordRegex.find(command) val username = usernamePasswordMatch?.groupValues?.get(1)?.trim() val password = usernamePasswordMatch?.groupValues?.get(2)?.trim() HttpDownloadCredentials( link = url, headers = headers, username = username, password = password ) } } fun generateCurlCommands(credentialsList: List): List { return credentialsList.map { credentials -> val curlCommand = StringBuilder("curl \"${credentials.link}\"") credentials.headers?.forEach { (headerName, headerValue) -> curlCommand.append(" -H \"${headerName}: ${headerValue}\"") } if (credentials.username != null) { if (credentials.password != null) { curlCommand.append(" -u \"${credentials.username}:${credentials.password}\"") } else { curlCommand.append(" -u \"${credentials.username}\"") } } curlCommand.toString() } } } ================================================ FILE: shared/app/src/commonMain/kotlin/com/abdownloadmanager/shared/util/extractors/linkextractor/URLExtractors.kt ================================================ package com.abdownloadmanager.shared.util.extractors.linkextractor import com.abdownloadmanager.shared.util.extractors.Extractor import ir.amirab.util.HttpUrlUtils object StringUrlExtractor : Extractor> { private val urlRegex by lazy { Regex("""\b(https?|ftp|file)://[-a-zA-Z0-9+&@#/%?=~_|!:,.;]*[-a-zA-Z0-9+&@#/%=~_|]""") } override fun extract(input: String): List { // maybe each line is a link val linksInEachLines = byEachLine(input) if (linksInEachLines.isNotEmpty()) { return linksInEachLines } // try to find links by regex return byRegex(input) } private fun byEachLine(input: String): List { return input .lineSequence() .map { it.trim() } .filter { HttpUrlUtils.isValidUrl(it) } .toList() } private fun byRegex(input: String): List { return urlRegex.findAll(input) .map { it.value } .filter { HttpUrlUtils.isValidUrl(it) } .toList() } } ================================================ FILE: shared/app/src/commonMain/kotlin/com/abdownloadmanager/shared/util/mvi/ContainsEffects.kt ================================================ package com.abdownloadmanager.shared.util.mvi import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import kotlinx.coroutines.channels.BufferOverflow import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.SharedFlow import kotlinx.coroutines.flow.launchIn import kotlinx.coroutines.flow.onEach interface ContainsEffects { val effects: SharedFlow fun sendEffect(effect:Effect) } private class SupportsEffects: ContainsEffects { private val _effects = MutableSharedFlow( extraBufferCapacity = 64, onBufferOverflow = BufferOverflow.DROP_OLDEST ) override val effects: SharedFlow = _effects override fun sendEffect(effect:Effect){ _effects.tryEmit(effect) } } fun supportEffects(): ContainsEffects = SupportsEffects() @Composable fun HandleEffects( effectContainer: ContainsEffects, handle:(T)->Unit ){ LaunchedEffect(effectContainer){ effectContainer.effects.onEach { handle(it) }.launchIn(this) } } ================================================ FILE: shared/app/src/commonMain/kotlin/com/abdownloadmanager/shared/util/mvi/ContainsScreenState.kt ================================================ package com.abdownloadmanager.shared.util.mvi import kotlinx.coroutines.channels.BufferOverflow import kotlinx.coroutines.flow.* interface ContainsScreenState { val state: StateFlow fun setState(state: ScreenState) fun setState(updater: (ScreenState) -> ScreenState) } class SupportsScreenState( initialState: ScreenState ) : ContainsScreenState { private val _state = MutableStateFlow(initialState) override val state = _state.asStateFlow() override fun setState(updater: (ScreenState) -> ScreenState) { _state.update(updater) } override fun setState(state: ScreenState) { setState { state } } } ================================================ FILE: shared/app/src/commonMain/kotlin/com/abdownloadmanager/shared/util/mvi/EventHandler.kt ================================================ package com.abdownloadmanager.shared.util.mvi import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.channels.BufferOverflow import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.launchIn import kotlinx.coroutines.flow.onEach interface SupportsEvents : IEventHandlerAndReceiver { fun getEventHandler(): IEventHandlerAndReceiver override fun sendEvent(event: Event) { getEventHandler().sendEvent(event) } } fun eventHandler( scope: CoroutineScope, handler: suspend (handle: Event) -> Unit, ) = object : EventHandler(scope) { override suspend fun handleEvent(event: Event) { handler(event) } } interface IEventReceiver { fun sendEvent(event: Event) } interface IEventHandler{ suspend fun handleEvent(event: Event) } interface IEventHandlerAndReceiver : IEventReceiver, IEventHandler abstract class EventHandler( private val scope: CoroutineScope, ) : IEventHandlerAndReceiver { private val _eventsFlow = MutableSharedFlow( extraBufferCapacity = 64, onBufferOverflow = BufferOverflow.DROP_OLDEST ) init { _eventsFlow.onEach { handleEvent(it) }.launchIn(scope) } override fun sendEvent(event: Event) { _eventsFlow.tryEmit(event) } } ================================================ FILE: shared/app/src/commonMain/kotlin/com/abdownloadmanager/shared/util/ondownloadcompletion/OnDownloadCompletionAction.kt ================================================ package com.abdownloadmanager.shared.util.ondownloadcompletion import ir.amirab.downloader.downloaditem.IDownloadItem interface OnDownloadCompletionAction { suspend fun onDownloadCompleted(downloadItem: IDownloadItem) } ================================================ FILE: shared/app/src/commonMain/kotlin/com/abdownloadmanager/shared/util/ondownloadcompletion/OnDownloadCompletionActionProvider.kt ================================================ package com.abdownloadmanager.shared.util.ondownloadcompletion import ir.amirab.downloader.downloaditem.IDownloadItem interface OnDownloadCompletionActionProvider { suspend fun getOnDownloadCompletionAction(downloadItem: IDownloadItem): List } class NoOpOnDownloadCompletionActionProvider : OnDownloadCompletionActionProvider { override suspend fun getOnDownloadCompletionAction(downloadItem: IDownloadItem): List { return emptyList() } } ================================================ FILE: shared/app/src/commonMain/kotlin/com/abdownloadmanager/shared/util/ondownloadcompletion/OnDownloadCompletionActionRunner.kt ================================================ package com.abdownloadmanager.shared.util.ondownloadcompletion import ir.amirab.downloader.DownloadManagerEvents import ir.amirab.downloader.DownloadManagerMinimalControl import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Job import kotlinx.coroutines.cancel import kotlinx.coroutines.flow.filterIsInstance import kotlinx.coroutines.flow.launchIn import kotlinx.coroutines.flow.onEach class OnDownloadCompletionActionRunner( private val downloadManagerMinimalControl: DownloadManagerMinimalControl, private val scope: CoroutineScope, private val onDownloadCompletionActionProvider: OnDownloadCompletionActionProvider, ) { private var job: Job? = null /** * Starts listening to download completion events and executes the corresponding actions. */ @Synchronized fun startListening() { job?.cancel() job = downloadManagerMinimalControl.listOfJobsEvents .filterIsInstance() .onEach { val downloadItem = it.downloadItem onDownloadCompletionActionProvider .getOnDownloadCompletionAction(it.downloadItem) .forEach { completionAction -> runCatching { completionAction.onDownloadCompleted(downloadItem) }.onFailure { e -> e.printStackTrace() } } } .launchIn(scope) } fun stopListening() { job?.cancel() job = null } } ================================================ FILE: shared/app/src/commonMain/kotlin/com/abdownloadmanager/shared/util/onqueuecompletion/OnQueueCompletionActionProvider.kt ================================================ package com.abdownloadmanager.shared.util.onqueuecompletion interface OnQueueCompletionActionProvider { suspend fun getOnQueueEventActions(queueId: Long): List } class NoopOnQueueCompletionActionProvider : OnQueueCompletionActionProvider { override suspend fun getOnQueueEventActions(queueId: Long): List { return emptyList() } } ================================================ FILE: shared/app/src/commonMain/kotlin/com/abdownloadmanager/shared/util/onqueuecompletion/OnQueueEventAction.kt ================================================ package com.abdownloadmanager.shared.util.onqueuecompletion interface OnQueueEventAction { suspend fun onQueueCompleted(queueId: Long) suspend fun onQueueEndTimeReached(queueId: Long) } ================================================ FILE: shared/app/src/commonMain/kotlin/com/abdownloadmanager/shared/util/onqueuecompletion/OnQueueEventActionRunner.kt ================================================ package com.abdownloadmanager.shared.util.onqueuecompletion import ir.amirab.downloader.queue.QueueEvent import ir.amirab.downloader.queue.QueueManager import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Job import kotlinx.coroutines.flow.launchIn import kotlinx.coroutines.flow.onEach class OnQueueEventActionRunner( private val queueManager: QueueManager, private val scope: CoroutineScope, private val onQueueCompletionActionProvider: OnQueueCompletionActionProvider, ) { private var job: Job? = null /** * Starts listening to queue events and executes the corresponding actions. */ @Synchronized fun startListening() { job?.cancel() job = queueManager.queueEvents .onEach { when (it) { is QueueEvent.OnQueueBecomesEmpty -> { val actions = onQueueCompletionActionProvider.getOnQueueEventActions(it.queueId) actions.forEach { action -> action.onQueueCompleted(it.queueId) } } is QueueEvent.QueueEndTimeReached -> { if (!it.wasActive) { return@onEach } val actions = onQueueCompletionActionProvider.getOnQueueEventActions(it.queueId) actions.forEach { action -> action.onQueueEndTimeReached(it.queueId) } } is QueueEvent.OnQueueStartTimeReached -> { // nothing } } } .launchIn(scope) } fun stopListening() { job?.cancel() job = null } } ================================================ FILE: shared/app/src/commonMain/kotlin/com/abdownloadmanager/shared/util/perhostsettings/PerHostSettingsItem.kt ================================================ package com.abdownloadmanager.shared.util.perhostsettings import kotlinx.serialization.Serializable @Serializable data class PerHostSettingsItem( val host: String, val username: String? = null, val password: String? = null, val userAgent: String? = null, val threadCount: Int? = null, val speedLimit: Long? = null, ) ================================================ FILE: shared/app/src/commonMain/kotlin/com/abdownloadmanager/shared/util/perhostsettings/PerHostSettingsManager.kt ================================================ package com.abdownloadmanager.shared.util.perhostsettings import ir.amirab.util.wildcardMatch import kotlinx.coroutines.flow.update import okhttp3.HttpUrl.Companion.toHttpUrlOrNull class PerHostSettingsManager( private val storage: IPerHostSettingsStorage ) { fun getStorageData(): List { return storage.perHostSettingsFlow.value } fun getSettingsForHost(host: String): PerHostSettingsItem? { return getStorageData() // hosts that doesn't have wildcards should be checked first .sortedBy { it.host.count { char -> char == '*' } } .firstOrNull { wildcardMatch(it.host, host) } } fun setSettingsData(hostSettingsList: List) { storage.perHostSettingsFlow.update { hostSettingsList .filter { it.host.isNotBlank() } .distinctBy { it.host } } } } fun PerHostSettingsManager.getSettingsForURL(url: String): PerHostSettingsItem? { return url .toHttpUrlOrNull() ?.host ?.let(::getSettingsForHost) } ================================================ FILE: shared/app/src/commonMain/kotlin/com/abdownloadmanager/shared/util/perhostsettings/PerHostSettingsStorage.kt ================================================ package com.abdownloadmanager.shared.util.perhostsettings import kotlinx.coroutines.flow.MutableStateFlow interface IPerHostSettingsStorage { val perHostSettingsFlow: MutableStateFlow> } ================================================ FILE: shared/app/src/commonMain/kotlin/com/abdownloadmanager/shared/util/proxy/IProxyStorage.kt ================================================ package com.abdownloadmanager.shared.util.proxy import kotlinx.coroutines.flow.MutableStateFlow interface IProxyStorage { val proxyDataFlow: MutableStateFlow } ================================================ FILE: shared/app/src/commonMain/kotlin/com/abdownloadmanager/shared/util/proxy/Proxy.kt ================================================ package com.abdownloadmanager.shared.util.proxy import ir.amirab.downloader.connection.proxy.Proxy import ir.amirab.util.platform.Platform import ir.amirab.util.platform.isAndroid import kotlinx.serialization.SerialName import kotlinx.serialization.Serializable @Serializable data class ProxyRules( val excludeURLPatterns: List, ) @Serializable data class ProxyWithRules( val proxy: Proxy, val rules: ProxyRules, ) @Serializable data class PACProxy( val uri: String,// an uri to get script path of the PAC ) { companion object { fun default() = PACProxy("http://localhost/some.pac") } } enum class ProxyMode { @SerialName("direct") Direct, @SerialName("system") UseSystem, @SerialName("manual") Manual, @SerialName("pac") Pac; companion object { fun usableValues(): List { return if (Platform.isAndroid()) { listOf( Direct, Manual, ) } else { listOf( Direct, UseSystem, Pac, Manual, ) } } } } // for persisting in storage @Serializable data class ProxyData( val proxyMode: ProxyMode, //manual proxy config val proxyWithRules: ProxyWithRules, //configuration script config val pac: PACProxy, ) { companion object { fun default() = ProxyData( proxyMode = ProxyMode.Direct, proxyWithRules = ProxyWithRules( proxy = Proxy.default(), rules = ProxyRules(emptyList()) ), pac = PACProxy.default() ) } } ================================================ FILE: shared/app/src/commonMain/kotlin/com/abdownloadmanager/shared/util/proxy/ProxyManager.kt ================================================ package com.abdownloadmanager.shared.util.proxy import ir.amirab.downloader.connection.proxy.Proxy import ir.amirab.downloader.connection.proxy.ProxyStrategy import ir.amirab.downloader.connection.proxy.ProxyStrategyProvider import ir.amirab.downloader.connection.proxy.ProxyType import ir.amirab.util.HttpUrlUtils import ir.amirab.util.wildcardMatch import java.net.Authenticator import java.net.PasswordAuthentication class ProxyManager( val storage: IProxyStorage, ) : ProxyStrategyProvider { val proxyData = storage.proxyDataFlow init { val mySocksProxyAuthenticator = MySocksProxyAuthenticator { proxyData.value.proxyWithRules.proxy } Authenticator.setDefault(mySocksProxyAuthenticator) } /** * I don't like this it's better to improve this later */ private fun getProxyModeForThisURL(url: String): ProxyStrategy { val usingProxy = proxyData.value return when (usingProxy.proxyMode) { ProxyMode.Direct -> ProxyStrategy.Direct ProxyMode.UseSystem -> ProxyStrategy.UseSystem ProxyMode.Manual -> { val proxyWithRules = usingProxy.proxyWithRules if (shouldUseProxyFor(url, proxyWithRules.rules)) { ProxyStrategy.ManualProxy(proxyWithRules.proxy) } else { ProxyStrategy.Direct } } ProxyMode.Pac -> { val pacURI = usingProxy.pac.uri if (HttpUrlUtils.isValidUrl(pacURI)) { ProxyStrategy.ByScript(pacURI) } else { ProxyStrategy.Direct } } } } private fun shouldUseProxyFor( url: String, rules: ProxyRules, ): Boolean { val isInExcludeList = rules.excludeURLPatterns.any { wildcardMatch(it, url) } return !isInExcludeList } override fun getProxyStrategyFor(url: String): ProxyStrategy { return getProxyModeForThisURL(url) } } /** * this is used for socks proxy authentication */ private class MySocksProxyAuthenticator( val currentProxy: () -> Proxy, ) : Authenticator() { override fun getPasswordAuthentication(): PasswordAuthentication? { val proxy = currentProxy() if (proxy.type == ProxyType.SOCKS && requestingPrompt == "SOCKS authentication") { if (proxy.host == requestingHost && proxy.port == requestingPort) { if (proxy.username != null) { return PasswordAuthentication( proxy.username, proxy.password.orEmpty().toCharArray(), ) } } } return null } } ================================================ FILE: shared/app/src/commonMain/kotlin/com/abdownloadmanager/shared/util/ui/BaseMyColors.kt ================================================ package com.abdownloadmanager.shared.util.ui import androidx.compose.ui.graphics.vector.ImageVector import ir.amirab.util.compose.IIconResolver import ir.amirab.util.compose.IconSource import ir.amirab.util.compose.contants.ICON_PROTOCOL abstract class BaseMyColors : IMyIcons, IIconResolver { val iconMap = mutableMapOf() fun ImageVector.asIconSource( name: String, requiredTint: Boolean = true, ): IconSource { val uri = "$ICON_PROTOCOL:$name" return IconSource.VectorIconSource(this, requiredTint, uri) .asIconSource() } // fun String.asIconSource( // path: String, // requiredTint: Boolean = true // ): IconSource = apply { // val uri = "$RESOURCE_PROTOCOL:$path?tint=${requiredTint}" // return IconSource.ResourceIconSource(this, requiredTint, uri).asSource() // } fun IconSource.asIconSource(): IconSource = apply { uri?.let { iconMap[it] = this } } override fun resolve(uri: String): IconSource? { return iconMap[uri] } } ================================================ FILE: shared/app/src/commonMain/kotlin/com/abdownloadmanager/shared/util/ui/IMyIcons.kt ================================================ package com.abdownloadmanager.shared.util.ui import ir.amirab.util.compose.IconSource interface IMyIcons { val appIcon: IconSource val settings: IconSource val flag: IconSource val fast: IconSource val search: IconSource val info: IconSource val check: IconSource val link: IconSource val download: IconSource val permission: IconSource val windowMinimize: IconSource val windowFloating: IconSource val windowMaximize: IconSource val windowClose: IconSource val exit: IconSource val edit: IconSource val undo: IconSource // val menu: IconSource // val menuClose: IconSource val openSource: IconSource val telegram: IconSource val speaker: IconSource val group: IconSource //browser icons val browserMozillaFirefox: IconSource val browserGoogleChrome: IconSource val browserMicrosoftEdge: IconSource val browserOpera: IconSource val next: IconSource val back: IconSource val up: IconSource val down: IconSource val activeCount: IconSource val speed: IconSource val resume: IconSource val pause: IconSource val stop: IconSource val queue: IconSource val queueStart: IconSource val queueStop: IconSource val remove: IconSource val clear: IconSource val add: IconSource val minus: IconSource val paste: IconSource val copy: IconSource val refresh: IconSource val editFolder: IconSource val share: IconSource val file: IconSource val folder: IconSource val fileOpen: IconSource val folderOpen: IconSource val pictureFile: IconSource val musicFile: IconSource val zipFile: IconSource val videoFile: IconSource val applicationFile: IconSource val documentFile: IconSource val otherFile: IconSource val lock: IconSource val question: IconSource val grip: IconSource val sortUp: IconSource val sortDown: IconSource val verticalDirection: IconSource val appearance: IconSource val downloadEngine: IconSource val browserIntegration: IconSource val network: IconSource val language: IconSource val externalLink: IconSource val earth: IconSource val hearth: IconSource val dragAndDrop: IconSource val selectAll: IconSource val selectInside: IconSource val selectInvert: IconSource val menu: IconSource val close: IconSource val data: IconSource val alphabet: IconSource val clock: IconSource } ================================================ FILE: shared/app/src/commonMain/kotlin/com/abdownloadmanager/shared/util/ui/LocalIsDebugMode.kt ================================================ package com.abdownloadmanager.shared.util.ui import androidx.compose.runtime.Composable import androidx.compose.runtime.CompositionLocalProvider import androidx.compose.runtime.staticCompositionLocalOf private val LocalIsDebugMode = staticCompositionLocalOf { false } @Composable fun ProvideDebugInfo( debug:Boolean, content:@Composable ()->Unit ){ CompositionLocalProvider( LocalIsDebugMode provides debug, content ) } @Composable fun useIsInDebugMode(): Boolean { return LocalIsDebugMode.current } ================================================ FILE: shared/app/src/commonMain/kotlin/com/abdownloadmanager/shared/util/ui/LocalProviders.kt ================================================ package com.abdownloadmanager.shared.util.ui import androidx.compose.runtime.Composable import androidx.compose.runtime.CompositionLocalProvider import androidx.compose.runtime.compositionLocalOf import androidx.compose.runtime.structuralEqualityPolicy import androidx.compose.ui.graphics.Color import androidx.compose.ui.text.TextStyle val LocalContentColor = compositionLocalOf { Color.Black } val LocalContentAlpha = compositionLocalOf { 1f } val LocalTextStyle = compositionLocalOf(structuralEqualityPolicy()) { TextStyle() } @Composable fun ProvideTextStyle(value: TextStyle, content: @Composable () -> Unit) { val mergedStyle = LocalTextStyle.current.merge(value) CompositionLocalProvider(LocalTextStyle provides mergedStyle, content = content) } @Composable fun WithContentAlpha( newAlpha: Float, content: @Composable () -> Unit ) { CompositionLocalProvider( LocalContentAlpha provides newAlpha, content = content ) } @Composable fun WithContentColor( newColor: Color, content: @Composable () -> Unit ) { CompositionLocalProvider( LocalContentColor provides newColor, content = content ) } ================================================ FILE: shared/app/src/commonMain/kotlin/com/abdownloadmanager/shared/util/ui/LocalTitleBarDirection.kt ================================================ package com.abdownloadmanager.shared.util.ui import androidx.compose.runtime.Composable import androidx.compose.runtime.CompositionLocalProvider import androidx.compose.runtime.staticCompositionLocalOf import androidx.compose.ui.platform.LocalLayoutDirection import androidx.compose.ui.unit.LayoutDirection val LocalTitleBarDirection = staticCompositionLocalOf { error("TitleBarDirection not provided") } @Composable fun WithTitleBarDirection( content: @Composable () -> Unit, ) { CompositionLocalProvider( LocalLayoutDirection provides LocalTitleBarDirection.current ) { content() } } ================================================ FILE: shared/app/src/commonMain/kotlin/com/abdownloadmanager/shared/util/ui/MyColors.kt ================================================ package com.abdownloadmanager.shared.util.ui import androidx.compose.animation.animateColor import com.abdownloadmanager.shared.util.darker import com.abdownloadmanager.shared.util.div import androidx.compose.animation.animateColorAsState import androidx.compose.animation.core.AnimationSpec import androidx.compose.animation.core.FiniteAnimationSpec import androidx.compose.animation.core.Transition import androidx.compose.animation.core.tween import androidx.compose.animation.core.updateTransition import androidx.compose.runtime.* import androidx.compose.ui.graphics.Brush import androidx.compose.ui.graphics.Color val LocalMyColors = compositionLocalOf { error("LocalMyColors not provided") } val myColors @Composable get() = LocalMyColors.current @Immutable data class MyColors( val id: String, val name: String, val primary: Color, val primaryVariant: Color = primary, val onPrimary: Color, val secondary: Color, val secondaryVariant: Color = secondary, val onSecondary: Color, val background: Color, val onBackground: Color, val onSurface: Color, val surface: Color, val error: Color, val onError: Color, val success: Color, val onSuccess: Color, val warning: Color, val onWarning: Color, val info: Color, val onInfo: Color, val isLight: Boolean, ) { val warningGradient: Brush by lazy { Brush.linearGradient( listOf(warning, warning.darker()) ) } val errorGradient: Brush by lazy { Brush.linearGradient( listOf(error, error.darker()) ) } val successGradient: Brush by lazy { Brush.linearGradient( listOf(success, success.darker()) ) } val infoGradient: Brush by lazy { Brush.linearGradient( listOf(info, info.darker()) ) } val menuGradientBackground = surface val menuBorderColor = onSurface / 0.1f val onMenuColor = onSurface val primaryGradientColors = listOf(primary, secondary) val primaryGradient by lazy { Brush.linearGradient(primaryGradientColors) } val onPrimaryGradient = Color.White fun selectionGradient( startAlpha: Float = 1f, endAlpha: Float = 0f, color: Color = surface, ): Brush { return Brush.linearGradient(listOf(color / startAlpha, color / endAlpha)) } val focusedBorderColor = primary fun getContentColorFor(color: Color): Color { return when (color) { primary, primaryVariant -> onPrimary secondary, secondaryVariant -> onSecondary error -> onError success -> onSuccess background -> onBackground surface -> onSurface else -> Color.Unspecified } } val contrast = if (isLight) Color.White else Color.Black val onContrast = if (isLight) Color.Black else Color.White } private object AnimateMyColors { @Composable fun animatedColors( toBeAnimated: MyColors, spec: FiniteAnimationSpec = tween(500), ): MyColors { val primary by animated(toBeAnimated.primary, spec) val primaryVariant by animated(toBeAnimated.primaryVariant, spec) val onPrimary by animated(toBeAnimated.onPrimary, spec) val secondary by animated(toBeAnimated.secondary, spec) val secondaryVariant by animated(toBeAnimated.secondaryVariant, spec) val onSecondary by animated(toBeAnimated.onSecondary, spec) val background by animated(toBeAnimated.background, spec) val onBackground by animated(toBeAnimated.onBackground, spec) val surface by animated(toBeAnimated.surface, spec) val onSurface by animated(toBeAnimated.onSurface, spec) val success by animated(toBeAnimated.success, spec) val onSuccess by animated(toBeAnimated.onSuccess, spec) val error by animated(toBeAnimated.error, spec) val onError by animated(toBeAnimated.onError, spec) val warning by animated(toBeAnimated.warning, spec) val onWarning by animated(toBeAnimated.onWarning, spec) val info by animated(toBeAnimated.info, spec) val onInfo by animated(toBeAnimated.onInfo, spec) val isLight = toBeAnimated.isLight return MyColors( primary = primary, primaryVariant = primaryVariant, onPrimary = onPrimary, secondary = secondary, secondaryVariant = secondaryVariant, onSecondary = onSecondary, background = background, onBackground = onBackground, onSurface = onSurface, surface = surface, error = error, onError = onError, success = success, onSuccess = onSuccess, warning = warning, onWarning = onWarning, info = info, onInfo = onInfo, isLight = isLight, name = toBeAnimated.name, id = toBeAnimated.id, ) } @Composable private fun animated( color: Color, animationSpec: AnimationSpec = tween(500), ): State { return animateColorAsState(color, animationSpec = animationSpec) } } // it seems this method is more laggy! even though it uses single transition! private object AnimateMyColorsWithSingleTransition { @Composable fun animatedColors( toBeAnimated: MyColors, spec: FiniteAnimationSpec = tween(500) ): MyColors { val spec: @Composable Transition.Segment.() -> FiniteAnimationSpec = { spec } val transition = updateTransition(toBeAnimated, "animateMyColors") val primary by transition.animateColor(spec, "primary") { it.primary } val primaryVariant by transition.animateColor(spec, "primaryVariant") { it.primaryVariant } val onPrimary by transition.animateColor(spec, "onPrimary") { it.onPrimary } val secondary by transition.animateColor(spec, "secondary") { it.secondary } val secondaryVariant by transition.animateColor(spec, "secondaryVariant") { it.secondaryVariant } val onSecondary by transition.animateColor(spec, "onSecondary") { it.onSecondary } val background by transition.animateColor(spec, "background") { it.background } val onBackground by transition.animateColor(spec, "onBackground") { it.onBackground } val surface by transition.animateColor(spec, "surface") { it.surface } val onSurface by transition.animateColor(spec, "onSurface") { it.onSurface } val success by transition.animateColor(spec, "success") { it.success } val onSuccess by transition.animateColor(spec, "onSuccess") { it.onSuccess } val error by transition.animateColor(spec, "error") { it.error } val onError by transition.animateColor(spec, "onError") { it.onError } val warning by transition.animateColor(spec, "warning") { it.warning } val onWarning by transition.animateColor(spec, "onWarning") { it.onWarning } val info by transition.animateColor(spec, "info") { it.info } val onInfo by transition.animateColor(spec, "onInfo") { it.onInfo } return MyColors( primary = primary, primaryVariant = primaryVariant, onPrimary = onPrimary, secondary = secondary, secondaryVariant = secondaryVariant, onSecondary = onSecondary, background = background, onBackground = onBackground, onSurface = onSurface, surface = surface, error = error, onError = onError, success = success, onSuccess = onSuccess, warning = warning, onWarning = onWarning, info = info, onInfo = onInfo, isLight = toBeAnimated.isLight, name = toBeAnimated.name, id = toBeAnimated.id, ) } } @Composable fun animatedColors( toBeAnimated: MyColors, spec: FiniteAnimationSpec = tween(500), ): MyColors { return AnimateMyColors.animatedColors( toBeAnimated = toBeAnimated, spec = spec, ) } ================================================ FILE: shared/app/src/commonMain/kotlin/com/abdownloadmanager/shared/util/ui/ScrollbableContent.kt ================================================ package com.abdownloadmanager.shared.util.ui import androidx.compose.foundation.ScrollState import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.width import androidx.compose.foundation.layout.wrapContentHeight import androidx.compose.foundation.layout.wrapContentWidth import androidx.compose.foundation.lazy.LazyListState import androidx.compose.runtime.Composable import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableIntStateOf import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.layout.onSizeChanged import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.unit.dp import io.github.oikvpqya.compose.fastscroller.ScrollbarAdapter import io.github.oikvpqya.compose.fastscroller.ScrollbarStyle import io.github.oikvpqya.compose.fastscroller.rememberScrollbarAdapter @Composable fun TwoDimensionScrollableContent( modifier: Modifier, content: @Composable () -> Unit, verticalAdapter: ScrollbarAdapter, horizontalAdapter: ScrollbarAdapter ) { Row(modifier) { Column(Modifier.weight(1f)) { Box(Modifier.weight(1f)) { content() } if (horizontalAdapter.needScroll()) { MultiplatformHorizontalScrollbar( horizontalAdapter, Modifier.padding( top = 4.dp, bottom = 4.dp, ) ) } } if (verticalAdapter.needScroll()) { MultiplatformVerticalScrollbar( verticalAdapter, Modifier.padding( start = 4.dp, end = 4.dp, bottom = 4.dp ) ) } } } @Composable fun VerticalScrollableContent( scrollState: ScrollState, modifier: Modifier = Modifier, content: @Composable () -> Unit, ) { VerticalScrollableContent( verticalAdapter = rememberScrollbarAdapter(scrollState), modifier = modifier, content = content, ) } @Composable fun VerticalScrollableContent( lazyListState: LazyListState, modifier: Modifier = Modifier, content: @Composable () -> Unit, ) { VerticalScrollableContent( verticalAdapter = rememberScrollbarAdapter(lazyListState), modifier = modifier, content = content, ) } @Composable fun VerticalScrollableContent( verticalAdapter: ScrollbarAdapter, modifier: Modifier = Modifier, style: ScrollbarStyle = LocalMultiplatformScrollbarStyle.current, content: @Composable () -> Unit, ) { Box(modifier) { val needScroll = verticalAdapter.needScroll() val horizontalPadding = 4.dp val endPadding = if (needScroll) { style.thickness + horizontalPadding } else { 0.dp } Box(Modifier.padding(end = endPadding)) { content() } if (needScroll) { MultiplatformVerticalScrollbar( verticalAdapter, modifier = Modifier .matchParentSize() .wrapContentWidth(Alignment.End) .padding( horizontal = horizontalPadding, ) .width(style.thickness), style = style, ) } } } ================================================ FILE: shared/app/src/commonMain/kotlin/com/abdownloadmanager/shared/util/ui/Scrollbar.kt ================================================ package com.abdownloadmanager.shared.util.ui import androidx.compose.foundation.interaction.MutableInteractionSource import androidx.compose.foundation.layout.Box import androidx.compose.runtime.Composable import androidx.compose.runtime.remember import androidx.compose.runtime.staticCompositionLocalOf import androidx.compose.ui.Modifier import io.github.oikvpqya.compose.fastscroller.* val LocalMultiplatformScrollbarStyle = staticCompositionLocalOf { error("Scrollbar style not provided") } fun ScrollbarAdapter.needScroll(): Boolean { return contentSize > viewportSize } fun multiplatformDefaultScrollbarStyle(): ScrollbarStyle { return defaultScrollbarStyle() } @Composable fun MultiplatformHorizontalScrollbar( adapter: ScrollbarAdapter, modifier: Modifier = Modifier, style: ScrollbarStyle = LocalMultiplatformScrollbarStyle.current, reverseLayout: Boolean = false, interactionSource: MutableInteractionSource = remember { MutableInteractionSource() }, enablePressToScroll: Boolean = true, indicator: @Composable (position: Float, isVisible: Boolean) -> Unit = { _, _ -> }, ) { // I intentionally wrapped it seems there is a bug in the library that consume more than thickness and breaks the UI Box(modifier) { HorizontalScrollbar( adapter = adapter, style = style, modifier = Modifier, reverseLayout = reverseLayout, interactionSource = interactionSource, enablePressToScroll = enablePressToScroll, indicator = indicator, ) } } @Composable fun MultiplatformVerticalScrollbar( adapter: ScrollbarAdapter, modifier: Modifier = Modifier, style: ScrollbarStyle = LocalMultiplatformScrollbarStyle.current, reverseLayout: Boolean = false, interactionSource: MutableInteractionSource = remember { MutableInteractionSource() }, enablePressToScroll: Boolean = true, indicator: @Composable (position: Float, isVisible: Boolean) -> Unit = { _, _ -> }, ) { Box(modifier) { VerticalScrollbar( adapter = adapter, style = style, modifier = Modifier, reverseLayout = reverseLayout, interactionSource = interactionSource, enablePressToScroll = enablePressToScroll, indicator = indicator, ) } } ================================================ FILE: shared/app/src/commonMain/kotlin/com/abdownloadmanager/shared/util/ui/icon/MyIcons.kt ================================================ package com.abdownloadmanager.shared.util.ui.icon import com.abdownloadmanager.resources.icons.ABDMIcons import com.abdownloadmanager.resources.icons.* import com.abdownloadmanager.shared.util.ui.BaseMyColors import ir.amirab.util.compose.IconSource object MyIcons : BaseMyColors() { override val appIcon = ABDMIcons.AppIcon.asIconSource("appIcon", false) override val settings = ABDMIcons.Settings.asIconSource("settings") override val flag = ABDMIcons.Flag.asIconSource("flag") override val fast = ABDMIcons.Fast.asIconSource("fast") override val search = ABDMIcons.Search.asIconSource("search") override val info = ABDMIcons.Info.asIconSource("info") override val check = ABDMIcons.Check.asIconSource("check") override val link = ABDMIcons.AddLink.asIconSource("link") override val download = ABDMIcons.DownSpeed.asIconSource("download") override val permission = ABDMIcons.Permission.asIconSource("permission") override val windowMinimize = ABDMIcons.WindowMinimize.asIconSource("windowMinimize") override val windowFloating = ABDMIcons.WindowFloating.asIconSource("windowFloating") override val windowMaximize = ABDMIcons.WindowMaximize.asIconSource("windowMaximize") override val windowClose = ABDMIcons.WindowClose.asIconSource("windowClose") override val exit = ABDMIcons.Exit.asIconSource("exit") override val edit = ABDMIcons.Edit.asIconSource("edit") override val undo = ABDMIcons.Undo.asIconSource("undo") override val openSource = ABDMIcons.OpenSource.asIconSource("openSource") override val telegram = ABDMIcons.Telegram.asIconSource("telegram", false) override val speaker = ABDMIcons.Speaker.asIconSource("speaker") override val group = ABDMIcons.Group.asIconSource("group") override val browserMozillaFirefox = ABDMIcons.BrowserMozillaFirefox.asIconSource("browserMozillaFirefox", false) override val browserGoogleChrome = ABDMIcons.BrowserGoogleChrome.asIconSource("browserGoogleChrome", false) override val browserMicrosoftEdge = ABDMIcons.BrowserMicrosoftEdge.asIconSource("browserMicrosoftEdge", false) override val browserOpera = ABDMIcons.BrowserOpera.asIconSource("browserOpera", false) override val next = ABDMIcons.Next.asIconSource("next") override val back = ABDMIcons.Back.asIconSource("back") override val up = ABDMIcons.Up.asIconSource("up") override val down = ABDMIcons.Down.asIconSource("down") override val activeCount = ABDMIcons.List.asIconSource("activeCount") override val speed = ABDMIcons.DownSpeed.asIconSource("speed") override val resume = ABDMIcons.Resume.asIconSource("resume") override val pause = ABDMIcons.Pause.asIconSource("pause") override val stop = ABDMIcons.Stop.asIconSource("stop") override val queue = ABDMIcons.Queue.asIconSource("queue") override val queueStart = ABDMIcons.QueueStart.asIconSource("queueStart") override val queueStop = ABDMIcons.QueueStop.asIconSource("queueStop") override val remove = ABDMIcons.Delete.asIconSource("remove") override val clear = ABDMIcons.Clear.asIconSource("clear") override val add = ABDMIcons.Plus.asIconSource("add") override val minus = ABDMIcons.Minus.asIconSource("add") override val paste = ABDMIcons.Clipboard.asIconSource("paste") override val copy = ABDMIcons.Copy.asIconSource("copy") override val refresh = ABDMIcons.Refresh.asIconSource("refresh") override val editFolder = ABDMIcons.Folder.asIconSource("editFolder") override val share = ABDMIcons.Share.asIconSource("share") override val file = ABDMIcons.File.asIconSource("file") override val folder = ABDMIcons.Folder.asIconSource("folder") override val fileOpen = file override val folderOpen = folder override val pictureFile = ABDMIcons.FilePicture.asIconSource("fileOpen") override val musicFile = ABDMIcons.FileMusic.asIconSource("folderOpen") override val zipFile = ABDMIcons.FileZip.asIconSource("pictureFile") override val videoFile = ABDMIcons.FileVideo.asIconSource("musicFile") override val applicationFile = ABDMIcons.FileApplication.asIconSource("zipFile") override val documentFile = ABDMIcons.FileDocument.asIconSource("videoFile") override val otherFile = ABDMIcons.FileUnknown.asIconSource("applicationFile") override val lock = ABDMIcons.Lock.asIconSource("lock") override val question = ABDMIcons.QuestionMark.asIconSource("question") override val grip = ABDMIcons.Grip.asIconSource("grip") override val sortUp = ABDMIcons.Sort123.asIconSource("sortUp") override val sortDown = ABDMIcons.Sort321.asIconSource("sortDown") override val verticalDirection = ABDMIcons.VerticalDirection.asIconSource("verticalDirection") override val browserIntegration = ABDMIcons.Earth.asIconSource("browserIntegration") override val appearance = ABDMIcons.Colors.asIconSource("appearance") override val downloadEngine = ABDMIcons.DownSpeed.asIconSource("downloadEngine") override val network = ABDMIcons.Network.asIconSource("network") override val language = ABDMIcons.Language.asIconSource("language") override val externalLink = ABDMIcons.ExternalLink.asIconSource("externalLink") override val earth = ABDMIcons.Earth.asIconSource("earth") override val hearth = ABDMIcons.Hearth.asIconSource("hearth") override val dragAndDrop = ABDMIcons.DragAndDrop.asIconSource("dragAndDrop") override val selectAll = ABDMIcons.SelectAll.asIconSource("selectAll") override val selectInside = ABDMIcons.SelectInside.asIconSource("selectInside") override val selectInvert = ABDMIcons.SelectInvert.asIconSource("selectInvert") override val menu = ABDMIcons.Menu.asIconSource("menu") override val close: IconSource = ABDMIcons.Clear.asIconSource("close") override val data: IconSource = ABDMIcons.Data.asIconSource("alphabet") override val alphabet: IconSource = ABDMIcons.Alphabet.asIconSource("alphabet") override val clock: IconSource = ABDMIcons.Clock.asIconSource("clock") } ================================================ FILE: shared/app/src/commonMain/kotlin/com/abdownloadmanager/shared/util/ui/theme/ISystemThemeDetector.kt ================================================ package com.abdownloadmanager.shared.util.ui.theme import kotlinx.coroutines.flow.Flow interface ISystemThemeDetector { val isSupported: Boolean fun isDark(): Boolean val systemThemeFlow: Flow } ================================================ FILE: shared/app/src/commonMain/kotlin/com/abdownloadmanager/shared/util/ui/theme/MaterialRipple.kt ================================================ /* * Copyright 2023 The Android Open Source Project * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ package com.abdownloadmanager.shared.util.ui.theme import androidx.compose.foundation.Indication import androidx.compose.foundation.IndicationNodeFactory import androidx.compose.foundation.interaction.Interaction import androidx.compose.foundation.interaction.InteractionSource import androidx.compose.foundation.interaction.PressInteraction import androidx.compose.material.ripple.RippleAlpha import androidx.compose.material.ripple.createRippleModifierNode import androidx.compose.runtime.Immutable import androidx.compose.runtime.ProvidableCompositionLocal import androidx.compose.runtime.Stable import androidx.compose.runtime.compositionLocalOf import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.ColorProducer import androidx.compose.ui.graphics.isSpecified import androidx.compose.ui.graphics.luminance import androidx.compose.ui.node.CompositionLocalConsumerModifierNode import androidx.compose.ui.node.DelegatableNode import androidx.compose.ui.node.DelegatingNode import androidx.compose.ui.node.ObserverModifierNode import androidx.compose.ui.node.currentValueOf import androidx.compose.ui.node.observeReads import androidx.compose.ui.unit.Dp import com.abdownloadmanager.shared.util.ui.LocalContentColor import com.abdownloadmanager.shared.util.ui.LocalMyColors /** * Creates a Ripple using the provided values and values inferred from the theme. * * A Ripple is a Material implementation of [Indication] that expresses different [Interaction]s * by drawing ripple animations and state layers. * * A Ripple responds to [PressInteraction.Press] by starting a new animation, and * responds to other [Interaction]s by showing a fixed state layer with varying alpha values * depending on the [Interaction]. * * [MaterialTheme] provides Ripples using [androidx.compose.foundation.LocalIndication], so a Ripple * will be used as the default [Indication] inside components such as * [androidx.compose.foundation.clickable] and [androidx.compose.foundation.indication], in * addition to Material provided components that use a Ripple as well. * * You can also explicitly create a Ripple and provide it to custom components in order to change * the parameters from the default, such as to create an unbounded ripple with a fixed size. * * To create a Ripple with a manually defined color that can change over time, see the other * [ripple] overload with a [ColorProducer] parameter. This will avoid unnecessary recompositions * when changing the color, and preserve existing ripple state when the color changes. * * @param bounded If true, ripples are clipped by the bounds of the target layout. Unbounded * ripples always animate from the target layout center, bounded ripples animate from the touch * position. * @param radius the radius for the ripple. If [Dp.Unspecified] is provided then the size will be * calculated based on the target layout size. * @param color the color of the ripple. This color is usually the same color used by the text or * iconography in the component. This color will then have [RippleDefaults.rippleAlpha] * applied to calculate the final color used to draw the ripple. If [Color.Unspecified] is * provided the color used will be [RippleDefaults.rippleColor] instead. */ @Stable fun ripple( bounded: Boolean = true, radius: Dp = Dp.Unspecified, color: Color = Color.Unspecified, ): IndicationNodeFactory { return if (radius == Dp.Unspecified && color == Color.Unspecified) { if (bounded) return DefaultBoundedRipple else DefaultUnboundedRipple } else { RippleNodeFactory(bounded, radius, color) } } /** * Creates a Ripple using the provided values and values inferred from the theme. * * A Ripple is a Material implementation of [Indication] that expresses different [Interaction]s * by drawing ripple animations and state layers. * * A Ripple responds to [PressInteraction.Press] by starting a new ripple animation, and * responds to other [Interaction]s by showing a fixed state layer with varying alpha values * depending on the [Interaction]. * * [MaterialTheme] provides Ripples using [androidx.compose.foundation.LocalIndication], so a Ripple * will be used as the default [Indication] inside components such as * [androidx.compose.foundation.clickable] and [androidx.compose.foundation.indication], in * addition to Material provided components that use a Ripple as well. * * You can also explicitly create a Ripple and provide it to custom components in order to change * the parameters from the default, such as to create an unbounded ripple with a fixed size. * * To create a Ripple with a static color, see the [ripple] overload with a [Color] parameter. This * overload is optimized for Ripples that have dynamic colors that change over time, to reduce * unnecessary recompositions. * * @param color the color of the ripple. This color is usually the same color used by the text or * iconography in the component. This color will then have [RippleDefaults.rippleAlpha] * applied to calculate the final color used to draw the ripple. If you are creating this * [ColorProducer] outside of composition (where it will be automatically remembered), make sure * that its instance is stable (such as by remembering the object that holds it), or remember the * returned [ripple] object to make sure that ripple nodes are not being created each recomposition. * @param bounded If true, ripples are clipped by the bounds of the target layout. Unbounded * ripples always animate from the target layout center, bounded ripples animate from the touch * position. * @param radius the radius for the ripple. If [Dp.Unspecified] is provided then the size will be * calculated based on the target layout size. */ @Stable fun ripple( color: ColorProducer, bounded: Boolean = true, radius: Dp = Dp.Unspecified, ): IndicationNodeFactory { return RippleNodeFactory(bounded, radius, color) } /** * Default values used by [ripple]. */ object RippleDefaults { /** * Represents the default color that will be used for a ripple if a color has not been * explicitly set on the ripple instance. * * @param contentColor the color of content (text or iconography) in the component that * contains the ripple. * @param lightTheme whether the theme is light or not */ fun rippleColor( contentColor: Color, lightTheme: Boolean, ): Color { val contentLuminance = contentColor.luminance() // If we are on a colored surface (typically indicated by low luminance content), the // ripple color should be white. return if (!lightTheme && contentLuminance < 0.5) { Color.White // Otherwise use contentColor } else { contentColor } } /** * Represents the default [RippleAlpha] that will be used for a ripple to indicate different * states. * * @param contentColor the color of content (text or iconography) in the component that * contains the ripple. * @param lightTheme whether the theme is light or not */ fun rippleAlpha(contentColor: Color, lightTheme: Boolean): RippleAlpha { return when { lightTheme -> { if (contentColor.luminance() > 0.5) { LightThemeHighContrastRippleAlpha } else { LightThemeLowContrastRippleAlpha } } else -> { DarkThemeRippleAlpha } } } } /** * CompositionLocal used for providing [RippleConfiguration] down the tree. This acts as a * tree-local 'override' for ripples used inside components that you cannot directly control, such * as to change the color of a specific component's ripple, or disable it entirely by providing * `null`. * * In most cases you should rely on the default theme behavior for consistency with other components * - this exists as an escape hatch for individual components and is not intended to be used for * full theme customization across an application. For this use case you should instead build your * own custom ripple that queries your design system theme values directly using * [createRippleModifierNode]. */ @Suppress("OPT_IN_MARKER_ON_WRONG_TARGET") val LocalRippleConfiguration: ProvidableCompositionLocal = compositionLocalOf { RippleConfiguration() } /** * Configuration for [ripple] appearance, provided using [LocalRippleConfiguration]. In most cases * the default values should be used, for custom design system use cases you should instead * build your own custom ripple using [createRippleModifierNode]. To disable the ripple, provide * `null` using [LocalRippleConfiguration]. * * @param color the color override for the ripple. If [Color.Unspecified], then the default color * from the theme will be used instead. Note that if the ripple has a color explicitly set with * the parameter on [ripple], that will always be used instead of this value. * @param rippleAlpha the [RippleAlpha] override for this ripple. If null, then the default alpha * will be used instead. */ @Immutable class RippleConfiguration( val color: Color = Color.Unspecified, val rippleAlpha: RippleAlpha? = null, ) { override fun equals(other: Any?): Boolean { if (this === other) return true if (other !is RippleConfiguration) return false if (color != other.color) return false if (rippleAlpha != other.rippleAlpha) return false return true } override fun hashCode(): Int { var result = color.hashCode() result = 31 * result + (rippleAlpha?.hashCode() ?: 0) return result } override fun toString(): String { return "RippleConfiguration(color=$color, rippleAlpha=$rippleAlpha)" } } @Stable private class RippleNodeFactory private constructor( private val bounded: Boolean, private val radius: Dp, private val colorProducer: ColorProducer?, private val color: Color, ) : IndicationNodeFactory { constructor( bounded: Boolean, radius: Dp, colorProducer: ColorProducer, ) : this(bounded, radius, colorProducer, Color.Unspecified) constructor( bounded: Boolean, radius: Dp, color: Color, ) : this(bounded, radius, null, color) override fun create(interactionSource: InteractionSource): DelegatableNode { val colorProducer = colorProducer ?: ColorProducer { color } return DelegatingThemeAwareRippleNode(interactionSource, bounded, radius, colorProducer) } override fun equals(other: Any?): Boolean { if (this === other) return true if (other !is RippleNodeFactory) return false if (bounded != other.bounded) return false if (radius != other.radius) return false if (colorProducer != other.colorProducer) return false return color == other.color } override fun hashCode(): Int { var result = bounded.hashCode() result = 31 * result + radius.hashCode() result = 31 * result + colorProducer.hashCode() result = 31 * result + color.hashCode() return result } } private class DelegatingThemeAwareRippleNode( private val interactionSource: InteractionSource, private val bounded: Boolean, private val radius: Dp, private val color: ColorProducer, ) : DelegatingNode(), CompositionLocalConsumerModifierNode, ObserverModifierNode { private var rippleNode: DelegatableNode? = null override fun onAttach() { updateConfiguration() } override fun onObservedReadsChanged() { updateConfiguration() } /** * Handles [LocalRippleConfiguration] changing between null / non-null. Changes to * [RippleConfiguration.color] and [RippleConfiguration.rippleAlpha] are handled as part of * the ripple definition. */ private fun updateConfiguration() { observeReads { val configuration = currentValueOf(LocalRippleConfiguration) if (configuration == null) { removeRipple() } else { if (rippleNode == null) attachNewRipple() } } } private fun attachNewRipple() { val calculateColor = ColorProducer { val userDefinedColor = color() if (userDefinedColor.isSpecified) { userDefinedColor } else { // If this is null, the ripple will be removed, so this should always be non-null in // normal use val rippleConfiguration = currentValueOf(LocalRippleConfiguration) if (rippleConfiguration?.color?.isSpecified == true) { rippleConfiguration.color } else { RippleDefaults.rippleColor( contentColor = currentValueOf(LocalContentColor), lightTheme = currentValueOf(LocalMyColors).isLight ) } } } val calculateRippleAlpha = { // If this is null, the ripple will be removed, so this should always be non-null in // normal use val rippleConfiguration = currentValueOf(LocalRippleConfiguration) rippleConfiguration?.rippleAlpha ?: RippleDefaults.rippleAlpha( contentColor = currentValueOf(LocalContentColor), lightTheme = currentValueOf(LocalMyColors).isLight ) } rippleNode = delegate( createRippleModifierNode( interactionSource, bounded, radius, calculateColor, calculateRippleAlpha ) ) } private fun removeRipple() { rippleNode?.let { undelegate(it) } } } private val DefaultBoundedRipple = RippleNodeFactory( bounded = true, radius = Dp.Unspecified, color = Color.Unspecified ) private val DefaultUnboundedRipple = RippleNodeFactory( bounded = false, radius = Dp.Unspecified, color = Color.Unspecified ) /** * Alpha values for high luminance content in a light theme. * * This content will typically be placed on colored surfaces, so it is important that the * contrast here is higher to meet accessibility standards, and increase legibility. * * These levels are typically used for text / iconography in primary colored tabs / * bottom navigation / etc. */ private val LightThemeHighContrastRippleAlpha = RippleAlpha( pressedAlpha = 0.24f, focusedAlpha = 0.24f, draggedAlpha = 0.16f, hoveredAlpha = 0.08f ) /** * Alpha levels for low luminance content in a light theme. * * This content will typically be placed on grayscale surfaces, so the contrast here can be lower * without sacrificing accessibility and legibility. * * These levels are typically used for body text on the main surface (white in light theme, grey * in dark theme) and text / iconography in surface colored tabs / bottom navigation / etc. */ private val LightThemeLowContrastRippleAlpha = RippleAlpha( pressedAlpha = 0.12f, focusedAlpha = 0.12f, draggedAlpha = 0.08f, hoveredAlpha = 0.04f ) /** * Alpha levels for all content in a dark theme. */ private val DarkThemeRippleAlpha = RippleAlpha( pressedAlpha = 0.10f, focusedAlpha = 0.12f, draggedAlpha = 0.08f, hoveredAlpha = 0.04f ) ================================================ FILE: shared/app/src/commonMain/kotlin/com/abdownloadmanager/shared/util/ui/theme/MyShapes.kt ================================================ package com.abdownloadmanager.shared.util.ui.theme import androidx.compose.foundation.shape.CornerSize import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.runtime.Composable import androidx.compose.runtime.Stable import androidx.compose.runtime.staticCompositionLocalOf import androidx.compose.ui.unit.dp val LocalMyShapes = staticCompositionLocalOf { error("LocalMyShapes not provided") } val myShapes @Composable get() = LocalMyShapes.current private val ZeroCornerSize = CornerSize(0.dp) @Stable data class MyShapes( val defaultRounded: RoundedCornerShape, ) { val bottomSheet = defaultRounded.copy( bottomStart = ZeroCornerSize, bottomEnd = ZeroCornerSize, ) val dialog = defaultRounded fun createSheetWithCustomEdges( topStart: Boolean, bottomStart: Boolean, topEnd: Boolean, bottomEnd: Boolean, ): RoundedCornerShape { return RoundedCornerShape( bottomStart = if (bottomStart) defaultRounded.bottomStart else ZeroCornerSize, bottomEnd = if (bottomEnd) defaultRounded.bottomEnd else ZeroCornerSize, topStart = if (topStart) defaultRounded.topStart else ZeroCornerSize, topEnd = if (topEnd) defaultRounded.topEnd else ZeroCornerSize, ) } } ================================================ FILE: shared/app/src/commonMain/kotlin/com/abdownloadmanager/shared/util/ui/theme/Sizing.kt ================================================ package com.abdownloadmanager.shared.util.ui.theme import androidx.compose.runtime.* import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.unit.Density import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.TextUnit val LocalSystemDensity = staticCompositionLocalOf { error("LocalSystemDensity not provided") } const val DEFAULT_UI_SCALE = 1f val LocalUiScale = staticCompositionLocalOf { DEFAULT_UI_SCALE } val LocalTextSizes = compositionLocalOf { error("LocalTextSizes not provided") } val myTextSizes @Composable get() = LocalTextSizes.current @Stable data class TextSizes( val xs: TextUnit, val sm: TextUnit, val base: TextUnit, val lg: TextUnit, val xl: TextUnit, val x2l: TextUnit, val x3l: TextUnit, val x4l: TextUnit, val x5l: TextUnit, ) val LocalSpacing = compositionLocalOf { error("LocalSpacing not provided") } val mySpacings @Composable get() = LocalSpacing.current @Stable data class MySpacings( val thumbSize: Dp, val iconSize: Dp, val smallSpace: Dp, val mediumSpace: Dp, val largeSpace: Dp, ) /** * put this in every window because [Window] composable override [LocalDensity] */ @Composable fun UiScaledContent( defaultDensity: Density = LocalDensity.current, uiScale: Float = LocalUiScale.current, content: @Composable () -> Unit, ) { val density = remember(defaultDensity, uiScale) { if (uiScale == DEFAULT_UI_SCALE) { defaultDensity } else { Density(uiScale * defaultDensity.density) } } CompositionLocalProvider( LocalDensity provides density, content, ) } ================================================ FILE: shared/app/src/commonMain/kotlin/com/abdownloadmanager/shared/util/ui/widget/Icon.kt ================================================ package com.abdownloadmanager.shared.util.ui.widget import androidx.compose.foundation.Image import com.abdownloadmanager.shared.util.ui.LocalContentAlpha import com.abdownloadmanager.shared.util.ui.LocalContentColor import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.size import androidx.compose.runtime.Composable import androidx.compose.runtime.NonRestartableComposable import androidx.compose.runtime.remember import androidx.compose.ui.Modifier import androidx.compose.ui.draw.paint import androidx.compose.ui.geometry.Size import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.ColorFilter import androidx.compose.ui.graphics.ImageBitmap import androidx.compose.ui.graphics.painter.BitmapPainter import androidx.compose.ui.graphics.painter.Painter import androidx.compose.ui.graphics.toolingGraphicsLayer import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.graphics.vector.rememberVectorPainter import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.semantics.Role import androidx.compose.ui.semantics.contentDescription import androidx.compose.ui.semantics.role import androidx.compose.ui.semantics.semantics import androidx.compose.ui.unit.dp import ir.amirab.util.compose.IconSource @Composable @NonRestartableComposable fun Icon( imageVector: ImageVector, contentDescription: String?, modifier: Modifier = Modifier, tint: Color = LocalContentColor.current.copy(alpha = LocalContentAlpha.current) ) { Icon( painter = rememberVectorPainter(imageVector), contentDescription = contentDescription, modifier = modifier, tint = tint ) } @Composable @NonRestartableComposable fun Icon( bitmap: ImageBitmap, contentDescription: String?, modifier: Modifier = Modifier, tint: Color = LocalContentColor.current.copy(alpha = LocalContentAlpha.current) ) { val painter = remember(bitmap) { BitmapPainter(bitmap) } Icon( painter = painter, contentDescription = contentDescription, modifier = modifier, tint = tint ) } @Composable fun Icon( painter: Painter, contentDescription: String?, modifier: Modifier = Modifier, tint: Color = LocalContentColor.current.copy(alpha = LocalContentAlpha.current) ) { val colorFilter = if (tint == Color.Unspecified) null else ColorFilter.tint(tint) val semantics = if (contentDescription != null) { Modifier.semantics { this.contentDescription = contentDescription this.role = Role.Image } } else { Modifier } Box( modifier.toolingGraphicsLayer().defaultSizeFor(painter) .paint( painter, colorFilter = colorFilter, contentScale = ContentScale.Fit ) .then(semantics) ) } private fun Modifier.defaultSizeFor(painter: Painter) = this.then( if (painter.intrinsicSize == Size.Unspecified || painter.intrinsicSize.isInfinite()) { DefaultIconSizeModifier } else { Modifier } ) private fun Size.isInfinite() = width.isInfinite() && height.isInfinite() // Default icon size, for icons with no intrinsic size information private val DefaultIconSizeModifier = Modifier.size(24.dp) @Composable fun MyIcon( icon: IconSource, contentDescription: String?, modifier: Modifier = Modifier, tint: Color = LocalContentColor.current.copy(alpha = LocalContentAlpha.current), ) { val painter = icon.rememberPainter() if (icon.requiredTint) { Icon( painter = painter, contentDescription = contentDescription, modifier = modifier, tint = tint, ) } else { Image( painter = painter, contentDescription = contentDescription, modifier = modifier, ) } } ================================================ FILE: shared/app/src/commonMain/kotlin/com/abdownloadmanager/shared/util/ui/widget/MPBackHandler.kt ================================================ package com.abdownloadmanager.shared.util.ui.widget import androidx.compose.runtime.Composable @Composable expect fun MPBackHandler( isEnabled: Boolean = true, onBack: () -> Unit, ) ================================================ FILE: shared/app/src/commonMain/kotlin/com/abdownloadmanager/shared/util/ui/widget/ScreenSurface.kt ================================================ package com.abdownloadmanager.shared.util.ui.widget import com.abdownloadmanager.shared.util.ui.myColors import com.abdownloadmanager.shared.util.ui.WithContentColor import androidx.compose.foundation.background import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.BoxScope import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Brush import androidx.compose.ui.graphics.Color @Composable fun ScreenSurface( modifier: Modifier, background: Brush, contentColor: Color, content: @Composable BoxScope.() -> Unit, ) { Box( modifier .background(background) ) { WithContentColor(contentColor) { content() } } } @Composable fun ScreenSurface( modifier: Modifier, background: Color, contentColor: Color = myColors.getContentColorFor(background), content: @Composable BoxScope.() -> Unit, ) { Box( modifier .background(background) ) { WithContentColor(contentColor) { content() } } } ================================================ FILE: shared/app/src/commonMain/kotlin/com/abdownloadmanager/shared/util/ui/widget/ScrolFade.kt ================================================ package com.abdownloadmanager.shared.util.ui.widget import androidx.compose.animation.AnimatedVisibility import androidx.compose.animation.fadeIn import androidx.compose.animation.fadeOut import androidx.compose.foundation.ScrollState import androidx.compose.foundation.background import androidx.compose.foundation.gestures.Orientation import androidx.compose.foundation.layout.BoxScope import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Brush import androidx.compose.ui.graphics.Color @Composable fun BoxScope.ScrollFade( scrollState: ScrollState, orientation: Orientation, gradientLength: Float = 0.2f, // 0f .. 1f targetBackground: Color, ) { AnimatedVisibility( scrollState.canScrollBackward, modifier = Modifier.matchParentSize(), enter = fadeIn(), exit = fadeOut(), ) { Spacer( Modifier .fillMaxSize() .background( Brush.gradientOrientation( colorStops = arrayOf( 0f to targetBackground, gradientLength to Color.Transparent, 1f to Color.Transparent, ), orientation = orientation, ) ) ) } AnimatedVisibility( scrollState.canScrollForward, modifier = Modifier.matchParentSize(), enter = fadeIn(), exit = fadeOut(), ) { Spacer( Modifier .fillMaxSize() .background( Brush.gradientOrientation( colorStops = arrayOf( 0f to Color.Transparent, (1 - gradientLength) to Color.Transparent, 1f to targetBackground, ), orientation = orientation, ) ) ) } } private fun Brush.Companion.gradientOrientation( vararg colorStops: Pair, orientation: Orientation, ): Brush { return when (orientation) { Orientation.Vertical -> Brush.verticalGradient( colorStops = colorStops ) Orientation.Horizontal -> { Brush.horizontalGradient( colorStops = colorStops ) } } } ================================================ FILE: shared/app/src/desktopMain/kotlin/com/abdownloadmanager/shared/ui/modifier/PointerHoverIcon.desktop.kt ================================================ package com.abdownloadmanager.shared.ui.modifier import androidx.compose.ui.Modifier import androidx.compose.ui.input.pointer.PointerIcon import androidx.compose.ui.input.pointer.pointerHoverIcon import org.jetbrains.skiko.Cursor actual fun Modifier.myPointerHoverIcon( pointerHoverIcon: MyPointerHoverIcon, overrideDescendants: Boolean ): Modifier { return pointerHoverIcon( pointerHoverIcon.toDesktopIcon(), overrideDescendants = overrideDescendants, ) } private fun MyPointerHoverIcon.toDesktopIcon(): PointerIcon { return when (this) { MyPointerHoverIcon.Crosshair -> PointerIcon.Crosshair MyPointerHoverIcon.Default -> PointerIcon.Default MyPointerHoverIcon.Hand -> PointerIcon.Hand MyPointerHoverIcon.Text -> PointerIcon.Text MyPointerHoverIcon.HorizontalResize -> pointerIconFromCursorInt(Cursor.S_RESIZE_CURSOR) MyPointerHoverIcon.VerticalResize -> pointerIconFromCursorInt(Cursor.E_RESIZE_CURSOR) } } private fun pointerIconFromCursorInt( cursorInt: Int ): PointerIcon { return PointerIcon(Cursor(cursorInt)) } ================================================ FILE: shared/app/src/desktopMain/kotlin/com/abdownloadmanager/shared/ui/theme/MyContextMenuRepresentation.kt ================================================ package com.abdownloadmanager.shared.ui.theme import androidx.compose.foundation.ContextMenuItem import androidx.compose.foundation.ContextMenuRepresentation import androidx.compose.foundation.ContextMenuState import androidx.compose.runtime.Composable import androidx.compose.runtime.remember import androidx.compose.ui.window.Popup import androidx.compose.ui.window.PopupProperties import com.abdownloadmanager.shared.ui.widget.menu.custom.SubMenu import com.abdownloadmanager.shared.ui.widget.rememberMyPopupPositionProviderAtPosition import ir.amirab.util.compose.action.buildMenu import ir.amirab.util.compose.asStringSource private class MyContextMenuRepresentation : ContextMenuRepresentation { @Composable override fun Representation(state: ContextMenuState, items: () -> List) { val status = state.status if (status !is ContextMenuState.Status.Open) { return } val contextItems = items() val menuItems = remember(contextItems) { buildMenu { contextItems.map { item(title = it.label.asStringSource(), onClick = { it.onClick() }) } } } val onCloseRequest = { state.status = ContextMenuState.Status.Closed } Popup( properties = PopupProperties( focusable = true, ), onDismissRequest = onCloseRequest, popupPositionProvider = rememberMyPopupPositionProviderAtPosition( positionPx = status.rect.center ), ) { SubMenu(menuItems, onCloseRequest) } } } @Composable internal fun myContextMenuRepresentation(): ContextMenuRepresentation { return remember { MyContextMenuRepresentation() } } ================================================ FILE: shared/app/src/desktopMain/kotlin/com/abdownloadmanager/shared/ui/theme/PlatformThemeDefinitions.desktop.kt ================================================ package com.abdownloadmanager.shared.ui.theme import androidx.compose.foundation.LocalContextMenuRepresentation import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.runtime.Composable import androidx.compose.runtime.CompositionLocalProvider import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.RectangleShape import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import com.abdownloadmanager.shared.util.div import com.abdownloadmanager.shared.util.ui.myColors import com.abdownloadmanager.shared.util.ui.theme.MyShapes import com.abdownloadmanager.shared.util.ui.theme.MySpacings import com.abdownloadmanager.shared.util.ui.theme.TextSizes import io.github.oikvpqya.compose.fastscroller.ScrollbarStyle import io.github.oikvpqya.compose.fastscroller.ThumbStyle import io.github.oikvpqya.compose.fastscroller.TrackStyle @Composable actual fun PlatformDependentProviders(content: @Composable (() -> Unit)) { CompositionLocalProvider( LocalContextMenuRepresentation provides myContextMenuRepresentation(), content = content, ) } @Composable actual fun myPlatformScrollbarStyle(): ScrollbarStyle { val shape = RoundedCornerShape(4.dp) return ScrollbarStyle( minimalHeight = 16.dp, thickness = 6.dp, thumbStyle = ThumbStyle( shape = shape, unhoverColor = myColors.onBackground / 10, hoverColor = myColors.onBackground / 30, ), trackStyle = TrackStyle( unhoverColor = Color.Transparent, hoverColor = Color.Transparent, shape = RectangleShape, ), hoverDurationMillis = 300, ) } private val desktopTextSizes = TextSizes( xs = 8.sp, sm = 10.sp, base = 12.sp, lg = 14.sp, xl = 16.sp, x2l = 18.sp, x3l = 20.sp, x4l = 22.sp, x5l = 24.sp, ) private val desktopSpacings = MySpacings( thumbSize = 24.dp, iconSize = 16.dp, smallSpace = 4.dp, mediumSpace = 8.dp, largeSpace = 16.dp, ) val desktopShapes = MyShapes( defaultRounded = RoundedCornerShape(6.dp) ) @Composable actual fun myPlatformTextSizes(): TextSizes { return desktopTextSizes } @Composable actual fun myPlatformShapes(): MyShapes { return desktopShapes } @Composable actual fun myPlatformSpacing(): MySpacings { return desktopSpacings } ================================================ FILE: shared/app/src/desktopMain/kotlin/com/abdownloadmanager/shared/ui/util/LocalWindow.desktop.kt ================================================ package com.abdownloadmanager.shared.ui.util import androidx.compose.runtime.ProvidableCompositionLocal import androidx.compose.runtime.compositionLocalOf import java.awt.Window val LocalWindow: ProvidableCompositionLocal = compositionLocalOf { error("LocalWindow not provided yet") } ================================================ FILE: shared/app/src/desktopMain/kotlin/com/abdownloadmanager/shared/ui/widget/Tooltip.desktop.kt ================================================ package com.abdownloadmanager.shared.ui.widget import androidx.compose.foundation.hoverable import androidx.compose.foundation.interaction.MutableInteractionSource import androidx.compose.foundation.interaction.collectIsHoveredAsState import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.MutableState import androidx.compose.runtime.getValue import androidx.compose.runtime.remember import androidx.compose.runtime.rememberUpdatedState import androidx.compose.ui.Modifier import androidx.compose.ui.composed import androidx.compose.ui.input.pointer.PointerEventPass import androidx.compose.ui.input.pointer.PointerEventType import androidx.compose.ui.input.pointer.PointerType import androidx.compose.ui.input.pointer.pointerInput import kotlinx.coroutines.coroutineScope actual fun Modifier.detectTooltip( state: MutableState, ): Modifier { return pointerInput(state) { coroutineScope { awaitPointerEventScope { val pass = PointerEventPass.Main while (true) { val event = awaitPointerEvent(pass) val inputType = event.changes[0].type if (inputType == PointerType.Mouse) { when (event.type) { PointerEventType.Enter -> { state.value = true } PointerEventType.Exit -> { state.value = false } } } } } } } } ================================================ FILE: shared/app/src/desktopMain/kotlin/com/abdownloadmanager/shared/ui/widget/menu/custom/MenuBar.kt ================================================ package com.abdownloadmanager.shared.ui.widget.menu.custom import com.abdownloadmanager.shared.util.ui.myColors import com.abdownloadmanager.shared.util.ui.theme.myTextSizes import ir.amirab.util.ifThen import ir.amirab.util.compose.action.MenuItem import androidx.compose.foundation.* import androidx.compose.foundation.interaction.MutableInteractionSource import androidx.compose.foundation.interaction.collectIsHoveredAsState import androidx.compose.foundation.layout.* import com.abdownloadmanager.shared.ui.widget.Text import androidx.compose.runtime.* import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.RectangleShape import androidx.compose.ui.unit.dp @Composable fun MenuBar( modifier: Modifier = Modifier, subMenuList: List, ) { var openedItem: MenuItem.SubMenu? by remember { mutableStateOf(null) } val onRequestClose = { openedItem = null } Row( verticalAlignment = Alignment.CenterVertically, ) { for (subMenu in subMenuList) { val isSelected = openedItem == subMenu val interactionSource = remember { MutableInteractionSource() } val isHovered by interactionSource.collectIsHoveredAsState() LaunchedEffect(isHovered) { if (isHovered && openedItem != null) { openedItem = subMenu } } Column { Column( modifier .hoverable(interactionSource) .clickable { openedItem = subMenu } .ifThen(isSelected) { background(myColors.surface) } .padding(horizontal = 8.dp, vertical = 4.dp) .wrapContentHeight(Alignment.CenterVertically) ) { val text = subMenu.title.collectAsState().value.rememberString() Text( text = text, maxLines = 1, fontSize = myTextSizes.base, color = myColors.onBackground, ) } if (isSelected) { MyDropDown( onDismissRequest = onRequestClose, focusable = false, ) { CompositionLocalProvider( LocalMenuBoxClip provides RectangleShape ) { SubMenu(subMenu, onRequestClose = onRequestClose) } } } } } } } ================================================ FILE: shared/app/src/desktopMain/kotlin/com/abdownloadmanager/shared/ui/widget/menu/custom/Option.desktop.kt ================================================ package com.abdownloadmanager.shared.ui.widget.menu.custom import androidx.compose.runtime.Composable import androidx.compose.ui.Alignment import androidx.compose.ui.window.Popup import androidx.compose.ui.window.rememberCursorPositionProvider import ir.amirab.util.compose.action.MenuItem @Composable actual fun ShowOptionsInPopup( menu: MenuItem.SubMenu, onDismissRequest: () -> Unit ) { Popup( popupPositionProvider = rememberCursorPositionProvider( alignment = Alignment.BottomEnd ), onDismissRequest = onDismissRequest ) { RenderOptions(menu, onDismissRequest) } } ================================================ FILE: shared/app/src/desktopMain/kotlin/com/abdownloadmanager/shared/ui/widget/menu/custom/WithContextMenu.desktop.kt ================================================ package com.abdownloadmanager.shared.ui.widget.menu.custom import androidx.compose.foundation.PointerMatcher import androidx.compose.foundation.layout.Box import androidx.compose.foundation.onClick import androidx.compose.runtime.Composable import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.input.pointer.PointerButton import androidx.compose.ui.window.Popup import androidx.compose.ui.window.rememberCursorPositionProvider import ir.amirab.util.compose.action.MenuItem @Composable actual fun WithContextMenu( menuProvider: () -> List, modifier: Modifier, content: @Composable (() -> Unit) ) { val menu = remember(menuProvider) { mutableStateOf(emptyList()) } val onDismissRequest = { menu.value = emptyList() } Box( modifier.onClick( matcher = PointerMatcher.mouse( PointerButton.Secondary ) ) { menu.value = menuProvider() } ) { content() if (menu.value.isNotEmpty()) { Popup( popupPositionProvider = rememberCursorPositionProvider( alignment = Alignment.BottomEnd ), onDismissRequest = onDismissRequest ) { SubMenu( subMenu = menu.value, onRequestClose = onDismissRequest, ) } } } } ================================================ FILE: shared/app/src/desktopMain/kotlin/com/abdownloadmanager/shared/ui/widget/menu/native/NativeMenuBar.kt ================================================ package com.abdownloadmanager.shared.ui.widget.menu.native import androidx.compose.foundation.isSystemInDarkTheme import androidx.compose.runtime.Composable import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue import androidx.compose.runtime.remember import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.ColorFilter import androidx.compose.ui.graphics.drawscope.DrawScope import androidx.compose.ui.graphics.painter.Painter import androidx.compose.ui.input.key.Key import androidx.compose.ui.input.key.KeyShortcut import androidx.compose.ui.window.FrameWindowScope import androidx.compose.ui.window.MenuBar import androidx.compose.ui.window.MenuScope import com.abdownloadmanager.shared.util.LocalShortCutManager import com.abdownloadmanager.shared.util.PlatformKeyStroke import com.abdownloadmanager.shared.util.ShortcutManager import ir.amirab.util.compose.IconSource import ir.amirab.util.compose.action.MenuItem @Composable fun NativeMenuBar( scope: FrameWindowScope, subMenuList: List ) { val shortcutManager = LocalShortCutManager.current scope.MenuBar { subMenuList.forEach { item -> val items by item.items.collectAsState() val title by item.title.collectAsState() val enabled by item.isEnabled.collectAsState() Menu(title.rememberString(), enabled = enabled) { items.forEach { renderMenuItem(it, shortcutManager) } } } } } @Composable private fun MenuScope.renderMenuItem(item: MenuItem, shortcutManager: ShortcutManager?) { when (item) { is MenuItem.SubMenu -> { val items by item.items.collectAsState() val title by item.title.collectAsState() val enabled by item.isEnabled.collectAsState() Menu(title.rememberString(), enabled = enabled) { items.forEach { renderMenuItem(it, shortcutManager) } } } is MenuItem.Separator -> Separator() is MenuItem.SingleItem -> { val title by item.title.collectAsState() val icon by item.icon.collectAsState() val enabled by item.isEnabled.collectAsState() val shortcut = remember(shortcutManager, item) { shortcutManager?.getShortCutOf(item)?.toKeyShortcut() } Item( title.rememberString(), onClick = item::onClick, icon = icon?.suitablePainterForMenu(), enabled = enabled, shortcut = shortcut ) } } } @Composable private fun IconSource.suitablePainterForMenu(): Painter { val isLight = !isSystemInDarkTheme() return if (isLight && requiredTint) { val painter = rememberPainter() remember(painter) { painter.withTint(Color.Black) } } else rememberPainter() } fun Painter.withTint(tint: Color): Painter = object : Painter() { override val intrinsicSize = this@withTint.intrinsicSize override fun DrawScope.onDraw() { with(this@withTint) { draw(size = size, colorFilter = ColorFilter.tint(tint)) } } } private fun PlatformKeyStroke.toKeyShortcut(): KeyShortcut { val mods = getModifiers().map { it.trim() } return KeyShortcut( key = Key(keyCode), ctrl = mods.any { it in listOf("⌃", "ctrl", "control") }, alt = mods.any { it in listOf("⌥", "alt", "option") }, shift = mods.any { it in listOf("⇧", "shift") }, meta = mods.any { it in listOf("⌘", "meta", "command") } ) } ================================================ FILE: shared/app/src/desktopMain/kotlin/com/abdownloadmanager/shared/ui/widget/table/customtable/CellPadding.kt ================================================ package com.abdownloadmanager.shared.ui.widget.table.customtable ================================================ FILE: shared/app/src/desktopMain/kotlin/com/abdownloadmanager/shared/ui/widget/table/customtable/Table.kt ================================================ package com.abdownloadmanager.shared.ui.widget.table.customtable import com.abdownloadmanager.shared.util.ui.LocalContentColor import com.abdownloadmanager.shared.util.ui.widget.MyIcon import com.abdownloadmanager.shared.util.ui.icon.MyIcons import com.abdownloadmanager.shared.util.ui.myColors import com.abdownloadmanager.shared.util.ui.theme.myTextSizes import ir.amirab.util.ifThen import com.abdownloadmanager.shared.ui.widget.CheckBox import com.abdownloadmanager.shared.ui.widget.menu.custom.MenuColumn import com.abdownloadmanager.shared.util.div import ir.amirab.util.flow.saved import androidx.compose.foundation.* import androidx.compose.foundation.layout.* import androidx.compose.foundation.lazy.* import androidx.compose.foundation.v2.ScrollbarAdapter import com.abdownloadmanager.shared.ui.widget.Text import androidx.compose.runtime.* import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.alpha import androidx.compose.ui.draw.clip import androidx.compose.ui.input.pointer.PointerButton import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp import androidx.compose.ui.window.Popup import androidx.compose.ui.window.rememberCursorPositionProvider import com.abdownloadmanager.resources.Res import com.abdownloadmanager.shared.ui.widget.sort.Sort import com.abdownloadmanager.shared.ui.widget.sort.SortIndicatorMode import com.abdownloadmanager.shared.ui.widget.sort.isAscending import com.abdownloadmanager.shared.ui.widget.sort.isDescending import com.abdownloadmanager.shared.ui.widget.sort.toSortIndicatorMode import com.abdownloadmanager.shared.util.ui.MultiplatformHorizontalScrollbar import com.abdownloadmanager.shared.util.ui.MultiplatformVerticalScrollbar import com.abdownloadmanager.shared.util.ui.needScroll import com.abdownloadmanager.shared.util.ui.theme.myShapes import ir.amirab.util.compose.resources.myStringResource import ir.amirab.util.shifted import kotlinx.coroutines.flow.* import sh.calvin.reorderable.ReorderableColumn import sh.calvin.reorderable.ReorderableListItemScope val LocalCellPadding = compositionLocalOf { PaddingValues(horizontal = 4.dp, vertical = 0.dp) } val LocalTableSize = compositionLocalOf { error("LocalTableConstraints not provided") } val LocalResizeCellsOnResizeTable = compositionLocalOf { error("LocalResizeCellsOnResizeTable not provided") } @Composable fun > Table( list: List, key: ((T) -> Any)? = null, tableState: TableState, modifier: Modifier = Modifier, listState: LazyListState = rememberLazyListState(), horizontalScrollState: ScrollState = rememberScrollState(), resizeCellsOnResizeTableWidth: Boolean = false, renderHeaderCell: @Composable (C) -> Unit = { DefaultRenderHeader(it) }, drawOnEmpty: @Composable BoxScope.() -> Unit = {}, wrapHeader: @Composable TableScope.(rowContent: @Composable () -> Unit) -> Unit = { content -> content() }, wrapItem: @Composable TableScope.(index: Int, item: T, rowContent: @Composable () -> Unit) -> Unit = { _, _, content -> content() }, renderCell: @Composable TableScope.(C, T) -> Unit, ) { val scope = TableScope val visibleCells by tableState.visibleCells.collectAsState() val cellOrder by tableState.order.collectAsState() val cells = remember(visibleCells, cellOrder) { cellOrder.filter { it in visibleCells } } val sortedBy by tableState.sortBy.collectAsState() val customWidths by tableState.customSizes.collectAsState() TwoDimensionScrollbar( modifier = modifier, content = { BoxWithConstraints(Modifier.fillMaxSize()) { CompositionLocalProvider( LocalTableSize provides TableSize( visibleWidth = maxWidth, visibleHeight = maxWidth, ), LocalResizeCellsOnResizeTable provides resizeCellsOnResizeTableWidth ) { var showColumnConfig by remember { mutableStateOf(false) } if (showColumnConfig) { ShowColumnConfigMenu( onDismissRequest = { showColumnConfig = false }, tableState = tableState ) } Column( modifier = Modifier .horizontalScroll(horizontalScrollState), ) { scope.wrapHeader { Row( Modifier .onClick( matcher = { it.button == PointerButton.Secondary } ) { showColumnConfig = true } .height(IntrinsicSize.Max), verticalAlignment = Alignment.CenterVertically, ) { cells.forEach { cell -> val delta = scope.deltaWidthFraction(cell.size) val shouldResizeWidthOnResizeTable = scope.getIsResizeCellOnResizeTable() LaunchedEffect(cell.size, delta) { if (shouldResizeWidthOnResizeTable && cell.size is CellSize.Resizeable) { tableState.onCellSizeChanged(cell) { it * delta } } } Row( Modifier .width(customWidths[cell] ?: cell.size.defaultWidth) // .border(width = LocalCellPadding.current.calculateTopPadding(), myColors.primary,) .padding(LocalCellPadding.current), verticalAlignment = Alignment.CenterVertically ) { MaybeResizeableCell( cell, onResizeCell = { tableState.onCellSizeChanged(cell, it) } ) { MaybeSortableCell( cell, sortedBy, { @Suppress("UNCHECKED_CAST") tableState.setSortBy(Sort(cell as SortableCell, it)) } ) { Box(Modifier.weight(1f)) { renderHeaderCell(cell) } } } } } } } val sortedList = remember(list, sortedBy) { tableState.sortedList(list, sortedBy) } LazyColumn( Modifier .fillMaxHeight(), state = listState, ) { itemsIndexed( sortedList, key = if (key != null) { _, item -> key(item) } else null ) { index, item -> scope.wrapItem(index, item) { Row( verticalAlignment = Alignment.CenterVertically ) { cells.forEach { cell -> Box( Modifier.width(customWidths[cell] ?: cell.size.defaultWidth) .padding(LocalCellPadding.current) ) { scope.renderCell(cell, item) } } } } } } // Spacer(Modifier.size(4.dp)) } if (list.isEmpty()) { Box(Modifier.padding().fillMaxSize()) { drawOnEmpty() } } } } }, horizontalAdapter = rememberScrollbarAdapter(horizontalScrollState), verticalAdapter = rememberScrollbarAdapter(listState), ) } @Composable private fun TwoDimensionScrollbar( modifier: Modifier, content: @Composable () -> Unit, verticalAdapter: ScrollbarAdapter, horizontalAdapter: ScrollbarAdapter ) { Row(modifier) { Column(Modifier.weight(1f)) { Box(Modifier.weight(1f)) { content() } if (horizontalAdapter.needScroll()) { MultiplatformHorizontalScrollbar( horizontalAdapter, Modifier.padding( top = 4.dp, bottom = 4.dp, ) ) } } if (verticalAdapter.needScroll()) { MultiplatformVerticalScrollbar( verticalAdapter, Modifier.padding( start = 4.dp, end = 4.dp, bottom = 4.dp ) ) } } } @Composable private fun > ShowColumnConfigMenu( onDismissRequest: () -> Unit, tableState: TableState, ) { Popup( popupPositionProvider = rememberCursorPositionProvider( alignment = Alignment.BottomEnd ), onDismissRequest = onDismissRequest ) { val visibleItems by tableState.visibleCells.collectAsState() val forceVisibleItems = tableState.forceVisibleCells MenuColumn { Row( Modifier.padding(8.dp), verticalAlignment = Alignment.CenterVertically ) { MyIcon(MyIcons.settings, null, Modifier.size(12.dp)) Spacer(Modifier.size(8.dp)) Text( myStringResource(Res.string.customize_columns), fontSize = myTextSizes.base ) } Spacer(Modifier.fillMaxWidth().height(1.dp).background(myColors.onSurface / 5)) val orderedCells by tableState.order.collectAsState() ReorderableColumn( list = orderedCells, onSettle = { from, to -> tableState.setOrder { it.shifted(from, to - from) } }, content = { _, cell, _ -> key(cell) { ReorderableItem { CellConfigItem( modifier = Modifier.fillMaxWidth(), cell = cell, isVisible = cell in visibleItems, isForceVisible = cell in forceVisibleItems, setVisible = { checked -> tableState.setVisibleCells { val contains = it.contains(cell) if (checked) { it.ifThen(!contains) { plus(cell) } } else { it.ifThen(contains) { minus(cell) } } } }, setSort = { sort -> tableState.setSortBy(sort) }, sortBy = tableState.sortBy.collectAsState().value ) } } }, ) Spacer(Modifier.fillMaxWidth().height(1.dp).background(myColors.onSurface / 5)) Row( verticalAlignment = Alignment.CenterVertically, modifier = Modifier .clickable(onClick = tableState::reset) .fillMaxWidth() .padding(8.dp) ) { MyIcon(MyIcons.undo, null, Modifier.size(12.dp)) Spacer(Modifier.size(8.dp)) Text(myStringResource(Res.string.reset)) } } } } @Composable private fun > ReorderableListItemScope.CellConfigItem( modifier: Modifier, cell: Cell, isVisible: Boolean, isForceVisible: Boolean, setVisible: (Boolean) -> Unit, sortBy: Sort>?, setSort: (Sort>?) -> Unit, ) { Row( modifier .padding(8.dp) .height(IntrinsicSize.Max), verticalAlignment = Alignment.CenterVertically, ) { MyIcon( icon = MyIcons.grip, contentDescription = null, modifier = Modifier .clip(myShapes.defaultRounded) .clickable {} .draggableHandle() .padding(4.dp) .size(12.dp), ) Spacer(Modifier.width(8.dp)) CheckBox( value = isVisible, enabled = !isForceVisible, onValueChange = { setVisible(it) }, size = 12.dp ) Spacer(Modifier.width(8.dp)) Text( cell.name.rememberString(), Modifier .weight(1f) .ifThen(!isVisible || isForceVisible) { alpha(0.5f) }, ) Spacer(Modifier.width(8.dp)) if (cell is SortableCell<*>) { SortIndicator( Modifier.fillMaxHeight() .clickable { @Suppress("UNCHECKED_CAST") setSort( sortBy?.takeIf { it.cell == cell }?.reverse() ?: Sort( cell as SortableCell, Sort.DEFAULT_IS_DESCENDING ) ) }.padding(horizontal = 2.dp) .wrapContentHeight(), sortBy ?.takeIf { it.cell == cell } ?.toSortIndicatorMode() ?: SortIndicatorMode.None ) } } } interface TableScope { companion object : TableScope @Composable fun getTableSize() = LocalTableSize.current @Composable fun getIsResizeCellOnResizeTable() = LocalResizeCellsOnResizeTable.current @Composable fun lastWidths(key: Any): Pair { val tableSize by rememberUpdatedState(getTableSize()) var result by remember(key) { mutableStateOf(tableSize.visibleWidth to tableSize.visibleWidth) } LaunchedEffect(key) { snapshotFlow { tableSize.visibleWidth } .saved(2) .onEach { when (it.size) { 0 -> null 1 -> { result.copy(second = it.first()) } else -> { it[0] to it[1] } }?.let { result = it } } .launchIn(this) } return result } @Composable fun deltaWidthFraction(key: Any): Float { return lastWidths(key).run { second / first } } @Composable fun deltaWidth(key: Any): Dp { return lastWidths(key).run { second - first } } } @Composable fun SortIndicator( modifier: Modifier = Modifier, mode: SortIndicatorMode, ) { val size = 6.dp Column(modifier) { // val currentAlpha = LocalContentAlpha.current val color = LocalContentColor.current val passiveAlpha = color / 0.25f val activeAlpha = color / 0.75f // val activeAlpha=(currentAlpha + 0.5f).coerceAtMost(1f) MyIcon( MyIcons.sortUp, null, Modifier .size(size), tint = if (mode.isAscending()) { activeAlpha } else { passiveAlpha } ) MyIcon( MyIcons.sortDown, null, Modifier .size(size), tint = if (mode.isDescending()) { activeAlpha } else { passiveAlpha } ) } } ================================================ FILE: shared/app/src/desktopMain/kotlin/com/abdownloadmanager/shared/ui/widget/table/customtable/TableUtils.kt ================================================ package com.abdownloadmanager.shared.ui.widget.table.customtable import com.abdownloadmanager.shared.util.ui.myColors import com.abdownloadmanager.shared.ui.widget.resizeHandle import com.abdownloadmanager.shared.util.div import androidx.compose.animation.animateColorAsState import androidx.compose.foundation.background import androidx.compose.foundation.gestures.Orientation import androidx.compose.foundation.hoverable import androidx.compose.foundation.interaction.MutableInteractionSource import androidx.compose.foundation.interaction.collectIsDraggedAsState import androidx.compose.foundation.interaction.collectIsHoveredAsState import androidx.compose.foundation.layout.* import androidx.compose.foundation.onClick import com.abdownloadmanager.shared.ui.widget.Text import androidx.compose.runtime.* import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color import androidx.compose.ui.input.pointer.PointerIcon import androidx.compose.ui.input.pointer.pointerHoverIcon import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp import com.abdownloadmanager.shared.ui.widget.sort.ComparatorProvider import com.abdownloadmanager.shared.ui.widget.sort.Sort import com.abdownloadmanager.shared.ui.widget.sort.SortIndicatorMode import com.abdownloadmanager.shared.ui.widget.sort.sorted import com.abdownloadmanager.shared.ui.widget.sort.toSortIndicatorMode import ir.amirab.util.compose.StringSource import ir.amirab.util.flow.mapStateFlow import ir.amirab.util.swapped import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.merge import kotlinx.coroutines.flow.update import kotlinx.serialization.Serializable import java.awt.Cursor @Stable data class TableSize( val visibleHeight: Dp, val visibleWidth: Dp, ) @Immutable sealed interface CellSize { val defaultWidth: Dp data class Resizeable( val range: ClosedRange, override val defaultWidth: Dp = range.start, ) : CellSize data class Fixed( override val defaultWidth: Dp ) : CellSize } @Stable interface TableCell { val id: String val name: StringSource val size: CellSize } interface CustomCellRenderer { @Composable fun drawHeader() } interface SortableCell : TableCell, ComparatorProvider { override fun comparator(): Comparator } @Composable fun DefaultRenderHeader(cell: TableCell<*>) { if (cell is CustomCellRenderer) { cell.drawHeader() } else { Text( cell.name.rememberString(), Modifier.fillMaxWidth(), maxLines = 1, overflow = TextOverflow.Ellipsis, ) } } @Composable fun RowScope.MaybeResizeableCell( cell: TableCell<*>, onResizeCell: ((Dp) -> Dp) -> Unit, content: @Composable () -> Unit, ) { when (cell.size) { is CellSize.Fixed -> { content() } is CellSize.Resizeable -> { Row( Modifier.weight(1f), verticalAlignment = Alignment.CenterVertically, ) { val mInteractionSource = remember { MutableInteractionSource() } content() CellResizeHandle( Modifier.width(12.dp) .fillMaxHeight(), orientation = Orientation.Horizontal, mInteractionSource, color = myColors.onBackground / 50, inactiveColor = myColors.onBackground / 10, ) { delta -> onResizeCell { it + delta } } } } } } @Composable fun CellResizeHandle( modifier: Modifier, orientation: Orientation = Orientation.Horizontal, interactionSource: MutableInteractionSource = remember { MutableInteractionSource() }, color: Color = myColors.surface, inactiveColor: Color = myColors.surface / 50, onDrag: (Dp) -> Unit, ) { val isHovered by interactionSource.collectIsHoveredAsState() val isDragging by interactionSource.collectIsDraggedAsState() val hoverIcon = remember(orientation) { PointerIcon( Cursor( when (orientation) { Orientation.Vertical -> Cursor.S_RESIZE_CURSOR Orientation.Horizontal -> Cursor.E_RESIZE_CURSOR } ) ) } val background = animateColorAsState( if (isHovered || isDragging) color else inactiveColor ).value Box( modifier .pointerHoverIcon(hoverIcon, true) .hoverable(interactionSource) .resizeHandle( orientation = orientation, interactionSource = interactionSource, onDrag = onDrag, ) ) { Row( Modifier .fillMaxSize().wrapContentSize() ) { val m = Modifier .fillMaxHeight() .width(1.dp) .background(background) Spacer(m) } } } @Composable fun RowScope.MaybeSortableCell( cell: TableCell, sortedBy: Sort>?, setSort: (isAscending: Boolean) -> Unit, content: @Composable () -> Unit, ) { if (cell is SortableCell) { val iHaveSorted = sortedBy.takeIf { it?.cell == cell } Row( Modifier .weight(1f) .onClick { setSort(iHaveSorted?.reverse()?.isDescending() ?: Sort.DEFAULT_IS_DESCENDING) }, verticalAlignment = Alignment.CenterVertically, ) { SortIndicator( Modifier, iHaveSorted?.toSortIndicatorMode() ?: SortIndicatorMode.None ) Spacer(Modifier.width(2.dp)) content() } } else { content() } } @Stable class TableState>( val cells: List, val forceVisibleCells: List = emptyList(), val initialCustomSizes: Map = emptyMap(), val initialSortBy: Sort>? = null, val initialOrder: List = cells, val initialVisibleItems: List = cells, ) { private val _customSizes = MutableStateFlow>(initialCustomSizes) val customSizes = _customSizes.asStateFlow() fun setCustomSizes(sizes: Map) { setCustomSizes { sizes } } fun setCustomSizes(sizes: (Map) -> Map) { _customSizes.update { sizes(it) } } fun onCellSizeChanged(cell: Cell, change: (Dp) -> Dp) { val customSizes = _customSizes.value val size = cell.size as? CellSize.Resizeable ?: run { error("can't resize this column because it have a FixedSize") } val dp = customSizes[cell] val x = change((dp ?: size.defaultWidth)).coerceIn(size.range) if (x == cell.size.defaultWidth) { setCustomSizes { it.minus(cell) } } else { setCustomSizes { customSizes.plus(cell to x) } } } private val _sortBy = MutableStateFlow>?>(initialSortBy) val sortBy = _sortBy.asStateFlow() fun setSortBy(cell: Sort>?) { this._sortBy.update { cell } } private val _order = MutableStateFlow(initialOrder) val order = _order .asStateFlow() .mapStateFlow { val remainingCells = cells.subtract(it.toSet()) it.plus(remainingCells) } fun setOrder(updater: (List) -> List) { _order.update { updater(it) } } fun setOrder(cell: Cell, delta: Int) { setOrder { val index = it.indexOf(cell) val newIndex = (index + delta) val shouldMove = newIndex in it.indices if (shouldMove) { it.swapped(index, newIndex) } else it } } fun setOrder(order: List) { setOrder { order } } private val _visibleCells = MutableStateFlow>(initialVisibleItems) val visibleCells = _visibleCells.asStateFlow() .mapStateFlow { it.plus(forceVisibleCells.subtract(it.toSet())) } fun setVisibleCells(cells: (List) -> List) { _visibleCells.update { cells(it).distinct().toMutableList() } } fun setVisibleCells(cells: List) { setVisibleCells { cells } } fun reset() { setCustomSizes(initialCustomSizes) setVisibleCells(initialVisibleItems) setOrder(initialOrder) setSortBy(initialSortBy) } val onPropChange = merge( order, customSizes, visibleCells, sortBy, ) fun save(): SerializableTableState { val sizes = customSizes.value.mapKeys { it.key.id }.mapValues { it.value.value } val sortBy = sortBy.value return SerializableTableState( sizes = sizes, sortBy = sortBy?.let { SortBy(name = sortBy.cell.id, descending = sortBy.isDescending()) }, order = order.value.map { it.id }, visibleCells = visibleCells.value.map { it.id } ) } fun load(s: SerializableTableState) { setCustomSizes { val cellsThatHaveCustomWidth = findCellById(s.sizes.keys) cellsThatHaveCustomWidth.associateWith { s.sizes[it.id]!!.dp } } setOrder(findCellById(s.order)) setSortBy( s.sortBy?.let { sortBy -> findCellById(sortBy.name)?.let { Sort(it as SortableCell, sortBy.descending) } } ) setVisibleCells(findCellById(s.visibleCells)) } private fun findCellById(name: String): Cell? { return cells.find { it.id == name } } private fun findCellById(list: Iterable): List { return list.mapNotNull { name -> findCellById(name) } } fun sortedList(list: List, sortBy: Sort>? = this.sortBy.value): List { return sortBy?.sorted(list) ?: list } /** * get range of items based on the current sort of table */ fun getARangeOfItems( list: List, id: (Item) -> ID, fromItem: ID, toItem: ID, ): List { return sortedList(list).map(id).dropWhile { it != fromItem && it != toItem }.dropLastWhile { it != fromItem && it != toItem } } fun getItemPosition( list: List, selector: (Item) -> Boolean, ): Int { return sortedList(list) .indexOfFirst(selector) } @Serializable data class SerializableTableState( val sizes: Map = emptyMap(), val sortBy: SortBy? = null, val visibleCells: List = emptyList(), val order: List = emptyList(), ) @Serializable data class SortBy( val name: String, val descending: Boolean, ) } ================================================ FILE: shared/app/src/desktopMain/kotlin/com/abdownloadmanager/shared/ui/widget/table/customtable/styled/MyStyledHeader.kt ================================================ package com.abdownloadmanager.shared.ui.widget.table.customtable.styled import com.abdownloadmanager.shared.util.ui.myColors import com.abdownloadmanager.shared.ui.widget.table.customtable.TableScope import com.abdownloadmanager.shared.util.ui.WithContentAlpha import androidx.compose.foundation.background import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.widthIn import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip import androidx.compose.ui.draw.shadow import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp import com.abdownloadmanager.shared.util.ui.theme.myShapes @Composable fun TableScope.MyStyledTableHeader( itemHorizontalPadding: Dp, content:@Composable ()->Unit, ) { val shape = myShapes.defaultRounded WithContentAlpha(0.75f) { Box(Modifier .widthIn(getTableSize().visibleWidth) .padding(bottom = 1.dp) .shadow( elevation = 1.dp, shape = shape, ) .padding(bottom = 1.dp) .clip(shape) .background(myColors.surface) .padding(vertical = 8.dp, horizontal = itemHorizontalPadding) ) { content() } } } ================================================ FILE: shared/app/src/desktopMain/kotlin/com/abdownloadmanager/shared/util/ClipboardUtil.desktop.kt ================================================ package com.abdownloadmanager.shared.util import org.jetbrains.skiko.ClipboardManager actual object ClipboardUtil { private val clipboardManager = ClipboardManager() actual fun copy(text: String) { runCatching { clipboardManager.setText(text.toString()) } } actual fun read(): String? { return runCatching { clipboardManager.getText() }.getOrNull() } } ================================================ FILE: shared/app/src/desktopMain/kotlin/com/abdownloadmanager/shared/util/DesktopDiskStat.desktop.kt ================================================ package com.abdownloadmanager.shared.util import ir.amirab.downloader.utils.IDiskStat import java.io.File actual typealias PlatformDiskStat = DesktopDiskStat class DesktopDiskStat : IDiskStat { override fun getRemainingSpace(path: File): Long { return path.freeSpace } } ================================================ FILE: shared/app/src/desktopMain/kotlin/com/abdownloadmanager/shared/util/PlatformThemeDetector.desktop.kt ================================================ package com.abdownloadmanager.shared.util import com.abdownloadmanager.shared.util.ui.theme.ISystemThemeDetector import com.jthemedetecor.OsThemeDetector import kotlinx.coroutines.channels.awaitClose import kotlinx.coroutines.flow.callbackFlow import kotlinx.coroutines.flow.emitAll import kotlinx.coroutines.flow.flow actual typealias PlatformThemeDetector = DesktopSystemThemeDetector class DesktopSystemThemeDetector : ISystemThemeDetector { override val isSupported by lazy { runCatching { OsThemeDetector.isSupported() }.getOrElse { false } } private val detector by lazy { OsThemeDetector.getDetector() } private val isSystemDarkFlowByLibrary = callbackFlow { val listener: (Boolean) -> Unit = { isDark: Boolean -> trySend(isDark) } detector.registerListener(listener) awaitClose { detector.removeListener(listener) } } override fun isDark() = detector.isDark override val systemThemeFlow = flow { if (!isSupported) { return@flow } emit(detector.isDark) emitAll(isSystemDarkFlowByLibrary) } } ================================================ FILE: shared/app/src/desktopMain/kotlin/com/abdownloadmanager/shared/util/downloadlocation/DesktopDownloadLocationProvider.kt ================================================ package com.abdownloadmanager.shared.util.downloadlocation import com.abdownloadmanager.shared.util.SystemDownloadLocationProvider import java.io.File abstract class DesktopDownloadLocationProvider() : SystemDownloadLocationProvider() { override fun getCommonDownloadLocation(): File { return File(System.getProperty("user.home"), "Downloads") } } ================================================ FILE: shared/app/src/desktopMain/kotlin/com/abdownloadmanager/shared/util/downloadlocation/LinuxDownloadLocationProvider.kt ================================================ package com.abdownloadmanager.shared.util.downloadlocation import java.io.File import java.io.Reader import java.util.Properties class LinuxDownloadLocationProvider : DesktopDownloadLocationProvider() { override fun getCurrentDownloadLocation(): File? { val properties = getConfigFileReader()?.use { Properties().apply { load(it) } } return properties ?.getWithResolvedVariables("XDG_DOWNLOAD_DIR") ?.let(::File) ?.canonicalFile } private fun getUserDirsFile(): File { val xdgConfigFromEnv: File? = System.getenv("XDG_CONFIG_HOME") ?.takeIf { it.isNotEmpty() } ?.let(::File) val configDir = (xdgConfigFromEnv ?: File(System.getProperty("user.home"), ".config")) return File(configDir, "user-dirs.dirs") } private fun getConfigFileReader(): Reader? { return getUserDirsFile() .takeIf { it.exists() } ?.bufferedReader(Charsets.UTF_8) } private fun Properties.getWithResolvedVariables(key: String): String? { return runCatching { getProperty(key) ?.hydrateEnvVariables() ?.trim('"') ?.trim('\'') }.onFailure { it.printStackTrace() }.getOrNull() } private val variableRegex = "\\$\\{?([A-Za-z0-9_]+)\\}?".toRegex() private fun String.hydrateEnvVariables(): String { return variableRegex.replace(this) { val variableName = it.groupValues[1] System.getenv(variableName) } } } ================================================ FILE: shared/app/src/desktopMain/kotlin/com/abdownloadmanager/shared/util/downloadlocation/MacDownloadLocationProvider.kt ================================================ package com.abdownloadmanager.shared.util.downloadlocation import java.io.File class MacDownloadLocationProvider : DesktopDownloadLocationProvider() { override fun getCurrentDownloadLocation(): File? { return getCommonDownloadLocation() } } ================================================ FILE: shared/app/src/desktopMain/kotlin/com/abdownloadmanager/shared/util/downloadlocation/PlatformDownloadLocationProvider.desktop.kt ================================================ package com.abdownloadmanager.shared.util.downloadlocation import com.abdownloadmanager.shared.util.SystemDownloadLocationProvider import ir.amirab.util.platform.Platform import ir.amirab.util.platform.asDesktop actual fun getPlatformDownloadLocationProvider(): SystemDownloadLocationProvider { return when (Platform.asDesktop()) { Platform.Desktop.Windows -> WindowsDownloadLocationProvider() Platform.Desktop.Linux -> LinuxDownloadLocationProvider() Platform.Desktop.MacOS -> MacDownloadLocationProvider() } } ================================================ FILE: shared/app/src/desktopMain/kotlin/com/abdownloadmanager/shared/util/downloadlocation/WindowsDownloadLocationProvider.kt ================================================ package com.abdownloadmanager.shared.util.downloadlocation import com.sun.jna.platform.win32.KnownFolders import com.sun.jna.platform.win32.Shell32 import com.sun.jna.platform.win32.WinNT import com.sun.jna.ptr.PointerByReference import java.io.File class WindowsDownloadLocationProvider : DesktopDownloadLocationProvider() { override fun getCurrentDownloadLocation(): File? { val pathRef = PointerByReference() val hr = Shell32.INSTANCE.SHGetKnownFolderPath(KnownFolders.FOLDERID_Downloads, 0, WinNT.HANDLE(), pathRef) if (hr.toInt() != 0) { throw RuntimeException("Failed to get Downloads folder (HRESULT=${hr.toInt()})") } val downloadsPath = pathRef.value.getWideString(0) return File(downloadsPath).canonicalFile } } ================================================ FILE: shared/app/src/desktopMain/kotlin/com/abdownloadmanager/shared/util/ui/widget/MPBackHandler.desktop.kt ================================================ package com.abdownloadmanager.shared.util.ui.widget import androidx.compose.runtime.Composable @Composable actual fun MPBackHandler(isEnabled: Boolean, onBack: () -> Unit) { // improvements: hook to [escape] button using onKeyEvent and trigger on back here } ================================================ FILE: shared/auto-start/build.gradle.kts ================================================ import org.jetbrains.kotlin.gradle.dsl.JvmTarget plugins { id(MyPlugins.kotlinMultiplatform) id(Plugins.Android.library) } kotlin { jvm("desktop") androidTarget("android") { compilerOptions { jvmTarget.set(JvmTarget.JVM_21) } } sourceSets { commonMain.dependencies { implementation(project(":shared:utils")) } val desktopMain by getting desktopMain.dependencies { // // for windows, we use registry implementation(libs.jna.platform) } } } android { compileSdk = 36 namespace = "ir.amirab.util.startup" defaultConfig { minSdk = 26 } } ================================================ FILE: shared/auto-start/src/androidMain/kotlin/ir/amirab/util/startup/AndroidStartupManager.kt ================================================ package ir.amirab.util.startup import android.content.BroadcastReceiver import android.content.ComponentName import android.content.Context import android.content.pm.PackageManager class AndroidStartupManager( private val context: Context, private val receiverClass: Class, ) : AbstractStartupManager() { override fun install() { context.packageManager.setComponentEnabledSetting( ComponentName(context, receiverClass), PackageManager.COMPONENT_ENABLED_STATE_ENABLED, PackageManager.DONT_KILL_APP ) } override fun uninstall() { context.packageManager.setComponentEnabledSetting( ComponentName(context, receiverClass), PackageManager.COMPONENT_ENABLED_STATE_DISABLED, PackageManager.DONT_KILL_APP ) } } ================================================ FILE: shared/auto-start/src/androidMain/kotlin/ir/amirab/util/startup/Startup.android.kt ================================================ package ir.amirab.util.startup import android.content.BroadcastReceiver import android.content.Context actual object Startup { fun getStartUpManager( context: Context, bootReceiver: Class, ): AndroidStartupManager { return AndroidStartupManager(context, bootReceiver) } } ================================================ FILE: shared/auto-start/src/commonMain/kotlin/ir/amirab/util/startup/AbstractStartupManager.kt ================================================ package ir.amirab.util.startup abstract class AbstractStartupManager { @Throws(Exception::class) abstract fun install() abstract fun uninstall() } ================================================ FILE: shared/auto-start/src/commonMain/kotlin/ir/amirab/util/startup/Startup.kt ================================================ package ir.amirab.util.startup expect object Startup ================================================ FILE: shared/auto-start/src/desktopMain/kotlin/ir/amirab/util/startup/AbstractDesktopStartupManager.kt ================================================ package ir.amirab.util.startup abstract class AbstractDesktopStartupManager( val name: String, val path: String, val args: List, ) : AbstractStartupManager() { protected fun getExecutableWithArgs(): String { return buildList { add(path.quoted()) addAll(args) }.joinToString(" ") } private fun String.quoted(): String { return "\"$this\"" } } ================================================ FILE: shared/auto-start/src/desktopMain/kotlin/ir/amirab/util/startup/HeadlessStartupDesktop.kt ================================================ package ir.amirab.util.startup class HeadlessStartupDesktop( name: String, path: String, args: List, ) : AbstractDesktopStartupManager( name = name, path = path, args = args, ) { @Throws(Exception::class) override fun install() { } override fun uninstall() { } } ================================================ FILE: shared/auto-start/src/desktopMain/kotlin/ir/amirab/util/startup/MacOSStartupDesktop.kt ================================================ package ir.amirab.util.startup import java.io.File class MacOSStartupDesktop( name: String, path: String, args: List, ) : AbstractDesktopStartupManager( path = path, name = name, args = args, ) { private fun getFile(): File { if (!launchAgentsDir.exists()) { launchAgentsDir.mkdirs() } return File(launchAgentsDir, super.name + ".plist") } @Throws(Exception::class) override fun install() { val file = getFile() val plistContent = buildString { appendLine("") appendLine("") appendLine("") appendLine("\tLabel") appendLine("\t${super.name}.startup") appendLine("\tProgramArguments") appendLine("\t") appendLine("\t\t/usr/bin/open") appendLine("\t\t-a") appendLine("\t\t${super.path}") appendLine("\t\t--args") args.forEach { appendLine("\t\t$it") } appendLine("\t") appendLine("\tRunAtLoad") appendLine("\t") appendLine("\tLimitLoadToSessionType") appendLine("\tAqua") appendLine("") appendLine("") } file.bufferedWriter().use { it.write(plistContent) } runLaunchctlCommand(LOAD_COMMAND, file.path) } override fun uninstall() { val file = getFile() if (file.exists()) { runLaunchctlCommand(UNLOAD_COMMAND, file.path) file.delete() } } private fun runLaunchctlCommand(command: String, filePath: String) { ProcessBuilder("launchctl", command, filePath) .inheritIO() .start() .waitFor() } companion object { @get:Throws(Exception::class) val launchAgentsDir: File get() { var home = System.getProperty("user.home") if (Utils.isRoot) { home = "" } return File("$home/Library/LaunchAgents/") } private const val UNLOAD_COMMAND = "unload" private const val LOAD_COMMAND = "load" } } ================================================ FILE: shared/auto-start/src/desktopMain/kotlin/ir/amirab/util/startup/Startup.kt ================================================ package ir.amirab.util.startup import ir.amirab.util.platform.Platform actual object Startup { /** * Add file to startup * @param name Name of key/file * @param path Path to file * @throws Exception */ @Throws(Exception::class) fun getStartUpManagerForDesktop( name: String, path: String?, args: List, packageName: String, ): AbstractDesktopStartupManager { if (path==null){ //there is no installation path provided so we use no-op return noImplStartUpManager() } val os = Platform.getCurrentPlatform() val startup=when (os) { Platform.Desktop.Linux -> { if (Utils.isHeadless) { HeadlessStartupDesktop(name, path, args) } else { UnixXDGStartupDesktop(name, path, args, packageName) } } Platform.Desktop.MacOS -> MacOSStartupDesktop(name, path, args) Platform.Desktop.Windows -> WindowsStartupDesktop(name, path, args) Platform.Android -> error("this code should not be called in android") } return startup } private fun noImplStartUpManager(): HeadlessStartupDesktop { return HeadlessStartupDesktop("", "", emptyList()) } } ================================================ FILE: shared/auto-start/src/desktopMain/kotlin/ir/amirab/util/startup/UnixXDGStartupDesktop.kt ================================================ package ir.amirab.util.startup import java.io.File class UnixXDGStartupDesktop( name: String, path: String, args: List, val desktopEntryFileName: String, ) : AbstractDesktopStartupManager( name = name, path = path, args = args, ) { private fun getIconFilePath(): String? { return runCatching { val file = File(path) val name = file.name return file .parentFile.parentFile .resolve("lib/$name.png") .takeIf { it.exists() }?.path }.getOrNull() } private fun getAutoStartFile(): File { if (!autostartDir.exists()) { autostartDir.mkdirs() } return File(autostartDir, "$desktopEntryFileName.desktop") } @Throws(Exception::class) override fun install() { val name = this.name val exec = getExecutableWithArgs() val icon = getIconFilePath() getAutoStartFile().writeText( buildString { appendLine("[Desktop Entry]") appendLine("Type=Application") appendLine("Name=$name") appendLine("Exec=$exec") icon?.let { icon -> appendLine("Icon=$icon") } appendLine("Terminal=false") appendLine("NoDisplay=true") } ) } override fun uninstall() { getAutoStartFile().delete() } companion object { val autostartDir: File get() { val home = System.getProperty("user.home") return File("$home/.config/autostart/") } } } ================================================ FILE: shared/auto-start/src/desktopMain/kotlin/ir/amirab/util/startup/Utils.kt ================================================ package ir.amirab.util.startup import ir.amirab.util.platform.Platform import java.awt.GraphicsEnvironment import java.io.BufferedReader import java.io.File import java.io.InputStreamReader object Utils { @get:Throws(Exception::class) val isRoot: Boolean get() = Platform.getCurrentPlatform() !== Platform.Desktop.Windows && BufferedReader( InputStreamReader(Runtime.getRuntime().exec("whoami").inputStream) ).readLine() == "root" val isHeadless: Boolean get() = GraphicsEnvironment.getLocalGraphicsEnvironment().isHeadlessInstance() } ================================================ FILE: shared/auto-start/src/desktopMain/kotlin/ir/amirab/util/startup/WindowsStartupDesktop.kt ================================================ package ir.amirab.util.startup import com.sun.jna.platform.win32.Advapi32Util import com.sun.jna.platform.win32.WinReg class WindowsStartupDesktop( name: String, path: String, args: List, ) : AbstractDesktopStartupManager( name = name, path = path, args = args ) { @Throws(Exception::class) override fun install() { val data = getExecutableWithArgs() Advapi32Util.registrySetStringValue( WinReg.HKEY_CURRENT_USER, "Software\\Microsoft\\Windows\\CurrentVersion\\Run", this.name, data ) } override fun uninstall() { try { Advapi32Util.registryDeleteValue( WinReg.HKEY_CURRENT_USER, "Software\\Microsoft\\Windows\\CurrentVersion\\Run", this.name, ) } catch (e: Exception) { e.printStackTrace() } } } ================================================ FILE: shared/compose-utils/build.gradle.kts ================================================ import org.jetbrains.kotlin.gradle.dsl.JvmTarget plugins { id(MyPlugins.kotlinMultiplatform) id(MyPlugins.composeBase) id(Plugins.Android.library) } kotlin { jvm("desktop") androidTarget("android") { compilerOptions { jvmTarget.set(JvmTarget.JVM_21) } } sourceSets { commonMain.dependencies { implementation(libs.compose.runtime) implementation(libs.compose.foundation) implementation(libs.compose.ui) implementation(project(":shared:utils")) api(project(":shared:resources:contracts")) } } } android { compileSdk = 36 namespace = "ir.amirab.util.compose" defaultConfig { minSdk = 26 } } ================================================ FILE: shared/compose-utils/src/commonMain/kotlin/ir/amirab/util/compose/Helpers.kt ================================================ package ir.amirab.util.compose import androidx.compose.ui.unit.Density import androidx.compose.ui.unit.Dp fun Dp.dpToPx(density: Density): Float { return with(density) { toPx() } } fun Int.pxToDp(density: Density): Dp { return with(density) { toDp() } } ================================================ FILE: shared/compose-utils/src/commonMain/kotlin/ir/amirab/util/compose/IIconResolver.kt ================================================ package ir.amirab.util.compose import androidx.compose.runtime.staticCompositionLocalOf interface IIconResolver { fun resolve(uri: String): IconSource? } val LocalIconFromUriResolver = staticCompositionLocalOf { error("LocalIconFromUriResolver not provided") } ================================================ FILE: shared/compose-utils/src/commonMain/kotlin/ir/amirab/util/compose/IconSource.kt ================================================ package ir.amirab.util.compose import androidx.compose.runtime.Composable import androidx.compose.runtime.Immutable import androidx.compose.ui.graphics.painter.Painter import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.graphics.vector.rememberVectorPainter @Immutable sealed interface IconSource { val value: Any val requiredTint: Boolean val uri: String? @Composable fun rememberPainter(): Painter @Immutable data class VectorIconSource( override val value: ImageVector, override val requiredTint: Boolean, override val uri: String? = null, ) : IconSource { @Composable override fun rememberPainter(): Painter = rememberVectorPainter(value) } @Immutable data class PainterIconSource( override val value: Painter, override val requiredTint: Boolean, override val uri: String? = null, ) : IconSource { @Composable override fun rememberPainter(): Painter = value } companion object } ================================================ FILE: shared/compose-utils/src/commonMain/kotlin/ir/amirab/util/compose/StringSource.kt ================================================ package ir.amirab.util.compose import androidx.compose.runtime.Composable import androidx.compose.runtime.Immutable import androidx.compose.runtime.remember import arrow.core.combine import ir.amirab.util.compose.localizationmanager.LanguageManager import ir.amirab.util.compose.localizationmanager.withReplacedArgs import ir.amirab.util.compose.resources.MyStringResource import ir.amirab.util.compose.resources.myStringResource @Immutable sealed interface StringSource { @Composable fun rememberString(): String @Composable fun rememberString(args: Map): String fun getString(): String fun getString(args: Map): String @Immutable data class FromString( val value: String, ) : StringSource { @Composable override fun rememberString(): String { return value } @Composable override fun rememberString(args: Map): String { return remember(args) { if (args.isEmpty()) { value } else { value.withReplacedArgs(args) } } } override fun getString(): String { return value } override fun getString(args: Map): String { return if (args.isEmpty()) { value } else { value.withReplacedArgs(args) } } } @Immutable data class FromStringResource( val value: MyStringResource, val extraArgs: Map = emptyMap(), ) : StringSource { @Composable override fun rememberString(): String { return myStringResource(value, extraArgs) } @Composable override fun rememberString(args: Map): String { val argList = remember(extraArgs, args) { extraArgs.plus(args) } return if (argList.isEmpty()) { myStringResource(value) } else { myStringResource(value, argList) } } private fun getLanguageManager(): LanguageManager { return LanguageManager.instance } override fun getString(): String { return getLanguageManager() .getMessage(value.id) .withReplacedArgs(extraArgs) } override fun getString(args: Map): String { return getLanguageManager() .getMessage(value.id) .withReplacedArgs(extraArgs.plus(args)) } } @Immutable data class CombinedStringSource( val values: List, val separator: String, ) : StringSource { @Composable override fun rememberString(): String { return values.map { it.rememberString() }.joinToString(separator) } @Composable override fun rememberString(args: Map): String { return values.map { it.rememberString(args) }.joinToString(separator) } override fun getString(): String { return values.map { it.getString() }.joinToString(separator) } override fun getString(args: Map): String { return values.map { it.getString(args) }.joinToString(separator) } } } fun MyStringResource.asStringSource(): StringSource { return StringSource.FromStringResource(this) } fun MyStringResource.asStringSourceWithARgs(args: Map): StringSource { return StringSource.FromStringResource(this, args) } fun String.asStringSource(): StringSource { return StringSource.FromString(this) } fun List.combineStringSources(separator: String = ""): StringSource { return StringSource.CombinedStringSource(this, separator) } ================================================ FILE: shared/compose-utils/src/commonMain/kotlin/ir/amirab/util/compose/action/AnAction.kt ================================================ package ir.amirab.util.compose.action import ir.amirab.util.compose.IconSource import ir.amirab.util.compose.StringSource abstract class AnAction( title: StringSource, icon: IconSource? = null, ) : MenuItem.SingleItem( title = title, icon = icon, ) { override fun onClick() = actionPerformed() abstract fun actionPerformed() } ================================================ FILE: shared/compose-utils/src/commonMain/kotlin/ir/amirab/util/compose/action/Extensions.kt ================================================ package ir.amirab.util.compose.action import ir.amirab.util.compose.IconSource import ir.amirab.util.compose.StringSource import kotlinx.coroutines.flow.* inline fun simpleAction( title: StringSource, icon: IconSource? = null, crossinline onActionPerformed: AnAction.() -> Unit, ): AnAction { return object : AnAction( title = title, icon = icon, ) { override fun actionPerformed() = onActionPerformed() } } inline fun simpleAction( title: StringSource, icon: IconSource? = null, checkEnable: StateFlow, crossinline onActionPerformed: AnAction.() -> Unit, ): AnAction { return object : AnAction( title = title, icon = icon, ) { override val isEnabled: StateFlow = checkEnable override fun actionPerformed() = onActionPerformed() } } ================================================ FILE: shared/compose-utils/src/commonMain/kotlin/ir/amirab/util/compose/action/MenuDsl.kt ================================================ package ir.amirab.util.compose.action import ir.amirab.util.compose.IconSource import ir.amirab.util.compose.StringSource @DslMarker private annotation class MenuDsl @MenuDsl class MenuScope { private val list = mutableListOf() fun item( title: StringSource, icon: IconSource? = null, onClick: AnAction.() -> Unit, ) { val action = simpleAction(title, icon, onClick) list.add(action) } fun subMenu( title: StringSource, icon: IconSource? = null, block: MenuScope.() -> Unit, ) { val subMenu = MenuItem.SubMenu( title = title, icon = icon, items = MenuScope().apply(block).build() ) list.add(subMenu) } fun separator() { MenuItem.Separator .let(list::add) } operator fun MenuItem.unaryPlus() { this.let(list::add) } fun build() = list.toList() } fun buildMenu(block: MenuScope.() -> Unit) = MenuScope().apply(block).build() ================================================ FILE: shared/compose-utils/src/commonMain/kotlin/ir/amirab/util/compose/action/MenuItem.kt ================================================ package ir.amirab.util.compose.action import ir.amirab.util.compose.IconSource import ir.amirab.util.flow.mapStateFlow import androidx.compose.runtime.* import ir.amirab.util.compose.StringSource import kotlinx.coroutines.flow.* sealed interface MenuItem { @Stable interface ReadableItem { //compose aware property val icon: StateFlow //compose aware property val title: StateFlow } interface CanBeModified { fun setIcon(icon: IconSource?) fun setTitle(title: StringSource) } interface HasEnable { //compose aware property val isEnabled: StateFlow } interface CanChangeEnabled { fun setEnabled(boolean: Boolean) } interface ClickableItem : HasEnable { fun onClick() } abstract class SingleItem( title: StringSource, icon: IconSource? = null, ) : MenuItem, ClickableItem, ReadableItem, CanBeModified, CanChangeEnabled, () -> Unit { var shouldDismissOnClick: Boolean = true private val _title: MutableStateFlow = MutableStateFlow(title) private val _icon: MutableStateFlow = MutableStateFlow(icon) private val _isEnabled: MutableStateFlow = MutableStateFlow(true) override val title: StateFlow = _title.asStateFlow() override val icon: StateFlow = _icon.asStateFlow() override val isEnabled: StateFlow = _isEnabled.asStateFlow() override fun setEnabled(boolean: Boolean) { _isEnabled.update { boolean } } override fun setIcon(icon: IconSource?) { _icon.update { icon } } override fun setTitle(title: StringSource) { _title.update { title } } final override fun invoke() { if (isEnabled.value) { onClick() } } abstract override fun onClick() } class SubMenu( icon: IconSource? = null, title: StringSource, items: List, ) : MenuItem, ReadableItem, HasEnable { private var _icon: MutableStateFlow = MutableStateFlow(icon) private var _title: MutableStateFlow = MutableStateFlow(title) private val _items: MutableStateFlow> = MutableStateFlow(items) override var icon: StateFlow = _icon.asStateFlow() override var title: StateFlow = _title.asStateFlow() val items: StateFlow> = _items.asStateFlow() fun setItems(newItems: List) { _items.update { newItems } } override val isEnabled: StateFlow = this.items.mapStateFlow { it.isNotEmpty() } } data object Separator : MenuItem } ================================================ FILE: shared/compose-utils/src/commonMain/kotlin/ir/amirab/util/compose/contants/Constants.kt ================================================ package ir.amirab.util.compose.contants /** * URIs used in this app */ const val RESOURCE_PROTOCOL = "app-resource" const val FILE_PROTOCOL = "file" const val SYSTEM_PROTOCOL = "system" const val ICON_PROTOCOL = "icon" ================================================ FILE: shared/compose-utils/src/commonMain/kotlin/ir/amirab/util/compose/layout/RelativeAlignment.kt ================================================ package ir.amirab.util.compose.layout import androidx.compose.ui.Alignment import androidx.compose.ui.unit.IntOffset import androidx.compose.ui.unit.IntSize import androidx.compose.ui.unit.LayoutDirection class RelativeAlignment( val mainAlignment: Alignment, val relative: IntOffset, ) : Alignment { override fun align( size: IntSize, space: IntSize, layoutDirection: LayoutDirection ): IntOffset { val result = mainAlignment.align(size, space, layoutDirection) val resultWithOffset = result + relative return IntOffset( resultWithOffset.x.coerceIn(0, space.width), resultWithOffset.y.coerceIn(0, space.height), ) } class Horizontal( val mainAlignment: Alignment.Horizontal, val relative: Int, ) : Alignment.Horizontal { override fun align( size: Int, space: Int, layoutDirection: LayoutDirection ): Int { val result = mainAlignment.align(size, space, layoutDirection) val resultWithOffset = result + relative return resultWithOffset.coerceIn(0..space) } } class Vertical( val mainAlignment: Alignment.Vertical, val relative: Int, ) : Alignment.Vertical { override fun align( size: Int, space: Int, ): Int { val result = mainAlignment.align(size, space) val resultWithOffset = result + relative return resultWithOffset.coerceIn(0..space) } } } ================================================ FILE: shared/compose-utils/src/commonMain/kotlin/ir/amirab/util/compose/localizationmanager/ILanguageNameProvider.kt ================================================ package ir.amirab.util.compose.localizationmanager import java.util.Locale interface ILanguageNameProvider { fun getNativeName(myLocale: MyLocale): String fun getEnglishName(myLocale: MyLocale): String fun getName(myLocale: MyLocale): LanguageName } data class LanguageName( val nativeName: String, val englishName: String, ) object LanguageNameProvider : ILanguageNameProvider { private val list = mapOf( "af" to LanguageName("Afrikaans", "Afrikaans"), "ak" to LanguageName("Akan", "Akan"), "am" to LanguageName("አማርኛ", "Amharic"), "ar" to LanguageName("العربية", "Arabic"), "as" to LanguageName("অসমীয়া", "Assamese"), "az" to LanguageName("Azərbaycanca", "Azerbaijani"), "be" to LanguageName("Беларуская", "Belarusian"), "bg" to LanguageName("Български", "Bulgarian"), "bm" to LanguageName("Bamanankan", "Bambara"), "bn" to LanguageName("বাংলা", "Bengali"), "bo" to LanguageName("བོད་སྐད་", "Tibetan"), "bqi" to LanguageName("لۊری بختیاری", "Luri Bakhtiari"), "br" to LanguageName("Brezhoneg", "Breton"), "bs" to LanguageName("Bosanski", "Bosnian"), "ca" to LanguageName("Català", "Catalan"), "ckb" to LanguageName("کوردیی سۆرانی", "Kurdish (Sorani)"), "cs" to LanguageName("Čeština", "Czech"), "cy" to LanguageName("Cymraeg", "Welsh"), "da" to LanguageName("Dansk", "Danish"), "de" to LanguageName("Deutsch", "German"), "de_AT" to LanguageName("Österreichisches Deutsch", "Austrian German"), "de_CH" to LanguageName("Schweizer Hochdeutsch", "Swiss German"), "dz" to LanguageName("རྫོང་ཁ", "Dzongkha"), "ee" to LanguageName("Eʋegbe", "Ewe"), "el" to LanguageName("Ελληνικά", "Greek"), "en" to LanguageName("English", "English"), "eo" to LanguageName("Esperanto", "Esperanto"), "es" to LanguageName("Español", "Spanish"), "et" to LanguageName("Eesti", "Estonian"), "eu" to LanguageName("Euskara", "Basque"), "fa" to LanguageName("فارسی", "Persian"), "ff" to LanguageName("Pulaar", "Fulah"), "fi" to LanguageName("Suomi", "Finnish"), "fo" to LanguageName("Føroyskt", "Faroese"), "fr" to LanguageName("Français", "French"), "fr_CA" to LanguageName("Français canadien", "Canadian French"), "fr_CH" to LanguageName("Français suisse", "Swiss French"), "fy" to LanguageName("Frysk", "Western Frisian"), "ga" to LanguageName("Gaeilge", "Irish"), "gd" to LanguageName("Gàidhlig", "Scottish Gaelic"), "gl" to LanguageName("Galego", "Galician"), "gu" to LanguageName("ગુજરાતી", "Gujarati"), "gv" to LanguageName("Gaelg", "Manx"), "ha" to LanguageName("Hausa", "Hausa"), "he" to LanguageName("עברית", "Hebrew"), "hi" to LanguageName("हिन्दी", "Hindi"), "hr" to LanguageName("Hrvatski", "Croatian"), "hu" to LanguageName("Magyar", "Hungarian"), "hy" to LanguageName("Հայերեն", "Armenian"), "id" to LanguageName("Bahasa Indonesia", "Indonesian"), "ig" to LanguageName("Igbo", "Igbo"), "ii" to LanguageName("ꆈꌠꉙ", "Sichuan Yi"), "is" to LanguageName("Íslenska", "Icelandic"), "it" to LanguageName("Italiano", "Italian"), "ja" to LanguageName("日本語", "Japanese"), "ka" to LanguageName("ქართული", "Georgian"), "ki" to LanguageName("Gikuyu", "Kikuyu"), "kk" to LanguageName("Қазақ тілі", "Kazakh"), "kl" to LanguageName("Kalaallisut", "Greenlandic"), "km" to LanguageName("ខ្មែរ", "Khmer"), "kn" to LanguageName("ಕನ್ನಡ", "Kannada"), "ko" to LanguageName("한국어", "Korean"), "ks" to LanguageName("کٲشُر", "Kashmiri"), "kw" to LanguageName("Kernewek", "Cornish"), "ky" to LanguageName("Кыргызча", "Kyrgyz"), "lb" to LanguageName("Lëtzebuergesch", "Luxembourgish"), "lg" to LanguageName("Luganda", "Ganda"), "ln" to LanguageName("Lingála", "Lingala"), "lo" to LanguageName("ລາວ", "Lao"), "lt" to LanguageName("Lietuvių", "Lithuanian"), "lu" to LanguageName("Tshiluba", "Luba-Katanga"), "lv" to LanguageName("Latviešu", "Latvian"), "mg" to LanguageName("Malagasy", "Malagasy"), "mk" to LanguageName("Македонски", "Macedonian"), "ml" to LanguageName("മലയാളം", "Malayalam"), "mn" to LanguageName("Монгол", "Mongolian"), "mr" to LanguageName("मराठी", "Marathi"), "ms" to LanguageName("Bahasa Melayu", "Malay"), "mt" to LanguageName("Malti", "Maltese"), "my" to LanguageName("ဗမာ", "Burmese"), "nb" to LanguageName("Norsk Bokmål", "Norwegian Bokmål"), "nd" to LanguageName("IsiNdebele", "North Ndebele"), "ne" to LanguageName("नेपाली", "Nepali"), "nl" to LanguageName("Nederlands", "Dutch"), "nl_BE" to LanguageName("Vlaams", "Flemish"), "nn" to LanguageName("Nynorsk", "Norwegian Nynorsk"), "no" to LanguageName("Norsk", "Norwegian"), "om" to LanguageName("Oromoo", "Oromo"), "or" to LanguageName("ଓଡ଼ିଆ", "Odia"), "os" to LanguageName("Ирон", "Ossetic"), "pa" to LanguageName("ਪੰਜਾਬੀ", "Punjabi"), "pl" to LanguageName("Polski", "Polish"), "ps" to LanguageName("پښتو", "Pashto"), "pt" to LanguageName("Português", "Portuguese"), "pt_BR" to LanguageName("Português do Brasil", "Brazilian Portuguese"), "qu" to LanguageName("Runasimi", "Quechua"), "rm" to LanguageName("Rumantsch", "Romansh"), "rn" to LanguageName("Ikirundi", "Rundi"), "ro" to LanguageName("Română", "Romanian"), "ro_MD" to LanguageName("moldovenească", "Moldovan"), "ru" to LanguageName("Русский", "Russian"), "rw" to LanguageName("Kinyarwanda", "Kinyarwanda"), "se" to LanguageName("Davvisámegiella", "Northern Sami"), "sg" to LanguageName("Sängö", "Sango"), "sh" to LanguageName("Srpskohrvatski", "Serbo-Croatian"), "si" to LanguageName("සිංහල", "Sinhala"), "sk" to LanguageName("Slovenčina", "Slovak"), "sl" to LanguageName("Slovenščina", "Slovene"), "sn" to LanguageName("chiShona", "Shona"), "so" to LanguageName("Soomaali", "Somali"), "sq" to LanguageName("Shqip", "Albanian"), "sr" to LanguageName("Српски", "Serbian"), "sv" to LanguageName("Svenska", "Swedish"), "sw" to LanguageName("Kiswahili", "Swahili"), "ta" to LanguageName("தமிழ்", "Tamil"), "te" to LanguageName("తెలుగు", "Telugu"), "th" to LanguageName("ไทย", "Thai"), "ti" to LanguageName("ትግርኛ", "Tigrinya"), "tl" to LanguageName("Tagalog", "Tagalog"), "to" to LanguageName("lea fakatonga", "Tongan"), "tr" to LanguageName("Türkçe", "Turkish"), "ug" to LanguageName("ئۇيغۇرچە", "Uyghur"), "uk" to LanguageName("Українська", "Ukrainian"), "ur" to LanguageName("اردو", "Urdu"), "uz" to LanguageName("Oʻzbekcha", "Uzbek"), "vi" to LanguageName("Tiếng Việt", "Vietnamese"), "yi" to LanguageName("ייִדיש", "Yiddish"), "yo" to LanguageName("Èdè Yorùbá", "Yoruba"), "zh" to LanguageName("中文", "Chinese"), "zh_CN" to LanguageName("简体中文", "Simplified Chinese"), "zh_TW" to LanguageName("正體中文", "Traditional Chinese"), "zu" to LanguageName("isiZulu", "Zulu") ) override fun getNativeName(myLocale: MyLocale): String { return getName(myLocale).nativeName } override fun getEnglishName(myLocale: MyLocale): String { return getName(myLocale).englishName } override fun getName(myLocale: MyLocale): LanguageName { val languageCode = myLocale.languageCode val countryCode = myLocale.countryCode if (countryCode != null) { list["${languageCode}_${countryCode}"]?.let { return it } } list[languageCode]?.let { return it } return default(myLocale).let { LanguageName(it, it) } } private fun default(myLocale: MyLocale): String { return myLocale .toLocale() .let { it.getDisplayName(it) } } } private fun MyLocale.toLocale(): Locale { val language = languageCode val country = countryCode return if (country == null) { Locale(language) } else { Locale(language, country) } } ================================================ FILE: shared/compose-utils/src/commonMain/kotlin/ir/amirab/util/compose/localizationmanager/LanguageManager.kt ================================================ package ir.amirab.util.compose.localizationmanager import androidx.compose.runtime.Immutable import ir.amirab.resources.contracts.MyLanguageResource import ir.amirab.util.flow.mapStateFlow import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.runBlocking import java.io.InputStream import java.util.* class LanguageManager( storage: LanguageStorage, private val languageSourceProvider: LanguageSourceProvider, ) { @Suppress("PrivatePropertyName") private val DefaultLanguageInfo = languageSourceProvider .defaultLanguageResource .let { MyLocale .fromLanguageResource(it) .toLanguageInfo(it) } private val _languageList: MutableStateFlow> = MutableStateFlow(emptyList()) val languageList = _languageList.asStateFlow() val systemLanguageOrDefault: LanguageInfo by lazy { getSystemLanguageIfWeCanUse() } val selectedLanguageInStorage = storage.selectedLanguage val selectedLanguage = storage.selectedLanguage.mapStateFlow { it ?: systemLanguageOrDefault.toLocaleString() } // val selectedLanguageInfo = selectedLanguage.mapStateFlow { // bestLanguageInfo(it) // } val isRtl = selectedLanguage.mapStateFlow { selectedLanguage -> rtlLanguages.any { selectedLanguage.startsWith(it) } } fun boot() { _languageList.value = getAvailableLanguages() instance = this } fun selectLanguage(languageInfo: LanguageInfo?) { // ensure that language info is in the list! // val languageInfo = languageList.value.find { it == languageInfo } // selectedLanguage.value = (languageInfo ?: DefaultLanguageInfo).toLocaleString() selectedLanguageInStorage.value = languageInfo?.toLocaleString() } fun getMessage(key: String): String { return getMessageContainer().getMessage(key)?.takeIf { it.isNotBlank() } ?: defaultLanguageData.value.getMessage(key) ?: key } private fun getRequestedLanguage(): String { return selectedLanguage.value } @Volatile private var loadedLanguage: LoadedLanguage? = null private val defaultLanguageData = lazy { createMessageContainer(DefaultLanguageInfo) } private fun createMessageContainer( languageInfo: LanguageInfo, ): MessageData { return when { languageInfo == DefaultLanguageInfo && defaultLanguageData.isInitialized() -> defaultLanguageData.value else -> PropertiesMessageContainer( Properties().apply { kotlin.runCatching { openStream(languageInfo.resource) .reader(Charsets.UTF_8) .use { load(it) } }.onFailure { println("Error while loading language data!") it.printStackTrace() } } ) } } /** * Find the best language info for the given locale. * the returned language is guaranteed to be available. (at least [DefaultLanguageInfo]) */ private fun bestLanguageInfo(locale: String): LanguageInfo { return languageList.value.find { it.toLocaleString() == locale } ?: DefaultLanguageInfo } private fun getMessageContainer(): MessageData { val requestedLanguage = getRequestedLanguage() this.loadedLanguage.let { loadedLanguage -> if (loadedLanguage != null && loadedLanguage.languageInfo.toLocaleString() == requestedLanguage) { return loadedLanguage.messageData } } synchronized(this) { // make sure not created earlier this.loadedLanguage.let { loadedLanguage -> if (loadedLanguage != null && loadedLanguage.languageInfo.toLocaleString() == requestedLanguage) { return loadedLanguage.messageData } } val languageInfo = bestLanguageInfo(requestedLanguage) val created = LoadedLanguage( languageInfo, createMessageContainer(languageInfo) ) this.loadedLanguage = created return created.messageData } } private fun getAvailableLanguages(): List { return languageSourceProvider.allLanguageResources .mapNotNull { runCatching { MyLocale .fromLanguageResource(it) .toLanguageInfo(it) }.onFailure { println("fail to load $it") it.printStackTrace() } .getOrNull() } } private fun getSystemLanguageIfWeCanUse(): LanguageInfo { val systemLocale = getSystemLocale().toString() return bestLanguageInfo(systemLocale) } companion object { lateinit var instance: LanguageManager fun openStream(source: MyLanguageResource): InputStream { return runBlocking { source.getData().inputStream() } } private fun MyLocale.toLanguageInfo( languageResource: MyLanguageResource, ): LanguageInfo { return LanguageInfo( locale = MyLocale( languageCode = languageCode, countryCode = countryCode, ), nativeName = LanguageNameProvider.getNativeName(this), resource = languageResource, ) } private val rtlLanguages = arrayOf("ar", "bqi", "ckb", "fa", "he", "iw", "ji", "ur", "yi") } } interface MessageData { fun getMessage(key: String): String? } class PropertiesMessageContainer( private val properties: Properties, ) : MessageData { override fun getMessage(key: String): String? { return properties.getProperty(key) } } private data class LoadedLanguage( val languageInfo: LanguageInfo, val messageData: MessageData, ) @Immutable data class LanguageInfo( val locale: MyLocale, val nativeName: String, val resource: MyLanguageResource, ) { fun toLocaleString(): String { return locale.toString() } } private fun getSystemLocale(): MyLocale { val javaSystemLocale = Locale.getDefault(Locale.Category.DISPLAY) return MyLocale( languageCode = javaSystemLocale.language, countryCode = javaSystemLocale.country, ) } fun MyLocale.Companion.fromLanguageResource(languageResource: MyLanguageResource): MyLocale { return languageResource.language.split("_").run { MyLocale( languageCode = get(0), countryCode = getOrNull(1) ) } } ================================================ FILE: shared/compose-utils/src/commonMain/kotlin/ir/amirab/util/compose/localizationmanager/LanguageSourceProvider.kt ================================================ package ir.amirab.util.compose.localizationmanager import ir.amirab.resources.contracts.MyLanguageResource /** * at the moment we only use bundled strings */ class LanguageSourceProvider( val defaultLanguageResource: MyLanguageResource, val allLanguageResources: List, ) ================================================ FILE: shared/compose-utils/src/commonMain/kotlin/ir/amirab/util/compose/localizationmanager/LanguageStorage.kt ================================================ package ir.amirab.util.compose.localizationmanager import kotlinx.coroutines.flow.MutableStateFlow interface LanguageStorage { // null means auto val selectedLanguage: MutableStateFlow } ================================================ FILE: shared/compose-utils/src/commonMain/kotlin/ir/amirab/util/compose/localizationmanager/LocalLanguageManager.kt ================================================ package ir.amirab.util.compose.localizationmanager import androidx.compose.runtime.Composable import androidx.compose.runtime.CompositionLocalProvider import androidx.compose.runtime.staticCompositionLocalOf import androidx.compose.ui.platform.LocalLayoutDirection import androidx.compose.ui.unit.LayoutDirection val LocalLanguageManager = staticCompositionLocalOf { error("LocalLanguageManager not provided") } val LocaleLanguageDirection = staticCompositionLocalOf { error("LocaleLanguageDirection not provided") } @Composable fun WithLanguageDirection( content: @Composable () -> Unit, ) { CompositionLocalProvider( LocalLayoutDirection provides LocaleLanguageDirection.current, ) { content() } } ================================================ FILE: shared/compose-utils/src/commonMain/kotlin/ir/amirab/util/compose/localizationmanager/MyLocale.kt ================================================ package ir.amirab.util.compose.localizationmanager import androidx.compose.runtime.Immutable import ir.amirab.resources.contracts.MyLanguageResource @Immutable data class MyLocale( val languageCode: String, val countryCode: String?, ) { override fun toString(): String { return buildString { append(languageCode) countryCode?.let { append("_") append(it) } } } companion object } ================================================ FILE: shared/compose-utils/src/commonMain/kotlin/ir/amirab/util/compose/localizationmanager/StringVariableReplacer.kt ================================================ package ir.amirab.util.compose.localizationmanager import arrow.core.fold private fun String.replaceWithVariable(name: String, value: String): String { return replace("{{$name}}", value) } internal fun String.withReplacedArgs(args: Map): String { return args.fold(this) { acc, entry -> acc.replaceWithVariable(entry.key, entry.value) } } ================================================ FILE: shared/compose-utils/src/commonMain/kotlin/ir/amirab/util/compose/modifiers/AutoMirror.kt ================================================ package ir.amirab.util.compose.modifiers import androidx.compose.ui.Modifier import androidx.compose.ui.composed import androidx.compose.ui.draw.scale import androidx.compose.ui.platform.LocalLayoutDirection import androidx.compose.ui.unit.LayoutDirection fun Modifier.autoMirror() = composed { val isRtl = LocalLayoutDirection.current == LayoutDirection.Rtl scale( scaleX = if (isRtl) -1f else 1f, scaleY = 1f ) } ================================================ FILE: shared/compose-utils/src/commonMain/kotlin/ir/amirab/util/compose/modifiers/Clickables.kt ================================================ package ir.amirab.util.compose.modifiers import androidx.compose.foundation.clickable import androidx.compose.foundation.interaction.InteractionSource import androidx.compose.foundation.interaction.MutableInteractionSource import androidx.compose.ui.Modifier fun Modifier.hijackClick(): Modifier { return silentClickable { // nothing } } fun Modifier.silentClickable( interactionSource: MutableInteractionSource? = null, onClick: () -> Unit ): Modifier { return clickable( interactionSource = interactionSource, indication = null, onClick = onClick, ) } ================================================ FILE: shared/compose-utils/src/commonMain/kotlin/ir/amirab/util/compose/resources/Resources.kt ================================================ package ir.amirab.util.compose.resources import androidx.compose.runtime.Composable import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue import androidx.compose.runtime.remember import ir.amirab.util.compose.localizationmanager.LocalLanguageManager import ir.amirab.util.compose.localizationmanager.withReplacedArgs typealias MyStringResource = ir.amirab.resources.contracts.MyStringResource @Composable fun myStringResource(key: MyStringResource): String { val languageManager = LocalLanguageManager.current val language by languageManager.selectedLanguage.collectAsState() return remember(language, key) { languageManager.getMessage(key.id) } } @Composable fun myStringResource(key: MyStringResource, args: Map): String { val languageManager = LocalLanguageManager.current val language by languageManager.selectedLanguage.collectAsState() return remember(language, key, args) { languageManager .getMessage(key.id) .withReplacedArgs(args) } } ================================================ FILE: shared/config/build.gradle.kts ================================================ plugins { id(MyPlugins.kotlin) } dependencies { implementation(libs.androidx.datastore) implementation(libs.kotlin.serialization.json) } ================================================ FILE: shared/config/src/main/kotlin/ir/amirab/util/config/Config.kt ================================================ package ir.amirab.util.config import java.util.Collections import kotlin.reflect.KType import kotlin.reflect.typeOf interface Config { fun toMap(): Map fun put(key: ConfigKey.OfPrimitiveType, value: T){ put(key.keyName,key.primitiveType,value) } fun put(key: String,type: PrimitiveType,value:T) fun putInt(key: String, value: Int) = put(key,PrimitiveType.Int,value) fun putFloat(key: String, value: Float) = put(key,PrimitiveType.Float,value) fun putLong(key: String, value: Long) = put(key,PrimitiveType.Long,value) fun putDouble(key: String, value: Double) = put(key,PrimitiveType.Double,value) fun putBoolean(key: String, value: Boolean) = put(key,PrimitiveType.Boolean,value) fun putString(key: String, value: String) = put(key,PrimitiveType.String,value) fun get(key: String, type: PrimitiveType): T? fun get(key: ConfigKey.OfPrimitiveType):T?{ return get(key.keyName,key.primitiveType) } fun getInt(key: String): Int? = get(key, Config.PrimitiveType.Int) fun getFloat(key: String): Float? = get(key, Config.PrimitiveType.Float) fun getLong(key: String): Long? = get(key, Config.PrimitiveType.Long) fun getDouble(key: String): Double? = get(key, Config.PrimitiveType.Double) fun getBoolean(key: String): Boolean? = get(key, Config.PrimitiveType.Boolean) fun getString(key: String): String? = get(key,Config.PrimitiveType.String) fun removeKey(key: String) fun removeKey(key: ConfigKey) { removeKey(key.keyName) } sealed interface PrimitiveType { companion object{ fun ensureIsPrimitive(value: Any) { require(value is Number || value is kotlin.String || value is kotlin.Boolean) { "value must be number|string|boolean was ${value::class.qualifiedName}" } } fun fromType(type:KType):PrimitiveType?{ @Suppress("UNCHECKED_CAST") return when(type){ typeOf() -> PrimitiveType.Int typeOf() -> PrimitiveType.Long typeOf() -> PrimitiveType.Float typeOf() -> PrimitiveType.Double typeOf() -> PrimitiveType.String typeOf() -> PrimitiveType.Boolean else -> null } as PrimitiveType? } fun fromValue(value: T):PrimitiveType?{ @Suppress("UNCHECKED_CAST") return when(value){ is kotlin.Int-> PrimitiveType.Int is kotlin.Long-> PrimitiveType.Long is kotlin.Float-> PrimitiveType.Float is kotlin.Double-> PrimitiveType.Double is kotlin.String-> PrimitiveType.String is kotlin.Boolean-> PrimitiveType.Boolean else -> null } as PrimitiveType? } } fun toType(value: Any):T? data object Int: PrimitiveType { override fun toType(value: Any): kotlin.Int? { return if (value is Number){ value.toInt() }else{ value.toString().toIntOrNull() } } } data object Long: PrimitiveType { override fun toType(value: Any): kotlin.Long? { return if (value is Number){ value.toLong() }else{ value.toString().toLongOrNull() } } } data object Float: PrimitiveType { override fun toType(value: Any): kotlin.Float? { return if (value is Number){ value.toFloat() }else{ value.toString().toFloatOrNull() } } } data object Double: PrimitiveType { override fun toType(value: Any): kotlin.Double? { return if (value is Number){ value.toDouble() }else{ value.toString().toDoubleOrNull() } } } data object String: PrimitiveType { override fun toType(value: Any): kotlin.String { return value.toString() } } data object Boolean: PrimitiveType { override fun toType(value: Any): kotlin.Boolean? { return if (value is kotlin.Boolean){ value }else{ value.toString().toBooleanStrictOrNull() } } } } } class MapConfig() : Config { constructor(config: Config) : this() { this.map.putAll(config.toMap()) } private val map = Collections.synchronizedMap(linkedMapOf()) override fun put(key: String, type: Config.PrimitiveType, value: T) { map[key] = value } override fun get(key: String, type: Config.PrimitiveType): T? { val value = map[key] ?: return null Config.PrimitiveType.ensureIsPrimitive(value) return type.toType(value) } override fun removeKey(key: String) { map.remove(key) } override fun toMap(): Map { return map.toMap() } override fun toString(): String { return toMap().toString() } override fun equals(other: Any?): Boolean { val otherMap = when(other){ is MapConfig -> other.map is Config -> other.toMap() else -> null } return map == otherMap } override fun hashCode(): Int { return map.hashCode() ?: 0 } } ================================================ FILE: shared/config/src/main/kotlin/ir/amirab/util/config/ConfigKeyWithPrimitiveType.kt ================================================ package ir.amirab.util.config sealed class ConfigKey { abstract val keyName: String class OfPrimitiveType internal constructor( override val keyName: String, val primitiveType: Config.PrimitiveType, ) : ConfigKey() class OfNotPrimitiveType( override val keyName: String, ) : ConfigKey() } fun keyOfEncoded(name: String) = ConfigKey.OfNotPrimitiveType( keyName = name, ) fun intKeyOf(name: String) = ConfigKey.OfPrimitiveType( keyName = name, primitiveType = Config.PrimitiveType.Int ) fun floatKeyOf(name: String) = ConfigKey.OfPrimitiveType( keyName = name, primitiveType = Config.PrimitiveType.Float ) fun longKeyOf(name: String) = ConfigKey.OfPrimitiveType( keyName = name, primitiveType = Config.PrimitiveType.Long ) fun doubleKeyOf(name: String) = ConfigKey.OfPrimitiveType( keyName = name, primitiveType = Config.PrimitiveType.Double ) fun booleanKeyOf(name: String) = ConfigKey.OfPrimitiveType( keyName = name, primitiveType = Config.PrimitiveType.Boolean ) fun stringKeyOf(name: String) = ConfigKey.OfPrimitiveType( keyName = name, primitiveType = Config.PrimitiveType.String ) ================================================ FILE: shared/config/src/main/kotlin/ir/amirab/util/config/ConfigToJson.kt ================================================ package ir.amirab.util.config import kotlinx.serialization.json.JsonElement import kotlinx.serialization.json.JsonObject object ConfigToJson { private val nestedMapCreator get() = NestedMapCreator() fun fromJson(configRegistry: Config, element: JsonObject) { val x = JsonObjectToMap().transformJsonObject(element) nestedMapCreator.createFlatten(x) .forEach { (key, value) -> if (value!=null){ val type = Config.PrimitiveType.fromValue(value) if (type!=null){ configRegistry.put(key,type,value) } } } } fun toJson(configRegistry: Config): JsonElement { return MapToJsonObject() .transformMap(nestedMapCreator.createdNested(configRegistry.toMap())) } } fun Config.toJson(): JsonElement { return ConfigToJson.toJson(this) } fun T.loadFromJson(json: JsonObject): T { ConfigToJson.fromJson(this, json) return this } ================================================ FILE: shared/config/src/main/kotlin/ir/amirab/util/config/JsonMapper.kt ================================================ package ir.amirab.util.config import kotlinx.serialization.json.* class JsonObjectToMap { private fun transform(jsonElement: JsonElement): Any? { return when (jsonElement) { is JsonArray -> transformJsonArray(jsonElement) is JsonObject -> transformJsonObject(jsonElement) is JsonPrimitive -> transformJsonPrimitive(jsonElement) } } fun transformJsonObject(jsonObject: JsonObject): Map { return jsonObject.mapValues { transform(it.value) } } fun transformJsonArray(jsonArray: JsonArray): List { return jsonArray.map { transform(it) } } private fun transformJsonPrimitive(primitive: JsonPrimitive): Any? { if (primitive.isString) { return primitive.content } if (primitive == JsonNull) return null //bool or number primitive.booleanOrNull?.let { return it } if (primitive.content.contains(".")) { return primitive.double } return primitive.long } } class MapToJsonObject { private fun transformList(list: List<*>): JsonArray { return JsonArray( list.map { transform(it) } ) } fun transformMap(map: Map): JsonObject { return map.mapValues { transform(it.value) }.let { JsonObject(it) } } private fun transform(value: Any?): JsonElement { return when (value) { null -> JsonNull is Map<*, *> -> { @Suppress("UNCHECKED_CAST") transformMap(value as Map) } is List<*> -> { transformList(value) } else -> transformPrimitive(value) } } private fun transformPrimitive(value: Any): JsonPrimitive { return when (value) { is Number -> JsonPrimitive(value) is Boolean -> JsonPrimitive(value) is String -> JsonPrimitive(value) else -> error("not supported this type ${value::class.qualifiedName}") } } } ================================================ FILE: shared/config/src/main/kotlin/ir/amirab/util/config/NestedCreator.kt ================================================ package ir.amirab.util.config class NestedMapCreator( private val separator: String = "." ) { private fun createNested( output: MutableMap, segments: List ): MutableMap { if (segments.isEmpty()) { return output } val sub = segments.drop(1) val segment = segments.first() val maybeMap = output[segment] val map = if (maybeMap is MutableMap<*, *>) { @Suppress("UNCHECKED_CAST") maybeMap as MutableMap } else { val createdMap = mutableMapOf().apply { if (maybeMap != null) { put("", maybeMap) } } createdMap } output[segment] = map return createNested(map, sub) } fun createdNested( flatten: Map, output: MutableMap = mutableMapOf() ): Map { for ((key, value) in flatten) { val segments = key.split(separator) val map = createNested(output, segments.dropLast(1)) val lastSegment = segments.last() val maybeMap = map[lastSegment] if (maybeMap is MutableMap<*, *>) { @Suppress("UNCHECKED_CAST") maybeMap as MutableMap maybeMap.put("", value) } else { map[lastSegment] = value } // println(map) } return output } fun createFlatten( nested: Map, prefixes: List = emptyList(), output: MutableMap = mutableMapOf() ): Map { for ((key, value) in nested) { val flattenKeySegments = prefixes + key // println(flattenKeySegments.joinToString(separator)) if (value is Map<*, *>) { @Suppress("UNCHECKED_CAST") createFlatten(value as Map, flattenKeySegments, output) } else { val flattenKey = flattenKeySegments .filter{ it.isNotEmpty() } .joinToString(separator) output[flattenKey] = value } } return output } } ================================================ FILE: shared/config/src/main/kotlin/ir/amirab/util/config/datastore/KotlinSerializationDataStore.kt ================================================ package ir.amirab.util.config.datastore import androidx.datastore.core.CorruptionException import androidx.datastore.core.DataStore import androidx.datastore.core.DataStoreFactory import kotlinx.serialization.KSerializer import kotlinx.serialization.json.Json import androidx.datastore.core.Serializer import androidx.datastore.core.handlers.ReplaceFileCorruptionHandler import kotlinx.serialization.ExperimentalSerializationApi import kotlinx.serialization.SerializationException import kotlinx.serialization.json.decodeFromStream import kotlinx.serialization.json.encodeToStream import kotlinx.serialization.serializer import java.io.File import java.io.InputStream import java.io.OutputStream class KotlinSerializationDataStore( val json: Json, val serializer: KSerializer, val default: () -> T, ) : Serializer { override val defaultValue: T get() = default() override suspend fun readFrom(input: InputStream): T { try { @OptIn(ExperimentalSerializationApi::class) return json.decodeFromStream(serializer, input) } catch (e: SerializationException) { throw CorruptionException("cant decode this input", e) } } override suspend fun writeTo(t: T, output: OutputStream) { @OptIn(ExperimentalSerializationApi::class) json.encodeToStream(serializer, t, output) } } inline fun kotlinxSerializationDataStore( file: File, json: Json, noinline default: () -> T, ): DataStore { return DataStoreFactory.create( serializer = KotlinSerializationDataStore( json = json, serializer = serializer(), default = { // no data found default() } ), produceFile = { file }, corruptionHandler = ReplaceFileCorruptionHandler( produceNewData = { // exception thrown during decoding default() } ), ) } ================================================ FILE: shared/config/src/main/kotlin/ir/amirab/util/config/datastore/MapConfigDataStore.kt ================================================ package ir.amirab.util.config.datastore import androidx.datastore.core.CorruptionException import androidx.datastore.core.DataStore import androidx.datastore.core.DataStoreFactory import androidx.datastore.core.Serializer import androidx.datastore.core.handlers.ReplaceFileCorruptionHandler import ir.amirab.util.config.MapConfig import ir.amirab.util.config.loadFromJson import ir.amirab.util.config.toJson import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.withContext import kotlinx.serialization.ExperimentalSerializationApi import kotlinx.serialization.SerializationException import kotlinx.serialization.encodeToString import kotlinx.serialization.json.Json import kotlinx.serialization.json.decodeFromStream import java.io.File import java.io.InputStream import java.io.OutputStream class MyConfigSerializer( private val json: Json ) : Serializer { override val defaultValue: MapConfig get() = MapConfig() @OptIn(ExperimentalSerializationApi::class) override suspend fun readFrom(input: InputStream): MapConfig { return withContext(Dispatchers.IO) { MapConfig().apply { try { loadFromJson(json.decodeFromStream(input)) }catch (e:SerializationException){ throw CorruptionException("Json is corrupted",e) } } } } override suspend fun writeTo(t: MapConfig, output: OutputStream) { withContext(Dispatchers.IO) { output.write(json.encodeToString(t.toJson()).toByteArray()) } } } fun createMapConfigDatastore( file: File, json: Json, ): DataStore { return DataStoreFactory.create( serializer = MyConfigSerializer(json), produceFile = { file }, corruptionHandler = ReplaceFileCorruptionHandler{ MapConfig() }, ) } suspend fun DataStore.edit( editor: (MapConfig) -> Unit, ) { updateData { val newConfig = MapConfig(it) editor(newConfig) newConfig } } ================================================ FILE: shared/config/src/main/kotlin/ir/amirab/util/config/extensions.kt ================================================ package ir.amirab.util.config import kotlinx.serialization.json.Json import kotlinx.serialization.serializer context(json: Json) inline fun MapConfig.putEncoded(key: String, value: T) { putString(key, json.encodeToString(serializer(), value)) } context(json: Json) inline fun MapConfig.putEncodedNullable(key: ConfigKey.OfNotPrimitiveType, value: T?) { if (value != null) { putString(key.keyName, json.encodeToString(serializer(), value)) } else { removeKey(key) } } context(_: Json) inline fun MapConfig.putEncoded(key: ConfigKey.OfNotPrimitiveType, value: T) { putEncoded(key.keyName, value) } context(json: Json) inline fun MapConfig.getDecoded(key: String): T? { val str = getString(key) ?: return null return runCatching { json.decodeFromString(str) } .onFailure { //log error } .getOrNull() } context(_: Json) inline fun MapConfig.getDecoded(key: ConfigKey.OfNotPrimitiveType): T? { return getDecoded(key.keyName) } inline fun MapConfig.putNullable(key: ConfigKey.OfPrimitiveType, value: T?) { if (value == null) { removeKey(key) } else { put(key, value) } } ================================================ FILE: shared/nanohttp4k/build.gradle.kts ================================================ plugins { id(MyPlugins.kotlin) } dependencies { api(libs.http4k.core) implementation(libs.nanoHttpd.core) } ================================================ FILE: shared/nanohttp4k/src/main/kotlin/ir/amirab/util/http4k/NanoHttpServer.kt ================================================ package ir.amirab.util.http4k import fi.iki.elonen.NanoHTTPD import org.http4k.core.HttpHandler import org.http4k.core.Response import org.http4k.core.Status import org.http4k.core.Request as Http4KRequest import org.http4k.core.Response as Http4kResponse import org.http4k.core.Method as Http4kMethod import org.http4k.server.Http4kServer import org.http4k.server.ServerConfig import java.io.FilterInputStream import java.io.InputStream import kotlin.math.min private open class LimitedInputStream(inputStream: InputStream, val maxRead: Long) : FilterInputStream(inputStream) { fun allowedRemaining() = maxRead - readCount var readCount = 0 private set fun maxReached() = allowedRemaining() <= 0 private fun reachedEnd(): Int { return -1 } override fun read(): Int { if (maxReached()) { return reachedEnd() } readCount++ return super.read() } override fun read(b: ByteArray): Int { return read(b, 0, b.size) } override fun read(b: ByteArray, off: Int, len: Int): Int { val allowedRead = allowedRemaining() if (allowedRead <= 0) { return reachedEnd() } val newLen = min(allowedRead, len.toLong()) val result = super.read(b, off, newLen.toInt()) if (result >= 0) { readCount += result } return result } override fun readAllBytes(): ByteArray { return readNBytes(Int.MAX_VALUE) } override fun readNBytes(len: Int): ByteArray { val newLen = constrainRequestedLength(len) if (newLen < 0) { return byteArrayOf() } return super.readNBytes(newLen) } fun constrainRequestedLength(len: Int): Int { return min(allowedRemaining(), len.toLong()).toInt() } } /** * Nano http give me the socket's input stream * so Instead of the close the underlying socket * I skipp the remaining body size * so the socket is not closed and can be reused */ private class NanoHttpDInputStream( inputStream: InputStream, length: Long ) : LimitedInputStream(inputStream, length) { override fun close() { skip(allowedRemaining()) } } private class NanoHttpDForHttp4K( hostName: String, port: Int, private val handler: HttpHandler, private val isDebugMode: Boolean, ) : NanoHTTPD(hostName, port) { private fun IHTTPSession.toHttp4KRequest(): Http4KRequest { val length = (this as HTTPSession).bodySize // I duplicate that input stream because http4k close that but it is the underlying input stream // val body = ByteArrayInputStream(inputStream.readNBytes(length)) val body = NanoHttpDInputStream(inputStream, length) return Http4KRequest( method = Http4kMethod.valueOf(method.name), uri = this.uri ).body( body = body, length = length ) .headers(headers.map { it.key to it.value }) } private fun Http4kResponse.toNanoHttpResponse(): Response { val length = this.body.length val bodyInputStream = this.body.stream val nanoResponseStatus = Response.Status.lookup(status.code) val response = if (length != null) { newFixedLengthResponse( nanoResponseStatus, null, bodyInputStream, length ) } else { newChunkedResponse( nanoResponseStatus, null, bodyInputStream, ) } headers.forEach { response.addHeader(it.first, it.second) } return response } override fun serve(session: IHTTPSession): Response { val response = kotlin.runCatching { session.toHttp4KRequest().use { request -> handler(request) } }.getOrElse { throwable -> val shortDescription = "${throwable::class.simpleName} ${throwable.localizedMessage}" val extraInfo = if (isDebugMode) { throwable.stackTraceToString() } else null Response(Status.INTERNAL_SERVER_ERROR) .body("Error $shortDescription\n$extraInfo") } return response.toNanoHttpResponse() } } private class NanoHttpServer( val hostName: String, val port: Int, handler: HttpHandler, debug: Boolean, ) : Http4kServer { val server = NanoHttpDForHttp4K(hostName, port, handler, debug) override fun port(): Int { return if (port > 0) port else server.listeningPort } override fun start(): Http4kServer = apply { server.start() } override fun stop(): Http4kServer = apply { server.stop() } } class NanoHttp( val hostName: String, val port: Int, val isDebugMode: Boolean = false, ) : ServerConfig { override fun toServer(http: HttpHandler): Http4kServer { return NanoHttpServer(hostName, port, http, isDebugMode) } } ================================================ FILE: shared/nanohttp4k/src/main/resources/rules.pro ================================================ -keep class fi.iki.elonen.**{ *; } -keep class org.http4k.** { *; } -dontwarn org.http4k.** ================================================ FILE: shared/resources/build.gradle.kts ================================================ import org.jetbrains.kotlin.gradle.dsl.JvmTarget import java.util.Properties plugins { id(MyPlugins.kotlinMultiplatform) id(MyPlugins.composeBase) id(Plugins.Android.library) } val ourPackageName = "com.abdownloadmanager.resources" val propertiesToKotlinTask by tasks.registering(PropertiesToKotlinTask::class) { outputDir.set(file("build/tasks/propertiesToKotlinTask")) generatedFileName.set("String.kt") packageName.set(ourPackageName) myStringResourceClass.set("ir.amirab.resources.contracts.MyStringResource") propertyFiles.from("src/commonMain/resources/com/abdownloadmanager/resources/locales/en_US.properties") } val generateResourceMap by tasks.registering(GenerateResourceMap::class) { outputDir.set(file("build/tasks/generateResourceMapTask")) generatedFileName.set("ResourceMap.kt") packageName.set(ourPackageName) baseFolder.set(file("src/commonMain/resources/")) } val generateResObject by tasks.registering(GenerateResObject::class) { outputDir.set(file("build/tasks/generateResObjectTask")) generatedFileName.set("Res.kt") packageName.set(ourPackageName) dependsOn(propertiesToKotlinTask) dependsOn(generateResourceMap) } kotlin { jvm("desktop") androidTarget { compilerOptions { jvmTarget.set(JvmTarget.JVM_21) } } sourceSets { commonMain { kotlin { srcDirs(propertiesToKotlinTask.map { it.outputDir }) srcDirs(generateResourceMap.map { it.outputDir }) srcDirs(generateResObject.map { it.outputDir }) } dependencies { implementation(libs.compose.ui) api(libs.okio.okio) implementation(project(":shared:resources:contracts")) } } } } android { compileSdk = 36 namespace = "com.abdownloadmanager.resources" defaultConfig { minSdk = 26 } sourceSets.named("main") { resources.srcDir("src/commonMain/resources") } } abstract class GenerateResObject @Inject constructor( project: Project ) : DefaultTask() { @get:Input val packageName = project.objects.property() @get:OutputDirectory val outputDir = project.objects.directoryProperty() @get:Input val generatedFileName = project.objects.property() @TaskAction fun run() { val content = buildString { appendLine("package ${packageName.get()}") appendLine("object Res {") appendLine(" val string = Strings") appendLine(" val sourceMap = ResourceMap") appendLine("}") } outputDir.file(generatedFileName).get().asFile.writer().use { it.write(content) } } } abstract class GenerateResourceMap @Inject constructor( project: Project ) : DefaultTask() { @get:Input val packageName = project.objects.property() @get:InputDirectory val baseFolder = project.objects.directoryProperty() @get:OutputDirectory val outputDir = project.objects.directoryProperty() @get:Input val generatedFileName = project.objects.property() @TaskAction fun run() { val base = baseFolder.asFile.get() val files = base.walkTopDown() .filter { it.isFile }.map { it .relativeTo(base).toString() .replace("\\", "/") } val content = buildString { appendLine("package ${packageName.get()}") appendLine("object ResourceMap {") appendLine(" val files = listOf(") val doubleQuotes = "\"".repeat(3) for (file in files) { appendLine(" $doubleQuotes$file$doubleQuotes,") } appendLine(" )") appendLine("}") } outputDir.file(generatedFileName).get().asFile.writer().use { it.write(content) } } } abstract class PropertiesToKotlinTask @Inject constructor( project: Project ) : DefaultTask() { @get:InputFiles val propertyFiles = project.objects.fileCollection() @get:Input val packageName = project.objects.property() @get:Input val myStringResourceClass = project.objects.property() @get:OutputDirectory val outputDir = project.objects.directoryProperty() @get:Input val generatedFileName = project.objects.property() @TaskAction fun run() { val properties = Properties() propertyFiles.forEach { file -> file.inputStream().use { inputStream -> properties.load(inputStream) } } val content = createFileString( packageName.get(), myStringResourceClass.get(), properties ) outputDir.file(generatedFileName).get().asFile.writer().use { it.write(content) } } private fun createFileString( packageName: String, myStringResourceClass: String, properties: Properties, ): String { val myStringResourceClassName = myStringResourceClass .split(".").last() val variableRegex by lazy { "\\{\\{(?.+?)\\}\\}".toRegex() } fun findVariablesOfValue(value: String): List { return variableRegex .findAll(value) .toList() .map { it.groups["variable"]!!.value } } fun propertyToCode(key: String, value: String): String { val args = findVariablesOfValue(value) val defination = "val `$key` = $myStringResourceClassName(\"$key\")" if (args.isEmpty()) { return defination } else { val comment = buildString { append("/**\n") append("accepted args:\n") args.forEach { value -> append("@param [$value]\n") } append("*/") } val argCreatorFunction = buildString { append("fun `${key}_createArgs`(") args.forEachIndexed { index, value -> append("$value: String") if (index != args.lastIndex) { append(", ") } } append(") = ") append("mapOf(") args.forEachIndexed { index, value -> append("\"$value\" to $value") if (index != args.lastIndex) { append(", ") } } append(")") } return "$defination\n$comment\n$argCreatorFunction" } } return buildString { appendLine("@file:Suppress(\"RemoveRedundantBackticks\", \"FunctionName\")") appendLine("package $packageName") appendLine("import $myStringResourceClass") appendLine("object Strings {") for (property in properties) { val key = property.key.toString() val value = property.value.toString() val codeLines = propertyToCode(key, value).lines() for (line in codeLines) { appendLine(" $line") } } appendLine("}") } } } ================================================ FILE: shared/resources/contracts/build.gradle.kts ================================================ import org.jetbrains.kotlin.gradle.dsl.JvmTarget plugins { id(MyPlugins.kotlinMultiplatform) id(Plugins.Android.library) } kotlin { jvm("desktop") androidTarget { compilerOptions { jvmTarget.set(JvmTarget.JVM_21) } } sourceSets.commonMain.dependencies { implementation(libs.okio.okio) } } android { compileSdk = 36 namespace = "com.abdownloadmanager.resources.contracts" defaultConfig { minSdk = 26 } } ================================================ FILE: shared/resources/contracts/src/commonMain/kotlin/ir/amirab/resources/contracts/MyLanguageResource.kt ================================================ package ir.amirab.resources.contracts import okio.FileSystem import okio.Path sealed interface MyLanguageResource { val language: String val getData: suspend () -> ByteArray data class BundledLanguageResource( override val language: String, override val getData: suspend () -> ByteArray, ) : MyLanguageResource class ExternalLanguageResource( val path: Path, ) : MyLanguageResource { override val language: String get() = path.name.substringBeforeLast(".") override val getData: suspend () -> ByteArray = { FileSystem.SYSTEM.read(path) { readByteArray() } } } } ================================================ FILE: shared/resources/contracts/src/commonMain/kotlin/ir/amirab/resources/contracts/MyStringResource.kt ================================================ package ir.amirab.resources.contracts @JvmInline value class MyStringResource(val id: String) ================================================ FILE: shared/resources/src/commonMain/kotlin/com/abdownloadmanager/resources/ABDMLanguageResources.kt ================================================ package com.abdownloadmanager.resources import ir.amirab.resources.contracts.MyLanguageResource import okio.FileSystem import okio.Path.Companion.toPath object ABDMLanguageResources { private const val LOCALES_DIRECTORY = "com/abdownloadmanager/resources/locales/" val defaultLanguageResource = run { val defaultName = "en_US" MyLanguageResource.BundledLanguageResource( language = defaultName, getData = suspend { ResourceUtil.readResourceAsByteArray("$LOCALES_DIRECTORY$defaultName.properties") } ) } val languages: List get() = ResourceMap .files .filter { it.startsWith(LOCALES_DIRECTORY) } .map { MyLanguageResource.BundledLanguageResource( language = it.split("/").last().split(".").first(), getData = suspend { ResourceUtil.readResourceAsByteArray(it) } ) } } internal object ResourceUtil { fun readResourceAsByteArray(path: String): ByteArray { return FileSystem.RESOURCES.read(path.toPath()) { readByteArray() } } fun readResourceAsString(path: String): String { return FileSystem.RESOURCES.read(path.toPath()) { readUtf8() } } } object ABDMResources { fun getTranslatorsContent(): String { return ResourceUtil .readResourceAsString("com/abdownloadmanager/resources/credits/translators.json") } } ================================================ FILE: shared/resources/src/commonMain/kotlin/com/abdownloadmanager/resources/icons/ABDMIcons.kt ================================================ package com.abdownloadmanager.resources.icons object ABDMIcons ================================================ FILE: shared/resources/src/commonMain/kotlin/com/abdownloadmanager/resources/icons/AddLink.kt ================================================ package com.abdownloadmanager.resources.icons import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.SolidColor import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.graphics.vector.path import androidx.compose.ui.unit.dp val ABDMIcons.AddLink: ImageVector get() { if (_AddLink != null) { return _AddLink!! } _AddLink = ImageVector.Builder( name = "AddLink", defaultWidth = 24.dp, defaultHeight = 24.dp, viewportWidth = 24f, viewportHeight = 24f ).apply { path(fill = SolidColor(Color.White)) { moveTo(12.11f, 15.39f) lineTo(8.23f, 19.27f) curveTo(8.001f, 19.5f, 7.728f, 19.683f, 7.427f, 19.808f) curveTo(7.127f, 19.933f, 6.805f, 19.997f, 6.48f, 19.997f) curveTo(6.155f, 19.997f, 5.833f, 19.933f, 5.533f, 19.808f) curveTo(5.232f, 19.683f, 4.96f, 19.5f, 4.73f, 19.27f) curveTo(4.498f, 19.041f, 4.315f, 18.769f, 4.189f, 18.468f) curveTo(4.064f, 18.168f, 3.999f, 17.846f, 3.999f, 17.52f) curveTo(3.999f, 17.194f, 4.064f, 16.872f, 4.189f, 16.572f) curveTo(4.315f, 16.271f, 4.498f, 15.999f, 4.73f, 15.77f) lineTo(8.61f, 11.89f) curveTo(8.798f, 11.702f, 8.904f, 11.446f, 8.904f, 11.18f) curveTo(8.904f, 10.914f, 8.798f, 10.658f, 8.61f, 10.47f) curveTo(8.422f, 10.282f, 8.166f, 10.176f, 7.9f, 10.176f) curveTo(7.634f, 10.176f, 7.378f, 10.282f, 7.19f, 10.47f) lineTo(3.31f, 14.36f) curveTo(2.528f, 15.211f, 2.106f, 16.331f, 2.13f, 17.486f) curveTo(2.155f, 18.641f, 2.624f, 19.742f, 3.441f, 20.559f) curveTo(4.258f, 21.376f, 5.359f, 21.846f, 6.514f, 21.87f) curveTo(7.669f, 21.894f, 8.789f, 21.472f, 9.64f, 20.69f) lineTo(13.53f, 16.81f) curveTo(13.623f, 16.717f, 13.697f, 16.606f, 13.748f, 16.484f) curveTo(13.798f, 16.362f, 13.824f, 16.232f, 13.824f, 16.1f) curveTo(13.824f, 15.968f, 13.798f, 15.838f, 13.748f, 15.716f) curveTo(13.697f, 15.594f, 13.623f, 15.483f, 13.53f, 15.39f) curveTo(13.437f, 15.297f, 13.326f, 15.223f, 13.204f, 15.172f) curveTo(13.082f, 15.122f, 12.952f, 15.096f, 12.82f, 15.096f) curveTo(12.688f, 15.096f, 12.558f, 15.122f, 12.436f, 15.172f) curveTo(12.314f, 15.223f, 12.203f, 15.297f, 12.11f, 15.39f) close() moveTo(8.83f, 15.17f) curveTo(8.923f, 15.263f, 9.034f, 15.336f, 9.156f, 15.386f) curveTo(9.278f, 15.436f, 9.408f, 15.461f, 9.54f, 15.46f) curveTo(9.672f, 15.461f, 9.802f, 15.436f, 9.924f, 15.386f) curveTo(10.046f, 15.336f, 10.157f, 15.263f, 10.25f, 15.17f) lineTo(15.17f, 10.25f) curveTo(15.358f, 10.062f, 15.464f, 9.806f, 15.464f, 9.54f) curveTo(15.464f, 9.274f, 15.358f, 9.018f, 15.17f, 8.83f) curveTo(14.982f, 8.642f, 14.726f, 8.536f, 14.46f, 8.536f) curveTo(14.194f, 8.536f, 13.938f, 8.642f, 13.75f, 8.83f) lineTo(8.83f, 13.75f) curveTo(8.736f, 13.843f, 8.662f, 13.954f, 8.611f, 14.075f) curveTo(8.56f, 14.197f, 8.534f, 14.328f, 8.534f, 14.46f) curveTo(8.534f, 14.592f, 8.56f, 14.723f, 8.611f, 14.845f) curveTo(8.662f, 14.967f, 8.736f, 15.077f, 8.83f, 15.17f) close() moveTo(21f, 18f) horizontalLineTo(20f) verticalLineTo(17f) curveTo(20f, 16.735f, 19.895f, 16.48f, 19.707f, 16.293f) curveTo(19.52f, 16.105f, 19.265f, 16f, 19f, 16f) curveTo(18.735f, 16f, 18.48f, 16.105f, 18.293f, 16.293f) curveTo(18.105f, 16.48f, 18f, 16.735f, 18f, 17f) verticalLineTo(18f) horizontalLineTo(17f) curveTo(16.735f, 18f, 16.48f, 18.105f, 16.293f, 18.293f) curveTo(16.105f, 18.48f, 16f, 18.735f, 16f, 19f) curveTo(16f, 19.265f, 16.105f, 19.52f, 16.293f, 19.707f) curveTo(16.48f, 19.895f, 16.735f, 20f, 17f, 20f) horizontalLineTo(18f) verticalLineTo(21f) curveTo(18f, 21.265f, 18.105f, 21.52f, 18.293f, 21.707f) curveTo(18.48f, 21.895f, 18.735f, 22f, 19f, 22f) curveTo(19.265f, 22f, 19.52f, 21.895f, 19.707f, 21.707f) curveTo(19.895f, 21.52f, 20f, 21.265f, 20f, 21f) verticalLineTo(20f) horizontalLineTo(21f) curveTo(21.265f, 20f, 21.52f, 19.895f, 21.707f, 19.707f) curveTo(21.895f, 19.52f, 22f, 19.265f, 22f, 19f) curveTo(22f, 18.735f, 21.895f, 18.48f, 21.707f, 18.293f) curveTo(21.52f, 18.105f, 21.265f, 18f, 21f, 18f) close() moveTo(16.81f, 13.53f) lineTo(20.69f, 9.64f) curveTo(21.472f, 8.789f, 21.894f, 7.669f, 21.87f, 6.514f) curveTo(21.846f, 5.359f, 21.376f, 4.258f, 20.559f, 3.441f) curveTo(19.742f, 2.624f, 18.641f, 2.155f, 17.486f, 2.13f) curveTo(16.331f, 2.106f, 15.211f, 2.528f, 14.36f, 3.31f) lineTo(10.47f, 7.19f) curveTo(10.377f, 7.283f, 10.303f, 7.394f, 10.252f, 7.516f) curveTo(10.202f, 7.638f, 10.176f, 7.768f, 10.176f, 7.9f) curveTo(10.176f, 8.032f, 10.202f, 8.162f, 10.252f, 8.284f) curveTo(10.303f, 8.406f, 10.377f, 8.517f, 10.47f, 8.61f) curveTo(10.563f, 8.703f, 10.674f, 8.777f, 10.796f, 8.828f) curveTo(10.918f, 8.878f, 11.048f, 8.904f, 11.18f, 8.904f) curveTo(11.312f, 8.904f, 11.442f, 8.878f, 11.564f, 8.828f) curveTo(11.686f, 8.777f, 11.797f, 8.703f, 11.89f, 8.61f) lineTo(15.77f, 4.73f) curveTo(16f, 4.5f, 16.272f, 4.317f, 16.573f, 4.192f) curveTo(16.873f, 4.067f, 17.195f, 4.003f, 17.52f, 4.003f) curveTo(17.845f, 4.003f, 18.167f, 4.067f, 18.468f, 4.192f) curveTo(18.768f, 4.317f, 19.041f, 4.5f, 19.27f, 4.73f) curveTo(19.502f, 4.959f, 19.685f, 5.231f, 19.811f, 5.532f) curveTo(19.937f, 5.832f, 20.001f, 6.154f, 20.001f, 6.48f) curveTo(20.001f, 6.806f, 19.937f, 7.128f, 19.811f, 7.428f) curveTo(19.685f, 7.729f, 19.502f, 8.001f, 19.27f, 8.23f) lineTo(15.39f, 12.11f) curveTo(15.296f, 12.203f, 15.222f, 12.314f, 15.171f, 12.435f) curveTo(15.12f, 12.557f, 15.094f, 12.688f, 15.094f, 12.82f) curveTo(15.094f, 12.952f, 15.12f, 13.083f, 15.171f, 13.205f) curveTo(15.222f, 13.326f, 15.296f, 13.437f, 15.39f, 13.53f) curveTo(15.483f, 13.624f, 15.594f, 13.698f, 15.715f, 13.749f) curveTo(15.837f, 13.8f, 15.968f, 13.826f, 16.1f, 13.826f) curveTo(16.232f, 13.826f, 16.363f, 13.8f, 16.485f, 13.749f) curveTo(16.607f, 13.698f, 16.717f, 13.624f, 16.81f, 13.53f) close() } }.build() return _AddLink!! } @Suppress("ObjectPropertyName") private var _AddLink: ImageVector? = null ================================================ FILE: shared/resources/src/commonMain/kotlin/com/abdownloadmanager/resources/icons/Alphabet.kt ================================================ package com.abdownloadmanager.resources.icons import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.SolidColor import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.graphics.vector.path import androidx.compose.ui.unit.dp val ABDMIcons.Alphabet: ImageVector get() { if (_Alphabet != null) { return _Alphabet!! } _Alphabet = ImageVector.Builder( name = "Alphabet", defaultWidth = 24.dp, defaultHeight = 24.dp, viewportWidth = 24f, viewportHeight = 24f ).apply { path(fill = SolidColor(Color.White)) { moveTo(9f, 12f) curveTo(9f, 11.735f, 8.895f, 11.481f, 8.707f, 11.293f) curveTo(8.519f, 11.105f, 8.265f, 11f, 8f, 11f) horizontalLineTo(6f) curveTo(5.448f, 11f, 5f, 10.552f, 5f, 10f) curveTo(5f, 9.448f, 5.448f, 9f, 6f, 9f) horizontalLineTo(8f) curveTo(8.796f, 9f, 9.558f, 9.316f, 10.121f, 9.879f) curveTo(10.684f, 10.441f, 11f, 11.204f, 11f, 12f) verticalLineTo(17f) curveTo(11f, 17.552f, 10.552f, 18f, 10f, 18f) horizontalLineTo(7f) curveTo(6.204f, 18f, 5.442f, 17.684f, 4.879f, 17.121f) curveTo(4.316f, 16.559f, 4f, 15.796f, 4f, 15f) curveTo(4f, 14.204f, 4.316f, 13.441f, 4.879f, 12.879f) curveTo(5.442f, 12.316f, 6.204f, 12f, 7f, 12f) horizontalLineTo(9f) close() moveTo(18f, 12f) curveTo(18f, 11.735f, 17.895f, 11.481f, 17.707f, 11.293f) curveTo(17.52f, 11.105f, 17.265f, 11f, 17f, 11f) horizontalLineTo(16f) curveTo(15.735f, 11f, 15.481f, 11.105f, 15.293f, 11.293f) curveTo(15.105f, 11.481f, 15f, 11.735f, 15f, 12f) verticalLineTo(15f) curveTo(15f, 15.265f, 15.105f, 15.519f, 15.293f, 15.707f) curveTo(15.481f, 15.895f, 15.735f, 16f, 16f, 16f) horizontalLineTo(17f) curveTo(17.265f, 16f, 17.52f, 15.895f, 17.707f, 15.707f) curveTo(17.895f, 15.519f, 18f, 15.265f, 18f, 15f) verticalLineTo(12f) close() moveTo(6.005f, 15.099f) curveTo(6.028f, 15.328f, 6.129f, 15.543f, 6.293f, 15.707f) curveTo(6.481f, 15.895f, 6.735f, 16f, 7f, 16f) horizontalLineTo(9f) verticalLineTo(14f) horizontalLineTo(7f) curveTo(6.735f, 14f, 6.481f, 14.105f, 6.293f, 14.293f) curveTo(6.105f, 14.481f, 6f, 14.735f, 6f, 15f) lineTo(6.005f, 15.099f) close() moveTo(20f, 15f) curveTo(20f, 15.796f, 19.684f, 16.559f, 19.121f, 17.121f) curveTo(18.559f, 17.684f, 17.796f, 18f, 17f, 18f) horizontalLineTo(16f) curveTo(15.548f, 18f, 15.109f, 17.895f, 14.709f, 17.704f) curveTo(14.528f, 17.886f, 14.277f, 18f, 14f, 18f) curveTo(13.448f, 18f, 13f, 17.552f, 13f, 17f) verticalLineTo(7f) curveTo(13f, 6.448f, 13.448f, 6f, 14f, 6f) curveTo(14.552f, 6f, 15f, 6.448f, 15f, 7f) verticalLineTo(9.175f) curveTo(15.318f, 9.062f, 15.656f, 9f, 16f, 9f) horizontalLineTo(17f) curveTo(17.796f, 9f, 18.559f, 9.316f, 19.121f, 9.879f) curveTo(19.684f, 10.441f, 20f, 11.204f, 20f, 12f) verticalLineTo(15f) close() } }.build() return _Alphabet!! } @Suppress("ObjectPropertyName") private var _Alphabet: ImageVector? = null ================================================ FILE: shared/resources/src/commonMain/kotlin/com/abdownloadmanager/resources/icons/AppIcon.kt ================================================ package com.abdownloadmanager.resources.icons import androidx.compose.ui.geometry.Offset import androidx.compose.ui.graphics.Brush import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.PathFillType import androidx.compose.ui.graphics.SolidColor import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.graphics.vector.path import androidx.compose.ui.unit.dp val ABDMIcons.AppIcon: ImageVector get() { if (_AppIcon != null) { return _AppIcon!! } _AppIcon = ImageVector.Builder( name = "AppIcon", defaultWidth = 48.dp, defaultHeight = 48.dp, viewportWidth = 48f, viewportHeight = 48f ).apply { path( fill = Brush.linearGradient( colorStops = arrayOf( 0f to Color(0xFFC631FF), 1f to Color(0xFF4DC4FE) ), start = Offset(50.062f, 23.014f), end = Offset(-1.681f, 23.014f) ), pathFillType = PathFillType.EvenOdd ) { moveTo(26.89f, 0.892f) curveTo(26.89f, 0.399f, 26.51f, 0f, 26.04f, 0f) horizontalLineTo(21.96f) curveTo(21.49f, 0f, 21.11f, 0.399f, 21.11f, 0.892f) verticalLineTo(17.84f) curveTo(21.11f, 18.333f, 20.729f, 18.732f, 20.26f, 18.732f) horizontalLineTo(18.851f) curveTo(18.16f, 18.732f, 17.758f, 19.552f, 18.16f, 20.143f) lineTo(23.308f, 27.706f) curveTo(23.647f, 28.205f, 24.353f, 28.205f, 24.692f, 27.706f) lineTo(29.84f, 20.143f) curveTo(30.242f, 19.552f, 29.84f, 18.732f, 29.149f, 18.732f) horizontalLineTo(27.74f) curveTo(27.271f, 18.732f, 26.89f, 18.333f, 26.89f, 17.84f) verticalLineTo(0.892f) close() moveTo(1.827f, 33.184f) curveTo(0.621f, 30.273f, 0f, 27.152f, 0f, 24f) horizontalLineTo(7.201f) curveTo(9.804f, 24f, 11.831f, 26.188f, 12.827f, 28.592f) curveTo(13.43f, 30.048f, 14.314f, 31.371f, 15.428f, 32.485f) curveTo(16.543f, 33.6f, 17.866f, 34.484f, 19.322f, 35.087f) curveTo(20.777f, 35.69f, 22.338f, 36f, 23.914f, 36f) curveTo(25.49f, 36f, 27.05f, 35.69f, 28.506f, 35.087f) curveTo(29.962f, 34.484f, 31.285f, 33.6f, 32.399f, 32.485f) curveTo(33.513f, 31.371f, 34.397f, 30.048f, 35f, 28.592f) curveTo(35.996f, 26.188f, 38.023f, 24f, 40.626f, 24f) horizontalLineTo(48f) curveTo(48f, 27.152f, 47.379f, 30.273f, 46.173f, 33.184f) curveTo(44.967f, 36.096f, 43.199f, 38.742f, 40.971f, 40.971f) curveTo(38.742f, 43.199f, 36.096f, 44.967f, 33.184f, 46.173f) curveTo(30.273f, 47.379f, 27.152f, 48f, 24f, 48f) curveTo(20.848f, 48f, 17.727f, 47.379f, 14.816f, 46.173f) curveTo(11.904f, 44.967f, 9.258f, 43.199f, 7.029f, 40.971f) curveTo(4.801f, 38.742f, 3.033f, 36.096f, 1.827f, 33.184f) close() moveTo(11.772f, 5.211f) curveTo(11.126f, 5.731f, 11.016f, 6.686f, 11.525f, 7.345f) curveTo(12.035f, 8.003f, 12.971f, 8.116f, 13.617f, 7.596f) lineTo(15.808f, 5.832f) curveTo(16.454f, 5.312f, 16.564f, 4.357f, 16.055f, 3.698f) curveTo(15.545f, 3.039f, 14.609f, 2.927f, 13.963f, 3.447f) lineTo(11.772f, 5.211f) close() moveTo(36.468f, 5.211f) curveTo(37.114f, 5.731f, 37.224f, 6.686f, 36.715f, 7.345f) curveTo(36.205f, 8.003f, 35.269f, 8.116f, 34.623f, 7.596f) lineTo(32.432f, 5.832f) curveTo(31.786f, 5.312f, 31.676f, 4.357f, 32.185f, 3.698f) curveTo(32.695f, 3.039f, 33.631f, 2.927f, 34.277f, 3.447f) lineTo(36.468f, 5.211f) close() moveTo(4.543f, 17.654f) curveTo(3.778f, 17.346f, 3.403f, 16.464f, 3.704f, 15.683f) lineTo(4.728f, 13.033f) curveTo(5.03f, 12.253f, 5.895f, 11.87f, 6.66f, 12.177f) curveTo(7.425f, 12.485f, 7.8f, 13.368f, 7.499f, 14.148f) lineTo(6.475f, 16.798f) curveTo(6.173f, 17.578f, 5.308f, 17.962f, 4.543f, 17.654f) close() moveTo(44.536f, 15.683f) curveTo(44.838f, 16.464f, 44.462f, 17.346f, 43.697f, 17.654f) curveTo(42.932f, 17.962f, 42.067f, 17.578f, 41.765f, 16.798f) lineTo(40.741f, 14.148f) curveTo(40.44f, 13.368f, 40.815f, 12.485f, 41.58f, 12.177f) curveTo(42.345f, 11.87f, 43.21f, 12.253f, 43.512f, 13.033f) lineTo(44.536f, 15.683f) close() } path( fill = SolidColor(Color.Black), fillAlpha = 0.25f ) { moveTo(40.97f, 40.97f) curveTo(36.47f, 45.471f, 30.365f, 48f, 24f, 48f) curveTo(17.635f, 48f, 11.53f, 45.471f, 7.029f, 40.97f) verticalLineTo(40.97f) curveTo(9.558f, 38.441f, 13.649f, 38.586f, 16.921f, 40.03f) curveTo(19.13f, 41.006f, 21.538f, 41.524f, 24f, 41.524f) curveTo(26.462f, 41.524f, 28.87f, 41.006f, 31.079f, 40.03f) curveTo(34.351f, 38.586f, 38.441f, 38.441f, 40.97f, 40.97f) verticalLineTo(40.97f) close() } }.build() return _AppIcon!! } @Suppress("ObjectPropertyName") private var _AppIcon: ImageVector? = null ================================================ FILE: shared/resources/src/commonMain/kotlin/com/abdownloadmanager/resources/icons/Back.kt ================================================ package com.abdownloadmanager.resources.icons import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.PathFillType import androidx.compose.ui.graphics.SolidColor import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.graphics.vector.path import androidx.compose.ui.unit.dp val ABDMIcons.Back: ImageVector get() { if (_Back != null) { return _Back!! } _Back = ImageVector.Builder( name = "Back", defaultWidth = 24.dp, defaultHeight = 24.dp, viewportWidth = 24f, viewportHeight = 24f ).apply { path( fill = SolidColor(Color.White), pathFillType = PathFillType.EvenOdd ) { moveTo(15.707f, 5.293f) curveTo(16.098f, 5.683f, 16.098f, 6.317f, 15.707f, 6.707f) lineTo(10.414f, 12f) lineTo(15.707f, 17.293f) curveTo(16.098f, 17.683f, 16.098f, 18.317f, 15.707f, 18.707f) curveTo(15.317f, 19.098f, 14.683f, 19.098f, 14.293f, 18.707f) lineTo(8.293f, 12.707f) curveTo(7.902f, 12.317f, 7.902f, 11.683f, 8.293f, 11.293f) lineTo(14.293f, 5.293f) curveTo(14.683f, 4.902f, 15.317f, 4.902f, 15.707f, 5.293f) close() } }.build() return _Back!! } @Suppress("ObjectPropertyName") private var _Back: ImageVector? = null ================================================ FILE: shared/resources/src/commonMain/kotlin/com/abdownloadmanager/resources/icons/BrowserGoogleChrome.kt ================================================ package com.abdownloadmanager.resources.icons import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.SolidColor import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.graphics.vector.path import androidx.compose.ui.unit.dp val ABDMIcons.BrowserGoogleChrome: ImageVector get() { if (_BrowserGoogleChrome != null) { return _BrowserGoogleChrome!! } _BrowserGoogleChrome = ImageVector.Builder( name = "BrowserGoogleChrome", defaultWidth = 24.dp, defaultHeight = 24.dp, viewportWidth = 24f, viewportHeight = 24f ).apply { path(fill = SolidColor(Color.White)) { moveTo(12f, 18.677f) curveTo(15.688f, 18.677f, 18.677f, 15.687f, 18.677f, 11.999f) curveTo(18.677f, 8.312f, 15.688f, 5.322f, 12f, 5.322f) curveTo(8.313f, 5.322f, 5.323f, 8.312f, 5.323f, 11.999f) curveTo(5.323f, 15.687f, 8.313f, 18.677f, 12f, 18.677f) } path(fill = SolidColor(Color(0xFF229342))) { moveTo(3.365f, 8.718f) curveTo(2.867f, 7.856f, 2.281f, 6.95f, 1.608f, 6.002f) curveTo(0.555f, 7.826f, 0f, 9.895f, 0f, 12.002f) curveTo(0f, 14.108f, 0.555f, 16.178f, 1.608f, 18.002f) curveTo(2.661f, 19.826f, 4.176f, 21.341f, 6.001f, 22.394f) curveTo(7.825f, 23.447f, 9.895f, 24.001f, 12.001f, 24f) curveTo(13.105f, 22.451f, 13.855f, 21.334f, 14.251f, 20.649f) curveTo(15.01f, 19.334f, 15.992f, 17.451f, 17.197f, 15.001f) verticalLineTo(15f) curveTo(16.67f, 15.912f, 15.913f, 16.67f, 15.001f, 17.197f) curveTo(14.089f, 17.724f, 13.054f, 18.001f, 12.001f, 18.001f) curveTo(10.947f, 18.002f, 9.912f, 17.724f, 9f, 17.198f) curveTo(8.088f, 16.671f, 7.33f, 15.913f, 6.804f, 15.001f) curveTo(5.168f, 11.95f, 4.021f, 9.855f, 3.365f, 8.718f) close() } path(fill = SolidColor(Color(0xFFFBC116))) { moveTo(12.001f, 24f) curveTo(13.577f, 24f, 15.137f, 23.69f, 16.593f, 23.087f) curveTo(18.049f, 22.484f, 19.372f, 21.6f, 20.486f, 20.486f) curveTo(21.601f, 19.371f, 22.484f, 18.048f, 23.087f, 16.592f) curveTo(23.69f, 15.136f, 24f, 13.576f, 24f, 12f) curveTo(24f, 9.893f, 23.444f, 7.824f, 22.391f, 6f) curveTo(20.118f, 5.776f, 18.44f, 5.664f, 17.358f, 5.664f) curveTo(16.131f, 5.664f, 14.345f, 5.776f, 12f, 6f) lineTo(11.998f, 6.001f) curveTo(13.052f, 6f, 14.087f, 6.277f, 14.999f, 6.804f) curveTo(15.912f, 7.33f, 16.67f, 8.087f, 17.196f, 9f) curveTo(17.723f, 9.912f, 18.001f, 10.947f, 18.001f, 12f) curveTo(18.001f, 13.054f, 17.723f, 14.088f, 17.196f, 15.001f) lineTo(12.001f, 24f) close() } path(fill = SolidColor(Color(0xFF1A73E8))) { moveTo(12f, 16.751f) curveTo(14.624f, 16.751f, 16.75f, 14.624f, 16.75f, 12.001f) curveTo(16.75f, 9.377f, 14.624f, 7.25f, 12f, 7.25f) curveTo(9.377f, 7.25f, 7.25f, 9.377f, 7.25f, 12.001f) curveTo(7.25f, 14.624f, 9.377f, 16.751f, 12f, 16.751f) close() } path(fill = SolidColor(Color(0xFFE33B2E))) { moveTo(12f, 6f) horizontalLineTo(22.391f) curveTo(21.338f, 4.176f, 19.824f, 2.661f, 17.999f, 1.608f) curveTo(16.175f, 0.554f, 14.106f, -0f, 11.999f, -0f) curveTo(9.893f, 0f, 7.824f, 0.555f, 6f, 1.608f) curveTo(4.175f, 2.662f, 2.661f, 4.177f, 1.608f, 6.002f) lineTo(6.804f, 15.001f) lineTo(6.805f, 15.002f) curveTo(6.278f, 14.09f, 6f, 13.055f, 6f, 12.001f) curveTo(6f, 10.948f, 6.277f, 9.913f, 6.803f, 9.001f) curveTo(7.33f, 8.088f, 8.088f, 7.331f, 9f, 6.804f) curveTo(9.912f, 6.277f, 10.947f, 6f, 12f, 6f) lineTo(12f, 6f) close() } }.build() return _BrowserGoogleChrome!! } @Suppress("ObjectPropertyName") private var _BrowserGoogleChrome: ImageVector? = null ================================================ FILE: shared/resources/src/commonMain/kotlin/com/abdownloadmanager/resources/icons/BrowserMicrosoftEdge.kt ================================================ package com.abdownloadmanager.resources.icons import androidx.compose.ui.geometry.Offset import androidx.compose.ui.graphics.Brush import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.graphics.vector.path import androidx.compose.ui.unit.dp val ABDMIcons.BrowserMicrosoftEdge: ImageVector get() { if (_BrowserMicrosoftEdge != null) { return _BrowserMicrosoftEdge!! } _BrowserMicrosoftEdge = ImageVector.Builder( name = "BrowserMicrosoftEdge", defaultWidth = 24.dp, defaultHeight = 24.dp, viewportWidth = 24f, viewportHeight = 24f ).apply { path( fill = Brush.linearGradient( colorStops = arrayOf( 0f to Color(0xFF0C59A4), 1f to Color(0xFF114A8B) ), start = Offset(5.504f, 16.596f), end = Offset(22.218f, 16.596f) ) ) { moveTo(21.656f, 17.859f) curveTo(21.337f, 18.028f, 21.009f, 18.178f, 20.672f, 18.3f) curveTo(19.594f, 18.703f, 18.459f, 18.909f, 17.306f, 18.909f) curveTo(12.872f, 18.909f, 9.009f, 15.863f, 9.009f, 11.944f) curveTo(9.019f, 10.875f, 9.609f, 9.891f, 10.547f, 9.384f) curveTo(6.534f, 9.553f, 5.503f, 13.734f, 5.503f, 16.181f) curveTo(5.503f, 23.109f, 11.887f, 23.813f, 13.266f, 23.813f) curveTo(14.006f, 23.813f, 15.122f, 23.597f, 15.797f, 23.381f) lineTo(15.919f, 23.344f) curveTo(18.506f, 22.453f, 20.7f, 20.709f, 22.163f, 18.394f) curveTo(22.275f, 18.216f, 22.219f, 17.991f, 22.05f, 17.878f) curveTo(21.928f, 17.803f, 21.778f, 17.794f, 21.656f, 17.859f) close() } path( fill = Brush.radialGradient( colorStops = arrayOf( 0.72f to Color.Transparent.copy(alpha = 0f), 0.95f to Color.Black.copy(alpha = 0.5294118f), 1f to Color.Black ), center = Offset(14.737f, 16.728f), radius = 8.941f ), fillAlpha = 0.35f, strokeAlpha = 0.35f ) { moveTo(21.656f, 17.859f) curveTo(21.337f, 18.028f, 21.009f, 18.178f, 20.672f, 18.3f) curveTo(19.594f, 18.703f, 18.459f, 18.909f, 17.306f, 18.909f) curveTo(12.872f, 18.909f, 9.009f, 15.863f, 9.009f, 11.944f) curveTo(9.019f, 10.875f, 9.609f, 9.891f, 10.547f, 9.384f) curveTo(6.534f, 9.553f, 5.503f, 13.734f, 5.503f, 16.181f) curveTo(5.503f, 23.109f, 11.887f, 23.813f, 13.266f, 23.813f) curveTo(14.006f, 23.813f, 15.122f, 23.597f, 15.797f, 23.381f) lineTo(15.919f, 23.344f) curveTo(18.506f, 22.453f, 20.7f, 20.709f, 22.163f, 18.394f) curveTo(22.275f, 18.216f, 22.219f, 17.991f, 22.05f, 17.878f) curveTo(21.928f, 17.803f, 21.778f, 17.794f, 21.656f, 17.859f) close() } path( fill = Brush.linearGradient( colorStops = arrayOf( 0f to Color(0xFF1B9DE2), 0.16f to Color(0xFF1595DF), 0.67f to Color(0xFF0680D7), 1f to Color(0xFF0078D4) ), start = Offset(14.322f, 9.351f), end = Offset(3.881f, 20.724f) ) ) { moveTo(9.909f, 22.631f) curveTo(9.075f, 22.116f, 8.353f, 21.431f, 7.781f, 20.634f) curveTo(5.316f, 17.259f, 6.056f, 12.525f, 9.431f, 10.059f) curveTo(9.788f, 9.806f, 10.153f, 9.572f, 10.547f, 9.384f) curveTo(10.838f, 9.244f, 11.334f, 9f, 12f, 9.009f) curveTo(12.947f, 9.019f, 13.838f, 9.469f, 14.409f, 10.228f) curveTo(14.784f, 10.734f, 15f, 11.344f, 15.009f, 11.981f) curveTo(15.009f, 11.962f, 17.306f, 4.519f, 7.509f, 4.519f) curveTo(3.394f, 4.519f, 0.009f, 8.428f, 0.009f, 11.85f) curveTo(-0.009f, 13.659f, 0.384f, 15.459f, 1.144f, 17.1f) curveTo(3.731f, 22.612f, 10.031f, 25.313f, 15.806f, 23.391f) curveTo(13.828f, 24.009f, 11.672f, 23.737f, 9.909f, 22.631f) close() } path( fill = Brush.radialGradient( colorStops = arrayOf( 0.76f to Color.Transparent.copy(alpha = 0f), 0.95f to Color.Black.copy(alpha = 0.49803922f), 1f to Color.Black ), center = Offset(6.62f, 18.657f), radius = 13.443f ), fillAlpha = 0.41f, strokeAlpha = 0.41f ) { moveTo(9.909f, 22.631f) curveTo(9.075f, 22.116f, 8.353f, 21.431f, 7.781f, 20.634f) curveTo(5.316f, 17.259f, 6.056f, 12.525f, 9.431f, 10.059f) curveTo(9.788f, 9.806f, 10.153f, 9.572f, 10.547f, 9.384f) curveTo(10.838f, 9.244f, 11.334f, 9f, 12f, 9.009f) curveTo(12.947f, 9.019f, 13.838f, 9.469f, 14.409f, 10.228f) curveTo(14.784f, 10.734f, 15f, 11.344f, 15.009f, 11.981f) curveTo(15.009f, 11.962f, 17.306f, 4.519f, 7.509f, 4.519f) curveTo(3.394f, 4.519f, 0.009f, 8.428f, 0.009f, 11.85f) curveTo(-0.009f, 13.659f, 0.384f, 15.459f, 1.144f, 17.1f) curveTo(3.731f, 22.612f, 10.031f, 25.313f, 15.806f, 23.391f) curveTo(13.828f, 24.009f, 11.672f, 23.737f, 9.909f, 22.631f) close() } path( fill = Brush.radialGradient( colorStops = arrayOf( 0f to Color(0xFF35C1F1), 0.11f to Color(0xFF34C1ED), 0.23f to Color(0xFF2FC2DF), 0.31f to Color(0xFF2BC3D2), 0.67f to Color(0xFF36C752) ), center = Offset(2.424f, 4.443f), radius = 18.989f ) ) { moveTo(14.278f, 13.959f) curveTo(14.203f, 14.053f, 13.969f, 14.194f, 13.969f, 14.494f) curveTo(13.969f, 14.738f, 14.128f, 14.972f, 14.409f, 15.169f) curveTo(15.759f, 16.106f, 18.3f, 15.984f, 18.309f, 15.984f) curveTo(19.313f, 15.984f, 20.288f, 15.712f, 21.15f, 15.206f) curveTo(22.913f, 14.175f, 24f, 12.291f, 24f, 10.247f) curveTo(24.028f, 8.147f, 23.25f, 6.75f, 22.941f, 6.131f) curveTo(20.953f, 2.241f, 16.659f, 0f, 12f, 0f) curveTo(5.438f, 0f, 0.094f, 5.269f, 0f, 11.831f) curveTo(0.047f, 8.409f, 3.45f, 5.644f, 7.5f, 5.644f) curveTo(7.828f, 5.644f, 9.703f, 5.672f, 11.438f, 6.591f) curveTo(12.966f, 7.397f, 13.772f, 8.363f, 14.325f, 9.328f) curveTo(14.906f, 10.331f, 15.009f, 11.587f, 15.009f, 12.094f) curveTo(15.009f, 12.591f, 14.756f, 13.341f, 14.278f, 13.959f) close() } path( fill = Brush.radialGradient( colorStops = arrayOf( 0f to Color(0xFF66EB6E), 1f to Color(0x0066EB6E) ), center = Offset(22.506f, 7.257f), radius = 9.124f ) ) { moveTo(14.278f, 13.959f) curveTo(14.203f, 14.053f, 13.969f, 14.194f, 13.969f, 14.494f) curveTo(13.969f, 14.738f, 14.128f, 14.972f, 14.409f, 15.169f) curveTo(15.759f, 16.106f, 18.3f, 15.984f, 18.309f, 15.984f) curveTo(19.313f, 15.984f, 20.288f, 15.712f, 21.15f, 15.206f) curveTo(22.913f, 14.175f, 24f, 12.291f, 24f, 10.247f) curveTo(24.028f, 8.147f, 23.25f, 6.75f, 22.941f, 6.131f) curveTo(20.953f, 2.241f, 16.659f, 0f, 12f, 0f) curveTo(5.438f, 0f, 0.094f, 5.269f, 0f, 11.831f) curveTo(0.047f, 8.409f, 3.45f, 5.644f, 7.5f, 5.644f) curveTo(7.828f, 5.644f, 9.703f, 5.672f, 11.438f, 6.591f) curveTo(12.966f, 7.397f, 13.772f, 8.363f, 14.325f, 9.328f) curveTo(14.906f, 10.331f, 15.009f, 11.587f, 15.009f, 12.094f) curveTo(15.009f, 12.591f, 14.756f, 13.341f, 14.278f, 13.959f) close() } }.build() return _BrowserMicrosoftEdge!! } @Suppress("ObjectPropertyName") private var _BrowserMicrosoftEdge: ImageVector? = null ================================================ FILE: shared/resources/src/commonMain/kotlin/com/abdownloadmanager/resources/icons/BrowserMozillaFirefox.kt ================================================ package com.abdownloadmanager.resources.icons import androidx.compose.ui.geometry.Offset import androidx.compose.ui.graphics.Brush import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.graphics.vector.PathData import androidx.compose.ui.graphics.vector.group import androidx.compose.ui.graphics.vector.path import androidx.compose.ui.unit.dp val ABDMIcons.BrowserMozillaFirefox: ImageVector get() { if (_BrowserMozillaFirefox != null) { return _BrowserMozillaFirefox!! } _BrowserMozillaFirefox = ImageVector.Builder( name = "BrowserMozillaFirefox", defaultWidth = 24.dp, defaultHeight = 24.dp, viewportWidth = 24f, viewportHeight = 24f ).apply { group( clipPathData = PathData { moveTo(0f, 0f) horizontalLineToRelative(24f) verticalLineToRelative(24f) horizontalLineToRelative(-24f) close() } ) { path( fill = Brush.linearGradient( colorStops = arrayOf( 0.048f to Color(0xFFFFF44F), 0.111f to Color(0xFFFFE847), 0.225f to Color(0xFFFFC830), 0.368f to Color(0xFFFF980E), 0.401f to Color(0xFFFF8B16), 0.462f to Color(0xFFFF672A), 0.534f to Color(0xFFFF3647), 0.705f to Color(0xFFE31587) ), start = Offset(21.172f, 3.71f), end = Offset(1.904f, 22.3f) ) ) { moveTo(22.708f, 8.034f) curveTo(22.204f, 6.82f, 21.181f, 5.51f, 20.379f, 5.096f) curveTo(20.951f, 6.203f, 21.347f, 7.391f, 21.555f, 8.619f) lineTo(21.557f, 8.639f) curveTo(20.245f, 5.367f, 18.019f, 4.048f, 16.202f, 1.175f) curveTo(16.108f, 1.029f, 16.017f, 0.88f, 15.928f, 0.731f) curveTo(15.883f, 0.652f, 15.84f, 0.572f, 15.801f, 0.491f) curveTo(15.725f, 0.345f, 15.667f, 0.191f, 15.627f, 0.031f) curveTo(15.627f, 0.024f, 15.625f, 0.017f, 15.62f, 0.011f) curveTo(15.615f, 0.006f, 15.608f, 0.002f, 15.601f, 0.001f) curveTo(15.594f, -0f, 15.586f, -0f, 15.579f, 0.001f) curveTo(15.578f, 0.001f, 15.575f, 0.004f, 15.573f, 0.005f) curveTo(15.572f, 0.005f, 15.568f, 0.008f, 15.565f, 0.009f) lineTo(15.57f, 0.001f) curveTo(12.654f, 1.708f, 11.665f, 4.868f, 11.574f, 6.449f) curveTo(10.41f, 6.529f, 9.297f, 6.958f, 8.38f, 7.68f) curveTo(8.284f, 7.599f, 8.184f, 7.523f, 8.08f, 7.453f) curveTo(7.816f, 6.528f, 7.805f, 5.548f, 8.048f, 4.616f) curveTo(6.977f, 5.135f, 6.026f, 5.87f, 5.254f, 6.775f) horizontalLineTo(5.249f) curveTo(4.789f, 6.192f, 4.821f, 4.27f, 4.847f, 3.868f) curveTo(4.711f, 3.923f, 4.581f, 3.992f, 4.46f, 4.074f) curveTo(4.054f, 4.364f, 3.674f, 4.689f, 3.325f, 5.046f) curveTo(2.928f, 5.449f, 2.565f, 5.884f, 2.24f, 6.348f) verticalLineTo(6.349f) verticalLineTo(6.347f) curveTo(1.494f, 7.405f, 0.965f, 8.6f, 0.683f, 9.864f) lineTo(0.668f, 9.941f) curveTo(0.625f, 10.181f, 0.587f, 10.423f, 0.553f, 10.665f) curveTo(0.553f, 10.674f, 0.552f, 10.682f, 0.551f, 10.691f) curveTo(0.449f, 11.219f, 0.386f, 11.754f, 0.362f, 12.291f) verticalLineTo(12.351f) curveTo(0.37f, 21.286f, 10.048f, 26.862f, 17.782f, 22.388f) curveTo(19.254f, 21.536f, 20.521f, 20.372f, 21.493f, 18.976f) curveTo(22.465f, 17.581f, 23.119f, 15.989f, 23.408f, 14.314f) curveTo(23.428f, 14.164f, 23.443f, 14.016f, 23.461f, 13.864f) curveTo(23.7f, 11.889f, 23.441f, 9.884f, 22.708f, 8.034f) close() moveTo(9.33f, 17.119f) curveTo(9.385f, 17.145f, 9.435f, 17.174f, 9.491f, 17.198f) lineTo(9.499f, 17.204f) curveTo(9.443f, 17.176f, 9.386f, 17.148f, 9.33f, 17.119f) close() moveTo(21.558f, 8.641f) verticalLineTo(8.63f) lineTo(21.56f, 8.642f) lineTo(21.558f, 8.641f) close() } path( fill = Brush.radialGradient( colorStops = arrayOf( 0.129f to Color(0xFFFFBD4F), 0.186f to Color(0xFFFFAC31), 0.247f to Color(0xFFFF9D17), 0.283f to Color(0xFFFF980E), 0.403f to Color(0xFFFF563B), 0.467f to Color(0xFFFF3750), 0.71f to Color(0xFFF5156C), 0.782f to Color(0xFFEB0878), 0.86f to Color(0xFFE50080) ), center = Offset(20.282f, 2.658f), radius = 24.197f ) ) { moveTo(22.708f, 8.034f) curveTo(22.204f, 6.82f, 21.181f, 5.51f, 20.379f, 5.096f) curveTo(20.951f, 6.203f, 21.347f, 7.391f, 21.555f, 8.619f) verticalLineTo(8.63f) lineTo(21.557f, 8.642f) curveTo(22.452f, 11.203f, 22.322f, 14.009f, 21.196f, 16.476f) curveTo(19.866f, 19.33f, 16.646f, 22.256f, 11.605f, 22.114f) curveTo(6.16f, 21.959f, 1.363f, 17.918f, 0.467f, 12.625f) curveTo(0.304f, 11.791f, 0.467f, 11.368f, 0.549f, 10.689f) curveTo(0.437f, 11.216f, 0.374f, 11.752f, 0.362f, 12.291f) verticalLineTo(12.351f) curveTo(0.37f, 21.286f, 10.048f, 26.862f, 17.782f, 22.388f) curveTo(19.254f, 21.536f, 20.521f, 20.372f, 21.493f, 18.976f) curveTo(22.465f, 17.581f, 23.119f, 15.989f, 23.408f, 14.314f) curveTo(23.428f, 14.164f, 23.443f, 14.016f, 23.461f, 13.864f) curveTo(23.7f, 11.889f, 23.441f, 9.884f, 22.708f, 8.034f) close() } path( fill = Brush.radialGradient( colorStops = arrayOf( 0.3f to Color(0xFF960E18), 0.351f to Color(0xBCB11927), 0.435f to Color(0x57DB293D), 0.497f to Color(0x17F5334B), 0.53f to Color(0x00FF3750) ), center = Offset(11.44f, 12.55f), radius = 24.197f ) ) { moveTo(22.708f, 8.034f) curveTo(22.204f, 6.82f, 21.181f, 5.51f, 20.379f, 5.096f) curveTo(20.951f, 6.203f, 21.347f, 7.391f, 21.555f, 8.619f) verticalLineTo(8.63f) lineTo(21.557f, 8.642f) curveTo(22.452f, 11.203f, 22.322f, 14.009f, 21.196f, 16.476f) curveTo(19.866f, 19.33f, 16.646f, 22.256f, 11.605f, 22.114f) curveTo(6.16f, 21.959f, 1.363f, 17.918f, 0.467f, 12.625f) curveTo(0.304f, 11.791f, 0.467f, 11.368f, 0.549f, 10.689f) curveTo(0.437f, 11.216f, 0.374f, 11.752f, 0.362f, 12.291f) verticalLineTo(12.351f) curveTo(0.37f, 21.286f, 10.048f, 26.862f, 17.782f, 22.388f) curveTo(19.254f, 21.536f, 20.521f, 20.372f, 21.493f, 18.976f) curveTo(22.465f, 17.581f, 23.119f, 15.989f, 23.408f, 14.314f) curveTo(23.428f, 14.164f, 23.443f, 14.016f, 23.461f, 13.864f) curveTo(23.7f, 11.889f, 23.441f, 9.884f, 22.708f, 8.034f) close() } path( fill = Brush.radialGradient( colorStops = arrayOf( 0.132f to Color(0xFFFFF44F), 0.252f to Color(0xFFFFDC3E), 0.506f to Color(0xFFFF9D12), 0.526f to Color(0xFFFF980E) ), center = Offset(14.357f, -2.833f), radius = 17.529f ) ) { moveTo(17.067f, 9.398f) curveTo(17.093f, 9.416f, 17.116f, 9.434f, 17.14f, 9.451f) curveTo(16.848f, 8.934f, 16.485f, 8.461f, 16.062f, 8.045f) curveTo(12.454f, 4.437f, 15.116f, 0.222f, 15.565f, 0.008f) lineTo(15.57f, 0.001f) curveTo(12.654f, 1.708f, 11.665f, 4.868f, 11.574f, 6.449f) curveTo(11.709f, 6.44f, 11.844f, 6.428f, 11.982f, 6.428f) curveTo(13.016f, 6.43f, 14.032f, 6.706f, 14.925f, 7.228f) curveTo(15.818f, 7.749f, 16.558f, 8.498f, 17.067f, 9.398f) close() } path( fill = Brush.radialGradient( colorStops = arrayOf( 0.353f to Color(0xFF3A8EE6), 0.472f to Color(0xFF5C79F0), 0.669f to Color(0xFF9059FF), 1f to Color(0xFFC139E6) ), center = Offset(8.763f, 18.871f), radius = 11.521f ) ) { moveTo(11.989f, 10.119f) curveTo(11.97f, 10.408f, 10.95f, 11.403f, 10.594f, 11.403f) curveTo(7.293f, 11.403f, 6.757f, 13.4f, 6.757f, 13.4f) curveTo(6.903f, 15.081f, 8.075f, 16.466f, 9.491f, 17.198f) curveTo(9.556f, 17.232f, 9.621f, 17.262f, 9.687f, 17.292f) curveTo(9.799f, 17.342f, 9.913f, 17.388f, 10.028f, 17.431f) curveTo(10.514f, 17.603f, 11.023f, 17.702f, 11.538f, 17.723f) curveTo(17.323f, 17.994f, 18.444f, 10.805f, 14.269f, 8.719f) curveTo(15.254f, 8.591f, 16.251f, 8.833f, 17.067f, 9.398f) curveTo(16.558f, 8.498f, 15.818f, 7.749f, 14.925f, 7.228f) curveTo(14.032f, 6.706f, 13.016f, 6.43f, 11.982f, 6.428f) curveTo(11.844f, 6.428f, 11.709f, 6.44f, 11.574f, 6.449f) curveTo(10.41f, 6.529f, 9.297f, 6.958f, 8.38f, 7.68f) curveTo(8.557f, 7.83f, 8.757f, 8.03f, 9.177f, 8.445f) curveTo(9.965f, 9.221f, 11.985f, 10.024f, 11.989f, 10.119f) close() } path( fill = Brush.radialGradient( colorStops = arrayOf( 0.206f to Color(0x009059FF), 0.278f to Color(0x108C4FF3), 0.747f to Color(0x727716A8), 0.975f to Color(0x996E008B) ), center = Offset(12.766f, 10.568f), radius = 6.108f ) ) { moveTo(11.989f, 10.119f) curveTo(11.97f, 10.408f, 10.95f, 11.403f, 10.594f, 11.403f) curveTo(7.293f, 11.403f, 6.757f, 13.4f, 6.757f, 13.4f) curveTo(6.903f, 15.081f, 8.075f, 16.466f, 9.491f, 17.198f) curveTo(9.556f, 17.232f, 9.621f, 17.262f, 9.687f, 17.292f) curveTo(9.799f, 17.342f, 9.913f, 17.388f, 10.028f, 17.431f) curveTo(10.514f, 17.603f, 11.023f, 17.702f, 11.538f, 17.723f) curveTo(17.323f, 17.994f, 18.444f, 10.805f, 14.269f, 8.719f) curveTo(15.254f, 8.591f, 16.251f, 8.833f, 17.067f, 9.398f) curveTo(16.558f, 8.498f, 15.818f, 7.749f, 14.925f, 7.228f) curveTo(14.032f, 6.706f, 13.016f, 6.43f, 11.982f, 6.428f) curveTo(11.844f, 6.428f, 11.709f, 6.44f, 11.574f, 6.449f) curveTo(10.41f, 6.529f, 9.297f, 6.958f, 8.38f, 7.68f) curveTo(8.557f, 7.83f, 8.757f, 8.03f, 9.177f, 8.445f) curveTo(9.965f, 9.221f, 11.985f, 10.024f, 11.989f, 10.119f) close() } path( fill = Brush.radialGradient( colorStops = arrayOf( 0f to Color(0xFFFFE226), 0.121f to Color(0xFFFFDB27), 0.295f to Color(0xFFFFC82A), 0.502f to Color(0xFFFFA930), 0.732f to Color(0xFFFF7E37), 0.792f to Color(0xFFFF7139) ), center = Offset(11.134f, 1.668f), radius = 8.288f ) ) { moveTo(7.839f, 7.294f) curveTo(7.92f, 7.346f, 7.999f, 7.399f, 8.078f, 7.453f) curveTo(7.814f, 6.528f, 7.802f, 5.548f, 8.046f, 4.616f) curveTo(6.975f, 5.135f, 6.024f, 5.87f, 5.252f, 6.776f) curveTo(5.308f, 6.774f, 6.992f, 6.744f, 7.839f, 7.294f) close() } path( fill = Brush.radialGradient( colorStops = arrayOf( 0.113f to Color(0xFFFFF44F), 0.456f to Color(0xFFFF980E), 0.622f to Color(0xFFFF5634), 0.716f to Color(0xFFFF3647), 0.904f to Color(0xFFE31587) ), center = Offset(17.649f, -3.589f), radius = 35.362f ) ) { moveTo(0.468f, 12.625f) curveTo(1.364f, 17.918f, 6.161f, 21.959f, 11.607f, 22.114f) curveTo(16.647f, 22.256f, 19.867f, 19.33f, 21.197f, 16.476f) curveTo(22.324f, 14.009f, 22.453f, 11.203f, 21.559f, 8.642f) verticalLineTo(8.631f) curveTo(21.559f, 8.623f, 21.557f, 8.618f, 21.559f, 8.62f) lineTo(21.561f, 8.64f) curveTo(21.972f, 11.328f, 20.605f, 13.933f, 18.467f, 15.694f) lineTo(18.461f, 15.709f) curveTo(14.296f, 19.101f, 10.31f, 17.755f, 9.503f, 17.206f) curveTo(9.446f, 17.179f, 9.39f, 17.151f, 9.334f, 17.122f) curveTo(6.905f, 15.961f, 5.902f, 13.749f, 6.117f, 11.851f) curveTo(5.541f, 11.86f, 4.974f, 11.701f, 4.486f, 11.394f) curveTo(3.998f, 11.087f, 3.61f, 10.645f, 3.368f, 10.122f) curveTo(4.005f, 9.731f, 4.732f, 9.511f, 5.479f, 9.481f) curveTo(6.226f, 9.451f, 6.968f, 9.612f, 7.635f, 9.951f) curveTo(9.01f, 10.575f, 10.574f, 10.636f, 11.993f, 10.122f) curveTo(11.988f, 10.027f, 9.969f, 9.223f, 9.181f, 8.448f) curveTo(8.76f, 8.033f, 8.56f, 7.833f, 8.383f, 7.683f) curveTo(8.288f, 7.602f, 8.188f, 7.526f, 8.084f, 7.456f) curveTo(8.015f, 7.409f, 7.937f, 7.358f, 7.844f, 7.297f) curveTo(6.998f, 6.747f, 5.314f, 6.777f, 5.258f, 6.778f) horizontalLineTo(5.253f) curveTo(4.793f, 6.195f, 4.825f, 4.273f, 4.852f, 3.871f) curveTo(4.716f, 3.926f, 4.586f, 3.995f, 4.464f, 4.077f) curveTo(4.058f, 4.367f, 3.678f, 4.692f, 3.33f, 5.049f) curveTo(2.931f, 5.45f, 2.567f, 5.885f, 2.24f, 6.348f) verticalLineTo(6.349f) verticalLineTo(6.347f) curveTo(1.494f, 7.405f, 0.965f, 8.6f, 0.683f, 9.864f) curveTo(0.677f, 9.888f, 0.265f, 11.69f, 0.468f, 12.625f) close() } path( fill = Brush.radialGradient( colorStops = arrayOf( 0f to Color(0xFFFFF44F), 0.06f to Color(0xFFFFE847), 0.168f to Color(0xFFFFC830), 0.304f to Color(0xFFFF980E), 0.356f to Color(0xFFFF8B16), 0.455f to Color(0xFFFF672A), 0.57f to Color(0xFFFF3647), 0.737f to Color(0xFFE31587) ), center = Offset(14.674f, -1.623f), radius = 25.918f ) ) { moveTo(16.062f, 8.045f) curveTo(16.486f, 8.461f, 16.849f, 8.935f, 17.14f, 9.453f) curveTo(17.201f, 9.498f, 17.258f, 9.545f, 17.314f, 9.595f) curveTo(19.946f, 12.021f, 18.567f, 15.45f, 18.464f, 15.694f) curveTo(20.602f, 13.933f, 21.968f, 11.328f, 21.558f, 8.64f) curveTo(20.245f, 5.367f, 18.019f, 4.048f, 16.202f, 1.175f) curveTo(16.108f, 1.029f, 16.017f, 0.88f, 15.928f, 0.731f) curveTo(15.883f, 0.652f, 15.84f, 0.572f, 15.8f, 0.491f) curveTo(15.725f, 0.345f, 15.667f, 0.191f, 15.627f, 0.031f) curveTo(15.627f, 0.024f, 15.625f, 0.017f, 15.62f, 0.011f) curveTo(15.615f, 0.006f, 15.608f, 0.002f, 15.601f, 0.001f) curveTo(15.594f, -0f, 15.586f, -0f, 15.579f, 0.001f) curveTo(15.578f, 0.001f, 15.575f, 0.004f, 15.573f, 0.005f) curveTo(15.572f, 0.005f, 15.568f, 0.008f, 15.565f, 0.009f) curveTo(15.116f, 0.222f, 12.454f, 4.437f, 16.062f, 8.045f) close() } path( fill = Brush.radialGradient( colorStops = arrayOf( 0.137f to Color(0xFFFFF44F), 0.48f to Color(0xFFFF980E), 0.592f to Color(0xFFFF5634), 0.655f to Color(0xFFFF3647), 0.904f to Color(0xFFE31587) ), center = Offset(10.939f, 4.738f), radius = 22.077f ) ) { moveTo(17.313f, 9.594f) curveTo(17.257f, 9.544f, 17.199f, 9.496f, 17.139f, 9.451f) curveTo(17.115f, 9.434f, 17.092f, 9.416f, 17.066f, 9.398f) curveTo(16.25f, 8.833f, 15.253f, 8.591f, 14.268f, 8.719f) curveTo(18.442f, 10.806f, 17.322f, 17.994f, 11.537f, 17.723f) curveTo(11.022f, 17.702f, 10.513f, 17.603f, 10.027f, 17.431f) curveTo(9.912f, 17.388f, 9.798f, 17.342f, 9.686f, 17.292f) curveTo(9.62f, 17.262f, 9.555f, 17.232f, 9.49f, 17.198f) lineTo(9.498f, 17.204f) curveTo(10.305f, 17.754f, 14.29f, 19.1f, 18.456f, 15.706f) lineTo(18.462f, 15.691f) curveTo(18.566f, 15.448f, 19.945f, 12.019f, 17.313f, 9.594f) close() } path( fill = Brush.radialGradient( colorStops = arrayOf( 0.094f to Color(0xFFFFF44F), 0.231f to Color(0xFFFFE141), 0.509f to Color(0xFFFFAF1E), 0.626f to Color(0xFFFF980E) ), center = Offset(16.767f, 6.03f), radius = 24.163f ) ) { moveTo(6.757f, 13.4f) curveTo(6.757f, 13.4f, 7.293f, 11.403f, 10.594f, 11.403f) curveTo(10.95f, 11.403f, 11.971f, 10.408f, 11.989f, 10.119f) curveTo(10.57f, 10.633f, 9.006f, 10.571f, 7.631f, 9.947f) curveTo(6.965f, 9.609f, 6.222f, 9.448f, 5.475f, 9.478f) curveTo(4.728f, 9.508f, 4.002f, 9.728f, 3.364f, 10.119f) curveTo(3.606f, 10.642f, 3.995f, 11.084f, 4.483f, 11.391f) curveTo(4.971f, 11.698f, 5.537f, 11.857f, 6.114f, 11.848f) curveTo(5.899f, 13.746f, 6.902f, 15.959f, 9.33f, 17.119f) curveTo(9.385f, 17.145f, 9.435f, 17.173f, 9.491f, 17.198f) curveTo(8.074f, 16.466f, 6.903f, 15.081f, 6.757f, 13.4f) close() } path( fill = Brush.linearGradient( colorStops = arrayOf( 0.167f to Color(0xCCFFF44F), 0.266f to Color(0xA1FFF44F), 0.489f to Color(0x37FFF44F), 0.6f to Color(0x00FFF44F) ), start = Offset(20.94f, 3.611f), end = Offset(4.545f, 20.005f) ) ) { moveTo(22.708f, 8.034f) curveTo(22.204f, 6.82f, 21.181f, 5.51f, 20.379f, 5.096f) curveTo(20.951f, 6.203f, 21.347f, 7.391f, 21.555f, 8.619f) lineTo(21.557f, 8.639f) curveTo(20.245f, 5.367f, 18.019f, 4.048f, 16.202f, 1.175f) curveTo(16.108f, 1.029f, 16.017f, 0.88f, 15.928f, 0.731f) curveTo(15.883f, 0.652f, 15.84f, 0.572f, 15.801f, 0.491f) curveTo(15.725f, 0.345f, 15.667f, 0.191f, 15.627f, 0.031f) curveTo(15.627f, 0.024f, 15.625f, 0.017f, 15.62f, 0.011f) curveTo(15.615f, 0.006f, 15.608f, 0.002f, 15.601f, 0.001f) curveTo(15.594f, -0f, 15.586f, -0f, 15.579f, 0.001f) curveTo(15.578f, 0.001f, 15.575f, 0.004f, 15.573f, 0.005f) curveTo(15.572f, 0.005f, 15.568f, 0.008f, 15.565f, 0.009f) lineTo(15.57f, 0.001f) curveTo(12.654f, 1.708f, 11.665f, 4.868f, 11.574f, 6.449f) curveTo(11.709f, 6.44f, 11.844f, 6.428f, 11.982f, 6.428f) curveTo(13.016f, 6.43f, 14.032f, 6.706f, 14.925f, 7.228f) curveTo(15.818f, 7.749f, 16.558f, 8.498f, 17.068f, 9.398f) curveTo(16.251f, 8.833f, 15.254f, 8.591f, 14.269f, 8.719f) curveTo(18.444f, 10.806f, 17.324f, 17.994f, 11.538f, 17.723f) curveTo(11.023f, 17.702f, 10.514f, 17.603f, 10.028f, 17.431f) curveTo(9.913f, 17.388f, 9.799f, 17.342f, 9.687f, 17.292f) curveTo(9.622f, 17.262f, 9.556f, 17.232f, 9.491f, 17.198f) lineTo(9.499f, 17.204f) curveTo(9.443f, 17.176f, 9.386f, 17.148f, 9.33f, 17.119f) curveTo(9.385f, 17.145f, 9.435f, 17.174f, 9.491f, 17.198f) curveTo(8.074f, 16.466f, 6.903f, 15.081f, 6.757f, 13.4f) curveTo(6.757f, 13.4f, 7.293f, 11.403f, 10.594f, 11.403f) curveTo(10.95f, 11.403f, 11.971f, 10.408f, 11.989f, 10.119f) curveTo(11.985f, 10.024f, 9.965f, 9.22f, 9.177f, 8.445f) curveTo(8.757f, 8.03f, 8.557f, 7.83f, 8.38f, 7.68f) curveTo(8.284f, 7.599f, 8.184f, 7.523f, 8.08f, 7.453f) curveTo(7.816f, 6.528f, 7.805f, 5.548f, 8.048f, 4.616f) curveTo(6.977f, 5.135f, 6.026f, 5.87f, 5.254f, 6.775f) horizontalLineTo(5.249f) curveTo(4.789f, 6.192f, 4.821f, 4.27f, 4.847f, 3.868f) curveTo(4.711f, 3.923f, 4.581f, 3.992f, 4.46f, 4.074f) curveTo(4.054f, 4.364f, 3.674f, 4.689f, 3.325f, 5.046f) curveTo(2.928f, 5.449f, 2.565f, 5.884f, 2.24f, 6.348f) verticalLineTo(6.349f) verticalLineTo(6.347f) curveTo(1.494f, 7.405f, 0.965f, 8.6f, 0.683f, 9.864f) lineTo(0.668f, 9.941f) curveTo(0.646f, 10.043f, 0.548f, 10.561f, 0.534f, 10.673f) curveTo(0.534f, 10.681f, 0.534f, 10.664f, 0.534f, 10.673f) curveTo(0.444f, 11.208f, 0.387f, 11.749f, 0.362f, 12.291f) verticalLineTo(12.351f) curveTo(0.37f, 21.286f, 10.048f, 26.862f, 17.782f, 22.388f) curveTo(19.254f, 21.536f, 20.521f, 20.372f, 21.493f, 18.976f) curveTo(22.465f, 17.581f, 23.119f, 15.989f, 23.408f, 14.314f) curveTo(23.428f, 14.164f, 23.443f, 14.016f, 23.461f, 13.864f) curveTo(23.7f, 11.889f, 23.441f, 9.884f, 22.708f, 8.034f) close() } } }.build() return _BrowserMozillaFirefox!! } @Suppress("ObjectPropertyName") private var _BrowserMozillaFirefox: ImageVector? = null ================================================ FILE: shared/resources/src/commonMain/kotlin/com/abdownloadmanager/resources/icons/BrowserOpera.kt ================================================ package com.abdownloadmanager.resources.icons import androidx.compose.ui.geometry.Offset import androidx.compose.ui.graphics.Brush import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.graphics.vector.path import androidx.compose.ui.unit.dp val ABDMIcons.BrowserOpera: ImageVector get() { if (_BrowserOpera != null) { return _BrowserOpera!! } _BrowserOpera = ImageVector.Builder( name = "BrowserOpera", defaultWidth = 24.dp, defaultHeight = 24.dp, viewportWidth = 24f, viewportHeight = 24f ).apply { path( fill = Brush.linearGradient( colorStops = arrayOf( 0.3f to Color(0xFFFF1B2D), 0.438f to Color(0xFFFA1A2C), 0.594f to Color(0xFFED1528), 0.758f to Color(0xFFD60E21), 0.927f to Color(0xFFB70519), 1f to Color(0xFFA70014) ), start = Offset(999.904f, 39.12f), end = Offset(999.904f, 2365.08f) ) ) { moveTo(8.053f, 18.759f) curveTo(6.722f, 17.194f, 5.869f, 14.878f, 5.813f, 12.281f) verticalLineTo(11.719f) curveTo(5.869f, 9.122f, 6.731f, 6.806f, 8.053f, 5.241f) curveTo(9.778f, 3.009f, 12.309f, 2.006f, 15.169f, 2.006f) curveTo(16.931f, 2.006f, 18.591f, 2.128f, 19.997f, 3.066f) curveTo(17.888f, 1.163f, 15.103f, 0.009f, 12.047f, 0f) horizontalLineTo(12f) curveTo(5.372f, 0f, 0f, 5.372f, 0f, 12f) curveTo(0f, 18.431f, 5.063f, 23.691f, 11.428f, 23.991f) curveTo(11.616f, 24f, 11.813f, 24f, 12f, 24f) curveTo(15.075f, 24f, 17.878f, 22.847f, 19.997f, 20.944f) curveTo(18.591f, 21.881f, 17.025f, 21.919f, 15.262f, 21.919f) curveTo(12.413f, 21.928f, 9.769f, 21f, 8.053f, 18.759f) close() } path( fill = Brush.linearGradient( colorStops = arrayOf( 0f to Color(0xFF9C0000), 0.7f to Color(0xFFFF4B4B) ), start = Offset(805.237f, 19.369f), end = Offset(805.237f, 2076.56f) ) ) { moveTo(8.053f, 5.241f) curveTo(9.15f, 3.937f, 10.575f, 3.159f, 12.131f, 3.159f) curveTo(15.628f, 3.159f, 18.459f, 7.116f, 18.459f, 12.009f) curveTo(18.459f, 16.903f, 15.628f, 20.859f, 12.131f, 20.859f) curveTo(10.575f, 20.859f, 9.159f, 20.072f, 8.053f, 18.778f) curveTo(9.778f, 21.009f, 12.337f, 22.434f, 15.188f, 22.434f) curveTo(16.941f, 22.434f, 18.591f, 21.9f, 19.997f, 20.962f) curveTo(22.453f, 18.75f, 24f, 15.553f, 24f, 12f) curveTo(24f, 8.447f, 22.453f, 5.25f, 19.997f, 3.056f) curveTo(18.591f, 2.119f, 16.95f, 1.584f, 15.188f, 1.584f) curveTo(12.328f, 1.584f, 9.769f, 3f, 8.053f, 5.241f) close() } }.build() return _BrowserOpera!! } @Suppress("ObjectPropertyName") private var _BrowserOpera: ImageVector? = null ================================================ FILE: shared/resources/src/commonMain/kotlin/com/abdownloadmanager/resources/icons/Check.kt ================================================ package com.abdownloadmanager.resources.icons import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.SolidColor import androidx.compose.ui.graphics.StrokeCap import androidx.compose.ui.graphics.StrokeJoin import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.graphics.vector.path import androidx.compose.ui.unit.dp val ABDMIcons.Check: ImageVector get() { if (_Check != null) { return _Check!! } _Check = ImageVector.Builder( name = "Check", defaultWidth = 24.dp, defaultHeight = 24.dp, viewportWidth = 24f, viewportHeight = 24f ).apply { path( stroke = SolidColor(Color.White), strokeLineWidth = 2f, strokeLineCap = StrokeCap.Round, strokeLineJoin = StrokeJoin.Round ) { moveTo(5f, 12f) lineTo(10f, 17f) lineTo(20f, 7f) } }.build() return _Check!! } @Suppress("ObjectPropertyName") private var _Check: ImageVector? = null ================================================ FILE: shared/resources/src/commonMain/kotlin/com/abdownloadmanager/resources/icons/Clear.kt ================================================ package com.abdownloadmanager.resources.icons import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.PathFillType import androidx.compose.ui.graphics.SolidColor import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.graphics.vector.path import androidx.compose.ui.unit.dp val ABDMIcons.Clear: ImageVector get() { if (_Clear != null) { return _Clear!! } _Clear = ImageVector.Builder( name = "Clear", defaultWidth = 24.dp, defaultHeight = 24.dp, viewportWidth = 24f, viewportHeight = 24f ).apply { path( fill = SolidColor(Color.White), pathFillType = PathFillType.EvenOdd ) { moveTo(5.293f, 5.293f) curveTo(5.683f, 4.902f, 6.317f, 4.902f, 6.707f, 5.293f) lineTo(12f, 10.586f) lineTo(17.293f, 5.293f) curveTo(17.683f, 4.902f, 18.317f, 4.902f, 18.707f, 5.293f) curveTo(19.098f, 5.683f, 19.098f, 6.317f, 18.707f, 6.707f) lineTo(13.414f, 12f) lineTo(18.707f, 17.293f) curveTo(19.098f, 17.683f, 19.098f, 18.317f, 18.707f, 18.707f) curveTo(18.317f, 19.098f, 17.683f, 19.098f, 17.293f, 18.707f) lineTo(12f, 13.414f) lineTo(6.707f, 18.707f) curveTo(6.317f, 19.098f, 5.683f, 19.098f, 5.293f, 18.707f) curveTo(4.902f, 18.317f, 4.902f, 17.683f, 5.293f, 17.293f) lineTo(10.586f, 12f) lineTo(5.293f, 6.707f) curveTo(4.902f, 6.317f, 4.902f, 5.683f, 5.293f, 5.293f) close() } }.build() return _Clear!! } @Suppress("ObjectPropertyName") private var _Clear: ImageVector? = null ================================================ FILE: shared/resources/src/commonMain/kotlin/com/abdownloadmanager/resources/icons/Clipboard.kt ================================================ package com.abdownloadmanager.resources.icons import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.SolidColor import androidx.compose.ui.graphics.StrokeCap import androidx.compose.ui.graphics.StrokeJoin import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.graphics.vector.path import androidx.compose.ui.unit.dp val ABDMIcons.Clipboard: ImageVector get() { if (_Clipboard != null) { return _Clipboard!! } _Clipboard = ImageVector.Builder( name = "Clipboard", defaultWidth = 24.dp, defaultHeight = 24.dp, viewportWidth = 24f, viewportHeight = 24f ).apply { path( stroke = SolidColor(Color.White), strokeLineWidth = 2f, strokeLineCap = StrokeCap.Round, strokeLineJoin = StrokeJoin.Round ) { moveTo(9f, 5f) horizontalLineTo(7f) curveTo(6.47f, 5f, 5.961f, 5.211f, 5.586f, 5.586f) curveTo(5.211f, 5.961f, 5f, 6.47f, 5f, 7f) verticalLineTo(19f) curveTo(5f, 19.53f, 5.211f, 20.039f, 5.586f, 20.414f) curveTo(5.961f, 20.789f, 6.47f, 21f, 7f, 21f) horizontalLineTo(17f) curveTo(17.53f, 21f, 18.039f, 20.789f, 18.414f, 20.414f) curveTo(18.789f, 20.039f, 19f, 19.53f, 19f, 19f) verticalLineTo(7f) curveTo(19f, 6.47f, 18.789f, 5.961f, 18.414f, 5.586f) curveTo(18.039f, 5.211f, 17.53f, 5f, 17f, 5f) horizontalLineTo(15f) moveTo(9f, 5f) curveTo(9f, 4.47f, 9.211f, 3.961f, 9.586f, 3.586f) curveTo(9.961f, 3.211f, 10.47f, 3f, 11f, 3f) horizontalLineTo(13f) curveTo(13.53f, 3f, 14.039f, 3.211f, 14.414f, 3.586f) curveTo(14.789f, 3.961f, 15f, 4.47f, 15f, 5f) moveTo(9f, 5f) curveTo(9f, 5.53f, 9.211f, 6.039f, 9.586f, 6.414f) curveTo(9.961f, 6.789f, 10.47f, 7f, 11f, 7f) horizontalLineTo(13f) curveTo(13.53f, 7f, 14.039f, 6.789f, 14.414f, 6.414f) curveTo(14.789f, 6.039f, 15f, 5.53f, 15f, 5f) } }.build() return _Clipboard!! } @Suppress("ObjectPropertyName") private var _Clipboard: ImageVector? = null ================================================ FILE: shared/resources/src/commonMain/kotlin/com/abdownloadmanager/resources/icons/Clock.kt ================================================ package com.abdownloadmanager.resources.icons import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.SolidColor import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.graphics.vector.path import androidx.compose.ui.unit.dp val ABDMIcons.Clock: ImageVector get() { if (_Clock != null) { return _Clock!! } _Clock = ImageVector.Builder( name = "Clock", defaultWidth = 24.dp, defaultHeight = 24.dp, viewportWidth = 24f, viewportHeight = 24f ).apply { path(fill = SolidColor(Color.White)) { moveTo(20f, 12f) curveTo(20f, 9.878f, 19.157f, 7.843f, 17.657f, 6.343f) curveTo(16.157f, 4.842f, 14.122f, 4f, 12f, 4f) curveTo(9.878f, 4f, 7.843f, 4.842f, 6.343f, 6.343f) curveTo(4.842f, 7.843f, 4f, 9.878f, 4f, 12f) curveTo(4f, 13.051f, 4.207f, 14.091f, 4.609f, 15.061f) curveTo(5.011f, 16.032f, 5.6f, 16.914f, 6.343f, 17.657f) curveTo(7.086f, 18.4f, 7.968f, 18.989f, 8.938f, 19.391f) curveTo(9.909f, 19.793f, 10.949f, 20f, 12f, 20f) lineTo(12.394f, 19.99f) curveTo(13.31f, 19.945f, 14.212f, 19.742f, 15.061f, 19.391f) curveTo(16.032f, 18.989f, 16.914f, 18.4f, 17.657f, 17.657f) curveTo(18.4f, 16.914f, 18.989f, 16.032f, 19.391f, 15.061f) curveTo(19.793f, 14.091f, 20f, 13.051f, 20f, 12f) close() moveTo(11f, 7f) curveTo(11f, 6.448f, 11.448f, 6f, 12f, 6f) curveTo(12.552f, 6f, 13f, 6.448f, 13f, 7f) verticalLineTo(11.586f) lineTo(15.707f, 14.293f) curveTo(16.098f, 14.684f, 16.098f, 15.316f, 15.707f, 15.707f) curveTo(15.316f, 16.098f, 14.684f, 16.098f, 14.293f, 15.707f) lineTo(11.293f, 12.707f) curveTo(11.105f, 12.519f, 11f, 12.265f, 11f, 12f) verticalLineTo(7f) close() moveTo(22f, 12f) curveTo(22f, 13.313f, 21.742f, 14.614f, 21.239f, 15.827f) curveTo(20.737f, 17.04f, 20f, 18.143f, 19.071f, 19.071f) curveTo(18.143f, 20f, 17.04f, 20.737f, 15.827f, 21.239f) curveTo(14.766f, 21.679f, 13.637f, 21.932f, 12.492f, 21.988f) lineTo(12f, 22f) curveTo(10.687f, 22f, 9.386f, 21.742f, 8.173f, 21.239f) curveTo(6.96f, 20.737f, 5.857f, 20f, 4.929f, 19.071f) curveTo(4f, 18.143f, 3.263f, 17.04f, 2.761f, 15.827f) curveTo(2.258f, 14.614f, 2f, 13.313f, 2f, 12f) curveTo(2f, 9.348f, 3.053f, 6.804f, 4.929f, 4.929f) curveTo(6.804f, 3.053f, 9.348f, 2f, 12f, 2f) curveTo(14.652f, 2f, 17.196f, 3.053f, 19.071f, 4.929f) curveTo(20.947f, 6.804f, 22f, 9.348f, 22f, 12f) close() } }.build() return _Clock!! } @Suppress("ObjectPropertyName") private var _Clock: ImageVector? = null ================================================ FILE: shared/resources/src/commonMain/kotlin/com/abdownloadmanager/resources/icons/Colors.kt ================================================ package com.abdownloadmanager.resources.icons import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.SolidColor import androidx.compose.ui.graphics.StrokeCap import androidx.compose.ui.graphics.StrokeJoin import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.graphics.vector.path import androidx.compose.ui.unit.dp val ABDMIcons.Colors: ImageVector get() { if (_Colors != null) { return _Colors!! } _Colors = ImageVector.Builder( name = "Colors", defaultWidth = 24.dp, defaultHeight = 24.dp, viewportWidth = 24f, viewportHeight = 24f ).apply { path( stroke = SolidColor(Color.White), strokeLineWidth = 2f, strokeLineCap = StrokeCap.Round, strokeLineJoin = StrokeJoin.Round ) { moveTo(12f, 21f) curveTo(9.613f, 21f, 7.324f, 20.052f, 5.636f, 18.364f) curveTo(3.948f, 16.676f, 3f, 14.387f, 3f, 12f) curveTo(3f, 9.613f, 3.948f, 7.324f, 5.636f, 5.636f) curveTo(7.324f, 3.948f, 9.613f, 3f, 12f, 3f) curveTo(16.97f, 3f, 21f, 6.582f, 21f, 11f) curveTo(21f, 12.06f, 20.526f, 13.078f, 19.682f, 13.828f) curveTo(18.838f, 14.578f, 17.693f, 15f, 16.5f, 15f) horizontalLineTo(14f) curveTo(13.554f, 14.993f, 13.118f, 15.135f, 12.762f, 15.404f) curveTo(12.406f, 15.673f, 12.15f, 16.053f, 12.035f, 16.484f) curveTo(11.92f, 16.916f, 11.953f, 17.373f, 12.128f, 17.783f) curveTo(12.302f, 18.194f, 12.609f, 18.534f, 13f, 18.75f) curveTo(13.2f, 18.934f, 13.337f, 19.176f, 13.392f, 19.442f) curveTo(13.446f, 19.708f, 13.417f, 19.985f, 13.306f, 20.233f) curveTo(13.196f, 20.482f, 13.011f, 20.689f, 12.776f, 20.827f) curveTo(12.542f, 20.964f, 12.271f, 21.025f, 12f, 21f) close() } path( stroke = SolidColor(Color.White), strokeLineWidth = 2f, strokeLineCap = StrokeCap.Round, strokeLineJoin = StrokeJoin.Round ) { moveTo(7.5f, 10.5f) curveTo(7.5f, 10.765f, 7.605f, 11.02f, 7.793f, 11.207f) curveTo(7.98f, 11.395f, 8.235f, 11.5f, 8.5f, 11.5f) curveTo(8.765f, 11.5f, 9.02f, 11.395f, 9.207f, 11.207f) curveTo(9.395f, 11.02f, 9.5f, 10.765f, 9.5f, 10.5f) curveTo(9.5f, 10.235f, 9.395f, 9.98f, 9.207f, 9.793f) curveTo(9.02f, 9.605f, 8.765f, 9.5f, 8.5f, 9.5f) curveTo(8.235f, 9.5f, 7.98f, 9.605f, 7.793f, 9.793f) curveTo(7.605f, 9.98f, 7.5f, 10.235f, 7.5f, 10.5f) close() } path( stroke = SolidColor(Color.White), strokeLineWidth = 2f, strokeLineCap = StrokeCap.Round, strokeLineJoin = StrokeJoin.Round ) { moveTo(11.5f, 7.5f) curveTo(11.5f, 7.765f, 11.605f, 8.02f, 11.793f, 8.207f) curveTo(11.98f, 8.395f, 12.235f, 8.5f, 12.5f, 8.5f) curveTo(12.765f, 8.5f, 13.02f, 8.395f, 13.207f, 8.207f) curveTo(13.395f, 8.02f, 13.5f, 7.765f, 13.5f, 7.5f) curveTo(13.5f, 7.235f, 13.395f, 6.98f, 13.207f, 6.793f) curveTo(13.02f, 6.605f, 12.765f, 6.5f, 12.5f, 6.5f) curveTo(12.235f, 6.5f, 11.98f, 6.605f, 11.793f, 6.793f) curveTo(11.605f, 6.98f, 11.5f, 7.235f, 11.5f, 7.5f) close() } path( stroke = SolidColor(Color.White), strokeLineWidth = 2f, strokeLineCap = StrokeCap.Round, strokeLineJoin = StrokeJoin.Round ) { moveTo(15.5f, 10.5f) curveTo(15.5f, 10.765f, 15.605f, 11.02f, 15.793f, 11.207f) curveTo(15.98f, 11.395f, 16.235f, 11.5f, 16.5f, 11.5f) curveTo(16.765f, 11.5f, 17.02f, 11.395f, 17.207f, 11.207f) curveTo(17.395f, 11.02f, 17.5f, 10.765f, 17.5f, 10.5f) curveTo(17.5f, 10.235f, 17.395f, 9.98f, 17.207f, 9.793f) curveTo(17.02f, 9.605f, 16.765f, 9.5f, 16.5f, 9.5f) curveTo(16.235f, 9.5f, 15.98f, 9.605f, 15.793f, 9.793f) curveTo(15.605f, 9.98f, 15.5f, 10.235f, 15.5f, 10.5f) close() } }.build() return _Colors!! } @Suppress("ObjectPropertyName") private var _Colors: ImageVector? = null ================================================ FILE: shared/resources/src/commonMain/kotlin/com/abdownloadmanager/resources/icons/Copy.kt ================================================ package com.abdownloadmanager.resources.icons import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.SolidColor import androidx.compose.ui.graphics.StrokeCap import androidx.compose.ui.graphics.StrokeJoin import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.graphics.vector.path import androidx.compose.ui.unit.dp val ABDMIcons.Copy: ImageVector get() { if (_Copy != null) { return _Copy!! } _Copy = ImageVector.Builder( name = "Copy", defaultWidth = 24.dp, defaultHeight = 24.dp, viewportWidth = 24f, viewportHeight = 24f ).apply { path( stroke = SolidColor(Color.White), strokeLineWidth = 2f, strokeLineCap = StrokeCap.Round, strokeLineJoin = StrokeJoin.Round ) { moveTo(16f, 8f) verticalLineTo(6f) curveTo(16f, 5.47f, 15.789f, 4.961f, 15.414f, 4.586f) curveTo(15.039f, 4.211f, 14.53f, 4f, 14f, 4f) horizontalLineTo(6f) curveTo(5.47f, 4f, 4.961f, 4.211f, 4.586f, 4.586f) curveTo(4.211f, 4.961f, 4f, 5.47f, 4f, 6f) verticalLineTo(14f) curveTo(4f, 14.53f, 4.211f, 15.039f, 4.586f, 15.414f) curveTo(4.961f, 15.789f, 5.47f, 16f, 6f, 16f) horizontalLineTo(8f) moveTo(8f, 10f) curveTo(8f, 9.47f, 8.211f, 8.961f, 8.586f, 8.586f) curveTo(8.961f, 8.211f, 9.47f, 8f, 10f, 8f) horizontalLineTo(18f) curveTo(18.53f, 8f, 19.039f, 8.211f, 19.414f, 8.586f) curveTo(19.789f, 8.961f, 20f, 9.47f, 20f, 10f) verticalLineTo(18f) curveTo(20f, 18.53f, 19.789f, 19.039f, 19.414f, 19.414f) curveTo(19.039f, 19.789f, 18.53f, 20f, 18f, 20f) horizontalLineTo(10f) curveTo(9.47f, 20f, 8.961f, 19.789f, 8.586f, 19.414f) curveTo(8.211f, 19.039f, 8f, 18.53f, 8f, 18f) verticalLineTo(10f) close() } }.build() return _Copy!! } @Suppress("ObjectPropertyName") private var _Copy: ImageVector? = null ================================================ FILE: shared/resources/src/commonMain/kotlin/com/abdownloadmanager/resources/icons/Data.kt ================================================ package com.abdownloadmanager.resources.icons import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.SolidColor import androidx.compose.ui.graphics.StrokeCap import androidx.compose.ui.graphics.StrokeJoin import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.graphics.vector.path import androidx.compose.ui.unit.dp val ABDMIcons.Data: ImageVector get() { if (_Data != null) { return _Data!! } _Data = ImageVector.Builder( name = "Data", defaultWidth = 24.dp, defaultHeight = 24.dp, viewportWidth = 24f, viewportHeight = 24f ).apply { path( stroke = SolidColor(Color.White), strokeLineWidth = 2f, strokeLineCap = StrokeCap.Round, strokeLineJoin = StrokeJoin.Round ) { moveTo(4f, 6f) curveTo(4f, 6.796f, 4.843f, 7.559f, 6.343f, 8.121f) curveTo(7.843f, 8.684f, 9.878f, 9f, 12f, 9f) curveTo(14.122f, 9f, 16.157f, 8.684f, 17.657f, 8.121f) curveTo(19.157f, 7.559f, 20f, 6.796f, 20f, 6f) moveTo(4f, 6f) curveTo(4f, 5.204f, 4.843f, 4.441f, 6.343f, 3.879f) curveTo(7.843f, 3.316f, 9.878f, 3f, 12f, 3f) curveTo(14.122f, 3f, 16.157f, 3.316f, 17.657f, 3.879f) curveTo(19.157f, 4.441f, 20f, 5.204f, 20f, 6f) moveTo(4f, 6f) verticalLineTo(12f) moveTo(20f, 6f) verticalLineTo(12f) moveTo(4f, 12f) curveTo(4f, 12.796f, 4.843f, 13.559f, 6.343f, 14.121f) curveTo(7.843f, 14.684f, 9.878f, 15f, 12f, 15f) curveTo(14.122f, 15f, 16.157f, 14.684f, 17.657f, 14.121f) curveTo(19.157f, 13.559f, 20f, 12.796f, 20f, 12f) moveTo(4f, 12f) verticalLineTo(18f) curveTo(4f, 18.796f, 4.843f, 19.559f, 6.343f, 20.121f) curveTo(7.843f, 20.684f, 9.878f, 21f, 12f, 21f) curveTo(14.122f, 21f, 16.157f, 20.684f, 17.657f, 20.121f) curveTo(19.157f, 19.559f, 20f, 18.796f, 20f, 18f) verticalLineTo(12f) } }.build() return _Data!! } @Suppress("ObjectPropertyName") private var _Data: ImageVector? = null ================================================ FILE: shared/resources/src/commonMain/kotlin/com/abdownloadmanager/resources/icons/Delete.kt ================================================ package com.abdownloadmanager.resources.icons import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.PathFillType import androidx.compose.ui.graphics.SolidColor import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.graphics.vector.path import androidx.compose.ui.unit.dp val ABDMIcons.Delete: ImageVector get() { if (_Delete != null) { return _Delete!! } _Delete = ImageVector.Builder( name = "Delete", defaultWidth = 24.dp, defaultHeight = 24.dp, viewportWidth = 24f, viewportHeight = 24f ).apply { path( fill = SolidColor(Color.White), pathFillType = PathFillType.EvenOdd ) { moveTo(10f, 3.75f) curveTo(9.934f, 3.75f, 9.87f, 3.776f, 9.823f, 3.823f) curveTo(9.776f, 3.87f, 9.75f, 3.934f, 9.75f, 4f) verticalLineTo(6.25f) horizontalLineTo(14.25f) verticalLineTo(4f) curveTo(14.25f, 3.934f, 14.224f, 3.87f, 14.177f, 3.823f) curveTo(14.13f, 3.776f, 14.066f, 3.75f, 14f, 3.75f) horizontalLineTo(10f) close() moveTo(15.75f, 6.25f) verticalLineTo(4f) curveTo(15.75f, 3.536f, 15.566f, 3.091f, 15.237f, 2.763f) curveTo(14.909f, 2.434f, 14.464f, 2.25f, 14f, 2.25f) horizontalLineTo(10f) curveTo(9.536f, 2.25f, 9.091f, 2.434f, 8.763f, 2.763f) curveTo(8.434f, 3.091f, 8.25f, 3.536f, 8.25f, 4f) verticalLineTo(6.25f) horizontalLineTo(5.009f) curveTo(5.003f, 6.25f, 4.998f, 6.25f, 4.993f, 6.25f) horizontalLineTo(4f) curveTo(3.586f, 6.25f, 3.25f, 6.586f, 3.25f, 7f) curveTo(3.25f, 7.414f, 3.586f, 7.75f, 4f, 7.75f) horizontalLineTo(4.31f) lineTo(5.25f, 19.034f) curveTo(5.259f, 19.751f, 5.548f, 20.437f, 6.055f, 20.944f) curveTo(6.571f, 21.46f, 7.271f, 21.75f, 8f, 21.75f) horizontalLineTo(16f) curveTo(16.729f, 21.75f, 17.429f, 21.46f, 17.944f, 20.944f) curveTo(18.452f, 20.437f, 18.741f, 19.751f, 18.75f, 19.034f) lineTo(19.69f, 7.75f) horizontalLineTo(20f) curveTo(20.414f, 7.75f, 20.75f, 7.414f, 20.75f, 7f) curveTo(20.75f, 6.586f, 20.414f, 6.25f, 20f, 6.25f) horizontalLineTo(19.007f) curveTo(19.002f, 6.25f, 18.997f, 6.25f, 18.991f, 6.25f) horizontalLineTo(15.75f) close() moveTo(5.815f, 7.75f) lineTo(6.747f, 18.938f) curveTo(6.749f, 18.958f, 6.75f, 18.979f, 6.75f, 19f) curveTo(6.75f, 19.331f, 6.882f, 19.649f, 7.116f, 19.884f) curveTo(7.351f, 20.118f, 7.668f, 20.25f, 8f, 20.25f) horizontalLineTo(16f) curveTo(16.331f, 20.25f, 16.649f, 20.118f, 16.884f, 19.884f) curveTo(17.118f, 19.649f, 17.25f, 19.331f, 17.25f, 19f) curveTo(17.25f, 18.979f, 17.251f, 18.958f, 17.253f, 18.938f) lineTo(18.185f, 7.75f) horizontalLineTo(5.815f) close() moveTo(9.47f, 12.53f) curveTo(9.177f, 12.237f, 9.177f, 11.763f, 9.47f, 11.47f) curveTo(9.763f, 11.177f, 10.237f, 11.177f, 10.53f, 11.47f) lineTo(12f, 12.939f) lineTo(13.47f, 11.47f) curveTo(13.763f, 11.177f, 14.237f, 11.177f, 14.53f, 11.47f) curveTo(14.823f, 11.763f, 14.823f, 12.237f, 14.53f, 12.53f) lineTo(13.061f, 14f) lineTo(14.53f, 15.47f) curveTo(14.823f, 15.763f, 14.823f, 16.237f, 14.53f, 16.53f) curveTo(14.237f, 16.823f, 13.763f, 16.823f, 13.47f, 16.53f) lineTo(12f, 15.061f) lineTo(10.53f, 16.53f) curveTo(10.237f, 16.823f, 9.763f, 16.823f, 9.47f, 16.53f) curveTo(9.177f, 16.237f, 9.177f, 15.763f, 9.47f, 15.47f) lineTo(10.939f, 14f) lineTo(9.47f, 12.53f) close() } }.build() return _Delete!! } @Suppress("ObjectPropertyName") private var _Delete: ImageVector? = null ================================================ FILE: shared/resources/src/commonMain/kotlin/com/abdownloadmanager/resources/icons/Down.kt ================================================ package com.abdownloadmanager.resources.icons import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.PathFillType import androidx.compose.ui.graphics.SolidColor import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.graphics.vector.path import androidx.compose.ui.unit.dp val ABDMIcons.Down: ImageVector get() { if (_Down != null) { return _Down!! } _Down = ImageVector.Builder( name = "Down", defaultWidth = 24.dp, defaultHeight = 24.dp, viewportWidth = 24f, viewportHeight = 24f ).apply { path( fill = SolidColor(Color.White), pathFillType = PathFillType.EvenOdd ) { moveTo(5.293f, 8.293f) curveTo(5.683f, 7.902f, 6.317f, 7.902f, 6.707f, 8.293f) lineTo(12f, 13.586f) lineTo(17.293f, 8.293f) curveTo(17.683f, 7.902f, 18.317f, 7.902f, 18.707f, 8.293f) curveTo(19.098f, 8.683f, 19.098f, 9.317f, 18.707f, 9.707f) lineTo(12.707f, 15.707f) curveTo(12.317f, 16.098f, 11.683f, 16.098f, 11.293f, 15.707f) lineTo(5.293f, 9.707f) curveTo(4.902f, 9.317f, 4.902f, 8.683f, 5.293f, 8.293f) close() } }.build() return _Down!! } @Suppress("ObjectPropertyName") private var _Down: ImageVector? = null ================================================ FILE: shared/resources/src/commonMain/kotlin/com/abdownloadmanager/resources/icons/DownSpeed.kt ================================================ package com.abdownloadmanager.resources.icons import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.PathFillType import androidx.compose.ui.graphics.SolidColor import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.graphics.vector.path import androidx.compose.ui.unit.dp val ABDMIcons.DownSpeed: ImageVector get() { if (_DownSpeed != null) { return _DownSpeed!! } _DownSpeed = ImageVector.Builder( name = "DownSpeed", defaultWidth = 24.dp, defaultHeight = 24.dp, viewportWidth = 24f, viewportHeight = 24f ).apply { path( fill = SolidColor(Color.White), pathFillType = PathFillType.EvenOdd ) { moveTo(11.292f, 4.306f) curveTo(12.102f, 4.163f, 12.936f, 4.165f, 13.745f, 4.312f) curveTo(14.555f, 4.459f, 15.328f, 4.749f, 16.02f, 5.168f) curveTo(16.712f, 5.587f, 17.31f, 6.127f, 17.778f, 6.762f) curveTo(18.247f, 7.397f, 18.575f, 8.113f, 18.739f, 8.87f) curveTo(18.838f, 9.325f, 18.877f, 9.789f, 18.855f, 10.25f) horizontalLineTo(19f) curveTo(20.127f, 10.25f, 21.208f, 10.698f, 22.005f, 11.495f) curveTo(22.802f, 12.292f, 23.25f, 13.373f, 23.25f, 14.5f) curveTo(23.25f, 15.627f, 22.802f, 16.708f, 22.005f, 17.505f) curveTo(21.208f, 18.302f, 20.127f, 18.75f, 19f, 18.75f) curveTo(18.586f, 18.75f, 18.25f, 18.414f, 18.25f, 18f) curveTo(18.25f, 17.586f, 18.586f, 17.25f, 19f, 17.25f) curveTo(19.729f, 17.25f, 20.429f, 16.96f, 20.944f, 16.445f) curveTo(21.46f, 15.929f, 21.75f, 15.229f, 21.75f, 14.5f) curveTo(21.75f, 13.771f, 21.46f, 13.071f, 20.944f, 12.556f) curveTo(20.429f, 12.04f, 19.729f, 11.75f, 19f, 11.75f) horizontalLineTo(18f) curveTo(17.772f, 11.75f, 17.557f, 11.646f, 17.414f, 11.469f) curveTo(17.272f, 11.291f, 17.218f, 11.058f, 17.268f, 10.836f) curveTo(17.39f, 10.292f, 17.392f, 9.733f, 17.274f, 9.189f) curveTo(17.155f, 8.645f, 16.918f, 8.122f, 16.571f, 7.652f) curveTo(16.224f, 7.182f, 15.774f, 6.773f, 15.243f, 6.451f) curveTo(14.712f, 6.13f, 14.112f, 5.903f, 13.477f, 5.788f) curveTo(12.842f, 5.672f, 12.188f, 5.671f, 11.552f, 5.783f) curveTo(10.916f, 5.895f, 10.315f, 6.118f, 9.781f, 6.437f) curveTo(8.704f, 7.08f, 7.978f, 8.068f, 7.732f, 9.164f) curveTo(7.652f, 9.518f, 7.332f, 9.764f, 6.97f, 9.749f) curveTo(6.069f, 9.713f, 5.186f, 9.978f, 4.474f, 10.494f) curveTo(3.761f, 11.008f, 3.265f, 11.739f, 3.061f, 12.555f) curveTo(2.857f, 13.37f, 2.956f, 14.229f, 3.342f, 14.986f) curveTo(3.729f, 15.743f, 4.383f, 16.357f, 5.2f, 16.712f) curveTo(5.579f, 16.878f, 5.753f, 17.32f, 5.588f, 17.7f) curveTo(5.422f, 18.079f, 4.98f, 18.253f, 4.6f, 18.087f) curveTo(3.475f, 17.597f, 2.556f, 16.744f, 2.006f, 15.668f) curveTo(1.456f, 14.59f, 1.314f, 13.361f, 1.606f, 12.191f) curveTo(1.898f, 11.022f, 2.605f, 9.994f, 3.595f, 9.278f) curveTo(4.424f, 8.679f, 5.41f, 8.328f, 6.432f, 8.259f) curveTo(6.874f, 6.976f, 7.79f, 5.879f, 9.012f, 5.149f) curveTo(9.706f, 4.734f, 10.481f, 4.448f, 11.292f, 4.306f) close() moveTo(12f, 12.25f) curveTo(12.414f, 12.25f, 12.75f, 12.586f, 12.75f, 13f) verticalLineTo(20.189f) lineTo(14.47f, 18.47f) curveTo(14.763f, 18.177f, 15.237f, 18.177f, 15.53f, 18.47f) curveTo(15.823f, 18.763f, 15.823f, 19.237f, 15.53f, 19.53f) lineTo(12.53f, 22.53f) curveTo(12.237f, 22.823f, 11.763f, 22.823f, 11.47f, 22.53f) lineTo(8.47f, 19.53f) curveTo(8.177f, 19.237f, 8.177f, 18.763f, 8.47f, 18.47f) curveTo(8.763f, 18.177f, 9.237f, 18.177f, 9.53f, 18.47f) lineTo(11.25f, 20.189f) verticalLineTo(13f) curveTo(11.25f, 12.586f, 11.586f, 12.25f, 12f, 12.25f) close() } }.build() return _DownSpeed!! } @Suppress("ObjectPropertyName") private var _DownSpeed: ImageVector? = null ================================================ FILE: shared/resources/src/commonMain/kotlin/com/abdownloadmanager/resources/icons/DragAndDrop.kt ================================================ package com.abdownloadmanager.resources.icons import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.SolidColor import androidx.compose.ui.graphics.StrokeCap import androidx.compose.ui.graphics.StrokeJoin import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.graphics.vector.path import androidx.compose.ui.unit.dp val ABDMIcons.DragAndDrop: ImageVector get() { if (_DragAndDrop != null) { return _DragAndDrop!! } _DragAndDrop = ImageVector.Builder( name = "DragAndDrop", defaultWidth = 24.dp, defaultHeight = 24.dp, viewportWidth = 24f, viewportHeight = 24f ).apply { path( stroke = SolidColor(Color.White), strokeLineWidth = 2f, strokeLineCap = StrokeCap.Round, strokeLineJoin = StrokeJoin.Round ) { moveTo(18f, 9f) lineTo(21f, 12f) moveTo(21f, 12f) lineTo(18f, 15f) moveTo(21f, 12f) horizontalLineTo(15f) moveTo(6f, 9f) lineTo(3f, 12f) moveTo(3f, 12f) lineTo(6f, 15f) moveTo(3f, 12f) horizontalLineTo(9f) moveTo(9f, 18f) lineTo(12f, 21f) moveTo(12f, 21f) lineTo(15f, 18f) moveTo(12f, 21f) verticalLineTo(15f) moveTo(15f, 6f) lineTo(12f, 3f) moveTo(12f, 3f) lineTo(9f, 6f) moveTo(12f, 3f) verticalLineTo(9f) } }.build() return _DragAndDrop!! } @Suppress("ObjectPropertyName") private var _DragAndDrop: ImageVector? = null ================================================ FILE: shared/resources/src/commonMain/kotlin/com/abdownloadmanager/resources/icons/Earth.kt ================================================ package com.abdownloadmanager.resources.icons import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.SolidColor import androidx.compose.ui.graphics.StrokeCap import androidx.compose.ui.graphics.StrokeJoin import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.graphics.vector.path import androidx.compose.ui.unit.dp val ABDMIcons.Earth: ImageVector get() { if (_Earth != null) { return _Earth!! } _Earth = ImageVector.Builder( name = "Earth", defaultWidth = 24.dp, defaultHeight = 24.dp, viewportWidth = 24f, viewportHeight = 24f ).apply { path( stroke = SolidColor(Color.White), strokeLineWidth = 2f, strokeLineCap = StrokeCap.Round, strokeLineJoin = StrokeJoin.Round ) { moveTo(3.6f, 9f) horizontalLineTo(20.4f) moveTo(3.6f, 15f) horizontalLineTo(20.4f) moveTo(11.5f, 3f) curveTo(9.815f, 5.7f, 8.922f, 8.818f, 8.922f, 12f) curveTo(8.922f, 15.182f, 9.815f, 18.3f, 11.5f, 21f) moveTo(12.5f, 3f) curveTo(14.185f, 5.7f, 15.078f, 8.818f, 15.078f, 12f) curveTo(15.078f, 15.182f, 14.185f, 18.3f, 12.5f, 21f) moveTo(3f, 12f) curveTo(3f, 13.182f, 3.233f, 14.352f, 3.685f, 15.444f) curveTo(4.137f, 16.536f, 4.8f, 17.528f, 5.636f, 18.364f) curveTo(6.472f, 19.2f, 7.464f, 19.863f, 8.556f, 20.315f) curveTo(9.648f, 20.767f, 10.818f, 21f, 12f, 21f) curveTo(13.182f, 21f, 14.352f, 20.767f, 15.444f, 20.315f) curveTo(16.536f, 19.863f, 17.528f, 19.2f, 18.364f, 18.364f) curveTo(19.2f, 17.528f, 19.863f, 16.536f, 20.315f, 15.444f) curveTo(20.767f, 14.352f, 21f, 13.182f, 21f, 12f) curveTo(21f, 9.613f, 20.052f, 7.324f, 18.364f, 5.636f) curveTo(16.676f, 3.948f, 14.387f, 3f, 12f, 3f) curveTo(9.613f, 3f, 7.324f, 3.948f, 5.636f, 5.636f) curveTo(3.948f, 7.324f, 3f, 9.613f, 3f, 12f) close() } }.build() return _Earth!! } @Suppress("ObjectPropertyName") private var _Earth: ImageVector? = null ================================================ FILE: shared/resources/src/commonMain/kotlin/com/abdownloadmanager/resources/icons/Edit.kt ================================================ package com.abdownloadmanager.resources.icons import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.SolidColor import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.graphics.vector.path import androidx.compose.ui.unit.dp val ABDMIcons.Edit: ImageVector get() { if (_Edit != null) { return _Edit!! } _Edit = ImageVector.Builder( name = "Edit", defaultWidth = 24.dp, defaultHeight = 24.dp, viewportWidth = 24f, viewportHeight = 24f ).apply { path(fill = SolidColor(Color.White)) { moveTo(3f, 17.46f) verticalLineTo(20.5f) curveTo(3f, 20.78f, 3.22f, 21f, 3.5f, 21f) horizontalLineTo(6.54f) curveTo(6.67f, 21f, 6.8f, 20.95f, 6.89f, 20.85f) lineTo(17.81f, 9.94f) lineTo(14.06f, 6.19f) lineTo(3.15f, 17.1f) curveTo(3.05f, 17.2f, 3f, 17.32f, 3f, 17.46f) close() moveTo(20.71f, 7.04f) curveTo(20.803f, 6.947f, 20.876f, 6.838f, 20.926f, 6.717f) curveTo(20.977f, 6.596f, 21.002f, 6.466f, 21.002f, 6.335f) curveTo(21.002f, 6.204f, 20.977f, 6.074f, 20.926f, 5.953f) curveTo(20.876f, 5.832f, 20.803f, 5.723f, 20.71f, 5.63f) lineTo(18.37f, 3.29f) curveTo(18.278f, 3.197f, 18.168f, 3.124f, 18.047f, 3.074f) curveTo(17.926f, 3.023f, 17.796f, 2.998f, 17.665f, 2.998f) curveTo(17.534f, 2.998f, 17.404f, 3.023f, 17.283f, 3.074f) curveTo(17.162f, 3.124f, 17.052f, 3.197f, 16.96f, 3.29f) lineTo(15.13f, 5.12f) lineTo(18.88f, 8.87f) lineTo(20.71f, 7.04f) close() } }.build() return _Edit!! } @Suppress("ObjectPropertyName") private var _Edit: ImageVector? = null ================================================ FILE: shared/resources/src/commonMain/kotlin/com/abdownloadmanager/resources/icons/Exit.kt ================================================ package com.abdownloadmanager.resources.icons import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.SolidColor import androidx.compose.ui.graphics.StrokeCap import androidx.compose.ui.graphics.StrokeJoin import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.graphics.vector.path import androidx.compose.ui.unit.dp val ABDMIcons.Exit: ImageVector get() { if (_Exit != null) { return _Exit!! } _Exit = ImageVector.Builder( name = "Exit", defaultWidth = 24.dp, defaultHeight = 24.dp, viewportWidth = 24f, viewportHeight = 24f ).apply { path( stroke = SolidColor(Color.White), strokeLineWidth = 2f, strokeLineCap = StrokeCap.Round, strokeLineJoin = StrokeJoin.Round ) { moveTo(14f, 8f) verticalLineTo(6f) curveTo(14f, 5.47f, 13.789f, 4.961f, 13.414f, 4.586f) curveTo(13.039f, 4.211f, 12.53f, 4f, 12f, 4f) horizontalLineTo(5f) curveTo(4.47f, 4f, 3.961f, 4.211f, 3.586f, 4.586f) curveTo(3.211f, 4.961f, 3f, 5.47f, 3f, 6f) verticalLineTo(18f) curveTo(3f, 18.53f, 3.211f, 19.039f, 3.586f, 19.414f) curveTo(3.961f, 19.789f, 4.47f, 20f, 5f, 20f) horizontalLineTo(12f) curveTo(12.53f, 20f, 13.039f, 19.789f, 13.414f, 19.414f) curveTo(13.789f, 19.039f, 14f, 18.53f, 14f, 18f) verticalLineTo(16f) moveTo(9f, 12f) horizontalLineTo(21f) moveTo(21f, 12f) lineTo(18f, 9f) moveTo(21f, 12f) lineTo(18f, 15f) } }.build() return _Exit!! } @Suppress("ObjectPropertyName") private var _Exit: ImageVector? = null ================================================ FILE: shared/resources/src/commonMain/kotlin/com/abdownloadmanager/resources/icons/ExternalLink.kt ================================================ package com.abdownloadmanager.resources.icons import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.SolidColor import androidx.compose.ui.graphics.StrokeCap import androidx.compose.ui.graphics.StrokeJoin import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.graphics.vector.path import androidx.compose.ui.unit.dp val ABDMIcons.ExternalLink: ImageVector get() { if (_ExternalLink != null) { return _ExternalLink!! } _ExternalLink = ImageVector.Builder( name = "ExternalLink", defaultWidth = 24.dp, defaultHeight = 24.dp, viewportWidth = 24f, viewportHeight = 24f ).apply { path( stroke = SolidColor(Color.White), strokeLineWidth = 2f, strokeLineCap = StrokeCap.Round, strokeLineJoin = StrokeJoin.Round ) { moveTo(12f, 6f) horizontalLineTo(6f) curveTo(5.47f, 6f, 4.961f, 6.211f, 4.586f, 6.586f) curveTo(4.211f, 6.961f, 4f, 7.47f, 4f, 8f) verticalLineTo(18f) curveTo(4f, 18.53f, 4.211f, 19.039f, 4.586f, 19.414f) curveTo(4.961f, 19.789f, 5.47f, 20f, 6f, 20f) horizontalLineTo(16f) curveTo(16.53f, 20f, 17.039f, 19.789f, 17.414f, 19.414f) curveTo(17.789f, 19.039f, 18f, 18.53f, 18f, 18f) verticalLineTo(12f) moveTo(11f, 13f) lineTo(20f, 4f) moveTo(20f, 4f) horizontalLineTo(15f) moveTo(20f, 4f) verticalLineTo(9f) } }.build() return _ExternalLink!! } @Suppress("ObjectPropertyName") private var _ExternalLink: ImageVector? = null ================================================ FILE: shared/resources/src/commonMain/kotlin/com/abdownloadmanager/resources/icons/Fast.kt ================================================ package com.abdownloadmanager.resources.icons import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.SolidColor import androidx.compose.ui.graphics.StrokeCap import androidx.compose.ui.graphics.StrokeJoin import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.graphics.vector.path import androidx.compose.ui.unit.dp val ABDMIcons.Fast: ImageVector get() { if (_Fast != null) { return _Fast!! } _Fast = ImageVector.Builder( name = "Fast", defaultWidth = 24.dp, defaultHeight = 24.dp, viewportWidth = 24f, viewportHeight = 24f ).apply { path( stroke = SolidColor(Color.White), strokeLineWidth = 2f, strokeLineCap = StrokeCap.Round, strokeLineJoin = StrokeJoin.Round ) { moveTo(13f, 3f) verticalLineTo(10f) horizontalLineTo(19f) lineTo(11f, 21f) verticalLineTo(14f) horizontalLineTo(5f) lineTo(13f, 3f) close() } }.build() return _Fast!! } @Suppress("ObjectPropertyName") private var _Fast: ImageVector? = null ================================================ FILE: shared/resources/src/commonMain/kotlin/com/abdownloadmanager/resources/icons/File.kt ================================================ package com.abdownloadmanager.resources.icons import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.SolidColor import androidx.compose.ui.graphics.StrokeCap import androidx.compose.ui.graphics.StrokeJoin import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.graphics.vector.path import androidx.compose.ui.unit.dp val ABDMIcons.File: ImageVector get() { if (_File != null) { return _File!! } _File = ImageVector.Builder( name = "File", defaultWidth = 24.dp, defaultHeight = 24.dp, viewportWidth = 24f, viewportHeight = 24f ).apply { path( stroke = SolidColor(Color.White), strokeLineWidth = 2f, strokeLineCap = StrokeCap.Round, strokeLineJoin = StrokeJoin.Round ) { moveTo(14f, 3f) verticalLineTo(7f) curveTo(14f, 7.265f, 14.105f, 7.52f, 14.293f, 7.707f) curveTo(14.48f, 7.895f, 14.735f, 8f, 15f, 8f) horizontalLineTo(19f) moveTo(14f, 3f) horizontalLineTo(7f) curveTo(6.47f, 3f, 5.961f, 3.211f, 5.586f, 3.586f) curveTo(5.211f, 3.961f, 5f, 4.47f, 5f, 5f) verticalLineTo(19f) curveTo(5f, 19.53f, 5.211f, 20.039f, 5.586f, 20.414f) curveTo(5.961f, 20.789f, 6.47f, 21f, 7f, 21f) horizontalLineTo(17f) curveTo(17.53f, 21f, 18.039f, 20.789f, 18.414f, 20.414f) curveTo(18.789f, 20.039f, 19f, 19.53f, 19f, 19f) verticalLineTo(8f) moveTo(14f, 3f) lineTo(19f, 8f) } }.build() return _File!! } @Suppress("ObjectPropertyName") private var _File: ImageVector? = null ================================================ FILE: shared/resources/src/commonMain/kotlin/com/abdownloadmanager/resources/icons/FileApplication.kt ================================================ package com.abdownloadmanager.resources.icons import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.SolidColor import androidx.compose.ui.graphics.StrokeCap import androidx.compose.ui.graphics.StrokeJoin import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.graphics.vector.path import androidx.compose.ui.unit.dp val ABDMIcons.FileApplication: ImageVector get() { if (_FileApplication != null) { return _FileApplication!! } _FileApplication = ImageVector.Builder( name = "FileApplication", defaultWidth = 24.dp, defaultHeight = 24.dp, viewportWidth = 24f, viewportHeight = 24f ).apply { path( stroke = SolidColor(Color.White), strokeLineWidth = 2f, strokeLineCap = StrokeCap.Round, strokeLineJoin = StrokeJoin.Round ) { moveTo(14f, 7f) horizontalLineTo(20f) moveTo(17f, 4f) verticalLineTo(10f) moveTo(4f, 5f) curveTo(4f, 4.735f, 4.105f, 4.48f, 4.293f, 4.293f) curveTo(4.48f, 4.105f, 4.735f, 4f, 5f, 4f) horizontalLineTo(9f) curveTo(9.265f, 4f, 9.52f, 4.105f, 9.707f, 4.293f) curveTo(9.895f, 4.48f, 10f, 4.735f, 10f, 5f) verticalLineTo(9f) curveTo(10f, 9.265f, 9.895f, 9.52f, 9.707f, 9.707f) curveTo(9.52f, 9.895f, 9.265f, 10f, 9f, 10f) horizontalLineTo(5f) curveTo(4.735f, 10f, 4.48f, 9.895f, 4.293f, 9.707f) curveTo(4.105f, 9.52f, 4f, 9.265f, 4f, 9f) verticalLineTo(5f) close() moveTo(4f, 15f) curveTo(4f, 14.735f, 4.105f, 14.48f, 4.293f, 14.293f) curveTo(4.48f, 14.105f, 4.735f, 14f, 5f, 14f) horizontalLineTo(9f) curveTo(9.265f, 14f, 9.52f, 14.105f, 9.707f, 14.293f) curveTo(9.895f, 14.48f, 10f, 14.735f, 10f, 15f) verticalLineTo(19f) curveTo(10f, 19.265f, 9.895f, 19.52f, 9.707f, 19.707f) curveTo(9.52f, 19.895f, 9.265f, 20f, 9f, 20f) horizontalLineTo(5f) curveTo(4.735f, 20f, 4.48f, 19.895f, 4.293f, 19.707f) curveTo(4.105f, 19.52f, 4f, 19.265f, 4f, 19f) verticalLineTo(15f) close() moveTo(14f, 15f) curveTo(14f, 14.735f, 14.105f, 14.48f, 14.293f, 14.293f) curveTo(14.48f, 14.105f, 14.735f, 14f, 15f, 14f) horizontalLineTo(19f) curveTo(19.265f, 14f, 19.52f, 14.105f, 19.707f, 14.293f) curveTo(19.895f, 14.48f, 20f, 14.735f, 20f, 15f) verticalLineTo(19f) curveTo(20f, 19.265f, 19.895f, 19.52f, 19.707f, 19.707f) curveTo(19.52f, 19.895f, 19.265f, 20f, 19f, 20f) horizontalLineTo(15f) curveTo(14.735f, 20f, 14.48f, 19.895f, 14.293f, 19.707f) curveTo(14.105f, 19.52f, 14f, 19.265f, 14f, 19f) verticalLineTo(15f) close() } }.build() return _FileApplication!! } @Suppress("ObjectPropertyName") private var _FileApplication: ImageVector? = null ================================================ FILE: shared/resources/src/commonMain/kotlin/com/abdownloadmanager/resources/icons/FileDocument.kt ================================================ package com.abdownloadmanager.resources.icons import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.PathFillType import androidx.compose.ui.graphics.SolidColor import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.graphics.vector.path import androidx.compose.ui.unit.dp val ABDMIcons.FileDocument: ImageVector get() { if (_FileDocument != null) { return _FileDocument!! } _FileDocument = ImageVector.Builder( name = "FileDocument", defaultWidth = 24.dp, defaultHeight = 24.dp, viewportWidth = 24f, viewportHeight = 24f ).apply { path( fill = SolidColor(Color.White), pathFillType = PathFillType.EvenOdd ) { moveTo(7f, 3.75f) curveTo(6.668f, 3.75f, 6.351f, 3.882f, 6.116f, 4.116f) curveTo(5.882f, 4.351f, 5.75f, 4.668f, 5.75f, 5f) verticalLineTo(19f) curveTo(5.75f, 19.331f, 5.882f, 19.649f, 6.116f, 19.884f) curveTo(6.351f, 20.118f, 6.668f, 20.25f, 7f, 20.25f) horizontalLineTo(17f) curveTo(17.331f, 20.25f, 17.649f, 20.118f, 17.884f, 19.884f) curveTo(18.118f, 19.649f, 18.25f, 19.331f, 18.25f, 19f) verticalLineTo(8.75f) horizontalLineTo(15f) curveTo(14.536f, 8.75f, 14.091f, 8.566f, 13.763f, 8.237f) curveTo(13.434f, 7.909f, 13.25f, 7.464f, 13.25f, 7f) verticalLineTo(3.75f) horizontalLineTo(7f) close() moveTo(14.75f, 4.811f) lineTo(17.189f, 7.25f) horizontalLineTo(15f) curveTo(14.934f, 7.25f, 14.87f, 7.224f, 14.823f, 7.177f) curveTo(14.776f, 7.13f, 14.75f, 7.066f, 14.75f, 7f) verticalLineTo(4.811f) close() moveTo(5.055f, 3.055f) curveTo(5.571f, 2.54f, 6.271f, 2.25f, 7f, 2.25f) horizontalLineTo(14f) curveTo(14.199f, 2.25f, 14.39f, 2.329f, 14.53f, 2.47f) lineTo(19.53f, 7.47f) curveTo(19.671f, 7.61f, 19.75f, 7.801f, 19.75f, 8f) verticalLineTo(19f) curveTo(19.75f, 19.729f, 19.46f, 20.429f, 18.944f, 20.944f) curveTo(18.429f, 21.46f, 17.729f, 21.75f, 17f, 21.75f) horizontalLineTo(7f) curveTo(6.271f, 21.75f, 5.571f, 21.46f, 5.055f, 20.944f) curveTo(4.54f, 20.429f, 4.25f, 19.729f, 4.25f, 19f) verticalLineTo(5f) curveTo(4.25f, 4.271f, 4.54f, 3.571f, 5.055f, 3.055f) close() moveTo(8.25f, 9f) curveTo(8.25f, 8.586f, 8.586f, 8.25f, 9f, 8.25f) horizontalLineTo(10f) curveTo(10.414f, 8.25f, 10.75f, 8.586f, 10.75f, 9f) curveTo(10.75f, 9.414f, 10.414f, 9.75f, 10f, 9.75f) horizontalLineTo(9f) curveTo(8.586f, 9.75f, 8.25f, 9.414f, 8.25f, 9f) close() moveTo(8.25f, 13f) curveTo(8.25f, 12.586f, 8.586f, 12.25f, 9f, 12.25f) horizontalLineTo(15f) curveTo(15.414f, 12.25f, 15.75f, 12.586f, 15.75f, 13f) curveTo(15.75f, 13.414f, 15.414f, 13.75f, 15f, 13.75f) horizontalLineTo(9f) curveTo(8.586f, 13.75f, 8.25f, 13.414f, 8.25f, 13f) close() moveTo(8.25f, 17f) curveTo(8.25f, 16.586f, 8.586f, 16.25f, 9f, 16.25f) horizontalLineTo(15f) curveTo(15.414f, 16.25f, 15.75f, 16.586f, 15.75f, 17f) curveTo(15.75f, 17.414f, 15.414f, 17.75f, 15f, 17.75f) horizontalLineTo(9f) curveTo(8.586f, 17.75f, 8.25f, 17.414f, 8.25f, 17f) close() } }.build() return _FileDocument!! } @Suppress("ObjectPropertyName") private var _FileDocument: ImageVector? = null ================================================ FILE: shared/resources/src/commonMain/kotlin/com/abdownloadmanager/resources/icons/FileMusic.kt ================================================ package com.abdownloadmanager.resources.icons import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.PathFillType import androidx.compose.ui.graphics.SolidColor import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.graphics.vector.path import androidx.compose.ui.unit.dp val ABDMIcons.FileMusic: ImageVector get() { if (_FileMusic != null) { return _FileMusic!! } _FileMusic = ImageVector.Builder( name = "FileMusic", defaultWidth = 24.dp, defaultHeight = 24.dp, viewportWidth = 24f, viewportHeight = 24f ).apply { path( fill = SolidColor(Color.White), pathFillType = PathFillType.EvenOdd ) { moveTo(8.25f, 4f) curveTo(8.25f, 3.586f, 8.586f, 3.25f, 9f, 3.25f) horizontalLineTo(19f) curveTo(19.414f, 3.25f, 19.75f, 3.586f, 19.75f, 4f) verticalLineTo(17f) curveTo(19.75f, 17.995f, 19.355f, 18.948f, 18.652f, 19.652f) curveTo(17.948f, 20.355f, 16.995f, 20.75f, 16f, 20.75f) curveTo(15.005f, 20.75f, 14.052f, 20.355f, 13.348f, 19.652f) curveTo(12.645f, 18.948f, 12.25f, 17.995f, 12.25f, 17f) curveTo(12.25f, 16.005f, 12.645f, 15.052f, 13.348f, 14.348f) curveTo(14.052f, 13.645f, 15.005f, 13.25f, 16f, 13.25f) curveTo(16.816f, 13.25f, 17.605f, 13.516f, 18.25f, 14f) verticalLineTo(8.75f) horizontalLineTo(9.75f) verticalLineTo(17f) curveTo(9.75f, 17.995f, 9.355f, 18.948f, 8.652f, 19.652f) curveTo(7.948f, 20.355f, 6.995f, 20.75f, 6f, 20.75f) curveTo(5.005f, 20.75f, 4.052f, 20.355f, 3.348f, 19.652f) curveTo(2.645f, 18.948f, 2.25f, 17.995f, 2.25f, 17f) curveTo(2.25f, 16.005f, 2.645f, 15.052f, 3.348f, 14.348f) curveTo(4.052f, 13.645f, 5.005f, 13.25f, 6f, 13.25f) curveTo(6.816f, 13.25f, 7.605f, 13.516f, 8.25f, 14f) verticalLineTo(4f) close() moveTo(9.75f, 7.25f) horizontalLineTo(18.25f) verticalLineTo(4.75f) horizontalLineTo(9.75f) verticalLineTo(7.25f) close() moveTo(8.25f, 17f) curveTo(8.25f, 16.403f, 8.013f, 15.831f, 7.591f, 15.409f) curveTo(7.169f, 14.987f, 6.597f, 14.75f, 6f, 14.75f) curveTo(5.403f, 14.75f, 4.831f, 14.987f, 4.409f, 15.409f) curveTo(3.987f, 15.831f, 3.75f, 16.403f, 3.75f, 17f) curveTo(3.75f, 17.597f, 3.987f, 18.169f, 4.409f, 18.591f) curveTo(4.831f, 19.013f, 5.403f, 19.25f, 6f, 19.25f) curveTo(6.597f, 19.25f, 7.169f, 19.013f, 7.591f, 18.591f) curveTo(8.013f, 18.169f, 8.25f, 17.597f, 8.25f, 17f) close() moveTo(18.25f, 17f) curveTo(18.25f, 16.403f, 18.013f, 15.831f, 17.591f, 15.409f) curveTo(17.169f, 14.987f, 16.597f, 14.75f, 16f, 14.75f) curveTo(15.403f, 14.75f, 14.831f, 14.987f, 14.409f, 15.409f) curveTo(13.987f, 15.831f, 13.75f, 16.403f, 13.75f, 17f) curveTo(13.75f, 17.597f, 13.987f, 18.169f, 14.409f, 18.591f) curveTo(14.831f, 19.013f, 15.403f, 19.25f, 16f, 19.25f) curveTo(16.597f, 19.25f, 17.169f, 19.013f, 17.591f, 18.591f) curveTo(18.013f, 18.169f, 18.25f, 17.597f, 18.25f, 17f) close() } }.build() return _FileMusic!! } @Suppress("ObjectPropertyName") private var _FileMusic: ImageVector? = null ================================================ FILE: shared/resources/src/commonMain/kotlin/com/abdownloadmanager/resources/icons/FilePicture.kt ================================================ package com.abdownloadmanager.resources.icons import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.PathFillType import androidx.compose.ui.graphics.SolidColor import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.graphics.vector.path import androidx.compose.ui.unit.dp val ABDMIcons.FilePicture: ImageVector get() { if (_FilePicture != null) { return _FilePicture!! } _FilePicture = ImageVector.Builder( name = "FilePicture", defaultWidth = 24.dp, defaultHeight = 24.dp, viewportWidth = 24f, viewportHeight = 24f ).apply { path( fill = SolidColor(Color.White), pathFillType = PathFillType.EvenOdd ) { moveTo(6f, 3.75f) curveTo(5.403f, 3.75f, 4.831f, 3.987f, 4.409f, 4.409f) curveTo(3.987f, 4.831f, 3.75f, 5.403f, 3.75f, 6f) verticalLineTo(14.189f) lineTo(7.47f, 10.47f) lineTo(7.48f, 10.46f) curveTo(8.057f, 9.905f, 8.753f, 9.58f, 9.5f, 9.58f) curveTo(10.247f, 9.58f, 10.943f, 9.905f, 11.52f, 10.46f) lineTo(11.53f, 10.47f) lineTo(14f, 12.939f) lineTo(14.47f, 12.47f) lineTo(14.48f, 12.46f) curveTo(15.057f, 11.905f, 15.753f, 11.58f, 16.5f, 11.58f) curveTo(17.247f, 11.58f, 17.943f, 11.905f, 18.52f, 12.46f) lineTo(18.53f, 12.47f) lineTo(20.25f, 14.189f) verticalLineTo(6f) curveTo(20.25f, 5.403f, 20.013f, 4.831f, 19.591f, 4.409f) curveTo(19.169f, 3.987f, 18.597f, 3.75f, 18f, 3.75f) horizontalLineTo(6f) close() moveTo(21.75f, 6f) curveTo(21.75f, 5.005f, 21.355f, 4.052f, 20.652f, 3.348f) curveTo(19.948f, 2.645f, 18.995f, 2.25f, 18f, 2.25f) horizontalLineTo(6f) curveTo(5.005f, 2.25f, 4.052f, 2.645f, 3.348f, 3.348f) curveTo(2.645f, 4.052f, 2.25f, 5.005f, 2.25f, 6f) verticalLineTo(18f) curveTo(2.25f, 18.995f, 2.645f, 19.948f, 3.348f, 20.652f) curveTo(4.052f, 21.355f, 5.005f, 21.75f, 6f, 21.75f) horizontalLineTo(18f) curveTo(18.995f, 21.75f, 19.948f, 21.355f, 20.652f, 20.652f) curveTo(21.355f, 19.948f, 21.75f, 18.995f, 21.75f, 18f) verticalLineTo(6f) close() moveTo(20.25f, 16.311f) lineTo(17.475f, 13.536f) curveTo(17.125f, 13.201f, 16.788f, 13.08f, 16.5f, 13.08f) curveTo(16.212f, 13.08f, 15.875f, 13.201f, 15.525f, 13.536f) lineTo(15.061f, 14f) lineTo(16.53f, 15.47f) curveTo(16.823f, 15.763f, 16.823f, 16.237f, 16.53f, 16.53f) curveTo(16.237f, 16.823f, 15.763f, 16.823f, 15.47f, 16.53f) lineTo(10.475f, 11.536f) curveTo(10.125f, 11.201f, 9.788f, 11.08f, 9.5f, 11.08f) curveTo(9.212f, 11.08f, 8.875f, 11.201f, 8.525f, 11.536f) lineTo(3.75f, 16.311f) verticalLineTo(18f) curveTo(3.75f, 18.597f, 3.987f, 19.169f, 4.409f, 19.591f) curveTo(4.831f, 20.013f, 5.403f, 20.25f, 6f, 20.25f) horizontalLineTo(18f) curveTo(18.597f, 20.25f, 19.169f, 20.013f, 19.591f, 19.591f) curveTo(20.013f, 19.169f, 20.25f, 18.597f, 20.25f, 18f) verticalLineTo(16.311f) close() moveTo(14.25f, 8f) curveTo(14.25f, 7.586f, 14.586f, 7.25f, 15f, 7.25f) horizontalLineTo(15.01f) curveTo(15.424f, 7.25f, 15.76f, 7.586f, 15.76f, 8f) curveTo(15.76f, 8.414f, 15.424f, 8.75f, 15.01f, 8.75f) horizontalLineTo(15f) curveTo(14.586f, 8.75f, 14.25f, 8.414f, 14.25f, 8f) close() } }.build() return _FilePicture!! } @Suppress("ObjectPropertyName") private var _FilePicture: ImageVector? = null ================================================ FILE: shared/resources/src/commonMain/kotlin/com/abdownloadmanager/resources/icons/FileUnknown.kt ================================================ package com.abdownloadmanager.resources.icons import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.SolidColor import androidx.compose.ui.graphics.StrokeCap import androidx.compose.ui.graphics.StrokeJoin import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.graphics.vector.path import androidx.compose.ui.unit.dp val ABDMIcons.FileUnknown: ImageVector get() { if (_FileUnknown != null) { return _FileUnknown!! } _FileUnknown = ImageVector.Builder( name = "FileUnknown", defaultWidth = 24.dp, defaultHeight = 24.dp, viewportWidth = 24f, viewportHeight = 24f ).apply { path( stroke = SolidColor(Color.White), strokeLineWidth = 2f, strokeLineCap = StrokeCap.Round, strokeLineJoin = StrokeJoin.Round ) { moveTo(14f, 3f) verticalLineTo(7f) curveTo(14f, 7.265f, 14.105f, 7.52f, 14.293f, 7.707f) curveTo(14.48f, 7.895f, 14.735f, 8f, 15f, 8f) horizontalLineTo(19f) moveTo(14f, 3f) horizontalLineTo(7f) curveTo(6.47f, 3f, 5.961f, 3.211f, 5.586f, 3.586f) curveTo(5.211f, 3.961f, 5f, 4.47f, 5f, 5f) verticalLineTo(19f) curveTo(5f, 19.53f, 5.211f, 20.039f, 5.586f, 20.414f) curveTo(5.961f, 20.789f, 6.47f, 21f, 7f, 21f) horizontalLineTo(17f) curveTo(17.53f, 21f, 18.039f, 20.789f, 18.414f, 20.414f) curveTo(18.789f, 20.039f, 19f, 19.53f, 19f, 19f) verticalLineTo(8f) moveTo(14f, 3f) lineTo(19f, 8f) moveTo(12f, 17f) verticalLineTo(17.01f) moveTo(12f, 14f) curveTo(12.252f, 14f, 12.499f, 13.937f, 12.72f, 13.816f) curveTo(12.941f, 13.695f, 13.128f, 13.521f, 13.264f, 13.309f) curveTo(13.4f, 13.097f, 13.48f, 12.854f, 13.497f, 12.603f) curveTo(13.514f, 12.352f, 13.468f, 12.101f, 13.363f, 11.872f) curveTo(13.258f, 11.644f, 13.097f, 11.445f, 12.894f, 11.295f) curveTo(12.692f, 11.145f, 12.456f, 11.049f, 12.206f, 11.014f) curveTo(11.957f, 10.98f, 11.703f, 11.009f, 11.468f, 11.098f) curveTo(11.232f, 11.187f, 11.023f, 11.335f, 10.86f, 11.526f) } }.build() return _FileUnknown!! } @Suppress("ObjectPropertyName") private var _FileUnknown: ImageVector? = null ================================================ FILE: shared/resources/src/commonMain/kotlin/com/abdownloadmanager/resources/icons/FileVideo.kt ================================================ package com.abdownloadmanager.resources.icons import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.SolidColor import androidx.compose.ui.graphics.StrokeCap import androidx.compose.ui.graphics.StrokeJoin import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.graphics.vector.path import androidx.compose.ui.unit.dp val ABDMIcons.FileVideo: ImageVector get() { if (_FileVideo != null) { return _FileVideo!! } _FileVideo = ImageVector.Builder( name = "FileVideo", defaultWidth = 24.dp, defaultHeight = 24.dp, viewportWidth = 24f, viewportHeight = 24f ).apply { path( stroke = SolidColor(Color.White), strokeLineWidth = 2f, strokeLineCap = StrokeCap.Round, strokeLineJoin = StrokeJoin.Round ) { moveTo(15f, 10f) lineTo(19.553f, 7.724f) curveTo(19.705f, 7.648f, 19.875f, 7.612f, 20.045f, 7.62f) curveTo(20.215f, 7.627f, 20.381f, 7.678f, 20.526f, 7.768f) curveTo(20.671f, 7.857f, 20.79f, 7.982f, 20.873f, 8.131f) curveTo(20.956f, 8.28f, 21f, 8.448f, 21f, 8.618f) verticalLineTo(15.382f) curveTo(21f, 15.552f, 20.956f, 15.72f, 20.873f, 15.869f) curveTo(20.79f, 16.017f, 20.671f, 16.143f, 20.526f, 16.232f) curveTo(20.381f, 16.322f, 20.215f, 16.373f, 20.045f, 16.381f) curveTo(19.875f, 16.388f, 19.705f, 16.352f, 19.553f, 16.276f) lineTo(15f, 14f) verticalLineTo(10f) close() } path( stroke = SolidColor(Color.White), strokeLineWidth = 2f, strokeLineCap = StrokeCap.Round, strokeLineJoin = StrokeJoin.Round ) { moveTo(3f, 8f) curveTo(3f, 7.47f, 3.211f, 6.961f, 3.586f, 6.586f) curveTo(3.961f, 6.211f, 4.47f, 6f, 5f, 6f) horizontalLineTo(13f) curveTo(13.53f, 6f, 14.039f, 6.211f, 14.414f, 6.586f) curveTo(14.789f, 6.961f, 15f, 7.47f, 15f, 8f) verticalLineTo(16f) curveTo(15f, 16.53f, 14.789f, 17.039f, 14.414f, 17.414f) curveTo(14.039f, 17.789f, 13.53f, 18f, 13f, 18f) horizontalLineTo(5f) curveTo(4.47f, 18f, 3.961f, 17.789f, 3.586f, 17.414f) curveTo(3.211f, 17.039f, 3f, 16.53f, 3f, 16f) verticalLineTo(8f) close() } }.build() return _FileVideo!! } @Suppress("ObjectPropertyName") private var _FileVideo: ImageVector? = null ================================================ FILE: shared/resources/src/commonMain/kotlin/com/abdownloadmanager/resources/icons/FileZip.kt ================================================ package com.abdownloadmanager.resources.icons import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.PathFillType import androidx.compose.ui.graphics.SolidColor import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.graphics.vector.path import androidx.compose.ui.unit.dp val ABDMIcons.FileZip: ImageVector get() { if (_FileZip != null) { return _FileZip!! } _FileZip = ImageVector.Builder( name = "FileZip", defaultWidth = 24.dp, defaultHeight = 24.dp, viewportWidth = 24f, viewportHeight = 24f ).apply { path( fill = SolidColor(Color.White), pathFillType = PathFillType.EvenOdd ) { moveTo(7f, 3.75f) curveTo(6.668f, 3.75f, 6.351f, 3.882f, 6.116f, 4.116f) curveTo(5.882f, 4.351f, 5.75f, 4.668f, 5.75f, 5f) lineTo(5.75f, 19.001f) curveTo(5.75f, 19.221f, 5.807f, 19.437f, 5.917f, 19.627f) curveTo(6.027f, 19.817f, 6.185f, 19.976f, 6.375f, 20.086f) curveTo(6.734f, 20.293f, 6.857f, 20.751f, 6.65f, 21.11f) curveTo(6.442f, 21.469f, 5.984f, 21.592f, 5.625f, 21.385f) curveTo(5.206f, 21.143f, 4.859f, 20.795f, 4.617f, 20.376f) curveTo(4.376f, 19.958f, 4.249f, 19.483f, 4.25f, 19f) curveTo(4.25f, 19f, 4.25f, 18.999f, 4.25f, 19f) verticalLineTo(5f) curveTo(4.25f, 4.271f, 4.54f, 3.571f, 5.055f, 3.055f) curveTo(5.571f, 2.54f, 6.271f, 2.25f, 7f, 2.25f) horizontalLineTo(14f) curveTo(14.199f, 2.25f, 14.39f, 2.329f, 14.53f, 2.47f) lineTo(19.53f, 7.47f) curveTo(19.671f, 7.61f, 19.75f, 7.801f, 19.75f, 8f) verticalLineTo(19f) curveTo(19.75f, 19.729f, 19.46f, 20.429f, 18.944f, 20.944f) curveTo(18.429f, 21.46f, 17.729f, 21.75f, 17f, 21.75f) horizontalLineTo(16f) curveTo(15.586f, 21.75f, 15.25f, 21.414f, 15.25f, 21f) curveTo(15.25f, 20.586f, 15.586f, 20.25f, 16f, 20.25f) horizontalLineTo(17f) curveTo(17.331f, 20.25f, 17.649f, 20.118f, 17.884f, 19.884f) curveTo(18.118f, 19.649f, 18.25f, 19.331f, 18.25f, 19f) verticalLineTo(8.311f) lineTo(13.689f, 3.75f) horizontalLineTo(7f) close() moveTo(9.25f, 5f) curveTo(9.25f, 4.586f, 9.586f, 4.25f, 10f, 4.25f) horizontalLineTo(11f) curveTo(11.414f, 4.25f, 11.75f, 4.586f, 11.75f, 5f) curveTo(11.75f, 5.414f, 11.414f, 5.75f, 11f, 5.75f) horizontalLineTo(10f) curveTo(9.586f, 5.75f, 9.25f, 5.414f, 9.25f, 5f) close() moveTo(11.25f, 7f) curveTo(11.25f, 6.586f, 11.586f, 6.25f, 12f, 6.25f) horizontalLineTo(13f) curveTo(13.414f, 6.25f, 13.75f, 6.586f, 13.75f, 7f) curveTo(13.75f, 7.414f, 13.414f, 7.75f, 13f, 7.75f) horizontalLineTo(12f) curveTo(11.586f, 7.75f, 11.25f, 7.414f, 11.25f, 7f) close() moveTo(9.25f, 9f) curveTo(9.25f, 8.586f, 9.586f, 8.25f, 10f, 8.25f) horizontalLineTo(11f) curveTo(11.414f, 8.25f, 11.75f, 8.586f, 11.75f, 9f) curveTo(11.75f, 9.414f, 11.414f, 9.75f, 11f, 9.75f) horizontalLineTo(10f) curveTo(9.586f, 9.75f, 9.25f, 9.414f, 9.25f, 9f) close() moveTo(11.25f, 11f) curveTo(11.25f, 10.586f, 11.586f, 10.25f, 12f, 10.25f) horizontalLineTo(13f) curveTo(13.414f, 10.25f, 13.75f, 10.586f, 13.75f, 11f) curveTo(13.75f, 11.414f, 13.414f, 11.75f, 13f, 11.75f) horizontalLineTo(12f) curveTo(11.586f, 11.75f, 11.25f, 11.414f, 11.25f, 11f) close() moveTo(9.25f, 13f) curveTo(9.25f, 12.586f, 9.586f, 12.25f, 10f, 12.25f) horizontalLineTo(11f) curveTo(11.414f, 12.25f, 11.75f, 12.586f, 11.75f, 13f) curveTo(11.75f, 13.414f, 11.414f, 13.75f, 11f, 13.75f) horizontalLineTo(10f) curveTo(9.586f, 13.75f, 9.25f, 13.414f, 9.25f, 13f) close() moveTo(11.25f, 15f) curveTo(11.25f, 14.586f, 11.586f, 14.25f, 12f, 14.25f) horizontalLineTo(13f) curveTo(13.414f, 14.25f, 13.75f, 14.586f, 13.75f, 15f) curveTo(13.75f, 15.414f, 13.414f, 15.75f, 13f, 15.75f) horizontalLineTo(12f) curveTo(11.586f, 15.75f, 11.25f, 15.414f, 11.25f, 15f) close() moveTo(11f, 17.75f) curveTo(10.668f, 17.75f, 10.351f, 17.882f, 10.116f, 18.116f) curveTo(9.882f, 18.351f, 9.75f, 18.669f, 9.75f, 19f) verticalLineTo(21f) curveTo(9.75f, 21.066f, 9.776f, 21.13f, 9.823f, 21.177f) curveTo(9.87f, 21.224f, 9.934f, 21.25f, 10f, 21.25f) horizontalLineTo(12f) curveTo(12.066f, 21.25f, 12.13f, 21.224f, 12.177f, 21.177f) curveTo(12.224f, 21.13f, 12.25f, 21.066f, 12.25f, 21f) verticalLineTo(19f) curveTo(12.25f, 18.669f, 12.118f, 18.351f, 11.884f, 18.116f) curveTo(11.649f, 17.882f, 11.332f, 17.75f, 11f, 17.75f) close() moveTo(9.055f, 17.056f) curveTo(9.571f, 16.54f, 10.271f, 16.25f, 11f, 16.25f) curveTo(11.729f, 16.25f, 12.429f, 16.54f, 12.944f, 17.056f) curveTo(13.46f, 17.571f, 13.75f, 18.271f, 13.75f, 19f) verticalLineTo(21f) curveTo(13.75f, 21.464f, 13.566f, 21.909f, 13.237f, 22.237f) curveTo(12.909f, 22.566f, 12.464f, 22.75f, 12f, 22.75f) horizontalLineTo(10f) curveTo(9.536f, 22.75f, 9.091f, 22.566f, 8.763f, 22.237f) curveTo(8.434f, 21.909f, 8.25f, 21.464f, 8.25f, 21f) verticalLineTo(19f) curveTo(8.25f, 18.271f, 8.54f, 17.571f, 9.055f, 17.056f) close() } }.build() return _FileZip!! } @Suppress("ObjectPropertyName") private var _FileZip: ImageVector? = null ================================================ FILE: shared/resources/src/commonMain/kotlin/com/abdownloadmanager/resources/icons/Flag.kt ================================================ package com.abdownloadmanager.resources.icons import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.SolidColor import androidx.compose.ui.graphics.StrokeCap import androidx.compose.ui.graphics.StrokeJoin import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.graphics.vector.path import androidx.compose.ui.unit.dp val ABDMIcons.Flag: ImageVector get() { if (_Flag != null) { return _Flag!! } _Flag = ImageVector.Builder( name = "Flag", defaultWidth = 24.dp, defaultHeight = 24.dp, viewportWidth = 24f, viewportHeight = 24f ).apply { path( stroke = SolidColor(Color.White), strokeLineWidth = 2f, strokeLineCap = StrokeCap.Round, strokeLineJoin = StrokeJoin.Round ) { moveTo(5f, 14f) curveTo(5.935f, 13.084f, 7.191f, 12.571f, 8.5f, 12.571f) curveTo(9.809f, 12.571f, 11.065f, 13.084f, 12f, 14f) curveTo(12.935f, 14.916f, 14.191f, 15.429f, 15.5f, 15.429f) curveTo(16.809f, 15.429f, 18.065f, 14.916f, 19f, 14f) verticalLineTo(5f) curveTo(18.065f, 5.916f, 16.809f, 6.429f, 15.5f, 6.429f) curveTo(14.191f, 6.429f, 12.935f, 5.916f, 12f, 5f) curveTo(11.065f, 4.084f, 9.809f, 3.571f, 8.5f, 3.571f) curveTo(7.191f, 3.571f, 5.935f, 4.084f, 5f, 5f) verticalLineTo(14f) close() moveTo(5f, 14f) verticalLineTo(21f) } }.build() return _Flag!! } @Suppress("ObjectPropertyName") private var _Flag: ImageVector? = null ================================================ FILE: shared/resources/src/commonMain/kotlin/com/abdownloadmanager/resources/icons/Folder.kt ================================================ package com.abdownloadmanager.resources.icons import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.SolidColor import androidx.compose.ui.graphics.StrokeCap import androidx.compose.ui.graphics.StrokeJoin import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.graphics.vector.path import androidx.compose.ui.unit.dp val ABDMIcons.Folder: ImageVector get() { if (_Folder != null) { return _Folder!! } _Folder = ImageVector.Builder( name = "Folder", defaultWidth = 24.dp, defaultHeight = 24.dp, viewportWidth = 24f, viewportHeight = 24f ).apply { path( stroke = SolidColor(Color.White), strokeLineWidth = 2f, strokeLineCap = StrokeCap.Round, strokeLineJoin = StrokeJoin.Round ) { moveTo(5f, 4f) horizontalLineTo(9f) lineTo(12f, 7f) horizontalLineTo(19f) curveTo(19.53f, 7f, 20.039f, 7.211f, 20.414f, 7.586f) curveTo(20.789f, 7.961f, 21f, 8.47f, 21f, 9f) verticalLineTo(17f) curveTo(21f, 17.53f, 20.789f, 18.039f, 20.414f, 18.414f) curveTo(20.039f, 18.789f, 19.53f, 19f, 19f, 19f) horizontalLineTo(5f) curveTo(4.47f, 19f, 3.961f, 18.789f, 3.586f, 18.414f) curveTo(3.211f, 18.039f, 3f, 17.53f, 3f, 17f) verticalLineTo(6f) curveTo(3f, 5.47f, 3.211f, 4.961f, 3.586f, 4.586f) curveTo(3.961f, 4.211f, 4.47f, 4f, 5f, 4f) close() } }.build() return _Folder!! } @Suppress("ObjectPropertyName") private var _Folder: ImageVector? = null ================================================ FILE: shared/resources/src/commonMain/kotlin/com/abdownloadmanager/resources/icons/FolderFinished.kt ================================================ package com.abdownloadmanager.resources.icons import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.SolidColor import androidx.compose.ui.graphics.StrokeCap import androidx.compose.ui.graphics.StrokeJoin import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.graphics.vector.path import androidx.compose.ui.unit.dp val ABDMIcons.FolderFinished: ImageVector get() { if (_FolderFinished != null) { return _FolderFinished!! } _FolderFinished = ImageVector.Builder( name = "FolderFinished", defaultWidth = 24.dp, defaultHeight = 24.dp, viewportWidth = 24f, viewportHeight = 24f ).apply { path( stroke = SolidColor(Color.White), strokeLineWidth = 2f, strokeLineCap = StrokeCap.Round, strokeLineJoin = StrokeJoin.Round ) { moveTo(11f, 19f) horizontalLineTo(5f) curveTo(4.47f, 19f, 3.961f, 18.789f, 3.586f, 18.414f) curveTo(3.211f, 18.039f, 3f, 17.53f, 3f, 17f) verticalLineTo(6f) curveTo(3f, 5.47f, 3.211f, 4.961f, 3.586f, 4.586f) curveTo(3.961f, 4.211f, 4.47f, 4f, 5f, 4f) horizontalLineTo(9f) lineTo(12f, 7f) horizontalLineTo(19f) curveTo(19.53f, 7f, 20.039f, 7.211f, 20.414f, 7.586f) curveTo(20.789f, 7.961f, 21f, 8.47f, 21f, 9f) verticalLineTo(13f) moveTo(15f, 19f) lineTo(17f, 21f) lineTo(21f, 17f) } }.build() return _FolderFinished!! } @Suppress("ObjectPropertyName") private var _FolderFinished: ImageVector? = null ================================================ FILE: shared/resources/src/commonMain/kotlin/com/abdownloadmanager/resources/icons/FolderUnfinished.kt ================================================ package com.abdownloadmanager.resources.icons import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.SolidColor import androidx.compose.ui.graphics.StrokeCap import androidx.compose.ui.graphics.StrokeJoin import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.graphics.vector.path import androidx.compose.ui.unit.dp val ABDMIcons.FolderUnfinished: ImageVector get() { if (_FolderUnfinished != null) { return _FolderUnfinished!! } _FolderUnfinished = ImageVector.Builder( name = "FolderUnfinished", defaultWidth = 24.dp, defaultHeight = 24.dp, viewportWidth = 24f, viewportHeight = 24f ).apply { path( stroke = SolidColor(Color.White), strokeLineWidth = 2f, strokeLineCap = StrokeCap.Round, strokeLineJoin = StrokeJoin.Round ) { moveTo(12f, 19f) horizontalLineTo(5f) curveTo(4.47f, 19f, 3.961f, 18.789f, 3.586f, 18.414f) curveTo(3.211f, 18.039f, 3f, 17.53f, 3f, 17f) verticalLineTo(6f) curveTo(3f, 5.47f, 3.211f, 4.961f, 3.586f, 4.586f) curveTo(3.961f, 4.211f, 4.47f, 4f, 5f, 4f) horizontalLineTo(9f) lineTo(12f, 7f) horizontalLineTo(19f) curveTo(19.53f, 7f, 20.039f, 7.211f, 20.414f, 7.586f) curveTo(20.789f, 7.961f, 21f, 8.47f, 21f, 9f) verticalLineTo(12.5f) moveTo(19f, 16f) verticalLineTo(22f) moveTo(19f, 22f) lineTo(22f, 19f) moveTo(19f, 22f) lineTo(16f, 19f) } }.build() return _FolderUnfinished!! } @Suppress("ObjectPropertyName") private var _FolderUnfinished: ImageVector? = null ================================================ FILE: shared/resources/src/commonMain/kotlin/com/abdownloadmanager/resources/icons/Grip.kt ================================================ package com.abdownloadmanager.resources.icons import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.SolidColor import androidx.compose.ui.graphics.StrokeCap import androidx.compose.ui.graphics.StrokeJoin import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.graphics.vector.path import androidx.compose.ui.unit.dp val ABDMIcons.Grip: ImageVector get() { if (_Grip != null) { return _Grip!! } _Grip = ImageVector.Builder( name = "Grip", defaultWidth = 24.dp, defaultHeight = 24.dp, viewportWidth = 24f, viewportHeight = 24f ).apply { path( stroke = SolidColor(Color.White), strokeLineWidth = 2f, strokeLineCap = StrokeCap.Round, strokeLineJoin = StrokeJoin.Round ) { moveTo(8f, 5f) curveTo(8f, 5.265f, 8.105f, 5.52f, 8.293f, 5.707f) curveTo(8.48f, 5.895f, 8.735f, 6f, 9f, 6f) curveTo(9.265f, 6f, 9.52f, 5.895f, 9.707f, 5.707f) curveTo(9.895f, 5.52f, 10f, 5.265f, 10f, 5f) curveTo(10f, 4.735f, 9.895f, 4.48f, 9.707f, 4.293f) curveTo(9.52f, 4.105f, 9.265f, 4f, 9f, 4f) curveTo(8.735f, 4f, 8.48f, 4.105f, 8.293f, 4.293f) curveTo(8.105f, 4.48f, 8f, 4.735f, 8f, 5f) close() } path( stroke = SolidColor(Color.White), strokeLineWidth = 2f, strokeLineCap = StrokeCap.Round, strokeLineJoin = StrokeJoin.Round ) { moveTo(8f, 12f) curveTo(8f, 12.265f, 8.105f, 12.52f, 8.293f, 12.707f) curveTo(8.48f, 12.895f, 8.735f, 13f, 9f, 13f) curveTo(9.265f, 13f, 9.52f, 12.895f, 9.707f, 12.707f) curveTo(9.895f, 12.52f, 10f, 12.265f, 10f, 12f) curveTo(10f, 11.735f, 9.895f, 11.48f, 9.707f, 11.293f) curveTo(9.52f, 11.105f, 9.265f, 11f, 9f, 11f) curveTo(8.735f, 11f, 8.48f, 11.105f, 8.293f, 11.293f) curveTo(8.105f, 11.48f, 8f, 11.735f, 8f, 12f) close() } path( stroke = SolidColor(Color.White), strokeLineWidth = 2f, strokeLineCap = StrokeCap.Round, strokeLineJoin = StrokeJoin.Round ) { moveTo(8f, 19f) curveTo(8f, 19.265f, 8.105f, 19.52f, 8.293f, 19.707f) curveTo(8.48f, 19.895f, 8.735f, 20f, 9f, 20f) curveTo(9.265f, 20f, 9.52f, 19.895f, 9.707f, 19.707f) curveTo(9.895f, 19.52f, 10f, 19.265f, 10f, 19f) curveTo(10f, 18.735f, 9.895f, 18.48f, 9.707f, 18.293f) curveTo(9.52f, 18.105f, 9.265f, 18f, 9f, 18f) curveTo(8.735f, 18f, 8.48f, 18.105f, 8.293f, 18.293f) curveTo(8.105f, 18.48f, 8f, 18.735f, 8f, 19f) close() } path( stroke = SolidColor(Color.White), strokeLineWidth = 2f, strokeLineCap = StrokeCap.Round, strokeLineJoin = StrokeJoin.Round ) { moveTo(14f, 5f) curveTo(14f, 5.265f, 14.105f, 5.52f, 14.293f, 5.707f) curveTo(14.48f, 5.895f, 14.735f, 6f, 15f, 6f) curveTo(15.265f, 6f, 15.52f, 5.895f, 15.707f, 5.707f) curveTo(15.895f, 5.52f, 16f, 5.265f, 16f, 5f) curveTo(16f, 4.735f, 15.895f, 4.48f, 15.707f, 4.293f) curveTo(15.52f, 4.105f, 15.265f, 4f, 15f, 4f) curveTo(14.735f, 4f, 14.48f, 4.105f, 14.293f, 4.293f) curveTo(14.105f, 4.48f, 14f, 4.735f, 14f, 5f) close() } path( stroke = SolidColor(Color.White), strokeLineWidth = 2f, strokeLineCap = StrokeCap.Round, strokeLineJoin = StrokeJoin.Round ) { moveTo(14f, 12f) curveTo(14f, 12.265f, 14.105f, 12.52f, 14.293f, 12.707f) curveTo(14.48f, 12.895f, 14.735f, 13f, 15f, 13f) curveTo(15.265f, 13f, 15.52f, 12.895f, 15.707f, 12.707f) curveTo(15.895f, 12.52f, 16f, 12.265f, 16f, 12f) curveTo(16f, 11.735f, 15.895f, 11.48f, 15.707f, 11.293f) curveTo(15.52f, 11.105f, 15.265f, 11f, 15f, 11f) curveTo(14.735f, 11f, 14.48f, 11.105f, 14.293f, 11.293f) curveTo(14.105f, 11.48f, 14f, 11.735f, 14f, 12f) close() } path( stroke = SolidColor(Color.White), strokeLineWidth = 2f, strokeLineCap = StrokeCap.Round, strokeLineJoin = StrokeJoin.Round ) { moveTo(14f, 19f) curveTo(14f, 19.265f, 14.105f, 19.52f, 14.293f, 19.707f) curveTo(14.48f, 19.895f, 14.735f, 20f, 15f, 20f) curveTo(15.265f, 20f, 15.52f, 19.895f, 15.707f, 19.707f) curveTo(15.895f, 19.52f, 16f, 19.265f, 16f, 19f) curveTo(16f, 18.735f, 15.895f, 18.48f, 15.707f, 18.293f) curveTo(15.52f, 18.105f, 15.265f, 18f, 15f, 18f) curveTo(14.735f, 18f, 14.48f, 18.105f, 14.293f, 18.293f) curveTo(14.105f, 18.48f, 14f, 18.735f, 14f, 19f) close() } }.build() return _Grip!! } @Suppress("ObjectPropertyName") private var _Grip: ImageVector? = null ================================================ FILE: shared/resources/src/commonMain/kotlin/com/abdownloadmanager/resources/icons/Group.kt ================================================ package com.abdownloadmanager.resources.icons import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.SolidColor import androidx.compose.ui.graphics.StrokeCap import androidx.compose.ui.graphics.StrokeJoin import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.graphics.vector.path import androidx.compose.ui.unit.dp val ABDMIcons.Group: ImageVector get() { if (_Group != null) { return _Group!! } _Group = ImageVector.Builder( name = "Group", defaultWidth = 24.dp, defaultHeight = 24.dp, viewportWidth = 24f, viewportHeight = 24f ).apply { path( stroke = SolidColor(Color.White), strokeLineWidth = 1.5f, strokeLineCap = StrokeCap.Round, strokeLineJoin = StrokeJoin.Round ) { moveTo(7f, 18f) verticalLineTo(17f) curveTo(7f, 15.674f, 7.527f, 14.402f, 8.464f, 13.465f) curveTo(9.402f, 12.527f, 10.674f, 12f, 12f, 12f) moveTo(12f, 12f) curveTo(13.326f, 12f, 14.598f, 12.527f, 15.535f, 13.465f) curveTo(16.473f, 14.402f, 17f, 15.674f, 17f, 17f) verticalLineTo(18f) moveTo(12f, 12f) curveTo(12.796f, 12f, 13.559f, 11.684f, 14.121f, 11.121f) curveTo(14.684f, 10.559f, 15f, 9.796f, 15f, 9f) curveTo(15f, 8.204f, 14.684f, 7.441f, 14.121f, 6.879f) curveTo(13.559f, 6.316f, 12.796f, 6f, 12f, 6f) curveTo(11.204f, 6f, 10.441f, 6.316f, 9.879f, 6.879f) curveTo(9.316f, 7.441f, 9f, 8.204f, 9f, 9f) curveTo(9f, 9.796f, 9.316f, 10.559f, 9.879f, 11.121f) curveTo(10.441f, 11.684f, 11.204f, 12f, 12f, 12f) close() moveTo(1f, 18f) verticalLineTo(17f) curveTo(1f, 16.204f, 1.316f, 15.441f, 1.879f, 14.879f) curveTo(2.441f, 14.316f, 3.204f, 14f, 4f, 14f) moveTo(4f, 14f) curveTo(4.53f, 14f, 5.039f, 13.789f, 5.414f, 13.414f) curveTo(5.789f, 13.039f, 6f, 12.53f, 6f, 12f) curveTo(6f, 11.47f, 5.789f, 10.961f, 5.414f, 10.586f) curveTo(5.039f, 10.211f, 4.53f, 10f, 4f, 10f) curveTo(3.47f, 10f, 2.961f, 10.211f, 2.586f, 10.586f) curveTo(2.211f, 10.961f, 2f, 11.47f, 2f, 12f) curveTo(2f, 12.53f, 2.211f, 13.039f, 2.586f, 13.414f) curveTo(2.961f, 13.789f, 3.47f, 14f, 4f, 14f) close() moveTo(23f, 18f) verticalLineTo(17f) curveTo(23f, 16.204f, 22.684f, 15.441f, 22.121f, 14.879f) curveTo(21.559f, 14.316f, 20.796f, 14f, 20f, 14f) moveTo(20f, 14f) curveTo(20.53f, 14f, 21.039f, 13.789f, 21.414f, 13.414f) curveTo(21.789f, 13.039f, 22f, 12.53f, 22f, 12f) curveTo(22f, 11.47f, 21.789f, 10.961f, 21.414f, 10.586f) curveTo(21.039f, 10.211f, 20.53f, 10f, 20f, 10f) curveTo(19.47f, 10f, 18.961f, 10.211f, 18.586f, 10.586f) curveTo(18.211f, 10.961f, 18f, 11.47f, 18f, 12f) curveTo(18f, 12.53f, 18.211f, 13.039f, 18.586f, 13.414f) curveTo(18.961f, 13.789f, 19.47f, 14f, 20f, 14f) close() } }.build() return _Group!! } @Suppress("ObjectPropertyName") private var _Group: ImageVector? = null ================================================ FILE: shared/resources/src/commonMain/kotlin/com/abdownloadmanager/resources/icons/Hearth.kt ================================================ package com.abdownloadmanager.resources.icons import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.SolidColor import androidx.compose.ui.graphics.StrokeCap import androidx.compose.ui.graphics.StrokeJoin import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.graphics.vector.path import androidx.compose.ui.unit.dp val ABDMIcons.Hearth: ImageVector get() { if (_Hearth != null) { return _Hearth!! } _Hearth = ImageVector.Builder( name = "Hearth", defaultWidth = 24.dp, defaultHeight = 24.dp, viewportWidth = 24f, viewportHeight = 24f ).apply { path( stroke = SolidColor(Color.White), strokeLineWidth = 2f, strokeLineCap = StrokeCap.Round, strokeLineJoin = StrokeJoin.Round ) { moveTo(19.5f, 12.572f) lineTo(12f, 20f) lineTo(4.5f, 12.572f) curveTo(4.005f, 12.091f, 3.616f, 11.512f, 3.356f, 10.873f) curveTo(3.096f, 10.233f, 2.971f, 9.547f, 2.989f, 8.857f) curveTo(3.007f, 8.167f, 3.168f, 7.488f, 3.461f, 6.863f) curveTo(3.755f, 6.239f, 4.174f, 5.681f, 4.694f, 5.227f) curveTo(5.213f, 4.772f, 5.821f, 4.43f, 6.479f, 4.221f) curveTo(7.137f, 4.013f, 7.831f, 3.944f, 8.517f, 4.017f) curveTo(9.204f, 4.09f, 9.868f, 4.305f, 10.467f, 4.647f) curveTo(11.066f, 4.989f, 11.588f, 5.452f, 12f, 6.006f) curveTo(12.414f, 5.456f, 12.936f, 4.997f, 13.535f, 4.659f) curveTo(14.134f, 4.32f, 14.797f, 4.108f, 15.481f, 4.038f) curveTo(16.165f, 3.967f, 16.857f, 4.038f, 17.513f, 4.246f) curveTo(18.169f, 4.455f, 18.774f, 4.797f, 19.292f, 5.25f) curveTo(19.809f, 5.704f, 20.228f, 6.259f, 20.521f, 6.882f) curveTo(20.813f, 7.504f, 20.975f, 8.181f, 20.994f, 8.869f) curveTo(21.014f, 9.557f, 20.891f, 10.241f, 20.634f, 10.879f) curveTo(20.377f, 11.517f, 19.991f, 12.096f, 19.5f, 12.578f) } }.build() return _Hearth!! } @Suppress("ObjectPropertyName") private var _Hearth: ImageVector? = null ================================================ FILE: shared/resources/src/commonMain/kotlin/com/abdownloadmanager/resources/icons/Info.kt ================================================ package com.abdownloadmanager.resources.icons import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.SolidColor import androidx.compose.ui.graphics.StrokeCap import androidx.compose.ui.graphics.StrokeJoin import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.graphics.vector.path import androidx.compose.ui.unit.dp val ABDMIcons.Info: ImageVector get() { if (_Info != null) { return _Info!! } _Info = ImageVector.Builder( name = "Info", defaultWidth = 24.dp, defaultHeight = 24.dp, viewportWidth = 24f, viewportHeight = 24f ).apply { path( stroke = SolidColor(Color.White), strokeLineWidth = 2f, strokeLineCap = StrokeCap.Round, strokeLineJoin = StrokeJoin.Round ) { moveTo(12f, 9f) horizontalLineTo(12.01f) moveTo(11f, 12f) horizontalLineTo(12f) verticalLineTo(16f) horizontalLineTo(13f) moveTo(3f, 12f) curveTo(3f, 13.182f, 3.233f, 14.352f, 3.685f, 15.444f) curveTo(4.137f, 16.536f, 4.8f, 17.528f, 5.636f, 18.364f) curveTo(6.472f, 19.2f, 7.464f, 19.863f, 8.556f, 20.315f) curveTo(9.648f, 20.767f, 10.818f, 21f, 12f, 21f) curveTo(13.182f, 21f, 14.352f, 20.767f, 15.444f, 20.315f) curveTo(16.536f, 19.863f, 17.528f, 19.2f, 18.364f, 18.364f) curveTo(19.2f, 17.528f, 19.863f, 16.536f, 20.315f, 15.444f) curveTo(20.767f, 14.352f, 21f, 13.182f, 21f, 12f) curveTo(21f, 9.613f, 20.052f, 7.324f, 18.364f, 5.636f) curveTo(16.676f, 3.948f, 14.387f, 3f, 12f, 3f) curveTo(9.613f, 3f, 7.324f, 3.948f, 5.636f, 5.636f) curveTo(3.948f, 7.324f, 3f, 9.613f, 3f, 12f) close() } }.build() return _Info!! } @Suppress("ObjectPropertyName") private var _Info: ImageVector? = null ================================================ FILE: shared/resources/src/commonMain/kotlin/com/abdownloadmanager/resources/icons/Language.kt ================================================ package com.abdownloadmanager.resources.icons import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.SolidColor import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.graphics.vector.path import androidx.compose.ui.unit.dp val ABDMIcons.Language: ImageVector get() { if (_Language != null) { return _Language!! } _Language = ImageVector.Builder( name = "Language", defaultWidth = 24.dp, defaultHeight = 24.dp, viewportWidth = 24f, viewportHeight = 24f ).apply { path(fill = SolidColor(Color.White)) { moveTo(12.87f, 15.07f) lineTo(10.33f, 12.56f) lineTo(10.36f, 12.53f) curveTo(12.055f, 10.648f, 13.321f, 8.42f, 14.07f, 6f) horizontalLineTo(17f) verticalLineTo(4f) horizontalLineTo(10f) verticalLineTo(2f) horizontalLineTo(8f) verticalLineTo(4f) horizontalLineTo(1f) verticalLineTo(6f) horizontalLineTo(12.17f) curveTo(11.5f, 7.92f, 10.44f, 9.75f, 9f, 11.35f) curveTo(8.07f, 10.32f, 7.3f, 9.19f, 6.69f, 8f) horizontalLineTo(4.69f) curveTo(5.42f, 9.63f, 6.42f, 11.17f, 7.67f, 12.56f) lineTo(2.58f, 17.58f) lineTo(4f, 19f) lineTo(9f, 14f) lineTo(12.11f, 17.11f) lineTo(12.87f, 15.07f) close() moveTo(18.5f, 10f) horizontalLineTo(16.5f) lineTo(12f, 22f) horizontalLineTo(14f) lineTo(15.12f, 19f) horizontalLineTo(19.87f) lineTo(21f, 22f) horizontalLineTo(23f) lineTo(18.5f, 10f) close() moveTo(15.88f, 17f) lineTo(17.5f, 12.67f) lineTo(19.12f, 17f) horizontalLineTo(15.88f) close() } }.build() return _Language!! } @Suppress("ObjectPropertyName") private var _Language: ImageVector? = null ================================================ FILE: shared/resources/src/commonMain/kotlin/com/abdownloadmanager/resources/icons/List.kt ================================================ package com.abdownloadmanager.resources.icons import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.SolidColor import androidx.compose.ui.graphics.StrokeCap import androidx.compose.ui.graphics.StrokeJoin import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.graphics.vector.path import androidx.compose.ui.unit.dp val ABDMIcons.List: ImageVector get() { if (_List != null) { return _List!! } _List = ImageVector.Builder( name = "List", defaultWidth = 24.dp, defaultHeight = 24.dp, viewportWidth = 24f, viewportHeight = 24f ).apply { path( stroke = SolidColor(Color.White), strokeLineWidth = 2f, strokeLineCap = StrokeCap.Round, strokeLineJoin = StrokeJoin.Round ) { moveTo(9f, 6f) horizontalLineTo(20f) moveTo(9f, 12f) horizontalLineTo(20f) moveTo(9f, 18f) horizontalLineTo(20f) moveTo(5f, 6f) verticalLineTo(6.01f) moveTo(5f, 12f) verticalLineTo(12.01f) moveTo(5f, 18f) verticalLineTo(18.01f) } }.build() return _List!! } @Suppress("ObjectPropertyName") private var _List: ImageVector? = null ================================================ FILE: shared/resources/src/commonMain/kotlin/com/abdownloadmanager/resources/icons/Lock.kt ================================================ package com.abdownloadmanager.resources.icons import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.SolidColor import androidx.compose.ui.graphics.StrokeCap import androidx.compose.ui.graphics.StrokeJoin import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.graphics.vector.path import androidx.compose.ui.unit.dp val ABDMIcons.Lock: ImageVector get() { if (_Lock != null) { return _Lock!! } _Lock = ImageVector.Builder( name = "Lock", defaultWidth = 24.dp, defaultHeight = 24.dp, viewportWidth = 24f, viewportHeight = 24f ).apply { path( stroke = SolidColor(Color.White), strokeLineWidth = 2f, strokeLineCap = StrokeCap.Round, strokeLineJoin = StrokeJoin.Round ) { moveTo(8f, 11f) verticalLineTo(7f) curveTo(8f, 5.939f, 8.421f, 4.922f, 9.172f, 4.172f) curveTo(9.922f, 3.421f, 10.939f, 3f, 12f, 3f) curveTo(13.061f, 3f, 14.078f, 3.421f, 14.828f, 4.172f) curveTo(15.579f, 4.922f, 16f, 5.939f, 16f, 7f) verticalLineTo(11f) moveTo(5f, 13f) curveTo(5f, 12.47f, 5.211f, 11.961f, 5.586f, 11.586f) curveTo(5.961f, 11.211f, 6.47f, 11f, 7f, 11f) horizontalLineTo(17f) curveTo(17.53f, 11f, 18.039f, 11.211f, 18.414f, 11.586f) curveTo(18.789f, 11.961f, 19f, 12.47f, 19f, 13f) verticalLineTo(19f) curveTo(19f, 19.53f, 18.789f, 20.039f, 18.414f, 20.414f) curveTo(18.039f, 20.789f, 17.53f, 21f, 17f, 21f) horizontalLineTo(7f) curveTo(6.47f, 21f, 5.961f, 20.789f, 5.586f, 20.414f) curveTo(5.211f, 20.039f, 5f, 19.53f, 5f, 19f) verticalLineTo(13f) close() moveTo(11f, 16f) curveTo(11f, 16.265f, 11.105f, 16.52f, 11.293f, 16.707f) curveTo(11.48f, 16.895f, 11.735f, 17f, 12f, 17f) curveTo(12.265f, 17f, 12.52f, 16.895f, 12.707f, 16.707f) curveTo(12.895f, 16.52f, 13f, 16.265f, 13f, 16f) curveTo(13f, 15.735f, 12.895f, 15.48f, 12.707f, 15.293f) curveTo(12.52f, 15.105f, 12.265f, 15f, 12f, 15f) curveTo(11.735f, 15f, 11.48f, 15.105f, 11.293f, 15.293f) curveTo(11.105f, 15.48f, 11f, 15.735f, 11f, 16f) close() } }.build() return _Lock!! } @Suppress("ObjectPropertyName") private var _Lock: ImageVector? = null ================================================ FILE: shared/resources/src/commonMain/kotlin/com/abdownloadmanager/resources/icons/Menu.kt ================================================ package com.abdownloadmanager.resources.icons import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.SolidColor import androidx.compose.ui.graphics.StrokeCap import androidx.compose.ui.graphics.StrokeJoin import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.graphics.vector.path import androidx.compose.ui.unit.dp val ABDMIcons.Menu: ImageVector get() { if (_Menu != null) { return _Menu!! } _Menu = ImageVector.Builder( name = "Menu", defaultWidth = 24.dp, defaultHeight = 24.dp, viewportWidth = 24f, viewportHeight = 24f ).apply { path( stroke = SolidColor(Color.White), strokeLineWidth = 2f, strokeLineCap = StrokeCap.Round, strokeLineJoin = StrokeJoin.Round ) { moveTo(4f, 6f) horizontalLineTo(20f) moveTo(4f, 12f) horizontalLineTo(20f) moveTo(4f, 18f) horizontalLineTo(20f) } }.build() return _Menu!! } @Suppress("ObjectPropertyName") private var _Menu: ImageVector? = null ================================================ FILE: shared/resources/src/commonMain/kotlin/com/abdownloadmanager/resources/icons/Minus.kt ================================================ package com.abdownloadmanager.resources.icons import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.SolidColor import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.graphics.vector.path import androidx.compose.ui.unit.dp val ABDMIcons.Minus: ImageVector get() { if (_Minus != null) { return _Minus!! } _Minus = ImageVector.Builder( name = "Minus", defaultWidth = 24.dp, defaultHeight = 24.dp, viewportWidth = 24f, viewportHeight = 24f ).apply { path(fill = SolidColor(Color.White)) { moveTo(19f, 11f) curveTo(19.552f, 11f, 20f, 11.448f, 20f, 12f) curveTo(20f, 12.552f, 19.552f, 13f, 19f, 13f) horizontalLineTo(5f) curveTo(4.448f, 13f, 4f, 12.552f, 4f, 12f) curveTo(4f, 11.448f, 4.448f, 11f, 5f, 11f) horizontalLineTo(19f) close() } }.build() return _Minus!! } @Suppress("ObjectPropertyName") private var _Minus: ImageVector? = null ================================================ FILE: shared/resources/src/commonMain/kotlin/com/abdownloadmanager/resources/icons/Network.kt ================================================ package com.abdownloadmanager.resources.icons import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.SolidColor import androidx.compose.ui.graphics.StrokeCap import androidx.compose.ui.graphics.StrokeJoin import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.graphics.vector.path import androidx.compose.ui.unit.dp val ABDMIcons.Network: ImageVector get() { if (_Network != null) { return _Network!! } _Network = ImageVector.Builder( name = "Network", defaultWidth = 24.dp, defaultHeight = 24.dp, viewportWidth = 24f, viewportHeight = 24f ).apply { path( stroke = SolidColor(Color.White), strokeLineWidth = 2f, strokeLineCap = StrokeCap.Round, strokeLineJoin = StrokeJoin.Round ) { moveTo(7f, 3f) verticalLineTo(21f) moveTo(7f, 3f) lineTo(10f, 6f) moveTo(7f, 3f) lineTo(4f, 6f) moveTo(20f, 18f) lineTo(17f, 21f) moveTo(17f, 21f) lineTo(14f, 18f) moveTo(17f, 21f) verticalLineTo(3f) } }.build() return _Network!! } @Suppress("ObjectPropertyName") private var _Network: ImageVector? = null ================================================ FILE: shared/resources/src/commonMain/kotlin/com/abdownloadmanager/resources/icons/Next.kt ================================================ package com.abdownloadmanager.resources.icons import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.PathFillType import androidx.compose.ui.graphics.SolidColor import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.graphics.vector.path import androidx.compose.ui.unit.dp val ABDMIcons.Next: ImageVector get() { if (_Next != null) { return _Next!! } _Next = ImageVector.Builder( name = "Next", defaultWidth = 24.dp, defaultHeight = 24.dp, viewportWidth = 24f, viewportHeight = 24f ).apply { path( fill = SolidColor(Color.White), pathFillType = PathFillType.EvenOdd ) { moveTo(8.293f, 5.293f) curveTo(8.683f, 4.902f, 9.317f, 4.902f, 9.707f, 5.293f) lineTo(15.707f, 11.293f) curveTo(16.098f, 11.683f, 16.098f, 12.317f, 15.707f, 12.707f) lineTo(9.707f, 18.707f) curveTo(9.317f, 19.098f, 8.683f, 19.098f, 8.293f, 18.707f) curveTo(7.902f, 18.317f, 7.902f, 17.683f, 8.293f, 17.293f) lineTo(13.586f, 12f) lineTo(8.293f, 6.707f) curveTo(7.902f, 6.317f, 7.902f, 5.683f, 8.293f, 5.293f) close() } }.build() return _Next!! } @Suppress("ObjectPropertyName") private var _Next: ImageVector? = null ================================================ FILE: shared/resources/src/commonMain/kotlin/com/abdownloadmanager/resources/icons/OpenSource.kt ================================================ package com.abdownloadmanager.resources.icons import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.SolidColor import androidx.compose.ui.graphics.StrokeCap import androidx.compose.ui.graphics.StrokeJoin import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.graphics.vector.path import androidx.compose.ui.unit.dp val ABDMIcons.OpenSource: ImageVector get() { if (_OpenSource != null) { return _OpenSource!! } _OpenSource = ImageVector.Builder( name = "OpenSource", defaultWidth = 24.dp, defaultHeight = 24.dp, viewportWidth = 24f, viewportHeight = 24f ).apply { path( stroke = SolidColor(Color.White), strokeLineWidth = 2f, strokeLineCap = StrokeCap.Round, strokeLineJoin = StrokeJoin.Round ) { moveTo(9f, 19f) curveTo(4.7f, 20.4f, 4.7f, 16.5f, 3f, 16f) moveTo(15f, 21f) verticalLineTo(17.5f) curveTo(15f, 16.5f, 15.1f, 16.1f, 14.5f, 15.5f) curveTo(17.3f, 15.2f, 20f, 14.1f, 20f, 9.5f) curveTo(19.999f, 8.305f, 19.532f, 7.157f, 18.7f, 6.3f) curveTo(19.09f, 5.262f, 19.055f, 4.112f, 18.6f, 3.1f) curveTo(18.6f, 3.1f, 17.5f, 2.8f, 15.1f, 4.4f) curveTo(13.067f, 3.871f, 10.933f, 3.871f, 8.9f, 4.4f) curveTo(6.5f, 2.8f, 5.4f, 3.1f, 5.4f, 3.1f) curveTo(4.945f, 4.112f, 4.91f, 5.262f, 5.3f, 6.3f) curveTo(4.467f, 7.157f, 4.001f, 8.305f, 4f, 9.5f) curveTo(4f, 14.1f, 6.7f, 15.2f, 9.5f, 15.5f) curveTo(8.9f, 16.1f, 8.9f, 16.7f, 9f, 17.5f) verticalLineTo(21f) } }.build() return _OpenSource!! } @Suppress("ObjectPropertyName") private var _OpenSource: ImageVector? = null ================================================ FILE: shared/resources/src/commonMain/kotlin/com/abdownloadmanager/resources/icons/Pause.kt ================================================ package com.abdownloadmanager.resources.icons import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.SolidColor import androidx.compose.ui.graphics.StrokeCap import androidx.compose.ui.graphics.StrokeJoin import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.graphics.vector.path import androidx.compose.ui.unit.dp val ABDMIcons.Pause: ImageVector get() { if (_Pause != null) { return _Pause!! } _Pause = ImageVector.Builder( name = "Pause", defaultWidth = 24.dp, defaultHeight = 24.dp, viewportWidth = 24f, viewportHeight = 24f ).apply { path( stroke = SolidColor(Color.White), strokeLineWidth = 2f, strokeLineCap = StrokeCap.Round, strokeLineJoin = StrokeJoin.Round ) { moveTo(6f, 6f) curveTo(6f, 5.735f, 6.105f, 5.48f, 6.293f, 5.293f) curveTo(6.48f, 5.105f, 6.735f, 5f, 7f, 5f) horizontalLineTo(9f) curveTo(9.265f, 5f, 9.52f, 5.105f, 9.707f, 5.293f) curveTo(9.895f, 5.48f, 10f, 5.735f, 10f, 6f) verticalLineTo(18f) curveTo(10f, 18.265f, 9.895f, 18.52f, 9.707f, 18.707f) curveTo(9.52f, 18.895f, 9.265f, 19f, 9f, 19f) horizontalLineTo(7f) curveTo(6.735f, 19f, 6.48f, 18.895f, 6.293f, 18.707f) curveTo(6.105f, 18.52f, 6f, 18.265f, 6f, 18f) verticalLineTo(6f) close() } path( stroke = SolidColor(Color.White), strokeLineWidth = 2f, strokeLineCap = StrokeCap.Round, strokeLineJoin = StrokeJoin.Round ) { moveTo(14f, 6f) curveTo(14f, 5.735f, 14.105f, 5.48f, 14.293f, 5.293f) curveTo(14.48f, 5.105f, 14.735f, 5f, 15f, 5f) horizontalLineTo(17f) curveTo(17.265f, 5f, 17.52f, 5.105f, 17.707f, 5.293f) curveTo(17.895f, 5.48f, 18f, 5.735f, 18f, 6f) verticalLineTo(18f) curveTo(18f, 18.265f, 17.895f, 18.52f, 17.707f, 18.707f) curveTo(17.52f, 18.895f, 17.265f, 19f, 17f, 19f) horizontalLineTo(15f) curveTo(14.735f, 19f, 14.48f, 18.895f, 14.293f, 18.707f) curveTo(14.105f, 18.52f, 14f, 18.265f, 14f, 18f) verticalLineTo(6f) close() } }.build() return _Pause!! } @Suppress("ObjectPropertyName") private var _Pause: ImageVector? = null ================================================ FILE: shared/resources/src/commonMain/kotlin/com/abdownloadmanager/resources/icons/Permission.kt ================================================ package com.abdownloadmanager.resources.icons import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.SolidColor import androidx.compose.ui.graphics.StrokeCap import androidx.compose.ui.graphics.StrokeJoin import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.graphics.vector.path import androidx.compose.ui.unit.dp val ABDMIcons.Permission: ImageVector get() { if (_Permission != null) { return _Permission!! } _Permission = ImageVector.Builder( name = "Permission", defaultWidth = 24.dp, defaultHeight = 24.dp, viewportWidth = 24f, viewportHeight = 24f ).apply { path( stroke = SolidColor(Color.White), strokeLineWidth = 2f, strokeLineCap = StrokeCap.Round, strokeLineJoin = StrokeJoin.Round ) { moveTo(15f, 21f) horizontalLineTo(6f) curveTo(5.204f, 21f, 4.441f, 20.684f, 3.879f, 20.121f) curveTo(3.316f, 19.559f, 3f, 18.796f, 3f, 18f) verticalLineTo(17f) horizontalLineTo(13f) verticalLineTo(19f) curveTo(13f, 19.53f, 13.211f, 20.039f, 13.586f, 20.414f) curveTo(13.961f, 20.789f, 14.47f, 21f, 15f, 21f) close() moveTo(15f, 21f) curveTo(15.53f, 21f, 16.039f, 20.789f, 16.414f, 20.414f) curveTo(16.789f, 20.039f, 17f, 19.53f, 17f, 19f) verticalLineTo(5f) curveTo(17f, 4.604f, 17.117f, 4.218f, 17.337f, 3.889f) curveTo(17.557f, 3.56f, 17.869f, 3.304f, 18.235f, 3.152f) curveTo(18.6f, 3.001f, 19.002f, 2.961f, 19.39f, 3.038f) curveTo(19.778f, 3.116f, 20.135f, 3.306f, 20.414f, 3.586f) curveTo(20.694f, 3.865f, 20.884f, 4.222f, 20.962f, 4.61f) curveTo(21.039f, 4.998f, 20.999f, 5.4f, 20.848f, 5.765f) curveTo(20.696f, 6.131f, 20.44f, 6.443f, 20.111f, 6.663f) curveTo(19.782f, 6.883f, 19.396f, 7f, 19f, 7f) horizontalLineTo(17f) moveTo(19f, 3f) horizontalLineTo(8f) curveTo(7.204f, 3f, 6.441f, 3.316f, 5.879f, 3.879f) curveTo(5.316f, 4.441f, 5f, 5.204f, 5f, 6f) verticalLineTo(17f) moveTo(9f, 7f) horizontalLineTo(13f) moveTo(9f, 11f) horizontalLineTo(13f) } }.build() return _Permission!! } @Suppress("ObjectPropertyName") private var _Permission: ImageVector? = null ================================================ FILE: shared/resources/src/commonMain/kotlin/com/abdownloadmanager/resources/icons/Plus.kt ================================================ package com.abdownloadmanager.resources.icons import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.SolidColor import androidx.compose.ui.graphics.StrokeCap import androidx.compose.ui.graphics.StrokeJoin import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.graphics.vector.path import androidx.compose.ui.unit.dp val ABDMIcons.Plus: ImageVector get() { if (_Plus != null) { return _Plus!! } _Plus = ImageVector.Builder( name = "Plus", defaultWidth = 24.dp, defaultHeight = 24.dp, viewportWidth = 24f, viewportHeight = 24f ).apply { path( stroke = SolidColor(Color.White), strokeLineWidth = 2f, strokeLineCap = StrokeCap.Round, strokeLineJoin = StrokeJoin.Round ) { moveTo(12f, 5f) verticalLineTo(19f) moveTo(5f, 12f) horizontalLineTo(19f) } }.build() return _Plus!! } @Suppress("ObjectPropertyName") private var _Plus: ImageVector? = null ================================================ FILE: shared/resources/src/commonMain/kotlin/com/abdownloadmanager/resources/icons/QuestionMark.kt ================================================ package com.abdownloadmanager.resources.icons import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.SolidColor import androidx.compose.ui.graphics.StrokeCap import androidx.compose.ui.graphics.StrokeJoin import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.graphics.vector.path import androidx.compose.ui.unit.dp val ABDMIcons.QuestionMark: ImageVector get() { if (_QuestionMark != null) { return _QuestionMark!! } _QuestionMark = ImageVector.Builder( name = "QuestionMark", defaultWidth = 24.dp, defaultHeight = 24.dp, viewportWidth = 24f, viewportHeight = 24f ).apply { path( stroke = SolidColor(Color.White), strokeLineWidth = 2f, strokeLineCap = StrokeCap.Round, strokeLineJoin = StrokeJoin.Round ) { moveTo(8f, 8f) curveTo(8f, 7.204f, 8.369f, 6.441f, 9.025f, 5.879f) curveTo(9.682f, 5.316f, 10.572f, 5f, 11.5f, 5f) horizontalLineTo(12.5f) curveTo(13.428f, 5f, 14.318f, 5.316f, 14.975f, 5.879f) curveTo(15.631f, 6.441f, 16f, 7.204f, 16f, 8f) curveTo(16.037f, 8.649f, 15.862f, 9.293f, 15.501f, 9.834f) curveTo(15.14f, 10.375f, 14.613f, 10.784f, 14f, 11f) curveTo(13.387f, 11.288f, 12.86f, 11.833f, 12.499f, 12.555f) curveTo(12.138f, 13.276f, 11.963f, 14.134f, 12f, 15f) moveTo(12f, 19f) verticalLineTo(19.01f) } }.build() return _QuestionMark!! } @Suppress("ObjectPropertyName") private var _QuestionMark: ImageVector? = null ================================================ FILE: shared/resources/src/commonMain/kotlin/com/abdownloadmanager/resources/icons/Queue.kt ================================================ package com.abdownloadmanager.resources.icons import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.SolidColor import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.graphics.vector.path import androidx.compose.ui.unit.dp val ABDMIcons.Queue: ImageVector get() { if (_Queue != null) { return _Queue!! } _Queue = ImageVector.Builder( name = "Queue", defaultWidth = 24.dp, defaultHeight = 24.dp, viewportWidth = 24f, viewportHeight = 24f ).apply { path(fill = SolidColor(Color.White)) { moveTo(4f, 6f) horizontalLineTo(2f) verticalLineTo(20f) curveTo(2f, 21.1f, 2.9f, 22f, 4f, 22f) horizontalLineTo(18f) verticalLineTo(20f) horizontalLineTo(4f) verticalLineTo(6f) close() moveTo(20f, 2f) horizontalLineTo(8f) curveTo(6.9f, 2f, 6f, 2.9f, 6f, 4f) verticalLineTo(16f) curveTo(6f, 17.1f, 6.9f, 18f, 8f, 18f) horizontalLineTo(20f) curveTo(21.1f, 18f, 22f, 17.1f, 22f, 16f) verticalLineTo(4f) curveTo(22f, 2.9f, 21.1f, 2f, 20f, 2f) close() moveTo(20f, 16f) horizontalLineTo(8f) verticalLineTo(4f) horizontalLineTo(20f) verticalLineTo(16f) close() moveTo(13f, 15f) horizontalLineTo(15f) verticalLineTo(11f) horizontalLineTo(19f) verticalLineTo(9f) horizontalLineTo(15f) verticalLineTo(5f) horizontalLineTo(13f) verticalLineTo(9f) horizontalLineTo(9f) verticalLineTo(11f) horizontalLineTo(13f) verticalLineTo(15f) close() } }.build() return _Queue!! } @Suppress("ObjectPropertyName") private var _Queue: ImageVector? = null ================================================ FILE: shared/resources/src/commonMain/kotlin/com/abdownloadmanager/resources/icons/QueueStart.kt ================================================ package com.abdownloadmanager.resources.icons import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.PathFillType import androidx.compose.ui.graphics.SolidColor import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.graphics.vector.path import androidx.compose.ui.unit.dp val ABDMIcons.QueueStart: ImageVector get() { if (_QueueStart != null) { return _QueueStart!! } _QueueStart = ImageVector.Builder( name = "QueueStart", defaultWidth = 24.dp, defaultHeight = 24.dp, viewportWidth = 24f, viewportHeight = 24f ).apply { path( fill = SolidColor(Color.White), pathFillType = PathFillType.EvenOdd ) { moveTo(2f, 6f) horizontalLineTo(4f) verticalLineTo(20f) horizontalLineTo(10f) verticalLineTo(22f) horizontalLineTo(4f) curveTo(2.9f, 22f, 2f, 21.1f, 2f, 20f) verticalLineTo(6f) close() moveTo(22f, 10f) verticalLineTo(4f) curveTo(22f, 2.9f, 21.1f, 2f, 20f, 2f) horizontalLineTo(8f) curveTo(6.9f, 2f, 6f, 2.9f, 6f, 4f) verticalLineTo(16f) curveTo(6f, 17.1f, 6.9f, 18f, 8f, 18f) horizontalLineTo(10f) verticalLineTo(16f) horizontalLineTo(8f) verticalLineTo(4f) horizontalLineTo(20f) verticalLineTo(10f) horizontalLineTo(22f) close() moveTo(19f, 10f) verticalLineTo(9f) horizontalLineTo(15f) verticalLineTo(5f) horizontalLineTo(13f) verticalLineTo(9f) horizontalLineTo(9f) verticalLineTo(11f) horizontalLineTo(10.764f) curveTo(11.313f, 10.386f, 12.111f, 10f, 13f, 10f) horizontalLineTo(19f) close() } path( stroke = SolidColor(Color.White), strokeLineWidth = 1.2f ) { moveTo(21.963f, 17.357f) lineTo(13.18f, 21.794f) curveTo(12.914f, 21.928f, 12.6f, 21.735f, 12.6f, 21.437f) verticalLineTo(12.563f) curveTo(12.6f, 12.265f, 12.914f, 12.072f, 13.18f, 12.206f) lineTo(21.963f, 16.643f) curveTo(22.256f, 16.791f, 22.256f, 17.209f, 21.963f, 17.357f) close() } }.build() return _QueueStart!! } @Suppress("ObjectPropertyName") private var _QueueStart: ImageVector? = null ================================================ FILE: shared/resources/src/commonMain/kotlin/com/abdownloadmanager/resources/icons/QueueStop.kt ================================================ package com.abdownloadmanager.resources.icons import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.PathFillType import androidx.compose.ui.graphics.SolidColor import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.graphics.vector.path import androidx.compose.ui.unit.dp val ABDMIcons.QueueStop: ImageVector get() { if (_QueueStop != null) { return _QueueStop!! } _QueueStop = ImageVector.Builder( name = "QueueStop", defaultWidth = 24.dp, defaultHeight = 24.dp, viewportWidth = 24f, viewportHeight = 24f ).apply { path( fill = SolidColor(Color.White), pathFillType = PathFillType.EvenOdd ) { moveTo(2f, 6f) horizontalLineTo(4f) verticalLineTo(20f) horizontalLineTo(10f) verticalLineTo(22f) horizontalLineTo(4f) curveTo(2.9f, 22f, 2f, 21.1f, 2f, 20f) verticalLineTo(6f) close() moveTo(22f, 10f) verticalLineTo(4f) curveTo(22f, 2.9f, 21.1f, 2f, 20f, 2f) horizontalLineTo(8f) curveTo(6.9f, 2f, 6f, 2.9f, 6f, 4f) verticalLineTo(16f) curveTo(6f, 17.1f, 6.9f, 18f, 8f, 18f) horizontalLineTo(10f) verticalLineTo(16f) horizontalLineTo(8f) verticalLineTo(4f) horizontalLineTo(20f) verticalLineTo(10f) horizontalLineTo(22f) close() moveTo(19f, 10f) verticalLineTo(9f) horizontalLineTo(15f) verticalLineTo(5f) horizontalLineTo(13f) verticalLineTo(9f) horizontalLineTo(9f) verticalLineTo(11f) horizontalLineTo(10.764f) curveTo(11.313f, 10.386f, 12.111f, 10f, 13f, 10f) horizontalLineTo(19f) close() } path( fill = SolidColor(Color.White), pathFillType = PathFillType.EvenOdd ) { moveTo(13.952f, 13.064f) curveTo(13.716f, 13.064f, 13.491f, 13.158f, 13.324f, 13.324f) curveTo(13.158f, 13.491f, 13.064f, 13.716f, 13.064f, 13.952f) verticalLineTo(21.048f) curveTo(13.064f, 21.284f, 13.158f, 21.509f, 13.324f, 21.676f) curveTo(13.491f, 21.842f, 13.716f, 21.935f, 13.952f, 21.935f) horizontalLineTo(21.048f) curveTo(21.284f, 21.935f, 21.509f, 21.842f, 21.676f, 21.676f) curveTo(21.842f, 21.509f, 21.935f, 21.284f, 21.935f, 21.048f) verticalLineTo(13.952f) curveTo(21.935f, 13.716f, 21.842f, 13.491f, 21.676f, 13.324f) curveTo(21.509f, 13.158f, 21.284f, 13.064f, 21.048f, 13.064f) horizontalLineTo(13.952f) close() moveTo(12.572f, 12.572f) curveTo(12.938f, 12.206f, 13.434f, 12f, 13.952f, 12f) horizontalLineTo(21.048f) curveTo(21.566f, 12f, 22.062f, 12.206f, 22.428f, 12.572f) curveTo(22.794f, 12.938f, 23f, 13.434f, 23f, 13.952f) verticalLineTo(21.048f) curveTo(23f, 21.566f, 22.794f, 22.062f, 22.428f, 22.428f) curveTo(22.062f, 22.794f, 21.566f, 23f, 21.048f, 23f) horizontalLineTo(13.952f) curveTo(13.434f, 23f, 12.938f, 22.794f, 12.572f, 22.428f) curveTo(12.206f, 22.062f, 12f, 21.566f, 12f, 21.048f) verticalLineTo(13.952f) curveTo(12f, 13.434f, 12.206f, 12.938f, 12.572f, 12.572f) close() } }.build() return _QueueStop!! } @Suppress("ObjectPropertyName") private var _QueueStop: ImageVector? = null ================================================ FILE: shared/resources/src/commonMain/kotlin/com/abdownloadmanager/resources/icons/Refresh.kt ================================================ package com.abdownloadmanager.resources.icons import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.SolidColor import androidx.compose.ui.graphics.StrokeCap import androidx.compose.ui.graphics.StrokeJoin import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.graphics.vector.path import androidx.compose.ui.unit.dp val ABDMIcons.Refresh: ImageVector get() { if (_Refresh != null) { return _Refresh!! } _Refresh = ImageVector.Builder( name = "Refresh", defaultWidth = 24.dp, defaultHeight = 24.dp, viewportWidth = 24f, viewportHeight = 24f ).apply { path( stroke = SolidColor(Color.White), strokeLineWidth = 2f, strokeLineCap = StrokeCap.Round, strokeLineJoin = StrokeJoin.Round ) { moveTo(21f, 10.873f) curveTo(20.725f, 8.889f, 19.806f, 7.052f, 18.386f, 5.643f) curveTo(16.966f, 4.233f, 15.123f, 3.331f, 13.14f, 3.075f) curveTo(11.158f, 2.819f, 9.147f, 3.223f, 7.416f, 4.224f) curveTo(5.685f, 5.226f, 4.331f, 6.77f, 3.563f, 8.619f) moveTo(3f, 4.11f) verticalLineTo(8.619f) horizontalLineTo(7.5f) moveTo(3f, 13.127f) curveTo(3.275f, 15.111f, 4.194f, 16.948f, 5.614f, 18.358f) curveTo(7.034f, 19.767f, 8.877f, 20.669f, 10.86f, 20.925f) curveTo(12.842f, 21.181f, 14.853f, 20.777f, 16.584f, 19.776f) curveTo(18.315f, 18.774f, 19.669f, 17.23f, 20.438f, 15.381f) moveTo(21f, 19.89f) verticalLineTo(15.381f) horizontalLineTo(16.5f) } }.build() return _Refresh!! } @Suppress("ObjectPropertyName") private var _Refresh: ImageVector? = null ================================================ FILE: shared/resources/src/commonMain/kotlin/com/abdownloadmanager/resources/icons/Resume.kt ================================================ package com.abdownloadmanager.resources.icons import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.PathFillType import androidx.compose.ui.graphics.SolidColor import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.graphics.vector.path import androidx.compose.ui.unit.dp val ABDMIcons.Resume: ImageVector get() { if (_Resume != null) { return _Resume!! } _Resume = ImageVector.Builder( name = "Resume", defaultWidth = 24.dp, defaultHeight = 24.dp, viewportWidth = 24f, viewportHeight = 24f ).apply { path( fill = SolidColor(Color.White), pathFillType = PathFillType.EvenOdd ) { moveTo(6.634f, 3.345f) curveTo(6.871f, 3.213f, 7.162f, 3.219f, 7.393f, 3.361f) lineTo(20.393f, 11.361f) curveTo(20.615f, 11.498f, 20.75f, 11.74f, 20.75f, 12f) curveTo(20.75f, 12.26f, 20.615f, 12.502f, 20.393f, 12.639f) lineTo(7.393f, 20.639f) curveTo(7.162f, 20.781f, 6.871f, 20.787f, 6.634f, 20.655f) curveTo(6.397f, 20.522f, 6.25f, 20.272f, 6.25f, 20f) verticalLineTo(4f) curveTo(6.25f, 3.728f, 6.397f, 3.478f, 6.634f, 3.345f) close() moveTo(7.75f, 5.342f) verticalLineTo(18.658f) lineTo(18.569f, 12f) lineTo(7.75f, 5.342f) close() } }.build() return _Resume!! } @Suppress("ObjectPropertyName") private var _Resume: ImageVector? = null ================================================ FILE: shared/resources/src/commonMain/kotlin/com/abdownloadmanager/resources/icons/Scheduler.kt ================================================ package com.abdownloadmanager.resources.icons import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.PathFillType import androidx.compose.ui.graphics.SolidColor import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.graphics.vector.path import androidx.compose.ui.unit.dp val ABDMIcons.Scheduler: ImageVector get() { if (_Scheduler != null) { return _Scheduler!! } _Scheduler = ImageVector.Builder( name = "Scheduler", defaultWidth = 24.dp, defaultHeight = 24.dp, viewportWidth = 24f, viewportHeight = 24f ).apply { path( fill = SolidColor(Color.White), pathFillType = PathFillType.EvenOdd ) { moveTo(7f, 2.25f) curveTo(7.414f, 2.25f, 7.75f, 2.586f, 7.75f, 3f) verticalLineTo(4.25f) horizontalLineTo(14.25f) verticalLineTo(3f) curveTo(14.25f, 2.586f, 14.586f, 2.25f, 15f, 2.25f) curveTo(15.414f, 2.25f, 15.75f, 2.586f, 15.75f, 3f) verticalLineTo(4.25f) horizontalLineTo(17f) curveTo(17.729f, 4.25f, 18.429f, 4.54f, 18.944f, 5.055f) curveTo(19.46f, 5.571f, 19.75f, 6.271f, 19.75f, 7f) verticalLineTo(11f) curveTo(19.75f, 11.414f, 19.414f, 11.75f, 19f, 11.75f) horizontalLineTo(3.75f) verticalLineTo(19f) curveTo(3.75f, 19.331f, 3.882f, 19.649f, 4.116f, 19.884f) curveTo(4.351f, 20.118f, 4.668f, 20.25f, 5f, 20.25f) horizontalLineTo(11.795f) curveTo(12.209f, 20.25f, 12.545f, 20.586f, 12.545f, 21f) curveTo(12.545f, 21.414f, 12.209f, 21.75f, 11.795f, 21.75f) horizontalLineTo(5f) curveTo(4.271f, 21.75f, 3.571f, 21.46f, 3.055f, 20.944f) curveTo(2.54f, 20.429f, 2.25f, 19.729f, 2.25f, 19f) verticalLineTo(7f) curveTo(2.25f, 6.271f, 2.54f, 5.571f, 3.055f, 5.055f) curveTo(3.571f, 4.54f, 4.271f, 4.25f, 5f, 4.25f) horizontalLineTo(6.25f) verticalLineTo(3f) curveTo(6.25f, 2.586f, 6.586f, 2.25f, 7f, 2.25f) close() moveTo(6.25f, 5.75f) horizontalLineTo(5f) curveTo(4.668f, 5.75f, 4.351f, 5.882f, 4.116f, 6.116f) curveTo(3.882f, 6.351f, 3.75f, 6.668f, 3.75f, 7f) verticalLineTo(10.25f) horizontalLineTo(18.25f) verticalLineTo(7f) curveTo(18.25f, 6.668f, 18.118f, 6.351f, 17.884f, 6.116f) curveTo(17.649f, 5.882f, 17.331f, 5.75f, 17f, 5.75f) horizontalLineTo(15.75f) verticalLineTo(7f) curveTo(15.75f, 7.414f, 15.414f, 7.75f, 15f, 7.75f) curveTo(14.586f, 7.75f, 14.25f, 7.414f, 14.25f, 7f) verticalLineTo(5.75f) horizontalLineTo(7.75f) verticalLineTo(7f) curveTo(7.75f, 7.414f, 7.414f, 7.75f, 7f, 7.75f) curveTo(6.586f, 7.75f, 6.25f, 7.414f, 6.25f, 7f) verticalLineTo(5.75f) close() moveTo(14.641f, 14.641f) curveTo(15.532f, 13.75f, 16.74f, 13.25f, 18f, 13.25f) curveTo(19.26f, 13.25f, 20.468f, 13.75f, 21.359f, 14.641f) curveTo(22.25f, 15.532f, 22.75f, 16.74f, 22.75f, 18f) curveTo(22.75f, 19.26f, 22.25f, 20.468f, 21.359f, 21.359f) curveTo(20.468f, 22.25f, 19.26f, 22.75f, 18f, 22.75f) curveTo(16.74f, 22.75f, 15.532f, 22.25f, 14.641f, 21.359f) curveTo(13.75f, 20.468f, 13.25f, 19.26f, 13.25f, 18f) curveTo(13.25f, 16.74f, 13.75f, 15.532f, 14.641f, 14.641f) close() moveTo(18f, 14.75f) curveTo(17.138f, 14.75f, 16.311f, 15.092f, 15.702f, 15.702f) curveTo(15.092f, 16.311f, 14.75f, 17.138f, 14.75f, 18f) curveTo(14.75f, 18.862f, 15.092f, 19.689f, 15.702f, 20.298f) curveTo(16.311f, 20.908f, 17.138f, 21.25f, 18f, 21.25f) curveTo(18.862f, 21.25f, 19.689f, 20.908f, 20.298f, 20.298f) curveTo(20.908f, 19.689f, 21.25f, 18.862f, 21.25f, 18f) curveTo(21.25f, 17.138f, 20.908f, 16.311f, 20.298f, 15.702f) curveTo(19.689f, 15.092f, 18.862f, 14.75f, 18f, 14.75f) close() moveTo(18f, 15.746f) curveTo(18.414f, 15.746f, 18.75f, 16.082f, 18.75f, 16.496f) verticalLineTo(17.689f) lineTo(19.53f, 18.47f) curveTo(19.823f, 18.763f, 19.823f, 19.237f, 19.53f, 19.53f) curveTo(19.237f, 19.823f, 18.763f, 19.823f, 18.47f, 19.53f) lineTo(17.47f, 18.53f) curveTo(17.329f, 18.39f, 17.25f, 18.199f, 17.25f, 18f) verticalLineTo(16.496f) curveTo(17.25f, 16.082f, 17.586f, 15.746f, 18f, 15.746f) close() } }.build() return _Scheduler!! } @Suppress("ObjectPropertyName") private var _Scheduler: ImageVector? = null ================================================ FILE: shared/resources/src/commonMain/kotlin/com/abdownloadmanager/resources/icons/Search.kt ================================================ package com.abdownloadmanager.resources.icons import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.PathFillType import androidx.compose.ui.graphics.SolidColor import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.graphics.vector.path import androidx.compose.ui.unit.dp val ABDMIcons.Search: ImageVector get() { if (_Search != null) { return _Search!! } _Search = ImageVector.Builder( name = "Search", defaultWidth = 24.dp, defaultHeight = 24.dp, viewportWidth = 24f, viewportHeight = 24f ).apply { path( fill = SolidColor(Color.White), pathFillType = PathFillType.EvenOdd ) { moveTo(7.034f, 2.84f) curveTo(7.974f, 2.45f, 8.982f, 2.25f, 10f, 2.25f) curveTo(11.018f, 2.25f, 12.026f, 2.45f, 12.966f, 2.84f) curveTo(13.906f, 3.229f, 14.76f, 3.8f, 15.48f, 4.52f) curveTo(16.2f, 5.24f, 16.771f, 6.094f, 17.16f, 7.034f) curveTo(17.549f, 7.974f, 17.75f, 8.982f, 17.75f, 10f) curveTo(17.75f, 11.018f, 17.549f, 12.026f, 17.16f, 12.966f) curveTo(16.867f, 13.674f, 16.47f, 14.334f, 15.985f, 14.924f) lineTo(21.53f, 20.47f) curveTo(21.823f, 20.763f, 21.823f, 21.237f, 21.53f, 21.53f) curveTo(21.237f, 21.823f, 20.763f, 21.823f, 20.47f, 21.53f) lineTo(14.924f, 15.985f) curveTo(14.334f, 16.47f, 13.674f, 16.867f, 12.966f, 17.16f) curveTo(12.026f, 17.549f, 11.018f, 17.75f, 10f, 17.75f) curveTo(8.982f, 17.75f, 7.974f, 17.549f, 7.034f, 17.16f) curveTo(6.094f, 16.771f, 5.24f, 16.2f, 4.52f, 15.48f) curveTo(3.8f, 14.76f, 3.229f, 13.906f, 2.84f, 12.966f) curveTo(2.45f, 12.026f, 2.25f, 11.018f, 2.25f, 10f) curveTo(2.25f, 8.982f, 2.45f, 7.974f, 2.84f, 7.034f) curveTo(3.229f, 6.094f, 3.8f, 5.24f, 4.52f, 4.52f) curveTo(5.24f, 3.8f, 6.094f, 3.229f, 7.034f, 2.84f) close() moveTo(10f, 3.75f) curveTo(9.179f, 3.75f, 8.367f, 3.912f, 7.608f, 4.226f) curveTo(6.85f, 4.54f, 6.161f, 5f, 5.581f, 5.581f) curveTo(5f, 6.161f, 4.54f, 6.85f, 4.226f, 7.608f) curveTo(3.912f, 8.367f, 3.75f, 9.179f, 3.75f, 10f) curveTo(3.75f, 10.821f, 3.912f, 11.634f, 4.226f, 12.392f) curveTo(4.54f, 13.15f, 5f, 13.839f, 5.581f, 14.419f) curveTo(6.161f, 15f, 6.85f, 15.46f, 7.608f, 15.774f) curveTo(8.367f, 16.088f, 9.179f, 16.25f, 10f, 16.25f) curveTo(10.821f, 16.25f, 11.634f, 16.088f, 12.392f, 15.774f) curveTo(13.15f, 15.46f, 13.839f, 15f, 14.419f, 14.419f) curveTo(15f, 13.839f, 15.46f, 13.15f, 15.774f, 12.392f) curveTo(16.088f, 11.634f, 16.25f, 10.821f, 16.25f, 10f) curveTo(16.25f, 9.179f, 16.088f, 8.367f, 15.774f, 7.608f) curveTo(15.46f, 6.85f, 15f, 6.161f, 14.419f, 5.581f) curveTo(13.839f, 5f, 13.15f, 4.54f, 12.392f, 4.226f) curveTo(11.634f, 3.912f, 10.821f, 3.75f, 10f, 3.75f) close() } }.build() return _Search!! } @Suppress("ObjectPropertyName") private var _Search: ImageVector? = null ================================================ FILE: shared/resources/src/commonMain/kotlin/com/abdownloadmanager/resources/icons/SelectAll.kt ================================================ package com.abdownloadmanager.resources.icons import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.SolidColor import androidx.compose.ui.graphics.StrokeCap import androidx.compose.ui.graphics.StrokeJoin import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.graphics.vector.path import androidx.compose.ui.unit.dp val ABDMIcons.SelectAll: ImageVector get() { if (_SelectAll != null) { return _SelectAll!! } _SelectAll = ImageVector.Builder( name = "SelectAll", defaultWidth = 24.dp, defaultHeight = 24.dp, viewportWidth = 24f, viewportHeight = 24f ).apply { path( stroke = SolidColor(Color.White), strokeLineWidth = 2f, strokeLineCap = StrokeCap.Round, strokeLineJoin = StrokeJoin.Round ) { moveTo(4f, 12f) horizontalLineTo(20f) moveTo(12f, 4f) verticalLineTo(20f) moveTo(4f, 6f) curveTo(4f, 5.47f, 4.211f, 4.961f, 4.586f, 4.586f) curveTo(4.961f, 4.211f, 5.47f, 4f, 6f, 4f) horizontalLineTo(18f) curveTo(18.53f, 4f, 19.039f, 4.211f, 19.414f, 4.586f) curveTo(19.789f, 4.961f, 20f, 5.47f, 20f, 6f) verticalLineTo(18f) curveTo(20f, 18.53f, 19.789f, 19.039f, 19.414f, 19.414f) curveTo(19.039f, 19.789f, 18.53f, 20f, 18f, 20f) horizontalLineTo(6f) curveTo(5.47f, 20f, 4.961f, 19.789f, 4.586f, 19.414f) curveTo(4.211f, 19.039f, 4f, 18.53f, 4f, 18f) verticalLineTo(6f) close() } }.build() return _SelectAll!! } @Suppress("ObjectPropertyName") private var _SelectAll: ImageVector? = null ================================================ FILE: shared/resources/src/commonMain/kotlin/com/abdownloadmanager/resources/icons/SelectInside.kt ================================================ package com.abdownloadmanager.resources.icons import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.SolidColor import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.graphics.vector.path import androidx.compose.ui.unit.dp val ABDMIcons.SelectInside: ImageVector get() { if (_SelectInside != null) { return _SelectInside!! } _SelectInside = ImageVector.Builder( name = "SelectInside", defaultWidth = 24.dp, defaultHeight = 24.dp, viewportWidth = 24f, viewportHeight = 24f ).apply { path(fill = SolidColor(Color.White)) { moveTo(5f, 22f) curveTo(4.717f, 22f, 4.479f, 21.904f, 4.288f, 21.712f) curveTo(4.097f, 21.52f, 4.001f, 21.283f, 4f, 21f) curveTo(3.999f, 20.717f, 4.095f, 20.48f, 4.288f, 20.288f) curveTo(4.481f, 20.096f, 4.718f, 20f, 5f, 20f) horizontalLineTo(19f) curveTo(19.283f, 20f, 19.521f, 20.096f, 19.713f, 20.288f) curveTo(19.905f, 20.48f, 20.001f, 20.717f, 20f, 21f) curveTo(19.999f, 21.283f, 19.903f, 21.52f, 19.712f, 21.713f) curveTo(19.521f, 21.906f, 19.283f, 22.001f, 19f, 22f) horizontalLineTo(5f) close() moveTo(12f, 18.575f) curveTo(11.867f, 18.575f, 11.742f, 18.554f, 11.625f, 18.513f) curveTo(11.508f, 18.472f, 11.4f, 18.401f, 11.3f, 18.3f) lineTo(8.7f, 15.7f) curveTo(8.517f, 15.517f, 8.421f, 15.288f, 8.413f, 15.013f) curveTo(8.405f, 14.738f, 8.501f, 14.501f, 8.7f, 14.3f) curveTo(8.883f, 14.117f, 9.113f, 14.021f, 9.388f, 14.012f) curveTo(9.663f, 14.003f, 9.901f, 14.091f, 10.1f, 14.275f) lineTo(11f, 15.15f) verticalLineTo(8.85f) lineTo(10.1f, 9.725f) curveTo(9.917f, 9.908f, 9.688f, 10f, 9.413f, 10f) curveTo(9.138f, 10f, 8.901f, 9.9f, 8.7f, 9.7f) curveTo(8.517f, 9.517f, 8.425f, 9.283f, 8.425f, 9f) curveTo(8.425f, 8.717f, 8.517f, 8.483f, 8.7f, 8.3f) lineTo(11.3f, 5.7f) curveTo(11.4f, 5.6f, 11.508f, 5.529f, 11.625f, 5.488f) curveTo(11.742f, 5.447f, 11.867f, 5.426f, 12f, 5.425f) curveTo(12.133f, 5.424f, 12.258f, 5.445f, 12.375f, 5.488f) curveTo(12.492f, 5.531f, 12.6f, 5.601f, 12.7f, 5.7f) lineTo(15.3f, 8.3f) curveTo(15.483f, 8.483f, 15.579f, 8.713f, 15.587f, 8.988f) curveTo(15.595f, 9.263f, 15.499f, 9.501f, 15.3f, 9.7f) curveTo(15.117f, 9.883f, 14.888f, 9.979f, 14.613f, 9.988f) curveTo(14.338f, 9.997f, 14.101f, 9.909f, 13.9f, 9.725f) lineTo(13f, 8.85f) verticalLineTo(15.15f) lineTo(13.9f, 14.275f) curveTo(14.083f, 14.092f, 14.313f, 14f, 14.588f, 14f) curveTo(14.863f, 14f, 15.101f, 14.1f, 15.3f, 14.3f) curveTo(15.483f, 14.483f, 15.575f, 14.717f, 15.575f, 15f) curveTo(15.575f, 15.283f, 15.483f, 15.517f, 15.3f, 15.7f) lineTo(12.7f, 18.3f) curveTo(12.6f, 18.4f, 12.492f, 18.471f, 12.375f, 18.513f) curveTo(12.258f, 18.555f, 12.133f, 18.576f, 12f, 18.575f) close() moveTo(5f, 4f) curveTo(4.717f, 4f, 4.479f, 3.904f, 4.288f, 3.712f) curveTo(4.097f, 3.52f, 4.001f, 3.283f, 4f, 3f) curveTo(3.999f, 2.717f, 4.095f, 2.48f, 4.288f, 2.288f) curveTo(4.481f, 2.096f, 4.718f, 2f, 5f, 2f) horizontalLineTo(19f) curveTo(19.283f, 2f, 19.521f, 2.096f, 19.713f, 2.288f) curveTo(19.905f, 2.48f, 20.001f, 2.717f, 20f, 3f) curveTo(19.999f, 3.283f, 19.903f, 3.52f, 19.712f, 3.713f) curveTo(19.521f, 3.906f, 19.283f, 4.001f, 19f, 4f) horizontalLineTo(5f) close() } }.build() return _SelectInside!! } @Suppress("ObjectPropertyName") private var _SelectInside: ImageVector? = null ================================================ FILE: shared/resources/src/commonMain/kotlin/com/abdownloadmanager/resources/icons/SelectInvert.kt ================================================ package com.abdownloadmanager.resources.icons import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.SolidColor import androidx.compose.ui.graphics.StrokeCap import androidx.compose.ui.graphics.StrokeJoin import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.graphics.vector.path import androidx.compose.ui.unit.dp val ABDMIcons.SelectInvert: ImageVector get() { if (_SelectInvert != null) { return _SelectInvert!! } _SelectInvert = ImageVector.Builder( name = "SelectInvert", defaultWidth = 24.dp, defaultHeight = 24.dp, viewportWidth = 24f, viewportHeight = 24f ).apply { path( stroke = SolidColor(Color.White), strokeLineWidth = 2f, strokeLineCap = StrokeCap.Round, strokeLineJoin = StrokeJoin.Round ) { moveTo(4f, 4f) verticalLineTo(4.01f) moveTo(8f, 4f) verticalLineTo(4.01f) moveTo(12f, 4f) verticalLineTo(4.01f) moveTo(16f, 4f) verticalLineTo(4.01f) moveTo(20f, 4f) verticalLineTo(4.01f) moveTo(4f, 8f) verticalLineTo(8.01f) moveTo(12f, 8f) verticalLineTo(8.01f) moveTo(20f, 8f) verticalLineTo(8.01f) moveTo(4f, 12f) verticalLineTo(12.01f) moveTo(8f, 12f) verticalLineTo(12.01f) moveTo(12f, 12f) verticalLineTo(12.01f) moveTo(16f, 12f) verticalLineTo(12.01f) moveTo(20f, 12f) verticalLineTo(12.01f) moveTo(4f, 16f) verticalLineTo(16.01f) moveTo(12f, 16f) verticalLineTo(16.01f) moveTo(20f, 16f) verticalLineTo(16.01f) moveTo(4f, 20f) verticalLineTo(20.01f) moveTo(8f, 20f) verticalLineTo(20.01f) moveTo(12f, 20f) verticalLineTo(20.01f) moveTo(16f, 20f) verticalLineTo(20.01f) moveTo(20f, 20f) verticalLineTo(20.01f) } }.build() return _SelectInvert!! } @Suppress("ObjectPropertyName") private var _SelectInvert: ImageVector? = null ================================================ FILE: shared/resources/src/commonMain/kotlin/com/abdownloadmanager/resources/icons/Settings.kt ================================================ package com.abdownloadmanager.resources.icons import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.PathFillType import androidx.compose.ui.graphics.SolidColor import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.graphics.vector.path import androidx.compose.ui.unit.dp val ABDMIcons.Settings: ImageVector get() { if (_Settings != null) { return _Settings!! } _Settings = ImageVector.Builder( name = "Settings", defaultWidth = 24.dp, defaultHeight = 24.dp, viewportWidth = 24f, viewportHeight = 24f ).apply { path( fill = SolidColor(Color.White), pathFillType = PathFillType.EvenOdd ) { moveTo(12.946f, 4.494f) curveTo(12.705f, 3.502f, 11.295f, 3.502f, 11.054f, 4.494f) lineTo(11.054f, 4.494f) curveTo(10.658f, 6.123f, 8.797f, 6.894f, 7.363f, 6.023f) lineTo(7.363f, 6.023f) curveTo(6.49f, 5.491f, 5.493f, 6.49f, 6.023f, 7.362f) curveTo(6.226f, 7.694f, 6.348f, 8.07f, 6.378f, 8.458f) curveTo(6.408f, 8.847f, 6.346f, 9.237f, 6.197f, 9.596f) curveTo(6.048f, 9.956f, 5.816f, 10.276f, 5.52f, 10.529f) curveTo(5.224f, 10.782f, 4.872f, 10.962f, 4.494f, 11.054f) curveTo(3.502f, 11.295f, 3.502f, 12.705f, 4.494f, 12.946f) lineTo(4.494f, 12.946f) curveTo(4.872f, 13.038f, 5.224f, 13.218f, 5.519f, 13.471f) curveTo(5.815f, 13.725f, 6.047f, 14.044f, 6.195f, 14.404f) curveTo(6.344f, 14.763f, 6.406f, 15.153f, 6.376f, 15.541f) curveTo(6.346f, 15.929f, 6.225f, 16.305f, 6.023f, 16.637f) curveTo(5.491f, 17.51f, 6.49f, 18.507f, 7.362f, 17.976f) curveTo(7.694f, 17.774f, 8.07f, 17.653f, 8.458f, 17.622f) curveTo(8.847f, 17.592f, 9.237f, 17.654f, 9.596f, 17.803f) curveTo(9.956f, 17.952f, 10.276f, 18.184f, 10.529f, 18.48f) curveTo(10.782f, 18.776f, 10.962f, 19.128f, 11.054f, 19.506f) curveTo(11.295f, 20.498f, 12.705f, 20.498f, 12.946f, 19.506f) lineTo(12.946f, 19.506f) curveTo(13.038f, 19.128f, 13.218f, 18.776f, 13.471f, 18.481f) curveTo(13.725f, 18.185f, 14.044f, 17.954f, 14.404f, 17.805f) curveTo(14.763f, 17.656f, 15.153f, 17.594f, 15.541f, 17.624f) curveTo(15.929f, 17.654f, 16.305f, 17.775f, 16.637f, 17.978f) curveTo(17.51f, 18.508f, 18.507f, 17.51f, 17.976f, 16.638f) curveTo(17.774f, 16.306f, 17.653f, 15.93f, 17.622f, 15.542f) curveTo(17.592f, 15.153f, 17.654f, 14.763f, 17.803f, 14.404f) curveTo(17.952f, 14.044f, 18.184f, 13.724f, 18.48f, 13.471f) curveTo(18.776f, 13.218f, 19.128f, 13.038f, 19.506f, 12.946f) curveTo(20.498f, 12.705f, 20.498f, 11.295f, 19.506f, 11.054f) lineTo(19.506f, 11.054f) curveTo(19.128f, 10.962f, 18.776f, 10.782f, 18.481f, 10.529f) curveTo(18.185f, 10.275f, 17.954f, 9.956f, 17.805f, 9.596f) curveTo(17.656f, 9.237f, 17.594f, 8.847f, 17.624f, 8.459f) curveTo(17.654f, 8.071f, 17.775f, 7.695f, 17.978f, 7.363f) curveTo(18.508f, 6.49f, 17.51f, 5.493f, 16.638f, 6.023f) curveTo(16.306f, 6.226f, 15.93f, 6.348f, 15.542f, 6.378f) curveTo(15.153f, 6.408f, 14.763f, 6.346f, 14.404f, 6.197f) curveTo(14.044f, 6.048f, 13.724f, 5.816f, 13.471f, 5.52f) curveTo(13.218f, 5.224f, 13.038f, 4.872f, 12.946f, 4.494f) close() moveTo(9.596f, 4.14f) curveTo(10.208f, 1.62f, 13.792f, 1.62f, 14.404f, 4.14f) lineTo(14.404f, 4.14f) curveTo(14.44f, 4.289f, 14.511f, 4.428f, 14.611f, 4.544f) curveTo(14.71f, 4.661f, 14.836f, 4.752f, 14.978f, 4.811f) curveTo(15.119f, 4.87f, 15.273f, 4.894f, 15.426f, 4.882f) curveTo(15.579f, 4.87f, 15.727f, 4.822f, 15.858f, 4.743f) lineTo(15.858f, 4.742f) curveTo(18.072f, 3.393f, 20.607f, 5.928f, 19.259f, 8.143f) lineTo(19.258f, 8.143f) curveTo(19.179f, 8.274f, 19.131f, 8.422f, 19.119f, 8.575f) curveTo(19.108f, 8.727f, 19.132f, 8.881f, 19.191f, 9.022f) curveTo(19.249f, 9.164f, 19.34f, 9.29f, 19.457f, 9.389f) curveTo(19.573f, 9.489f, 19.711f, 9.56f, 19.86f, 9.596f) curveTo(22.38f, 10.208f, 22.38f, 13.792f, 19.86f, 14.404f) lineTo(19.86f, 14.404f) curveTo(19.711f, 14.44f, 19.572f, 14.511f, 19.456f, 14.611f) curveTo(19.339f, 14.71f, 19.248f, 14.836f, 19.189f, 14.978f) curveTo(19.13f, 15.119f, 19.106f, 15.273f, 19.118f, 15.426f) curveTo(19.13f, 15.579f, 19.177f, 15.727f, 19.257f, 15.858f) lineTo(19.257f, 15.858f) curveTo(20.607f, 18.072f, 18.072f, 20.607f, 15.857f, 19.259f) lineTo(15.857f, 19.258f) curveTo(15.726f, 19.179f, 15.578f, 19.131f, 15.425f, 19.119f) curveTo(15.273f, 19.108f, 15.119f, 19.132f, 14.978f, 19.191f) curveTo(14.836f, 19.249f, 14.71f, 19.34f, 14.611f, 19.457f) curveTo(14.511f, 19.573f, 14.44f, 19.711f, 14.404f, 19.86f) curveTo(13.792f, 22.38f, 10.208f, 22.38f, 9.596f, 19.86f) lineTo(9.596f, 19.86f) curveTo(9.56f, 19.711f, 9.489f, 19.572f, 9.389f, 19.456f) curveTo(9.29f, 19.339f, 9.164f, 19.248f, 9.022f, 19.189f) curveTo(8.881f, 19.13f, 8.727f, 19.106f, 8.574f, 19.118f) curveTo(8.421f, 19.13f, 8.273f, 19.177f, 8.142f, 19.257f) lineTo(8.142f, 19.257f) curveTo(5.928f, 20.607f, 3.393f, 18.072f, 4.741f, 15.857f) lineTo(4.741f, 15.857f) curveTo(4.821f, 15.726f, 4.869f, 15.578f, 4.881f, 15.425f) curveTo(4.893f, 15.273f, 4.868f, 15.119f, 4.81f, 14.978f) curveTo(4.751f, 14.836f, 4.66f, 14.71f, 4.543f, 14.611f) curveTo(4.427f, 14.511f, 4.289f, 14.44f, 4.14f, 14.404f) curveTo(1.62f, 13.792f, 1.62f, 10.208f, 4.14f, 9.596f) lineTo(4.14f, 9.596f) curveTo(4.289f, 9.56f, 4.428f, 9.489f, 4.544f, 9.389f) curveTo(4.661f, 9.29f, 4.752f, 9.164f, 4.811f, 9.022f) curveTo(4.87f, 8.881f, 4.894f, 8.727f, 4.882f, 8.574f) curveTo(4.87f, 8.421f, 4.822f, 8.273f, 4.743f, 8.142f) lineTo(4.742f, 8.142f) curveTo(3.394f, 5.928f, 5.927f, 3.393f, 8.143f, 4.741f) curveTo(8.709f, 5.086f, 9.44f, 4.782f, 9.596f, 4.14f) moveTo(9.348f, 9.348f) curveTo(10.052f, 8.645f, 11.005f, 8.25f, 12f, 8.25f) curveTo(12.995f, 8.25f, 13.948f, 8.645f, 14.652f, 9.348f) curveTo(15.355f, 10.052f, 15.75f, 11.005f, 15.75f, 12f) curveTo(15.75f, 12.995f, 15.355f, 13.948f, 14.652f, 14.652f) curveTo(13.948f, 15.355f, 12.995f, 15.75f, 12f, 15.75f) curveTo(11.005f, 15.75f, 10.052f, 15.355f, 9.348f, 14.652f) curveTo(8.645f, 13.948f, 8.25f, 12.995f, 8.25f, 12f) curveTo(8.25f, 11.005f, 8.645f, 10.052f, 9.348f, 9.348f) close() moveTo(12f, 9.75f) curveTo(11.403f, 9.75f, 10.831f, 9.987f, 10.409f, 10.409f) curveTo(9.987f, 10.831f, 9.75f, 11.403f, 9.75f, 12f) curveTo(9.75f, 12.597f, 9.987f, 13.169f, 10.409f, 13.591f) curveTo(10.831f, 14.013f, 11.403f, 14.25f, 12f, 14.25f) curveTo(12.597f, 14.25f, 13.169f, 14.013f, 13.591f, 13.591f) curveTo(14.013f, 13.169f, 14.25f, 12.597f, 14.25f, 12f) curveTo(14.25f, 11.403f, 14.013f, 10.831f, 13.591f, 10.409f) curveTo(13.169f, 9.987f, 12.597f, 9.75f, 12f, 9.75f) close() } }.build() return _Settings!! } @Suppress("ObjectPropertyName") private var _Settings: ImageVector? = null ================================================ FILE: shared/resources/src/commonMain/kotlin/com/abdownloadmanager/resources/icons/Share.kt ================================================ package com.abdownloadmanager.resources.icons import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.PathFillType import androidx.compose.ui.graphics.SolidColor import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.graphics.vector.path import androidx.compose.ui.unit.dp val ABDMIcons.Share: ImageVector get() { if (_Share != null) { return _Share!! } _Share = ImageVector.Builder( name = "Share", defaultWidth = 24.dp, defaultHeight = 24.dp, viewportWidth = 24f, viewportHeight = 24f ).apply { path( fill = SolidColor(Color.White), pathFillType = PathFillType.EvenOdd ) { moveTo(12.69f, 3.317f) curveTo(12.958f, 3.195f, 13.272f, 3.242f, 13.494f, 3.436f) lineTo(21.494f, 10.436f) curveTo(21.657f, 10.578f, 21.75f, 10.784f, 21.75f, 11f) curveTo(21.75f, 11.216f, 21.657f, 11.422f, 21.494f, 11.564f) lineTo(13.494f, 18.564f) curveTo(13.272f, 18.758f, 12.958f, 18.805f, 12.69f, 18.683f) curveTo(12.422f, 18.561f, 12.25f, 18.294f, 12.25f, 18f) verticalLineTo(14.816f) curveTo(11.47f, 14.941f, 10.65f, 15.237f, 9.821f, 15.653f) curveTo(8.725f, 16.203f, 7.66f, 16.938f, 6.72f, 17.68f) curveTo(5.781f, 18.421f, 4.982f, 19.156f, 4.416f, 19.697f) curveTo(4.219f, 19.885f, 4.055f, 20.046f, 3.919f, 20.179f) curveTo(3.857f, 20.239f, 3.802f, 20.293f, 3.752f, 20.341f) curveTo(3.677f, 20.414f, 3.605f, 20.483f, 3.549f, 20.532f) lineTo(3.547f, 20.534f) curveTo(3.528f, 20.55f, 3.47f, 20.601f, 3.4f, 20.643f) curveTo(3.379f, 20.656f, 3.338f, 20.679f, 3.284f, 20.701f) curveTo(3.246f, 20.716f, 3.117f, 20.766f, 2.946f, 20.753f) curveTo(2.716f, 20.736f, 2.446f, 20.601f, 2.315f, 20.31f) curveTo(2.222f, 20.101f, 2.253f, 19.917f, 2.262f, 19.867f) lineTo(2.263f, 19.861f) lineTo(2.263f, 19.861f) curveTo(3.223f, 14.755f, 5.645f, 8.745f, 12.25f, 7.374f) verticalLineTo(4f) curveTo(12.25f, 3.706f, 12.422f, 3.439f, 12.69f, 3.317f) close() moveTo(13.75f, 5.653f) verticalLineTo(8f) curveTo(13.75f, 8.37f, 13.481f, 8.684f, 13.116f, 8.741f) curveTo(7.983f, 9.543f, 5.515f, 13.458f, 4.29f, 17.769f) curveTo(4.731f, 17.374f, 5.237f, 16.94f, 5.791f, 16.503f) curveTo(6.778f, 15.724f, 7.931f, 14.923f, 9.149f, 14.312f) curveTo(10.361f, 13.704f, 11.682f, 13.261f, 12.994f, 13.25f) curveTo(13.194f, 13.248f, 13.386f, 13.327f, 13.528f, 13.467f) curveTo(13.67f, 13.608f, 13.75f, 13.8f, 13.75f, 14f) verticalLineTo(16.347f) lineTo(19.861f, 11f) lineTo(13.75f, 5.653f) close() moveTo(2.555f, 19.408f) curveTo(2.555f, 19.408f, 2.557f, 19.406f, 2.561f, 19.403f) curveTo(2.557f, 19.406f, 2.555f, 19.408f, 2.555f, 19.408f) close() } }.build() return _Share!! } @Suppress("ObjectPropertyName") private var _Share: ImageVector? = null ================================================ FILE: shared/resources/src/commonMain/kotlin/com/abdownloadmanager/resources/icons/Sort123.kt ================================================ package com.abdownloadmanager.resources.icons import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.SolidColor import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.graphics.vector.path import androidx.compose.ui.unit.dp val ABDMIcons.Sort123: ImageVector get() { if (_Sort123 != null) { return _Sort123!! } _Sort123 = ImageVector.Builder( name = "Sort123", defaultWidth = 24.dp, defaultHeight = 24.dp, viewportWidth = 24f, viewportHeight = 24f ).apply { path(fill = SolidColor(Color.White)) { moveTo(10.79f, 5.513f) curveTo(11.085f, 5.211f, 11.477f, 5.03f, 11.894f, 5.003f) curveTo(12.311f, 4.977f, 12.722f, 5.106f, 13.052f, 5.367f) lineTo(13.213f, 5.513f) lineTo(23.498f, 16.013f) lineTo(23.64f, 16.177f) lineTo(23.733f, 16.312f) lineTo(23.825f, 16.48f) lineTo(23.854f, 16.543f) lineTo(23.901f, 16.66f) lineTo(23.955f, 16.849f) lineTo(23.973f, 16.942f) lineTo(23.99f, 17.047f) lineTo(23.997f, 17.147f) lineTo(24f, 17.25f) lineTo(23.997f, 17.353f) lineTo(23.988f, 17.455f) lineTo(23.973f, 17.56f) lineTo(23.955f, 17.651f) lineTo(23.901f, 17.84f) lineTo(23.854f, 17.957f) lineTo(23.734f, 18.188f) lineTo(23.623f, 18.346f) lineTo(23.498f, 18.487f) lineTo(23.337f, 18.632f) lineTo(23.205f, 18.727f) lineTo(23.04f, 18.822f) lineTo(22.978f, 18.851f) lineTo(22.864f, 18.899f) lineTo(22.678f, 18.955f) lineTo(22.588f, 18.972f) lineTo(22.485f, 18.99f) lineTo(22.387f, 18.997f) lineTo(22.286f, 19f) horizontalLineTo(1.717f) curveTo(0.257f, 19f, -0.506f, 17.274f, 0.375f, 16.16f) lineTo(0.505f, 16.013f) lineTo(10.79f, 5.513f) close() } }.build() return _Sort123!! } @Suppress("ObjectPropertyName") private var _Sort123: ImageVector? = null ================================================ FILE: shared/resources/src/commonMain/kotlin/com/abdownloadmanager/resources/icons/Sort321.kt ================================================ package com.abdownloadmanager.resources.icons import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.SolidColor import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.graphics.vector.path import androidx.compose.ui.unit.dp val ABDMIcons.Sort321: ImageVector get() { if (_Sort321 != null) { return _Sort321!! } _Sort321 = ImageVector.Builder( name = "Sort321", defaultWidth = 24.dp, defaultHeight = 24.dp, viewportWidth = 24f, viewportHeight = 24f ).apply { path(fill = SolidColor(Color.White)) { moveTo(22.283f, 5f) curveTo(23.743f, 5f, 24.506f, 6.726f, 23.625f, 7.84f) lineTo(23.495f, 7.987f) lineTo(13.209f, 18.487f) curveTo(12.914f, 18.789f, 12.521f, 18.97f, 12.104f, 18.997f) curveTo(11.688f, 19.023f, 11.276f, 18.894f, 10.946f, 18.633f) lineTo(10.785f, 18.487f) lineTo(0.499f, 7.987f) lineTo(0.357f, 7.823f) lineTo(0.264f, 7.688f) lineTo(0.171f, 7.52f) lineTo(0.142f, 7.457f) lineTo(0.096f, 7.34f) lineTo(0.041f, 7.151f) lineTo(0.024f, 7.058f) lineTo(0.007f, 6.953f) lineTo(0f, 6.853f) verticalLineTo(6.647f) lineTo(0.009f, 6.545f) lineTo(0.024f, 6.44f) lineTo(0.041f, 6.349f) lineTo(0.096f, 6.16f) lineTo(0.142f, 6.043f) lineTo(0.262f, 5.812f) lineTo(0.374f, 5.655f) lineTo(0.499f, 5.513f) lineTo(0.66f, 5.368f) lineTo(0.792f, 5.273f) lineTo(0.957f, 5.179f) lineTo(1.018f, 5.149f) lineTo(1.133f, 5.102f) lineTo(1.318f, 5.045f) lineTo(1.409f, 5.028f) lineTo(1.512f, 5.01f) lineTo(1.61f, 5.003f) lineTo(22.283f, 5f) close() } }.build() return _Sort321!! } @Suppress("ObjectPropertyName") private var _Sort321: ImageVector? = null ================================================ FILE: shared/resources/src/commonMain/kotlin/com/abdownloadmanager/resources/icons/Speaker.kt ================================================ package com.abdownloadmanager.resources.icons import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.PathFillType import androidx.compose.ui.graphics.SolidColor import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.graphics.vector.path import androidx.compose.ui.unit.dp val ABDMIcons.Speaker: ImageVector get() { if (_Speaker != null) { return _Speaker!! } _Speaker = ImageVector.Builder( name = "Speaker", defaultWidth = 24.dp, defaultHeight = 24.dp, viewportWidth = 24f, viewportHeight = 24f ).apply { path( fill = SolidColor(Color.White), pathFillType = PathFillType.EvenOdd ) { moveTo(11.94f, 16.139f) lineTo(16.839f, 18.968f) lineTo(18.771f, 18.45f) lineTo(14.63f, 2.996f) lineTo(12.698f, 3.513f) lineTo(9.869f, 8.412f) lineTo(3.108f, 10.224f) curveTo(2.045f, 10.509f, 1.409f, 11.611f, 1.694f, 12.673f) lineTo(2.729f, 16.537f) curveTo(3.014f, 17.6f, 4.116f, 18.236f, 5.178f, 17.951f) lineTo(6.144f, 17.692f) lineTo(6.921f, 20.59f) curveTo(7.206f, 21.653f, 8.308f, 22.289f, 9.37f, 22.004f) lineTo(11.302f, 21.487f) lineTo(10.008f, 16.657f) lineTo(11.94f, 16.139f) close() moveTo(9.832f, 20.638f) lineTo(8.538f, 15.809f) lineTo(12.109f, 14.852f) lineTo(17.008f, 17.68f) lineTo(17.301f, 17.602f) lineTo(13.781f, 4.465f) lineTo(13.488f, 4.544f) lineTo(10.66f, 9.443f) lineTo(3.418f, 11.383f) curveTo(2.996f, 11.496f, 2.74f, 11.94f, 2.853f, 12.363f) lineTo(3.888f, 16.226f) curveTo(4.001f, 16.649f, 4.445f, 16.905f, 4.868f, 16.792f) lineTo(6.993f, 16.223f) lineTo(8.08f, 20.28f) curveTo(8.193f, 20.702f, 8.637f, 20.958f, 9.06f, 20.845f) lineTo(9.832f, 20.638f) close() moveTo(20.507f, 12.565f) curveTo(20.194f, 12.806f, 19.824f, 12.992f, 19.409f, 13.103f) lineTo(17.856f, 7.308f) curveTo(18.271f, 7.196f, 18.684f, 7.172f, 19.076f, 7.224f) curveTo(20.23f, 7.379f, 21.201f, 8.203f, 21.53f, 9.429f) curveTo(21.858f, 10.655f, 21.429f, 11.853f, 20.507f, 12.565f) close() moveTo(19.436f, 8.57f) lineTo(20.146f, 11.22f) curveTo(20.424f, 10.811f, 20.517f, 10.285f, 20.371f, 9.739f) curveTo(20.225f, 9.194f, 19.881f, 8.785f, 19.436f, 8.57f) close() } }.build() return _Speaker!! } @Suppress("ObjectPropertyName") private var _Speaker: ImageVector? = null ================================================ FILE: shared/resources/src/commonMain/kotlin/com/abdownloadmanager/resources/icons/SpeedLimiter.kt ================================================ package com.abdownloadmanager.resources.icons import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.SolidColor import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.graphics.vector.path import androidx.compose.ui.unit.dp val ABDMIcons.SpeedLimiter: ImageVector get() { if (_SpeedLimiter != null) { return _SpeedLimiter!! } _SpeedLimiter = ImageVector.Builder( name = "SpeedLimiter", defaultWidth = 24.dp, defaultHeight = 24.dp, viewportWidth = 24f, viewportHeight = 24f ).apply { path(fill = SolidColor(Color.White)) { moveTo(18f, 15f) curveTo(18f, 17.6f, 16.8f, 19.9f, 14.9f, 21.3f) lineTo(14.4f, 20.8f) lineTo(12.3f, 18.7f) lineTo(13.7f, 17.3f) lineTo(14.9f, 18.5f) curveTo(15.4f, 17.8f, 15.8f, 16.9f, 15.9f, 16f) horizontalLineTo(14f) verticalLineTo(14f) horizontalLineTo(15.9f) curveTo(15.7f, 13.1f, 15.4f, 12.3f, 14.9f, 11.5f) lineTo(13.7f, 12.7f) lineTo(12.3f, 11.3f) lineTo(13.5f, 10.1f) curveTo(12.8f, 9.6f, 11.9f, 9.2f, 11f, 9.1f) verticalLineTo(11f) horizontalLineTo(9f) verticalLineTo(9.1f) curveTo(8.1f, 9.3f, 7.3f, 9.6f, 6.5f, 10.1f) lineTo(9.5f, 13.1f) curveTo(9.7f, 13.1f, 9.8f, 13f, 10f, 13f) curveTo(10.53f, 13f, 11.039f, 13.211f, 11.414f, 13.586f) curveTo(11.789f, 13.961f, 12f, 14.47f, 12f, 15f) curveTo(12f, 15.53f, 11.789f, 16.039f, 11.414f, 16.414f) curveTo(11.039f, 16.789f, 10.53f, 17f, 10f, 17f) curveTo(8.89f, 17f, 8f, 16.11f, 8f, 15f) curveTo(8f, 14.8f, 8f, 14.7f, 8.1f, 14.5f) lineTo(5.1f, 11.5f) curveTo(4.6f, 12.2f, 4.2f, 13.1f, 4.1f, 14f) horizontalLineTo(6f) verticalLineTo(16f) horizontalLineTo(4.1f) curveTo(4.3f, 16.9f, 4.6f, 17.7f, 5.1f, 18.5f) lineTo(6.3f, 17.3f) lineTo(7.7f, 18.7f) lineTo(5.1f, 21.3f) curveTo(3.2f, 19.9f, 2f, 17.6f, 2f, 15f) curveTo(2f, 10.58f, 5.58f, 7f, 10f, 7f) curveTo(14.42f, 7f, 18f, 10.58f, 18f, 15f) close() moveTo(23f, 5f) curveTo(23f, 3.34f, 21.66f, 2f, 20f, 2f) curveTo(18.34f, 2f, 17f, 3.34f, 17f, 5f) curveTo(17f, 6.3f, 17.84f, 7.4f, 19f, 7.82f) verticalLineTo(11f) horizontalLineTo(21f) verticalLineTo(7.82f) curveTo(22.16f, 7.4f, 23f, 6.3f, 23f, 5f) close() moveTo(20f, 6f) curveTo(19.45f, 6f, 19f, 5.55f, 19f, 5f) curveTo(19f, 4.45f, 19.45f, 4f, 20f, 4f) curveTo(20.55f, 4f, 21f, 4.45f, 21f, 5f) curveTo(21f, 5.55f, 20.55f, 6f, 20f, 6f) close() } }.build() return _SpeedLimiter!! } @Suppress("ObjectPropertyName") private var _SpeedLimiter: ImageVector? = null ================================================ FILE: shared/resources/src/commonMain/kotlin/com/abdownloadmanager/resources/icons/Stop.kt ================================================ package com.abdownloadmanager.resources.icons import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.PathFillType import androidx.compose.ui.graphics.SolidColor import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.graphics.vector.path import androidx.compose.ui.unit.dp val ABDMIcons.Stop: ImageVector get() { if (_Stop != null) { return _Stop!! } _Stop = ImageVector.Builder( name = "Stop", defaultWidth = 24.dp, defaultHeight = 24.dp, viewportWidth = 24f, viewportHeight = 24f ).apply { path( fill = SolidColor(Color.White), pathFillType = PathFillType.EvenOdd ) { moveTo(7f, 5.75f) curveTo(6.668f, 5.75f, 6.351f, 5.882f, 6.116f, 6.116f) curveTo(5.882f, 6.351f, 5.75f, 6.668f, 5.75f, 7f) verticalLineTo(17f) curveTo(5.75f, 17.331f, 5.882f, 17.649f, 6.116f, 17.884f) curveTo(6.351f, 18.118f, 6.668f, 18.25f, 7f, 18.25f) horizontalLineTo(17f) curveTo(17.331f, 18.25f, 17.649f, 18.118f, 17.884f, 17.884f) curveTo(18.118f, 17.649f, 18.25f, 17.331f, 18.25f, 17f) verticalLineTo(7f) curveTo(18.25f, 6.668f, 18.118f, 6.351f, 17.884f, 6.116f) curveTo(17.649f, 5.882f, 17.331f, 5.75f, 17f, 5.75f) horizontalLineTo(7f) close() moveTo(5.055f, 5.055f) curveTo(5.571f, 4.54f, 6.271f, 4.25f, 7f, 4.25f) horizontalLineTo(17f) curveTo(17.729f, 4.25f, 18.429f, 4.54f, 18.944f, 5.055f) curveTo(19.46f, 5.571f, 19.75f, 6.271f, 19.75f, 7f) verticalLineTo(17f) curveTo(19.75f, 17.729f, 19.46f, 18.429f, 18.944f, 18.944f) curveTo(18.429f, 19.46f, 17.729f, 19.75f, 17f, 19.75f) horizontalLineTo(7f) curveTo(6.271f, 19.75f, 5.571f, 19.46f, 5.055f, 18.944f) curveTo(4.54f, 18.429f, 4.25f, 17.729f, 4.25f, 17f) verticalLineTo(7f) curveTo(4.25f, 6.271f, 4.54f, 5.571f, 5.055f, 5.055f) close() } }.build() return _Stop!! } @Suppress("ObjectPropertyName") private var _Stop: ImageVector? = null ================================================ FILE: shared/resources/src/commonMain/kotlin/com/abdownloadmanager/resources/icons/StopAll.kt ================================================ package com.abdownloadmanager.resources.icons import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.PathFillType import androidx.compose.ui.graphics.SolidColor import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.graphics.vector.path import androidx.compose.ui.unit.dp val ABDMIcons.StopAll: ImageVector get() { if (_StopAll != null) { return _StopAll!! } _StopAll = ImageVector.Builder( name = "StopAll", defaultWidth = 24.dp, defaultHeight = 24.dp, viewportWidth = 24f, viewportHeight = 24f ).apply { path( fill = SolidColor(Color.White), pathFillType = PathFillType.EvenOdd ) { moveTo(10.098f, 2.437f) curveTo(11.989f, 2.061f, 13.95f, 2.254f, 15.731f, 2.992f) curveTo(17.513f, 3.73f, 19.035f, 4.98f, 20.107f, 6.583f) curveTo(21.178f, 8.187f, 21.75f, 10.072f, 21.75f, 12f) curveTo(21.75f, 12.414f, 21.414f, 12.75f, 21f, 12.75f) curveTo(20.586f, 12.75f, 20.25f, 12.414f, 20.25f, 12f) curveTo(20.25f, 10.368f, 19.766f, 8.773f, 18.86f, 7.417f) curveTo(17.953f, 6.06f, 16.665f, 5.002f, 15.157f, 4.378f) curveTo(13.65f, 3.754f, 11.991f, 3.59f, 10.391f, 3.909f) curveTo(8.79f, 4.227f, 7.32f, 5.013f, 6.166f, 6.166f) curveTo(5.013f, 7.32f, 4.227f, 8.79f, 3.909f, 10.391f) curveTo(3.59f, 11.991f, 3.754f, 13.65f, 4.378f, 15.157f) curveTo(5.002f, 16.665f, 6.06f, 17.953f, 7.417f, 18.86f) curveTo(8.773f, 19.766f, 10.368f, 20.25f, 12f, 20.25f) curveTo(12.414f, 20.25f, 12.75f, 20.586f, 12.75f, 21f) curveTo(12.75f, 21.414f, 12.414f, 21.75f, 12f, 21.75f) curveTo(10.072f, 21.75f, 8.187f, 21.178f, 6.583f, 20.107f) curveTo(4.98f, 19.035f, 3.73f, 17.513f, 2.992f, 15.731f) curveTo(2.254f, 13.95f, 2.061f, 11.989f, 2.437f, 10.098f) curveTo(2.814f, 8.207f, 3.742f, 6.469f, 5.106f, 5.106f) curveTo(6.469f, 3.742f, 8.207f, 2.814f, 10.098f, 2.437f) close() moveTo(12f, 6.25f) curveTo(12.414f, 6.25f, 12.75f, 6.586f, 12.75f, 7f) verticalLineTo(11.689f) lineTo(13.53f, 12.47f) curveTo(13.823f, 12.763f, 13.823f, 13.237f, 13.53f, 13.53f) curveTo(13.237f, 13.823f, 12.763f, 13.823f, 12.47f, 13.53f) lineTo(11.47f, 12.53f) curveTo(11.329f, 12.39f, 11.25f, 12.199f, 11.25f, 12f) verticalLineTo(7f) curveTo(11.25f, 6.586f, 11.586f, 6.25f, 12f, 6.25f) close() moveTo(15.25f, 16f) curveTo(15.25f, 15.586f, 15.586f, 15.25f, 16f, 15.25f) horizontalLineTo(22f) curveTo(22.414f, 15.25f, 22.75f, 15.586f, 22.75f, 16f) verticalLineTo(22f) curveTo(22.75f, 22.414f, 22.414f, 22.75f, 22f, 22.75f) horizontalLineTo(16f) curveTo(15.586f, 22.75f, 15.25f, 22.414f, 15.25f, 22f) verticalLineTo(16f) close() moveTo(16.75f, 16.75f) verticalLineTo(21.25f) horizontalLineTo(21.25f) verticalLineTo(16.75f) horizontalLineTo(16.75f) close() } }.build() return _StopAll!! } @Suppress("ObjectPropertyName") private var _StopAll: ImageVector? = null ================================================ FILE: shared/resources/src/commonMain/kotlin/com/abdownloadmanager/resources/icons/Telegram.kt ================================================ package com.abdownloadmanager.resources.icons import androidx.compose.ui.geometry.Offset import androidx.compose.ui.graphics.Brush import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.SolidColor import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.graphics.vector.path import androidx.compose.ui.unit.dp val ABDMIcons.Telegram: ImageVector get() { if (_Telegram != null) { return _Telegram!! } _Telegram = ImageVector.Builder( name = "Telegram", defaultWidth = 24.dp, defaultHeight = 24.dp, viewportWidth = 24f, viewportHeight = 24f ).apply { path( fill = Brush.linearGradient( colorStops = arrayOf( 0f to Color(0xFF2AABEE), 1f to Color(0xFF229ED9) ), start = Offset(1200f, 0f), end = Offset(1200f, 2400f) ) ) { moveTo(12f, 0f) curveTo(8.818f, 0f, 5.764f, 1.265f, 3.516f, 3.515f) curveTo(1.265f, 5.765f, 0.001f, 8.817f, 0f, 12f) curveTo(0f, 15.181f, 1.266f, 18.236f, 3.516f, 20.485f) curveTo(5.764f, 22.735f, 8.818f, 24f, 12f, 24f) curveTo(15.182f, 24f, 18.236f, 22.735f, 20.484f, 20.485f) curveTo(22.734f, 18.236f, 24f, 15.181f, 24f, 12f) curveTo(24f, 8.819f, 22.734f, 5.764f, 20.484f, 3.515f) curveTo(18.236f, 1.265f, 15.182f, 0f, 12f, 0f) close() } path(fill = SolidColor(Color.White)) { moveTo(5.432f, 11.873f) curveTo(8.931f, 10.349f, 11.263f, 9.344f, 12.429f, 8.859f) curveTo(15.763f, 7.473f, 16.455f, 7.232f, 16.907f, 7.224f) curveTo(17.006f, 7.222f, 17.228f, 7.247f, 17.372f, 7.364f) curveTo(17.492f, 7.462f, 17.526f, 7.595f, 17.542f, 7.689f) curveTo(17.558f, 7.782f, 17.578f, 7.995f, 17.561f, 8.161f) curveTo(17.381f, 10.058f, 16.599f, 14.663f, 16.202f, 16.788f) curveTo(16.035f, 17.688f, 15.703f, 17.989f, 15.382f, 18.018f) curveTo(14.685f, 18.083f, 14.156f, 17.558f, 13.481f, 17.116f) curveTo(12.426f, 16.423f, 11.829f, 15.992f, 10.804f, 15.317f) curveTo(9.619f, 14.536f, 10.387f, 14.107f, 11.063f, 13.406f) curveTo(11.239f, 13.222f, 14.31f, 10.429f, 14.368f, 10.176f) curveTo(14.376f, 10.144f, 14.383f, 10.026f, 14.312f, 9.964f) curveTo(14.243f, 9.901f, 14.139f, 9.923f, 14.064f, 9.94f) curveTo(13.958f, 9.964f, 12.272f, 11.079f, 9.002f, 13.285f) curveTo(8.524f, 13.614f, 8.091f, 13.774f, 7.701f, 13.766f) curveTo(7.273f, 13.757f, 6.448f, 13.524f, 5.835f, 13.325f) curveTo(5.085f, 13.08f, 4.487f, 12.951f, 4.539f, 12.536f) curveTo(4.566f, 12.32f, 4.864f, 12.099f, 5.432f, 11.873f) close() } }.build() return _Telegram!! } @Suppress("ObjectPropertyName") private var _Telegram: ImageVector? = null ================================================ FILE: shared/resources/src/commonMain/kotlin/com/abdownloadmanager/resources/icons/Theme.kt ================================================ package com.abdownloadmanager.resources.icons import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.SolidColor import androidx.compose.ui.graphics.StrokeCap import androidx.compose.ui.graphics.StrokeJoin import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.graphics.vector.path import androidx.compose.ui.unit.dp val ABDMIcons.Theme: ImageVector get() { if (_Theme != null) { return _Theme!! } _Theme = ImageVector.Builder( name = "Theme", defaultWidth = 24.dp, defaultHeight = 24.dp, viewportWidth = 24f, viewportHeight = 24f ).apply { path( stroke = SolidColor(Color.White), strokeLineWidth = 2f, strokeLineCap = StrokeCap.Round, strokeLineJoin = StrokeJoin.Round ) { moveTo(8.2f, 13.2f) curveTo(9.218f, 10.505f, 10.944f, 8.135f, 13.197f, 6.339f) curveTo(15.45f, 4.544f, 18.146f, 3.39f, 21f, 3f) curveTo(20.61f, 5.854f, 19.456f, 8.55f, 17.66f, 10.803f) curveTo(15.865f, 13.056f, 13.495f, 14.782f, 10.8f, 15.8f) moveTo(10.6f, 9f) curveTo(12.543f, 9.897f, 14.103f, 11.457f, 15f, 13.4f) moveTo(3f, 21f) verticalLineTo(17f) curveTo(3f, 16.209f, 3.235f, 15.436f, 3.674f, 14.778f) curveTo(4.114f, 14.12f, 4.738f, 13.607f, 5.469f, 13.304f) curveTo(6.2f, 13.002f, 7.004f, 12.922f, 7.78f, 13.077f) curveTo(8.556f, 13.231f, 9.269f, 13.612f, 9.828f, 14.172f) curveTo(10.388f, 14.731f, 10.769f, 15.444f, 10.923f, 16.22f) curveTo(11.078f, 16.996f, 10.998f, 17.8f, 10.696f, 18.531f) curveTo(10.393f, 19.262f, 9.88f, 19.886f, 9.222f, 20.326f) curveTo(8.564f, 20.765f, 7.791f, 21f, 7f, 21f) horizontalLineTo(3f) close() } }.build() return _Theme!! } @Suppress("ObjectPropertyName") private var _Theme: ImageVector? = null ================================================ FILE: shared/resources/src/commonMain/kotlin/com/abdownloadmanager/resources/icons/Undo.kt ================================================ package com.abdownloadmanager.resources.icons import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.SolidColor import androidx.compose.ui.graphics.StrokeCap import androidx.compose.ui.graphics.StrokeJoin import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.graphics.vector.path import androidx.compose.ui.unit.dp val ABDMIcons.Undo: ImageVector get() { if (_Undo != null) { return _Undo!! } _Undo = ImageVector.Builder( name = "Undo", defaultWidth = 24.dp, defaultHeight = 24.dp, viewportWidth = 24f, viewportHeight = 24f ).apply { path( stroke = SolidColor(Color.White), strokeLineWidth = 2f, strokeLineCap = StrokeCap.Round, strokeLineJoin = StrokeJoin.Round ) { moveTo(9f, 14f) lineTo(5f, 10f) moveTo(5f, 10f) lineTo(9f, 6f) moveTo(5f, 10f) horizontalLineTo(16f) curveTo(17.061f, 10f, 18.078f, 10.421f, 18.828f, 11.172f) curveTo(19.579f, 11.922f, 20f, 12.939f, 20f, 14f) curveTo(20f, 15.061f, 19.579f, 16.078f, 18.828f, 16.828f) curveTo(18.078f, 17.579f, 17.061f, 18f, 16f, 18f) horizontalLineTo(15f) } }.build() return _Undo!! } @Suppress("ObjectPropertyName") private var _Undo: ImageVector? = null ================================================ FILE: shared/resources/src/commonMain/kotlin/com/abdownloadmanager/resources/icons/Up.kt ================================================ package com.abdownloadmanager.resources.icons import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.PathFillType import androidx.compose.ui.graphics.SolidColor import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.graphics.vector.path import androidx.compose.ui.unit.dp val ABDMIcons.Up: ImageVector get() { if (_Up != null) { return _Up!! } _Up = ImageVector.Builder( name = "Up", defaultWidth = 24.dp, defaultHeight = 24.dp, viewportWidth = 24f, viewportHeight = 24f ).apply { path( fill = SolidColor(Color.White), pathFillType = PathFillType.EvenOdd ) { moveTo(11.293f, 8.293f) curveTo(11.683f, 7.902f, 12.317f, 7.902f, 12.707f, 8.293f) lineTo(18.707f, 14.293f) curveTo(19.098f, 14.683f, 19.098f, 15.317f, 18.707f, 15.707f) curveTo(18.317f, 16.098f, 17.683f, 16.098f, 17.293f, 15.707f) lineTo(12f, 10.414f) lineTo(6.707f, 15.707f) curveTo(6.317f, 16.098f, 5.683f, 16.098f, 5.293f, 15.707f) curveTo(4.902f, 15.317f, 4.902f, 14.683f, 5.293f, 14.293f) lineTo(11.293f, 8.293f) close() } }.build() return _Up!! } @Suppress("ObjectPropertyName") private var _Up: ImageVector? = null ================================================ FILE: shared/resources/src/commonMain/kotlin/com/abdownloadmanager/resources/icons/VerticalDirection.kt ================================================ package com.abdownloadmanager.resources.icons import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.SolidColor import androidx.compose.ui.graphics.StrokeCap import androidx.compose.ui.graphics.StrokeJoin import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.graphics.vector.path import androidx.compose.ui.unit.dp val ABDMIcons.VerticalDirection: ImageVector get() { if (_VerticalDirection != null) { return _VerticalDirection!! } _VerticalDirection = ImageVector.Builder( name = "VerticalDirection", defaultWidth = 24.dp, defaultHeight = 24.dp, viewportWidth = 24f, viewportHeight = 24f ).apply { path( stroke = SolidColor(Color.White), strokeLineWidth = 2f, strokeLineCap = StrokeCap.Round, strokeLineJoin = StrokeJoin.Round ) { moveTo(9f, 10f) lineTo(12f, 7f) lineTo(15f, 10f) moveTo(9f, 14f) lineTo(12f, 17f) lineTo(15f, 14f) } }.build() return _VerticalDirection!! } @Suppress("ObjectPropertyName") private var _VerticalDirection: ImageVector? = null ================================================ FILE: shared/resources/src/commonMain/kotlin/com/abdownloadmanager/resources/icons/WindowClose.kt ================================================ package com.abdownloadmanager.resources.icons import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.SolidColor import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.graphics.vector.path import androidx.compose.ui.unit.dp val ABDMIcons.WindowClose: ImageVector get() { if (_WindowClose != null) { return _WindowClose!! } _WindowClose = ImageVector.Builder( name = "WindowClose", defaultWidth = 24.dp, defaultHeight = 24.dp, viewportWidth = 24f, viewportHeight = 24f ).apply { path(fill = SolidColor(Color.White)) { moveTo(0f, 22f) lineTo(22f, 0f) lineTo(24f, 2f) lineTo(2f, 24f) lineTo(0f, 22f) close() } path(fill = SolidColor(Color.White)) { moveTo(22f, 24f) lineTo(0f, 2f) lineTo(2f, 0f) lineTo(24f, 22f) lineTo(22f, 24f) close() } }.build() return _WindowClose!! } @Suppress("ObjectPropertyName") private var _WindowClose: ImageVector? = null ================================================ FILE: shared/resources/src/commonMain/kotlin/com/abdownloadmanager/resources/icons/WindowFloating.kt ================================================ package com.abdownloadmanager.resources.icons import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.PathFillType import androidx.compose.ui.graphics.SolidColor import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.graphics.vector.path import androidx.compose.ui.unit.dp val ABDMIcons.WindowFloating: ImageVector get() { if (_WindowFloating != null) { return _WindowFloating!! } _WindowFloating = ImageVector.Builder( name = "WindowFloating", defaultWidth = 24.dp, defaultHeight = 24.dp, viewportWidth = 24f, viewportHeight = 24f ).apply { path( fill = SolidColor(Color.White), pathFillType = PathFillType.EvenOdd ) { moveTo(6.545f, 0f) verticalLineTo(6.545f) horizontalLineTo(0f) verticalLineTo(24f) horizontalLineTo(17.455f) verticalLineTo(17.455f) horizontalLineTo(24f) verticalLineTo(0f) horizontalLineTo(6.545f) close() moveTo(21.818f, 2.182f) horizontalLineTo(8.727f) verticalLineTo(6.545f) horizontalLineTo(17.455f) verticalLineTo(15.273f) horizontalLineTo(21.818f) verticalLineTo(2.182f) close() moveTo(15.273f, 15.273f) verticalLineTo(21.818f) horizontalLineTo(2.182f) verticalLineTo(8.727f) horizontalLineTo(8.727f) horizontalLineTo(15.273f) verticalLineTo(15.273f) close() } }.build() return _WindowFloating!! } @Suppress("ObjectPropertyName") private var _WindowFloating: ImageVector? = null ================================================ FILE: shared/resources/src/commonMain/kotlin/com/abdownloadmanager/resources/icons/WindowMaximize.kt ================================================ package com.abdownloadmanager.resources.icons import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.PathFillType import androidx.compose.ui.graphics.SolidColor import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.graphics.vector.path import androidx.compose.ui.unit.dp val ABDMIcons.WindowMaximize: ImageVector get() { if (_WindowMaximize != null) { return _WindowMaximize!! } _WindowMaximize = ImageVector.Builder( name = "WindowMaximize", defaultWidth = 24.dp, defaultHeight = 24.dp, viewportWidth = 24f, viewportHeight = 24f ).apply { path( fill = SolidColor(Color.White), pathFillType = PathFillType.EvenOdd ) { moveTo(21.818f, 2.182f) horizontalLineTo(2.182f) verticalLineTo(21.818f) horizontalLineTo(21.818f) verticalLineTo(2.182f) close() moveTo(0f, 0f) verticalLineTo(24f) horizontalLineTo(24f) verticalLineTo(0f) horizontalLineTo(0f) close() } }.build() return _WindowMaximize!! } @Suppress("ObjectPropertyName") private var _WindowMaximize: ImageVector? = null ================================================ FILE: shared/resources/src/commonMain/kotlin/com/abdownloadmanager/resources/icons/WindowMinimize.kt ================================================ package com.abdownloadmanager.resources.icons import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.SolidColor import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.graphics.vector.path import androidx.compose.ui.unit.dp val ABDMIcons.WindowMinimize: ImageVector get() { if (_WindowMinimize != null) { return _WindowMinimize!! } _WindowMinimize = ImageVector.Builder( name = "WindowMinimize", defaultWidth = 24.dp, defaultHeight = 24.dp, viewportWidth = 24f, viewportHeight = 24f ).apply { path(fill = SolidColor(Color.White)) { moveTo(0f, 10.909f) horizontalLineTo(24f) verticalLineTo(13.091f) horizontalLineTo(0f) verticalLineTo(10.909f) close() } }.build() return _WindowMinimize!! } @Suppress("ObjectPropertyName") private var _WindowMinimize: ImageVector? = null ================================================ FILE: shared/resources/src/commonMain/resources/com/abdownloadmanager/resources/credits/translators.json ================================================ { "ar": [ { "name": "Hani Rouatbi (lamjed001)", "link": "https://crowdin.com/profile/lamjed001" }, { "name": "BENZAZOU Omar (lamtarius)", "link": "https://crowdin.com/profile/lamtarius" }, { "name": "abuda7m (abuda7mx)", "link": "https://crowdin.com/profile/abuda7mx" }, { "name": "Mohamed Mahmoud (master3lwa)", "link": "https://crowdin.com/profile/master3lwa" }, { "name": "amirhosseinshammari (hosseinSh1379)", "link": "https://crowdin.com/profile/hosseinSh1379" }, { "name": "خالد العمري (KhaledAl2mri)", "link": "https://crowdin.com/profile/KhaledAl2mri" } ], "bn": [ { "name": "Md Shariful Islam (Shariful7972)", "link": "https://crowdin.com/profile/Shariful7972" } ], "bqi": [ { "name": "Hossein Abaspanah (hosseinabaspanah)", "link": "https://crowdin.com/profile/hosseinabaspanah" } ], "ckb": [ { "name": "Halbast Abdullah (halbast)", "link": "https://crowdin.com/profile/halbast" } ], "de": [ { "name": "u!^DEV (uDEV)", "link": "https://crowdin.com/profile/uDEV" }, { "name": "xNino", "link": "https://crowdin.com/profile/xNino" }, { "name": "Leon Aissa (Dimiikou)", "link": "https://crowdin.com/profile/Dimiikou" }, { "name": "Lukas (lukasitaly)", "link": "https://github.com/lukasitaly" } ], "es-ES": [ { "name": "c-sanchez", "link": "https://crowdin.com/profile/c-sanchez" }, { "name": "seniordevops", "link": "https://crowdin.com/profile/seniordevops" }, { "name": "Armin Deck (armindeck)", "link": "https://crowdin.com/profile/armindeck" } ], "fa": [ { "name": "AmirHossein Abdolmotallebi (amira1376)", "link": "https://crowdin.com/profile/amira1376" }, { "name": "HAM3D", "link": "https://crowdin.com/profile/HAM3D" }, { "name": "Ehsan Narmani (kotlinx)", "link": "https://crowdin.com/profile/kotlinx" } ], "fi": [ { "name": "Oskari Lavinto (olavinto)", "link": "https://crowdin.com/profile/olavinto" } ], "fr": [ { "name": "Gooseman (realgooseman)", "link": "https://crowdin.com/profile/realgooseman" }, { "name": "Hani Rouatbi (lamjed001)", "link": "https://crowdin.com/profile/lamjed001" } ], "hu": [ { "name": "John Fowler (JohnFowler58)", "link": "https://crowdin.com/profile/JohnFowler58" } ], "id": [ { "name": "Nicedward (dayat219onlyme)", "link": "https://crowdin.com/profile/dayat219onlyme" }, { "name": "Christian Elbrianno (crse)", "link": "https://crowdin.com/profile/crse" }, { "name": "kuydukuy (hilmy2nd)", "link": "https://crowdin.com/profile/hilmy2nd" }, { "name": "andybr", "link": "https://crowdin.com/profile/andybr" }, { "name": "exodius", "link": "https://crowdin.com/profile/exodius" } ], "it": [ { "name": "Hanamichi27", "link": "https://crowdin.com/profile/Hanamichi27" }, { "name": "ROBERTO BORIOTTI (bovirus)", "link": "https://crowdin.com/profile/bovirus" }, { "name": "Lukas (lukasitaly)", "link": "https://github.com/lukasitaly" } ], "ja": [ { "name": "ユタ (dsi3020)", "link": "https://crowdin.com/profile/dsi3020" } ], "ka": [ { "name": "system32 (Symtax)", "link": "https://crowdin.com/profile/Symtax" } ], "ko": [ { "name": "doda (ddarkr)", "link": "https://crowdin.com/profile/ddarkr" }, { "name": "VenusGirl", "link": "https://crowdin.com/profile/VenusGirl" } ], "lt": [ { "name": "arvaidasr", "link": "https://crowdin.com/profile/arvaidasr" } ], "pl": [ { "name": "Patrxgt", "link": "https://crowdin.com/profile/Patrxgt" } ], "pt-BR": [ { "name": "Guilherme (whatahelll)", "link": "https://crowdin.com/profile/whatahelll" }, { "name": "Jean Pereira (JeanxPereira)", "link": "https://crowdin.com/profile/JeanxPereira" }, { "name": "Davy J. (deaveipslon)", "link": "https://crowdin.com/profile/deaveipslon" }, { "name": "Juliano Eduardo (zerocoolroot)", "link": "https://crowdin.com/profile/zerocoolroot" }, { "name": "John Peter Sá (johnppetersa)", "link": "https://crowdin.com/profile/johnppetersa" } ], "ru": [ { "name": "in-ferno", "link": "https://crowdin.com/profile/in-ferno" }, { "name": "Semyon (maz1lovo)", "link": "https://crowdin.com/profile/maz1lovo" } ], "sq": [ { "name": "Mario Balla (marjob1234)", "link": "https://crowdin.com/profile/marjob1234" } ], "th": [ { "name": "Anucha (achn.syps)", "link": "https://crowdin.com/profile/achn.syps" } ], "tr": [ { "name": "𝗛𝗼𝗹𝗶 (mikropsoft)", "link": "https://crowdin.com/profile/mikropsoft" }, { "name": "Nightmare837 gaming (mutlupide)", "link": "https://crowdin.com/profile/mutlupide" } ], "uk": [ { "name": "YALdysse", "link": "https://crowdin.com/profile/YALdysse" }, { "name": "Misha Dyshlenko (lony_official)", "link": "https://crowdin.com/profile/lony_official" } ], "vi": [ { "name": "Quân Xinh Tươi (chetaoquocte)", "link": "https://crowdin.com/profile/chetaoquocte" }, { "name": "Zenfast", "link": "https://crowdin.com/profile/Zenfast" }, { "name": "shellawa", "link": "https://crowdin.com/profile/shellawa" } ], "zh-CN": [ { "name": "Ipmlosion", "link": "https://crowdin.com/profile/Ipmlosion" }, { "name": "Pylogmon (pylogmon)", "link": "https://crowdin.com/profile/pylogmon" }, { "name": "Pesy Wu (GamerNoTitle)", "link": "https://crowdin.com/profile/GamerNoTitle" }, { "name": "Aira_Nadih", "link": "https://crowdin.com/profile/Aira_Nadih" }, { "name": "Sendevia", "link": "https://crowdin.com/profile/Sendevia" }, { "name": "none2003", "link": "https://crowdin.com/profile/none2003" }, { "name": "pompurin404", "link": "https://github.com/pompurin404" } ], "zh-TW": [ { "name": "ɴᴇᴋᴏ (NeKoOuO)", "link": "https://crowdin.com/profile/NeKoOuO" }, { "name": "notlin4", "link": "https://crowdin.com/profile/notlin4" }, { "name": "塔可努丨TechNoob (TN_TechNoob)", "link": "https://crowdin.com/profile/TN_TechNoob" }, { "name": "none2003", "link": "https://crowdin.com/profile/none2003" }, { "name": "jrthsr700tmax", "link": "https://crowdin.com/profile/jrthsr700tmax" }, { "name": "abc0922001", "link": "https://crowdin.com/profile/abc0922001" }, { "name": "Gholts", "link": "https://crowdin.com/profile/Gholts" } ] } ================================================ FILE: shared/resources/src/commonMain/resources/com/abdownloadmanager/resources/locales/ar_SA.properties ================================================ app_title=مدير التنزيل AB confirm_auto_categorize_downloads_title=تصنيف التنزيلات تلقائياً confirm_auto_categorize_downloads_description=سيتم إضافة أي عنصر غير مصنف تلقائيًا إلى الفئة ذات الصلة به. confirm_reset_to_default_categories_title=إعادة تعيين الفئات الافتراضية confirm_reset_to_default_categories_description=سيؤدي هذا إلى إزالة جميع الفئات واستعادة الفئات الافتراضية\! confirm_delete_download_items_title=تأكيد الحذف confirm_delete_download_items_description=هل أنت متأكد من أنك تريد حذف {{count}} عنصرًا؟ confirm_delete_download_unfinished_items_description=هل أنت متأكد أنك تريد حذف {{count}} من التنزيلات غير المكتملة؟ confirm_delete_download_finished_and_unfinished_items_description=هل أنت متأكد أنك تريد حذف {{finishedCount}} التنزيلات المنتهية و{{unfinishedCount}} التنزيلات غير المكتملة؟ also_delete_file_from_disk=أيضا حذف الملف من القرص confirm_delete_category_item_title=إزالة الفئة {{name}} confirm_delete_category_item_description=هل أنت متأكد من أنك تريد حذف الفئة "{{value}}"؟ your_download_will_not_be_deleted=لن يتم حذف التنزيلات الخاصة بك drag_the_file_to_another_app=سحب المِلف إلى تطبيق آخر drop_link_or_file_here=أسقط الرابط أو المِلف هنا. nothing_will_be_imported=لن يتم استيراد أي شيء n_links_will_be_imported={{count}} روابط سيتم استيرادها n_items_selected={{count}} عناصر محددة window_close=إغلاق window_minimize=إخفاء window_maximize=تكبير window_restore=استعادة delete=حذف remove=إزالة cancel=إلغاء close=إغلاق menu=القائمة more_options=خيارات إضافية ok=موافق add=أضف paste=لصق change=تغيير edit=تعديل change_anyway=تغيير على أي حال download=تنزيل refresh=تحديث settings=الإعدادات on_completion=عند الانتهاء unknown=غير معروف unknown_error=خطأ غير معروف download_item_not_found=لم يتم العثور على عنصر التنزيل name=الاسم download_link=رابط التنزيل not_finished=لم ينتهي all=الكل finished=انتهى Unfinished=غير مكتمل canceled=ملغي error=خطأ paused=متوقف downloading=جاري التنزيل added=أضيف بنجاح idle=خامل preparing_file=تحضير الملف creating_file=إنشاء مِلف resuming=جارٍ الاستئناف retrying=إعادة المحاولة list_is_empty=القائمة فارغة\! search_in_the_list=البحث في القائمة search=البحث clear=مسح general=العامة enabled=مفعّل disabled=معطَّل default=افتراضي file=الملف tasks=المهام tools=الأدوات help=المساعدة system=النظام all_missing_files=جميع الملفات المفقودة all_finished=الكل انتهى all_unfinished=الكل غير مكتمل entire_list=القائمة بأكملها download_browser_integration=تحميل ملحق الإندماج للمتصفح exit=خروج show_downloads=عرض التنزيلات new_download=تنزيل جديد stop_all=إيقاف الكل import_from_clipboard=استيراد من الحافظة batch_download=تنزيل دفعة open=فتح share=مشاركة open_file=فتح الملف open_folder=فتح المجلد resume=استئناف pause=إيقاف مؤقت restart_download=إعادة التنزيل copy=نسخ copy_link=نسخ الرابط copy_as_curl=نسخ كـ cURL show_properties=عرض الخصائص move_to_queue=نقل إلى قائمة الانتظار move_to_this_queue=نقل إلى هذا الطابور move_to_category=نقل إلى فئة move_to_this_category=نقل إلى هذه الفئة categories=الفئات add_category=إضافة فئة edit_category=تعديل الفئة delete_category=حذف الفئة category_name=إسم الفئة category_download_location=موقع تنزيل الفئة category_download_location_description=عند اختيار هذه الفئة في "إضافة تنزيل"، استخدم هذا الدليل كـ "موقع التنزيل" category_file_types=أنواع ملفات الفئة category_file_types_description=ضع هذه الأنواع من الملفات تلقائيًا في هذه الفئة. (عند إضافة تنزيل جديد)\nافصل امتدادات الملفات بمسافة (ext2, ext1 ...) category_url_patterns=أنماط الرابط category_url_patterns_description=ضع التنزيل تلقائيًا من هذه الروابط في هذه الفئة. (عند إضافة تنزيل جديد)\nافصل الروابط بمسافة، يمكنك أيضًا استخدام * كرمز بديل auto_categorize_downloads=تصنيف التنزيلات تلقائيًا restore_defaults=استعادة الإعدادات الافتراضية about=حول version_n=الإصدار {{value}} developed_with_love_for_you=تم تطويره بـــ❤️ لك donate=تبرّع visit_the_project_website=زيارة موقع المشروع this_is_a_free_and_open_source_software=هذا البرنامج مجاني ومفتوح المصدر view_the_source_code=الإطلاع على الكود المصدري third_party_libraries=مكتبات الجهات الخارجية powered_by_open_source_software=مدعوم بواسطة برمجيات مفتوحة المصدر view_the_open_source_licenses=عرض تراخيص المصدر المفتوح support_and_community=الدعم والمجتمع telegram=تيليجرام channel=القناة group=المجموعة add_download=إضافة تنزيل add_multi_download_page_header=اختر العناصر التي تريد تنزيلها save_to=حفظ في where_should_each_item_saved=أين يجب حفظ كل عنصر؟ there_are_multiple_items_please_select_a_way_you_want_to_save_them=هناك العديد من العناصر\! يرجى تحديد الطريقة التي تريد حفظها بها each_item_on_its_own_category=كل عنصر في فئته الخاصة each_item_on_its_own_category_description=سيتم وضع كل عنصر في فئة تحتوي على نوع هذا المِلف all_items_in_one_category=كل العناصر في فئة واحدة all_items_in_one_category_description=سيتم حفظ كل الملفات في الفئة المختارة all_items_in_one_Location=كل العناصر في موقع واحد all_items_in_one_Location_description=سيتم حفظ كل العناصر في الدليل المحدد unselected_all_items_in_specific_location_description=سيتم حفظ جميع الملفات في موقع الفئة المحددة no_category_selected=لم يتم اختيار فئة no_categories_found=لم يتم العثور على فئات download_location=موقع التنزيل location=المكان select_queue=اختر قائمة الانتظار without_queue=بدون قائمة انتظار use_category=استخدام فئة cant_write_to_this_folder=لا يمكن الكتابة إلى هذا المجلد file_name_already_exists=اسم المِلف موجود سابقا download_already_exists=الملف موجود بالفعل invalid_file_name=اسم المِلف غير صالح show_solutions=عرض الحلول... change_solution=تغيير الحل select_a_solution=اختر حلاً select_download_strategy_description=الرابط الذي قدمته موجود بالفعل في قوائم التنزيل يرجى تحديد ما تريد القيام به download_strategy_add_a_numbered_file=إضافة ملف مرقَّم download_strategy_add_a_numbered_file_description=إضافة مؤشر في نهاية اسم ملف التنزيل download_strategy_override_existing_file=استبدال الملف الموجود download_strategy_override_existing_file_description=استبدال الملف القديم بالجديد download_strategy_update_download_link=تحديث التنزيل الحالي download_strategy_update_download_link_description=تحديث رابط التنزيل الحالي وبيانات اعتماده download_strategy_show_downloaded_file=عرض الملف الذي تم تنزيله download_strategy_show_downloaded_file_description=إظهار عنصر التنزيل الموجود بالفعل، حتى تتمكن من الضغط على استئناف أو فتحه batch_download_link_help=أدخل رابطًا يحتوي على رموز بديلة (استخدم *) invalid_url=رابط غير صالح list_is_too_large_maximum_n_items_allowed=القائمة كبيرة جدًا\! الحد الأقصى المسموح به هو {{count}} عنصرًا enter_range=أدخل النطاق range_from=من range_to=إلى batch_download_wildcard_length=طول الرمز البديل first_link=أول رابط last_link=آخر رابط open_source_software_used_in_this_app=البرمجيات المفتوحة المصدر المستخدمة في هذا التطبيق links=الروابط website=الموقع الإلكتروني developers=المطورون source_code=النص البرمجي المصدري license=الترخيص no_license_found=لم يعثر على ترخيص organization=المنظمة add_new_queue=إضافة قائمة انتظار جديدة queue_name=اسم قائمة الانتظار queues=قوائم الانتظار stop_queue=إيقاف قائمة الانتظار start_queue=بدء قائمة الانتظار clear_queue_items=قائمة الانتظار فارغة config=التكوين items=العناصر move_down=تحريك للأسفل move_up=تحريك للأعلى remove_queue=إزالة قائمة الانتظار queue_name_help=حدد اسمًا لهذه القائمة queue_name_describe=اسم قائمة الانتظار هو {{value}} queue_max_concurrent_download=أقصى عدد تنزيلات متزامنة queue_max_concurrent_download_description=الحد الأقصى للتنزيل لهذه القائمة queue_automatic_stop=إيقاف تلقائي queue_automatic_stop_description=إيقاف تلقائي عند عدم وجود عنصر في قائمة الانتظار queue_scheduler=جدولة queue_enable_scheduler=تفعيل المجدول queue_active_days=الأيام النشطة queue_active_days_description=ما هي الأيام التي تعمل فيها الجدولة؟ queue_scheduler_enable_auto_start_time=تفعيل وقت البدء التلقائي queue_scheduler_auto_start_time=وقت البدء التلقائي queue_scheduler_enable_auto_stop_time=تمكين وقت التوقف التلقائي queue_scheduler_auto_stop_time=وقت الإيقاف التلقائي queue_shutdown_on_completion=إيقاف تشغيل النظام عند الانتهاء queue_shutdown_on_completion_description=إيقاف تشغيل النظام تلقائياً عند اكتمال قائمة الانتظار هذه أو عند الوصول إلى وقت الانتهاء المقرر. appearance=المظهر download_engine=محرك التنزيل browser_integration=التكامل مع المتصفح settings_download_max_retries_count=الحد الأقصى لمحاولات التنزيل settings_download_max_retries_count_description=الحد الأقصى لعدد المرات التي سيحاول فيها التطبيق إعادة محاولة التنزيل الفاشل قبل الاستسلام settings_download_max_retries_count_describe_no_retries=لن تتم محاولة إعادة التنزيلات الفاشلة settings_download_max_retries_count_describe_n_retries=سيتم إعادة تجربة التنزيلات الفاشلة {{count}} مرة(ات) settings_download_thread_count=عدد الخيوط settings_download_thread_count_description=الحد الأقصى لعدد الخيوط لكل عنصر تنزيل settings_download_thread_count_describe=يمكن أن يحتوي التنزيل على ما يصل إلى {{count}} من الخيوط settings_download_thread_count_with_large_value_describe=تحذير\: قد يؤدي تعيين عدد كبير من الخيوط إلى زيادة استهلاك موارد النظام، تقليل الأداء، أو التسبب في مشاكل الاتصال مع الخوادم. استخدم القيم العالية فقط إذا كنت تفهم التأثير المحتمل على نظامك وشبكتك. settings_use_server_last_modified_time=استخدام وقت التعديل الأخير للخادم settings_use_server_last_modified_time_description=عند تنزيل ملف، استخدم آخر وقت تعديل للخادم للملف المحلي settings_append_extension_to_incomplete_downloads=إضافة الامتداد إلى التنزيلات غير المكتملة settings_append_extension_to_incomplete_downloads_description=أضف الامتداد "part." إلى التنزيلات غير المكتملة. يساعد ذلك في التعرف على الملفات التي لم تكتمل عملية تنزيلها ويمنع فتحها عن طريق الخطأ. settings_use_sparse_file_allocation=تخصيص الملفات المتفرقة settings_use_sparse_file_allocation_description=يقوم بإنشاء الملفات بكفاءة أكبر، خاصة على الأقراص الصلبة (SSD)، عن طريق تقليل كتابة البيانات غير الضرورية. يمكن أن يسرع هذا من بدء التنزيلات ويوفر مساحة على القرص. إذا بدأت التنزيلات ببطء، ففكر في تعطيل هذا الخيار، حيث قد لا يكون مدعومًا بشكل صحيح على بعض الأجهزة. settings_ignore_ssl_certificates=تجاهل شهادات SSL settings_ignore_ssl_certificates_description=تعطيل التحقق من شهادة SSL. استخدمها فقط إذا لزم الأمر، لأنه قد يعرض اتصالك لمخاطر أمنية. settings_global_speed_limiter=محدد السرعة العام settings_global_speed_limiter_description=حد سرعة التنزيل العام (0 يعني غير محدود) settings_show_average_speed=عرض متوسط السرعة settings_show_average_speed_description=عرض سرعة التنزيل كمعدل أو كدقة settings_use_category_by_default=استخدام الفئة بشكل افتراضي settings_use_category_by_default_description=استخدام الفئة بشكل افتراضي عند إضافة تنزيل. settings_default_download_folder=مجلد التنزيل الافتراضي settings_default_download_folder_description=عند إضافة تنزيل جديد يتم استخدام هذا الموقع بشكل افتراضي settings_default_download_folder_describe=سيتم استخدام "{{folder}}" settings_use_proxy=استخدام بروكسي settings_use_proxy_description=استخدام البروكسي لتنزيل الملفات settings_use_proxy_describe_no_proxy=لن يتم استخدام أي بروكسي settings_use_proxy_describe_system_proxy=سيتم استخدام بروكسي النظام settings_use_proxy_describe_manual_proxy=سيتم استخدام "{{value}}" settings_use_proxy_describe_pac_proxy=سيتم استخدام ملف pac التالي \: {{value}} settings_track_deleted_files_on_disk=تتبع الملفات المحذوفة على القرص settings_track_deleted_files_on_disk_description=إزالة الملفات تلقائياً من القائمة عند حذفها أو نقلها من دليل التنزيل. settings_delete_partial_file_on_download_cancellation=حذف الملف الجزئي عند إلغاء التنزيل settings_delete_partial_file_on_download_cancellation_description=عند إلغاء التنزيل، سيتم حذف الملف الذي تم تنزيله جزئيًا من القرص. يساعد ذلك في الحفاظ على نظافة مجلد التنزيلات وتقليل استهلاك المساحة غير الضرورية على القرص. ومع ذلك، سيُعاد التنزيل من البداية في المرة القادمة التي تبدأ فيها به. settings_default_user_agent=وكيل المستخدم الافتراضي settings_default_user_agent_description=حدد سلسلة وكيل المستخدم الافتراضية لتحديد كيفية تعريف الطلبات للخوادم. يمكن أن يساعد ذلك في الوصول إلى المحتوى المحسن لأجهزة معينة أو في التحايل على قيود التنزيل التي تفرضها مواقع ويب معينة. settings_download_size_unit=وحدة حجم التنزيل settings_download_size_unit_description=الوحدة المستخدمة لعرض حجم التنزيل settings_download_speed_unit=وحدة سرعة التنزيل settings_download_speed_unit_description=الوحدة المستخدمة لعرض سرعة التنزيل settings_theme=النسق settings_theme_description=اختر نسق للتطبيق settings_default_dark_theme=الوضع الداكن الافتراضي settings_default_dark_theme_description=ينطبق عندما يتبع التطبيق سمة النظام ويكون الوضع الداكن فعال settings_default_light_theme=الوضع النهاري الافتراضي settings_default_light_theme_description=ينطبق عندما يتبع التطبيق سمة النظام ويكون الوضع النهاري فعال settings_font=الخط settings_font_description=تغيير الخط المستخدم في واجهة التطبيق، قد لا تظهر بعض الخطوط بشكل صحيح في التطبيق. settings_ui_scale=مقياس واجهة المستخدم settings_ui_scale_description=ضبط حجم عناصر واجهة التطبيق settings_language=اللغة settings_compact_top_bar=شريط علوي مدمج settings_compact_top_bar_description=دمج الشريط العلوي مع شريط العنوان عندما يكون للنافذة الرئيسة عرض كافٍ settings_use_native_menu_bar=استخدام شريط القائمة الأصلية settings_use_native_menu_bar_description=استخدام نمط شريط القائمة الافتراضي للنظام settings_use_relative_date_time=استخدام التاريخ/الوقت النسبي settings_use_relative_date_time_description=استخدام تنسيق التاريخ/الوقت النسبي في التطبيق (مثل "منذ 2 يوم" بدلاً من التاريخ/الوقت الدقيق) settings_show_icon_labels=إظهار تسميات الأيقونات settings_show_icon_labels_description=إظهار التسميات تحت الأيقونات عندما يكون ذلك ممكنا (مثل إجراءات شريط الأدوات الرئيسي) settings_use_system_tray=استخدام أيقونة النظام settings_use_system_tray_description=إظهار أيقونة النظام عند تشغيل التطبيق settings_start_on_boot=بدء التشغيل عند الإقلاع settings_start_on_boot_description=تشغيل التطبيق تلقائيًا عند تسجيل دخول المستخدم settings_notification_sound=صوت الإشعار settings_notification_sound_description=تشغيل الصوت عند وجود إشعار جديد settings_browser_integration=التكامل مع المتصفح settings_browser_integration_description=قبول التنزيلات من المتصفحات settings_browser_integration_server_port=منفذ الخادم settings_browser_integration_server_port_description=منفذ التكامل مع المتصفح settings_browser_integration_server_port_describe=سيستمع التطبيق على المنفذ {{port}} settings_dynamic_part_creation=إنشاء أجزاء ديناميكية settings_dynamic_part_creation_description=عند الانتهاء من جزء، قم بإنشاء جزء آخر عن طريق تقسيم الأجزاء الأخرى لتحسين سرعة التنزيل settings_show_completion_dialog=إظهار مربع حوار اكتمال التنزيل settings_show_completion_dialog_description=إظهار مربع حوار "اكتمال التنزيل" تلقائياً عند انتهاء التنزيل. settings_show_download_progress_dialog=إظهار مربع الحوار "تقدم التنزيل" settings_show_download_progress_dialog_description=إظهار مربع الحوار "تقدم التنزيل" تلقائيًا عند بدء التنزيل. settings_per_host_settings=إعدادات لكل مضيف settings_per_host_settings_descriptions=سيتم تطبيق هذه الإعدادات تلقائياً على أي تنزيل جديد يطابق المضيف المحدد. settings_download_max_concurrent_downloads=الحد الأقصى للتنزيلات المتزامنة settings_download_max_concurrent_downloads_description=الحد الأقصى لعدد الملفات التي يمكن تنزيلها في الوقت نفسه (لا يتم احتساب التنزيلات التي تتم إدارتها عبر قوائم الانتظار؛ اضبط القيمة على 0 لعدم وجود حد أقصى) download_item_settings_speed_limit=الحد الأقصى للسرعة download_item_settings_speed_limit_description=الحد من سرعة التنزيل لهذا العنصر download_item_settings_show_download_completion_dialog=إظهار مربع حوار اكتمال التنزيل download_item_settings_show_download_completion_dialog_description=إظهار مربع حوار "اكتمال التنزيل" تلقائياً عند الانتهاء من هذا التنزيل. download_item_settings_shutdown_on_completion=إيقاف تشغيل النظام عند الانتهاء download_item_settings_shutdown_on_completion_description=إيقاف تشغيل النظام تلقائياً عند الانتهاء من هذا التنزيل. download_item_settings_thread_count=عدد الخيوط download_item_settings_thread_count_description=ما عدد الخيوط المستخدمة لتنزيل عنصر التنزيل هذا (0 افتراضيًا) download_item_settings_thread_count_describe={{count}} خيوط لهذا التنزيل download_item_settings_username_description=قدّم إسم المستخدم إذا كان الرابط محميًا download_item_settings_password_description=قدّم كلمة المرور إذا كان الرابط محميًا download_item_settings_download_page=صفحة التنزيل download_item_settings_download_page_description=صفحة الويب التي بدأ فيها هذا التحميل download_item_settings_file_checksum=تحقق مجموع الملف download_item_settings_file_checksum_description=سلسلة تجزئة يمكن استخدامها للتحقق من تنزيل الملف بشكل صحيح download_item_settings_user_agent=وكيل المستخدم download_item_settings_user_agent_description=وكيل المستخدم المخصص لهذا العنصر (اتركه فارغاً لاستخدام الافتراضي) file_checksum=تحقق مجموع الملف file_checksum_page=مدقق مجموع الملف file_checksum_page_file_checksum_default_algorithm=الخوارزمية الافتراضية file_checksum_page_file_checksum_default_algorithm_help=الخوارزمية الافتراضية المستخدمة لحساب مجموع الملف عند عدم توفيرها. start=إبدأ calculated_checksum=مجموع الملف المحسوب saved_checksum=مجموع الملف المحفوظ checksum_algorithm=الخوارزمية file_not_found=لم يتم العثور على الملف download_not_finished=لم ينتهي التنزيل done=تم waiting=قيد الانتظار matches=مطابق not_matches=غير مطابق copy_to_clipboard=نسخ إلى الحافظة username=اسم المستخدم password=كلمة المرور average_speed=متوسط السرعة exact_speed=السرعة الدقيقة unlimited=غير محدود use_global_settings=استخدم الإعدادات العامة cant_run_browser_integration=لا يمكن تشغيل التكامل مع المتصفح cant_open_file=لا يمكن فتح الملف cant_open_folder=لا يمكن فتح المجلد # times for example 2 seconds ago relative_time_long_years={{years}} سنوات relative_time_long_months={{months}} أشهر relative_time_long_days={{days}} أيام relative_time_long_hours={{hours}} ساعات relative_time_long_minutes={{minutes}} دقائق relative_time_long_seconds={{seconds}} ثواني relative_time_short_years={{years}} س relative_time_short_months={{months}} ش relative_time_short_days={{days}} ي relative_time_short_hours={{hours}} سا relative_time_short_minutes={{minutes}} دق relative_time_short_seconds={{seconds}} ثانية relative_time_left={{time}} متبقي relative_time_ago=منذ {{time}} auto=تلقائي unspecified=غير محدد custom=مخصص icon=أيقونة author=الكاتب link=الرابط size=الحجم status=الحالة parts_info_downloaded_size=تم التنزيل parts_info_total_size=الإجمالي speed=السرعة time_left=الوقت المتبقي date_added=تاريخ الإضافة info=المعلومات download_page_downloaded_size=تم التنزيل download_page_download_completed=اكتمل التنزيل resume_support=دعم الاستئناف yes=نعم no=لا parts_info=معلومات عن الأجزاء disconnected=قطع الاتصال receiving_data=تلقي البيانات connecting=إرسال الحصول على البيانات warning=تحذير unsupported_resume_warning=هذا التنزيل لا يدعم الإستئناف\! قد تحتاج إلى إعادة تنزيل في وقت لاحق في قائمة التنزيل stop_anyway=إيقاف على أي حال customize_columns=تخصيص الأعمدة reset=إعادة الضبط monday=الاثنين tuesday=الثلاثاء wednesday=الأربعاء thursday=الخميس friday=الجمعة saturday=السبت sunday=الأحد proxy_open_system_proxy_settings=فتح إعدادات بروكسي النظام proxy_type=نوع البروكسي proxy_do_not_use_proxy_for=عدم استخدام البروكسي لـ proxy_do_not_use_proxy_for_description=قائمة الروابط التي قد لا يتم توجيهها عبر البروكسي\nيمكنك استخدام الرمز * كرمز بديل\nعلى سبيل المثال\: 192.168.1.* example.com (يتم فصلها بمسافة) proxy_change_title=تغيير البروكسي change_proxy=تغيير البروكسي proxy_no=بدون بروكسي proxy_system=بروكسي النظام proxy_manual=بروكسي يدوي proxy_pac=تكوين البروكسي تلقائيًا proxy_pac_url=رابط التكوين التلقائي للبروكسي address=العنوان port=المنفذ address_and_port=العنوان و المنفذ use_authentication=استخدم المصادقة warning_you_may_have_to_restart_the_download_later=قد تحتاج إلى إعادة تشغيل التنزيل لاحقًا\! edit_download_title=تعديل التنزيل edit_download_update_from_download_page=التحديث من صفحة التنزيل edit_download_update_from_download_page_description=عندما تكون هذه النافذة مفتوحة، يمكنك الانتقال إلى صفحة التنزيل والنقر على زر التنزيل. سيقوم التطبيق بالتقاط وتحديث بيانات الاعتماد الجديدة حتى تتمكن من حفظها. edit_download_saved_download_item_size_not_match=حجم العنصر المحفوظ {{currentSize}}، لا يتطابق مع حجم العنصر الجديد {{newSize}}. translators_page_thanks=كامل التقدير لمن ساعدوا في ترجمة المشروع ❤️ translators=المترجمون language=اللغة translators_contribute_title=لتحسين الترجمة translators_contribute_description=هل ترغب في المساعدة في تحسين هذا المشروع؟ إذا لم تكن لغتك مدرجة أو تحتاج إلى بعض التعديلات، يمكنك المساهمة لجعلها أفضل\! contribute=ساهم meet_the_translators=التعرف على المترجمين localized_by_translators=تمت الترجمة بواسطة المترجمين confirm_exit=تأكيد الخروج confirm_exit_description=هل أنت متأكد من أنك تريد الخروج من مدير التنزيل AB؟\nسيتم إيقاف التنزيلات/قوائم الانتظار النشطة\! update=تحديث update_updater=المحدث update_available=التحديث متاح update_error=خطأ فى التحديث update_available_suggest_to_to_update=يمكنك التحديث إلى أحدث إصدار للاستمتاع بالميزات الجديدة والتحسينات وتحسين الأداء. update_release_notes=ملاحظات الإصدار update_check_for_update=التحقق من وجود تحديث update_checking_for_update=جاري التحقق من وجود تحديث update_no_update=أنت تستخدم أحدث إصدار update_check_error=حدث خطأ أثناء التحقق من وجود تحديث update_app_updated_to_version_n=تم تحديث التطبيق إلى الإصدار {{version}} create_desktop_entry=إنشاء اختصار على سطح المكتب shutdown_alert=تنبيه إيقاف التشغيل system_shutdown_soon=سيتم إيقاف تشغيل النظام قريبًا\! system_shutdown_failed=فشل إيقاف تشغيل النظام\! system_shutdown_soon_description=سيتم إيقاف تشغيل النظام قريبًا. إذا كنت لا تزال تستخدم الحاسوب، يُرجى حفظ عملك أو إلغاء عملية الإيقاف. system_shutdown_reason_queue_completed=اكتملت جميع التنزيلات في قائمة الانتظار. system_shutdown_reason_queue_end_time_reached=تم الوصول إلى وقت الانتهاء من التنزيل المجدول لقائمة انتظار. system_shutdown_download_finished=اكتمل التنزيل. shutdown_now=إيقاف التشغيل الآن settings_per_host_settings_new_host=<مضيف جديد> settings_per_host_settings_not_selected=قم بإنشاء أو تحديد عنصر جديد أولاً\! settings_per_host_settings_host=مضيف settings_per_host_settings_host_description=سيتم تطبيق هذه الإعدادات على التنزيلات المطابقة لاسم المضيف. الحروف البديلة (*) مدعومة (على سبيل المثال\: example.com, *.example.com - استخدم واحد فقط). settings_browser_in_launcher=أيقونة المتصفح في المشغّل settings_browser_in_launcher_description=إظهار أو إخفاء أيقونة المتصفح في المشغّل (قائمة التطبيقات). sort_by=ترتيب حسب welcome=مرحبًا new_folder=مجلد جديد skip=تخطي lets_go=لنبدأ next=التالي select_all=تحديد الكل select_inside=تحديد المحتويات select_invert=عكس التحديد open_settings=فتح الإعدادات back=رجوع service_is_running=الخدمة قيد التشغيل initial_setup_description=لنقم بالإعداد initial_setup_notice=يمكنك تغيير هذه الإعدادات لاحقًا في أي وقت permission_granted=تم منح الإذن permission_not_granted=لم يتم منح الإذن permissions=الأذونات give_permission=السماح بالإذن give_storage_permission=السماح بالوصول إلى التخزين storage_roots=جذور التخزين permissions_initial_title=لنقم بالإعداد permissions_initial_description=لكي يعمل التطبيق بشكل صحيح، يحتاج إلى بعض الأذونات. في الشاشة التالية ستتعرف على سبب كل إذن ويمكنك اختيار السماح به أو تخطيه. permissions_done_title=كل شيء جاهز permissions_done_description=كل شيء جاهز. تم منح جميع الأذونات المطلوبة والتطبيق مستعد للاستخدام. permissions_manage_storage_title=إدارة الوصول إلى التخزين permissions_manage_storage_reason=يسمح هذا الإذن للتطبيق بتغيير مجلد التنزيل، واكتشاف التنزيلات المكررة بدقة أكبر، وتفعيل بعض الميزات الإضافية. الإذن اختياري، لكنه موصى به للحصول على أفضل تجربة. permission_read_write_external_storage_title=قراءة وكتابة التخزين permission_read_write_external_storage_reason=يسمح هذا الإذن للتطبيق بحفظ وإدارة الملفات التي تم تنزيلها، وتغيير موقع التنزيل، وتحسين اكتشاف التنزيلات المكررة. permissions_post_notification_title=الوصول إلى الإشعارات permissions_post_notification_reason=يحتاج التطبيق إلى العمل في الخلفية لإدارة التنزيلات. تُستخدم الإشعارات لإبقائك على اطلاع والسماح للتطبيق بالعمل في الخلفية. permissions_ignore_battery_optimization_title=تجاهل تحسين استهلاك البطارية permissions_ignore_battery_optimization_reason=تقوم بعض الأجهزة بتقييد نشاط التطبيقات في الخلفية بشكل صارم للحفاظ على البطارية، مما قد يؤدي إلى إيقاف التنزيلات مؤقتًا أو إيقافها عند عدم فتح التطبيق. يمكنك، بشكل اختياري، استثناء التطبيق من تحسين استهلاك البطارية لضمان استمرار التنزيلات دون انقطاع open_in_browser=فتح في المتصفح browser=المتصفح browser_new_tab=تبويب جديد browser_close_tab=إغلاق التبويب browser_open_in_new_tab=فتح في علامة تبويب جديدة browser_open_in_new_background_tab=فتح في علامة تبويب جديدة في الخلفية browser_no_tab_open=لا توجد علامات تبويب مفتوحة browser_tabs=علامات التبويب browser_paste_and_go=لصق والانتقال browser_bookmarks=المفضلة browser_add_bookmark=أضف إلى المفضلة browser_edit_bookmark=تحرير المفضلة browser_add_to_bookmarks=أضف إلى المفضلة browser_remove_from_bookmarks=إزالة من المفضلة ================================================ FILE: shared/resources/src/commonMain/resources/com/abdownloadmanager/resources/locales/bn_BD.properties ================================================ app_title=এবি ডাউনলোড ম্যানেজার confirm_auto_categorize_downloads_title=স্বয়ংক্রিয় ডাউনলোড শ্রেণীবদ্ধ confirm_auto_categorize_downloads_description=কোনো অশ্রেণীভুক্ত আইটেম স্বয়ংক্রিয়ভাবে তার সম্পর্কিত বিভাগে যোগ হবেl confirm_reset_to_default_categories_title=ডিফল্ট বিভাগগুলিতে রিসেট করুন confirm_reset_to_default_categories_description=এটি সমস্ত বিভাগ মুছে ফেলবে এবং ডিফল্ট বিভাগগুলি ফিরিয়ে আনবে\! confirm_delete_download_items_title=মুছে ফেলা নিশ্চিত করুন confirm_delete_download_items_description=আপনি কি {{count}} টি আইটেম মুছতে চান? confirm_delete_download_unfinished_items_description=আপনি কি {{count}} টি অসমাপ্ত ডাউনলোড মুছতে চান? confirm_delete_download_finished_and_unfinished_items_description=আপনি কি {{finished Count}} টি সমাপ্ত এবং {{unfinished Count}} টি অসমাপ্ত ডাউনলোড মুছতে চান? also_delete_file_from_disk=এছাড়াও ডিস্ক থেকে ফাইল মুছে ফেলুন confirm_delete_category_item_title={{name}} বিভাগ সরানো হচ্ছে confirm_delete_category_item_description=আপনি কি "{{value}}" বিভাগটি মুছতে চান? your_download_will_not_be_deleted=আপনার ডাউনলোড মুছে ফেলা হবে না drag_the_file_to_another_app=ফাইলটি অন্য অ্যাপে টেনে আনুন drop_link_or_file_here=লিঙ্ক বা ফাইল এখানে ড্রপ করুন nothing_will_be_imported=কোনো কিছুই ইম্পোর্ট করা হবে না n_links_will_be_imported={{count}} লিংক ইম্পোর্ট করা হবে n_items_selected={{count}} আইটেম নির্বাচিত window_close=বন্ধ করুন window_minimize=মিনিমাইজ করুন window_maximize=সর্বাধিক করুন window_restore=পুনরুদ্ধার করুন delete=মুছে ফেলুন remove=অপসারণ করুন cancel=বাতিল করুন close=বন্ধ করুন menu=সূচি more_options=আরও বিকল্প ok=ঠিক আছে add=যোগ করুন paste=পেস্ট করুন change=পরিবর্তন করুন edit=সম্পাদন করুন change_anyway=যেভাবেই হোক পরিবর্তন করুন download=ডাউনলোড করুন refresh=রিফ্রেশ করুন settings=সেটিংস সমূহ on_completion=সমাপ্তির উপর unknown=অজানা unknown_error=অজানা ত্রুটি download_item_not_found=ডাউনলোড আইটেম পাওয়া যায়নি name=ফাইলের নাম download_link=ডাউনলোড লিঙ্ক লিখুন /পেস্ট করুন... not_finished=সমাপ্ত হয়নি all=সব finished=সম্পন্ন হয়েছে। Unfinished=অসমাপ্ত। canceled=বাতিল করা হয়েছে error=ত্রুটি paused=বিরতি দেওয়া হয়েছে downloading=ডাউনলোড হচ্ছে added=যুক্ত হয়েছে idle=আই.ডি.এল.ই preparing_file=ফাইল প্রস্তুত হচ্ছে creating_file=ফাইল তৈরি হচ্ছে resuming=আবার শুরু হচ্ছে retrying=পুনরায় চেষ্টা করা হচ্ছে list_is_empty=তালিকাটি ফাঁকা\! search_in_the_list=তালিকায় খুঁজুন search=খুঁজুন clear=পরিষ্কার করুন general=সাধারণ enabled=সক্রিয় হয়েছে। disabled=অক্ষম রয়েছে। default=পূর্ব-নির্ধারিত file=ফাইল tasks=কার্যাবলী tools=টুলস help=সাহায্য system=সিস্টেম all_missing_files=সব অনুপস্থিত ফাইল all_finished=সম্পন্ন সব all_unfinished=অসম্পূর্ণ সব entire_list=সম্পূর্ণ তালিকা download_browser_integration=ব্রাউজার ইন্টিগ্রেশন ডাউনলোড করুন exit=প্রস্থান করুন show_downloads=ডাউনলোডগুলি দেখান new_download=নতুন ডাউনলোড stop_all=সব বন্ধ করুন import_from_clipboard=ক্লিপবোর্ড থেকে ইমপোর্ট করুন batch_download=ব্যাচ ডাউনলোড করুন open=খুলুন share=শেয়ার করুন open_file=ফাইল খুলুন open_folder=ফোল্ডার খুলুন resume=পুনরায় শুরু করুন pause=বিরতি restart_download=ডাউনলোড পুনরায় চালু করুন copy=অনুলিপি করুন copy_link=লিঙ্ক কপি করুন copy_as_curl=CURL হিসেবে কপি করুন show_properties=বৈশিষ্ট্য প্রদর্শন করুন move_to_queue=সারিতে সরান move_to_this_queue=এই সারিতে সরান move_to_category=শ্রেণীতে /ক্যাটাগরিতে সরান move_to_this_category=এই শ্রেণীতে সরান categories=শ্রেণীসমূহ add_category=শ্রেণী /ক্যাটাগরি যুক্ত করুন edit_category=শ্রেণী /ক্যাটাগরি সম্পাদন করুন delete_category=শ্রেণী /ক্যাটাগরি মুছুন category_name=শ্রেণী /ক্যাটাগরি নাম category_download_location=শ্রেণী /ক্যাটাগরি ডাউনলোড স্থান category_download_location_description="ডাউনলোড যোগ করুন"-এ এই ক্যাটাগরিটি বেছে নেওয়া হলে এই ডিরেক্টরিটিকে "ডাউনলোড স্থান" হিসেবে ব্যবহার করুন category_file_types=শ্রেণী /ক্যাটাগরি ফাইলের ধরন category_file_types_description=স্বয়ংক্রিয়ভাবে এই বিভাগে এই ধরনের ফাইল রাখুন. (যখন আপনি নতুন ডাউনলোড যোগ করেন)\nস্থান সহ পৃথক ফাইল এক্সটেনশন (ext1, ext2 ...) category_url_patterns=ইউআরএল প্যাটার্নস category_url_patterns_description=স্বয়ংক্রিয়ভাবে এই URL গুলি থেকে এই বিভাগে ডাউনলোড করুন৷ (যখন আপনি নতুন ডাউনলোড যোগ করেন)\nস্পেস সহ আলাদা ইউআরএল, আপনি ওয়াইল্ডকার্ডের জন্য *ও ব্যবহার করতে পারেন auto_categorize_downloads=স্বয়ংক্রিয় শ্রেণীভুক্ত ডাউনলোড restore_defaults=ডিফল্টকে পুনঃস্থাপন করুন about=সম্পর্কে version_n=সংস্করণ /ভার্সন {{value}} developed_with_love_for_you=আপনার জন্য ❤️ (হৃদয়) দিয়ে ডেভেলপ করা হয়েছে। donate=ডোনেট করুন visit_the_project_website=প্রকল্পের ওয়েবসাইট দেখুন this_is_a_free_and_open_source_software=এটি একটি বিনামূল্যের ও ওপেন সোর্স সফটওয়্যার view_the_source_code=সোর্স কোড দেখুন third_party_libraries=তৃতীয় পক্ষের লাইব্রেরি powered_by_open_source_software=ওপেন সোর্স সফটওয়্যার দ্বারা চালিত view_the_open_source_licenses=ওপেন সোর্স লাইসেন্স দেখুন support_and_community=সমর্থন এবং কমিউনিটি telegram=টেলিগ্রাম channel=চ্যানেল group=গ্রুপ add_download=ডাউনলোড যুক্ত করুন add_multi_download_page_header=আপনি যে আইটেমগুলি ডাউনলোড করতে চান তা নির্বাচন করুন৷ save_to=এতে সংরক্ষণ করুন where_should_each_item_saved=প্রতিটি আইটেম কোথায় সংরক্ষণ করা উচিত? there_are_multiple_items_please_select_a_way_you_want_to_save_them=একাধিক আইটেম আছে\! আপনি তাদের সংরক্ষণ করতে চান একটি উপায় নির্বাচন করুন each_item_on_its_own_category=প্রতিটি আইটেম নিজস্ব শ্রেণীতে each_item_on_its_own_category_description=প্রতিটি আইটেম একটি শ্রেণীতে স্থাপন করা হবে যে ফাইল টাইপ আছে all_items_in_one_category=এক বিভাগে সব আইটেম all_items_in_one_category_description=সমস্ত ফাইল নির্বাচিত ক্যাটাগরি স্থানে সংরক্ষণ করা হবে all_items_in_one_Location=সমস্ত আইটেম এক স্থানে all_items_in_one_Location_description=সমস্ত আইটেম নির্বাচিত ডিরেক্টরিতে সংরক্ষিত হবে unselected_all_items_in_specific_location_description=সমস্ত ফাইল নির্বাচিত ক্যাটাগরি স্থানে সংরক্ষণ করা হবে no_category_selected=কোন ক্যাটাগরি নির্বাচন করা হয়নি no_categories_found=কোন বিভাগ পাওয়া যায়নি download_location=ডাউনলোড এর স্থান location=অবস্থান বা স্থান select_queue=সারি নির্বাচন করুন without_queue=সারি ছাড়া use_category=শ্রেণী /ক্যাটাগরি ব্যবহার করুন cant_write_to_this_folder=এই ফোল্ডার কিছু রাখা যাচ্ছে না file_name_already_exists=ফাইলের নাম ইতিমধ্যেই বিদ্যমান৷ download_already_exists=ডাউনলোডটি ইতিমধ্যে রয়েছে invalid_file_name=ফাইলের নামটি বৈধ নয় show_solutions=সমাধান দেখান... change_solution=সমাধান পরিবর্তন করুন select_a_solution=একটি সমাধান নির্বাচন করুন select_download_strategy_description=আপনার দেওয়া লিঙ্কটি ইতিমধ্যেই ডাউনলোড তালিকায় রয়েছে, আপনি কি করতে চান তা উল্লেখ করুন download_strategy_add_a_numbered_file=একটি সংখ্যাযুক্ত ফাইল যোগ করুন download_strategy_add_a_numbered_file_description=ডাউনলোড ফাইলের নাম শেষে একটি সূচী যোগ করুন download_strategy_override_existing_file=বিদ্যমান ফাইল ওভাররাইড করুন download_strategy_override_existing_file_description=বিদ্যমান ডাউনলোড সরান এবং সেই ফাইলটিতে লিখুন download_strategy_update_download_link=বিদ্যমান আপডেট ডাউনলোড করুন download_strategy_update_download_link_description=বিদ্যমান ডাউনলোড লিঙ্ক এবং এর প্রমাণপত্রাদি আপডেট করুন download_strategy_show_downloaded_file=ডাউনলোড করা ফাইল দেখান download_strategy_show_downloaded_file_description=ইতিমধ্যে বিদ্যমান ডাউনলোড আইটেম দেখান, যাতে আপনি পুনরারম্ভ করা টিপুন বা এটি খুলুন batch_download_link_help=ওয়াইল্ডকার্ড রয়েছে এমন একটি লিঙ্ক লিখুন (* ব্যবহার করুন) invalid_url=ইউ আর এল বৈধ নয় list_is_too_large_maximum_n_items_allowed=তালিকাটা অনেক বড়\! সর্বাধিক {{count}} টি আইটেম অনুমোদিত৷ enter_range=পরিসীমা লিখুন range_from=থেকে range_to=প্রতি batch_download_wildcard_length=ওয়াইল্ডকার্ডের দৈর্ঘ্য first_link=প্রথম লিঙ্ক last_link=শেষ লিঙ্ক open_source_software_used_in_this_app=এই অ্যাপে ওপেন সোর্স সফটওয়্যার ব্যবহার করা হয়েছে links=লিংকস website=ওয়েবসাইট developers=ডেভেলপার'স source_code=সোর্স কোড license=লাইসেন্স/ অনুমতি পত্র no_license_found=কোনো লাইসেন্স পাওয়া যায়নি organization=সংস্থা add_new_queue=সারিতে নতুন যোগ করুন queue_name=সারির নাম queues=সারিগুলো stop_queue=সারি থামান start_queue=সারি শুরু করুন clear_queue_items=খালি সারি config=কনফিগ items=আইটেম'স move_down=নিচে নামান move_up=উপরে তুলুন remove_queue=সারি অপসারণ করুন queue_name_help=এই সারির জন্য একটি নাম উল্লেখ করুন queue_name_describe=সারির নাম হল {{value}} queue_max_concurrent_download=সর্বোচ্চ সমসাময়িক ডাউনলোড queue_max_concurrent_download_description=এই সারির জন্য সর্বোচ্চ ডাউনলোড হচ্ছে queue_automatic_stop=স্বয়ংক্রিয়ভাবে বন্ধ queue_automatic_stop_description=কোন আইটেম না থাকলে সারি স্বয়ংক্রিয়ভাবে বন্ধ হবে queue_scheduler=সময়সূচী queue_enable_scheduler=সময়সূচী সক্রিয় করুন queue_active_days=সক্রিয় দিনগুলি queue_active_days_description=কোন দিন নির্ধারণকারী কাজ করে? queue_scheduler_enable_auto_start_time=স্বয়ংক্রিয়ভাবে শুরুর সময় সক্রিয় করুন queue_scheduler_auto_start_time=স্বয়ংক্রিয় শুরুর সময় queue_scheduler_enable_auto_stop_time=স্বয়ংক্রিয়ভাবে বন্ধর সময় সক্রিয় করুন queue_scheduler_auto_stop_time=স্বয়ংক্রিয়ভাবে থামার সময় queue_shutdown_on_completion=সম্পূর্ণ হলে সিস্টেম বন্ধ করুন queue_shutdown_on_completion_description=এই সারিটি সম্পন্ন হলে, সিস্টেমটি স্বয়ংক্রিয়ভাবে বন্ধ হয়ে যাবে অথবা নির্ধারিত শেষ সময় শেষ হলে। appearance=এপিয়ারেন্স / বাহ্যিক রুপ download_engine=ডাউনলোড ইঞ্জিন browser_integration=ব্রাউজার ইন্টিগ্রেশন settings_download_max_retries_count=সর্বাধিক ডাউনলোড পুনঃপ্রচেষ্টা settings_download_max_retries_count_description=অ্যাপটি ডাউনলোড ব্যর্থ হওয়ার পর হাল ছেড়ে দেওয়ার সর্বোচ্চ কতবার পুনরায় চেষ্টা করবে settings_download_max_retries_count_describe_no_retries=ব্যর্থ ডাউনলোডগুলি পুনরায় চেষ্টা করা হবে না settings_download_max_retries_count_describe_n_retries=ব্যর্থ ডাউনলোডগুলি {{count}} বার পুনরায় চেষ্টা করা হবে settings_download_thread_count=থ্রেড গণনা settings_download_thread_count_description=আইটেম প্রতি সর্বোচ্চ ডাউনলোড থ্রেড settings_download_thread_count_describe=একটি ডাউনলোডে {{count}} টি পর্যন্ত থ্রেড থাকতে পারে৷ settings_download_thread_count_with_large_value_describe=সতর্কবার্তা\: উচ্চ থ্রেড সংখ্যা নির্ধারণ করলে সিস্টেমের রিসোর্স ব্যবহার বৃদ্ধি পেতে পারে, কর্মক্ষমতা হ্রাস পেতে পারে, অথবা সার্ভারগুলোর সাথে সংযোগ সমস্যার সৃষ্টি হতে পারে। কেবল তখনই উচ্চ মান নির্ধারণ করুন যখন আপনি আপনার সিস্টেম এবং নেটওয়ার্কে এর সম্ভাব্য প্রভাব সম্পর্কে সচেতন থাকেন। settings_use_server_last_modified_time=সার্ভারের শেষ-সংশোধিত সময় ব্যবহার করুন settings_use_server_last_modified_time_description=একটি ফাইল ডাউনলোড করার সময়, স্থানীয় ফাইলের জন্য সার্ভারের সর্বশেষ পরিবর্তিত সময় ব্যবহার করুন settings_append_extension_to_incomplete_downloads=অসম্পূর্ণ ডাউনলোডগুলিতে এক্সটেনশন যুক্ত করুন settings_append_extension_to_incomplete_downloads_description=অসম্পূর্ণ ডাউনলোডগুলিতে ".part" এক্সটেনশন যুক্ত করুন। এটি অসম্পূর্ণ ডাউনলোডগুলি সনাক্ত করতে সাহায্য করে এবং অসম্পূর্ণ ফাইলগুলি দুর্ঘটনাক্রমে খোলা রোধ করে। settings_use_sparse_file_allocation=অল্পপবিমাণে বিক্ষিপ্ত ফাইল বরাদ্দ settings_use_sparse_file_allocation_description=অপ্রয়োজনীয় ডেটা রাইটিং কমিয়ে বিশেষ করে SSD-তে আরও দক্ষতার সাথে ফাইল তৈরি করুন। এটি ডাউনলোড শুরুর গতি বাড়াতে পারে এবং ডিস্কের ব্যবহার কমাতে পারে। যদি ডাউনলোডগুলি ধীরে শুরু হয়, বা আপনি অস্বাভাবিক ডাউনলোডের গতি অনুভব করেন, তাহলে এই বিকল্পটি নিষ্ক্রিয় করার কথা বিবেচনা করুন, কারণ এটি কিছু ডিভাইসে সম্পূর্ণরূপে সমর্থিত নাও হতে পারে৷ settings_ignore_ssl_certificates=SSL সার্টিফিকেট উপেক্ষা করুন settings_ignore_ssl_certificates_description=SSL সার্টিফিকেট যাচাইকরণ অক্ষম করুন। শুধুমাত্র প্রয়োজন হলেই ব্যবহার করুন, কারণ এটি আপনার সংযোগকে নিরাপত্তা ঝুঁকির মুখে ফেলতে পারে। settings_global_speed_limiter=গ্লোবাল স্পীড লিমিটার settings_global_speed_limiter_description=বিশ্বব্যাপী ডাউনলোড গতি সীমা (0 মানে সীমাহীন) settings_show_average_speed=গড় গতি দেখান settings_show_average_speed_description=গড় বা নির্ভুলতা ডাউনলোড গতি settings_use_category_by_default=ডিফল্টরূপে বিভাগ ব্যবহার করুন settings_use_category_by_default_description=ডাউনলোড যোগ করার সময় ডিফল্টরূপে বিভাগ ব্যবহার করুন। settings_default_download_folder=ডিফল্ট ডাউনলোড ফোল্ডার হিসেবে settings_default_download_folder_description=আপনি যখন একটি "নতুন ডাউনলোড" যোগ করেন, তখন এই অবস্থানটি ডিফল্টরূপে ব্যবহৃত হয় settings_default_download_folder_describe="{{folder}}" ব্যবহৃত হচ্ছে। settings_use_proxy=প্রক্সি ব্যবহার করুন settings_use_proxy_description=ফাইল ডাউনলোড করার জন্য প্রক্সি ব্যবহার করুন settings_use_proxy_describe_no_proxy=কোনো প্রক্সি ব্যবহার ব্যবহৃত হচ্ছে না settings_use_proxy_describe_system_proxy=সিস্টেম প্রক্সি ব্যবহার করা হবে৷ settings_use_proxy_describe_manual_proxy="{{value}}" ব্যবহৃত হচ্ছে। settings_use_proxy_describe_pac_proxy=pac ফাইল "{{value}}" ব্যবহার করা হবে settings_track_deleted_files_on_disk=ডিস্কে মুছে ফেলা ফাইল ট্র্যাক করুন settings_track_deleted_files_on_disk_description=ডাউনলোড ডিরেক্টরি থেকে ফাইলগুলি মুছে ফেলা বা সরানো হলে তালিকা থেকে স্বয়ংক্রিয়ভাবে মুছে ফেলুন। settings_delete_partial_file_on_download_cancellation=ডাউনলোড বাতিল করার সময় আংশিক ফাইল মুছে ফেলুন settings_delete_partial_file_on_download_cancellation_description=যখন কোনও ডাউনলোড বাতিল করা হয়, তখন আংশিকভাবে ডাউনলোড করা ফাইলটি ডিস্ক থেকে মুছে ফেলা হবে। এটি আপনার ডাউনলোড ফোল্ডারটি পরিষ্কার রাখতে সাহায্য করে এবং অপ্রয়োজনীয় ডিস্ক স্থান ব্যবহার হ্রাস করে। তবে, পরের বার যখন আপনি ডাউনলোড শুরু করবেন তখন ডাউনলোডটি শুরু থেকেই পুনরায় চালু হবে। settings_default_user_agent=ডিফল্ট ব্যবহারকারী এজেন্ট settings_default_user_agent_description=সার্ভারে অনুরোধগুলি কীভাবে শনাক্ত করা হয় তা নির্ধারণ করতে ডিফল্ট ব্যবহারকারী এজেন্ট স্ট্রিং নির্দিষ্ট করুন। এটি নির্দিষ্ট ডিভাইসের জন্য অপ্টিমাইজ করা সামগ্রী অ্যাক্সেস করতে বা নির্দিষ্ট ওয়েবসাইট দ্বারা আরোপিত ডাউনলোড সীমাবদ্ধতা এড়াতে সহায়তা করতে পারে। settings_download_size_unit=ডাউনলোড করুন আকার ইউনিট settings_download_size_unit_description=ডাউনলোডের আকার প্রদর্শনের জন্য ব্যবহৃত ইউনিট settings_download_speed_unit=ডাউনলোড স্পিড ইউনিট settings_download_speed_unit_description=ডাউনলোডের গতি প্রদর্শনের জন্য ব্যবহৃত ইউনিট settings_theme=অ্যাপ্লিকেশন থিম settings_theme_description=অ্যাপের জন্য একটি থিম নির্বাচন করুন settings_default_dark_theme=ডিফল্ট ডার্ক থিম settings_default_dark_theme_description=অ্যাপটি সিস্টেম থিম অনুসরণ করলে এবং ডার্ক মোড সক্রিয় থাকলে প্রযোজ্য হয় settings_default_light_theme=ডিফল্ট সাদা থিম settings_default_light_theme_description=অ্যাপটি সিস্টেম থিম অনুসরণ করলে এবং লাইট মোড সক্রিয় থাকলে প্রযোজ্য হয় settings_font=হরফ settings_font_description=অ্যাপ ইন্টারফেসে ব্যবহৃত ফন্ট পরিবর্তন করুন, কিছু ফন্ট অ্যাপে সঠিকভাবে প্রদর্শিত নাও হতে পারে settings_ui_scale=ইউজার ইন্টারফেস স্কেল settings_ui_scale_description=অ্যাপের ইন্টারফেস উপাদানের আকার সামঞ্জস্য করুন settings_language=ভাষা settings_compact_top_bar=কমপ্যাক্ট শীর্ষ বার settings_compact_top_bar_description=প্রধান উইন্ডোর যথেষ্ট প্রস্থ থাকলে শীর্ষ বারটি শিরোনাম বারের সাথে মার্জ করুন settings_use_native_menu_bar=নেটিভ মেনু বার ব্যবহার করুন settings_use_native_menu_bar_description=সিস্টেমের ডিফল্ট মেনু বার স্টাইল ব্যবহার করুন settings_use_relative_date_time=আপেক্ষিক তারিখ/সময় ব্যবহার করুন settings_use_relative_date_time_description=অ্যাপে তারিখের জন্য আপেক্ষিক তারিখ/সময় বিন্যাস ব্যবহার করুন (যেমন, "২ দিন আগে" যথাযথ তারিখ/সময়ের পরিবর্তে) settings_show_icon_labels=আইকন লেবেল দেখান settings_show_icon_labels_description=সম্ভব হলে আইকনের নিচে লেবেল দেখান (যেমন হোম টুলবার অ্যাকশন) settings_use_system_tray=সিস্টেম ট্রে ব্যবহার করুন settings_use_system_tray_description=অ্যাপটি চলাকালীন সিস্টেম ট্রে আইকনটি দেখান settings_start_on_boot="Boot" এ স্বয়ংক্রিয়ভাবে শুরু করুন settings_start_on_boot_description=ব্যবহারকারী লগইনে স্বয়ংক্রিয়ভাবে অ্যাপ্লিকেশন শুরু করুন settings_notification_sound=বিজ্ঞপ্তির আওয়াজ /শব্দ settings_notification_sound_description=নতুন বিজ্ঞপ্তিতে আওয়াজ /শব্দ চালান settings_browser_integration=ব্রাউজার ইন্টিগ্রেশন settings_browser_integration_description=ব্রাউজার থেকে ডাউনলোড গ্রহণ করুন settings_browser_integration_server_port=সার্ভার পোর্ট settings_browser_integration_server_port_description=ব্রাউজার ইন্টিগ্রেশন জন্য পোর্ট settings_browser_integration_server_port_describe=অ্যাপ {{port}} পোর্ট ব্যবহৃত হচ্ছে। settings_dynamic_part_creation=ডাইনামিক অংশ উদ্ভাবন settings_dynamic_part_creation_description=একটি অংশ শেষ হয়ে গেলে, ডাউনলোডের গতি উন্নত করতে অন্য অংশগুলিকে বিভক্ত করে আরেকটি অংশ তৈরি করুন settings_show_completion_dialog=সমাপ্ত ডাউনলোড ডায়ালগ দেখান settings_show_completion_dialog_description=একটি ডাউনলোড শেষ হলে স্বয়ংক্রিয়ভাবে "ডাউনলোড সমাপ্ত" ডায়ালগ দেখান৷ settings_show_download_progress_dialog=ডাউনলোড অগ্রগতি ডায়ালগ দেখান settings_show_download_progress_dialog_description=একটি ডাউনলোড শুরু হলে স্বয়ংক্রিয়ভাবে "ডাউনলোড অগ্রগতি" ডায়ালগ দেখান৷ settings_per_host_settings=প্রতি হোস্ট সেটিংস settings_per_host_settings_descriptions=এই সেটিংসগুলি নির্দিষ্ট হোস্টের সাথে মেলে এমন যেকোনো নতুন ডাউনলোডের ক্ষেত্রে স্বয়ংক্রিয়ভাবে প্রয়োগ করা হবে। settings_download_max_concurrent_downloads=Maximum Concurrent Downloads settings_download_max_concurrent_downloads_description=The maximum number of files that can be downloaded at the same time (downloads managed by queues are not counted; set to 0 for unlimited) download_item_settings_speed_limit=গতিসীমা download_item_settings_speed_limit_description=এই আইটেমটির জন্য ডাউনলোডের গতি সীমিত করুন download_item_settings_show_download_completion_dialog=সমাপ্ত ডাউনলোড ডায়ালগ দেখান download_item_settings_show_download_completion_dialog_description=একটি ডাউনলোড শেষ হলে স্বয়ংক্রিয়ভাবে "ডাউনলোড সমাপ্ত" ডায়ালগ দেখান৷ download_item_settings_shutdown_on_completion=সম্পূর্ণ হলে সিস্টেম বন্ধ করুন download_item_settings_shutdown_on_completion_description=এই ডাউনলোড শেষ হলে সিস্টেমটি স্বয়ংক্রিয়ভাবে বন্ধ হয়ে যাবে। download_item_settings_thread_count=থ্রেড গণনা download_item_settings_thread_count_description=এই ডাউনলোড আইটেমটি ডাউনলোড করতে কত থ্রেড ব্যবহার করা হয়েছে (ডিফল্টের জন্য 0) download_item_settings_thread_count_describe=এই ডাউনলোডের জন্য {{count}} থ্রেড download_item_settings_username_description=লিঙ্কটি একটি সুরক্ষিত সম্পদ হলে একটি "ব্যবহারকারীর নাম" প্রদান করুন download_item_settings_password_description=লিঙ্কটি সুরক্ষিত সম্পদ হলে একটি "পাসওয়ার্ড" প্রদান করুন৷ download_item_settings_download_page=ডাউনলোড পেজ download_item_settings_download_page_description=যে ওয়েবপৃষ্ঠাটি এই ডাউনলোড শুরু করা হয়েছিল download_item_settings_file_checksum=ফাইল চেকসাম download_item_settings_file_checksum_description=একটি হ্যাশ স্ট্রিং যা ফাইলটি সঠিকভাবে ডাউনলোড হয়েছে কিনা তা পরীক্ষা করতে ব্যবহার করা যেতে পারে। download_item_settings_user_agent=ব্যবহারকারী-দূত download_item_settings_user_agent_description=এই আইটেমের জন্য কাস্টম ব্যবহারকারী-এজেন্ট (ডিফল্ট ব্যবহার করতে খালি রাখুন) file_checksum=ফাইল চেকসাম file_checksum_page=ফাইল চেকসাম পরীক্ষক file_checksum_page_file_checksum_default_algorithm=ডিফল্ট অ্যালগরিদম file_checksum_page_file_checksum_default_algorithm_help=ফাইল চেকসাম প্রদান না করা হলে তা গণনা করার জন্য ব্যবহৃত ডিফল্ট অ্যালগরিদম। start=শুরু করুন calculated_checksum=গণনাকৃত চেকসাম saved_checksum=সংরক্ষিত চেকসাম checksum_algorithm=অ্যালগরিদম file_not_found=ফাইলটি পাওয়া যায়নি। download_not_finished=ডাউনলোড শেষ হয়নি। done=সম্পন্ন হয়েছে waiting=প্রতীক্ষারত matches=মিলেছে not_matches=মেলেনি copy_to_clipboard=ক্লিপবোর্ডে কপি করুন username=ইউজারনেম password=পাসওয়ার্ড average_speed=গড় গতি exact_speed=নির্ভুল /সঠিক গতি unlimited=সীমাহীন use_global_settings=গ্লোবাল /বিশ্বব্যাপী সেটিংস ব্যবহার করুন cant_run_browser_integration=ব্রাউজার ইন্টিগ্রেশন চালানো যাচ্ছে না cant_open_file=ফাইল খোলা যাচ্ছে না cant_open_folder=ফোল্ডার খোলা যাচ্ছে না # times for example 2 seconds ago relative_time_long_years={{years}} বছর relative_time_long_months={{months}} মাস relative_time_long_days={{days}} দিন relative_time_long_hours={{hours}} ঘন্টা relative_time_long_minutes={{minutes}} মিনিট''স relative_time_long_seconds={{seconds}} সেকেন্ড''স relative_time_short_years={{years}} বছর relative_time_short_months={{months}} মাস relative_time_short_days={{days}} দিন relative_time_short_hours={{hours}} ঘন্টা relative_time_short_minutes={{minutes}} মিনিট relative_time_short_seconds={{seconds}} সেকেন্ড relative_time_left={{time}} বাকি relative_time_ago={{time}} আগে auto=স্বয়ংক্রিয় unspecified=অনির্ধারিত custom=পছন্দসই /কাস্টম icon=আইকন author=সম্পাদক link=লিংক size=সাইজ status=স্ট্যাটাস /অবস্থা parts_info_downloaded_size=ডাউনলোড সম্পন্ন হয়েছে parts_info_total_size=মোট speed=গতি time_left=অবশিষ্ট সময় date_added=তারিখে যুক্ত হয়েছে info=তথ্য download_page_downloaded_size=ডাউনলোড সম্পন্ন হয়েছে download_page_download_completed=ডাউনলোড সম্পূর্ণ হয়েছে resume_support=পুনরায় শুরু সমর্থন করে yes=হ্যাঁ no=না parts_info=অংশ তথ্য disconnected=সংযোগ বিছিন্ন receiving_data=তথ্য গ্রহণ করা হচ্ছে connecting=পেতে প্রেরণ করুন warning=সতর্কতা unsupported_resume_warning=এই ডাউনলোড পুনরায় শুরু করা সমর্থন করে না\! ডাউনলোড তালিকা থেকে আপনাকে পরে এটি পুনরায় চালু করতে হতে পারে stop_anyway=যেভাবেই হোক থামুন customize_columns=কলাম কাস্টমাইজ করুন reset=রিসেট /পুনরায় সেট করুন monday=সোমবার tuesday=মঙ্গলবার wednesday=বুধবার thursday=বৃহস্পতিবার friday=শুক্রবার saturday=শনিবার sunday=রবিবার proxy_open_system_proxy_settings=সিস্টেম প্রক্সি সেটিংস খুলুন proxy_type=প্রক্সি টাইপ proxy_do_not_use_proxy_for=এর জন্য প্রক্সি ব্যবহার করবেন না৷ proxy_do_not_use_proxy_for_description=ইউআরএলগুলির একটি তালিকা যা প্রক্সি করা যাবে না\nআপনি * এর সাথে ওয়াইল্ডকার্ড ব্যবহার করতে পারেন\nউদাহরণস্বরূপ, 192.168.1.* example.com (স্পেস আলাদা করা হয়েছে) proxy_change_title=প্রক্সি পরিবর্তন change_proxy=প্রক্সি পরিবর্তন proxy_no=কোন প্রক্সি নেই proxy_system=সিস্টেম প্রক্সি proxy_manual=ম্যানুয়াল প্রক্সি proxy_pac=প্রক্সি অটো কনফিগারেশন proxy_pac_url=প্রক্সি অটো কনফিগারেশন URL address=ঠিকানা port=পোর্ট address_and_port=ঠিকানা ও পোর্ট use_authentication=অথেনটিকেশন /প্রমাণীকরণ ব্যবহার করুন warning_you_may_have_to_restart_the_download_later=আপনাকে পরে ডাউনলোড পুনরায় চালু করতে হতে পারে\! edit_download_title=ডাউনলোড সম্পাদনা করুন edit_download_update_from_download_page=ডাউনলোড পেজ থেকে আপডেট edit_download_update_from_download_page_description=এই উইন্ডোটি খোলা হলে, আপনি ডাউনলোড পেজে যেতে পারেন এবং ডাউনলোড বোতামে ক্লিক করতে পারেন। অ্যাপটি নতুন ডাউনলোডের শংসাপত্রগুলি ক্যাপচার করবে এবং আপডেট করবে যাতে আপনি সেগুলি সংরক্ষণ করতে পারেন। edit_download_saved_download_item_size_not_match=সংরক্ষিত ডাউনলোড আইটেমটির আকার {{currentSize}}, যেটি {{newSize}} এর নতুন আকারের সাথে মেলে না৷ translators_page_thanks=যারা এই প্রকল্পটি অনুবাদ করতে সাহায্য করেছেন তাদের প্রতি কৃতজ্ঞতা ❤️ translators=অনুবাদক language=ভাষা translators_contribute_title=অনুবাদ উন্নত করুন translators_contribute_description=এই প্রকল্পের উন্নতি করতে সাহায্য করতে চান? যদি আপনার ভাষা তালিকাভুক্ত না হয় বা কিছু পরিবর্তনের প্রয়োজন হয়, তাহলে আপনি আপনার অনুবাদে অবদান রাখতে পারেন এবং এটি আরও ভাল করতে পারেন\! contribute=অবদান meet_the_translators=অনুবাদকারীদের দেখুন localized_by_translators=অনুবাদকদের দ্বারা স্থানীয়করণ confirm_exit=প্রস্থান নিশ্চিত করুন confirm_exit_description=আপনি কি নিশ্চিত যে আপনি AB Download Manager থেকে প্রস্থান করতে চান?\nচলমান ডাউনলোড/সারি বন্ধ হয়ে যাবে\! update=হালনাগাদ update_updater=হালনাগাদকারী update_available=হালনাগাদ উপলব্ধ update_error=আপডেট ত্রুটি update_available_suggest_to_to_update=আপনি নতুন বৈশিষ্ট্য, বর্ধিতকরণ, এবং কর্মক্ষমতা উন্নতি উপভোগ করতে সর্বশেষ সংস্করণে আপডেট করতে পারেন৷ update_release_notes=মুক্তির চিরকুট update_check_for_update=হালনাগাদ পরীক্ষা করুন update_checking_for_update=হালনাগাদ পরীক্ষা করা হচ্ছে update_no_update=আপনি সর্বশেষ সংস্করণ ব্যবহার করছেন update_check_error=ত্রুটি যখন হালনাগাদ পরীক্ষা করা হচ্ছে update_app_updated_to_version_n=অ্যাপ {{version}} সংস্করণে আপডেট হয়েছে create_desktop_entry=ডেস্কটপ এন্ট্রি তৈরি করুন shutdown_alert=শাট ডাউন সতর্কতা system_shutdown_soon=সিস্টেম শীঘ্রই বন্ধ হয়ে যাবে\! system_shutdown_failed=সিস্টেম শাট ডাউন ব্যর্থ হয়েছে\! system_shutdown_soon_description=সিস্টেমটি শীঘ্রই বন্ধ হয়ে যাবে। আপনি যদি এখনও কম্পিউটার ব্যবহার করেন, তাহলে অনুগ্রহ করে আপনার কাজটি সংরক্ষণ করুন অথবা শাটডাউন বাতিল করুন। system_shutdown_reason_queue_completed=সারিতে থাকা সমস্ত ডাউনলোড সম্পূর্ণ হয়েছে। system_shutdown_reason_queue_end_time_reached=ডাউনলোড সারির নির্ধারিত সমাপ্তির সময় শেষ হয়ে গেছে। system_shutdown_download_finished=ডাউনলোড সম্পন্ন হয়েছে। shutdown_now=এখনই বন্ধ করুন settings_per_host_settings_new_host=<নতুন হোস্ট> settings_per_host_settings_not_selected=প্রথমে একটি নতুন আইটেম তৈরি করুন বা নির্বাচন করুন\! settings_per_host_settings_host=হোস্ট settings_per_host_settings_host_description=এই সেটিংস এই হোস্টনামের সাথে মিলে যাওয়া ডাউনলোডগুলিতে প্রয়োগ করা হবে। ওয়াইল্ডকার্ড (*) সমর্থিত (যেমন, example.com, *.example.com — শুধুমাত্র একটি ব্যবহার করুন)। settings_browser_in_launcher=Browser Icon In Launcher settings_browser_in_launcher_description=Show or hide the browser icon in the launcher (app list). sort_by=অনুসারে সাজান welcome=স্বাগতম new_folder=নতুন ফোল্ডার skip=এড়িয়ে যান lets_go=চলুন যাই next=পরবর্তী select_all=সবগুলো নির্বাচন করুন select_inside=ভিতরে নির্বাচন করুন select_invert=বাহিরে নির্বাচন করুন open_settings=সেটিংস খুলুন back=ফিরে যান service_is_running=পরিষেবা চলছে initial_setup_description=আসুন জিনিসগুলি সেট আপ করি initial_setup_notice=আপনি পরে যেকোনো সময় এই সেটিংস পরিবর্তন করতে পারেন permission_granted=অনুমতি দেওয়া হয়েছে permission_not_granted=অনুমতি দেওয়া হয়নি permissions=অনুমতি give_permission=অনুমতি দিন give_storage_permission=স্টোরেজ অ্যাক্সেসের অনুমতি দিন storage_roots=Storage Roots permissions_initial_title=অনুমতি সেটআপ permissions_initial_description=সঠিকভাবে কাজ করার জন্য, অ্যাপটির কয়েকটি অনুমতির প্রয়োজন। পরবর্তী স্ক্রিনে, আপনি প্রতিটি অনুমতি কীসের জন্য ব্যবহার করা হয়েছে তা দেখতে পাবেন এবং আপনি সিদ্ধান্ত নিতে পারেন কোনটি অনুমতি দেবেন বা এড়িয়ে যাবেন। permissions_done_title=আপনি সম্পূর্ণ প্রস্তুত\! permissions_done_description=সবকিছু প্রস্তুত।সমস্ত প্রয়োজনীয় অনুমতি দেওয়া হয়েছে এবং অ্যাপটি ব্যবহার করা ভাল। permissions_manage_storage_title=স্টোরেজ অ্যাক্সেস পরিচালনা করুন permissions_manage_storage_reason=এই অনুমতি অ্যাপটিকে ডাউনলোড ফোল্ডার পরিবর্তন করতে, ডুপ্লিকেট ডাউনলোডগুলি আরও সঠিকভাবে সনাক্ত করতে এবং কিছু অতিরিক্ত বৈশিষ্ট্য সক্ষম করতে দেয়৷ এটি ঐচ্ছিক, কিন্তু সেরা অভিজ্ঞতার জন্য প্রস্তাবিত। permission_read_write_external_storage_title=স্টোরেজ পড়া এবং লিখুন permission_read_write_external_storage_reason=এই অনুমতি অ্যাপটিকে ডাউনলোড করা ফাইলগুলি সংরক্ষণ এবং পরিচালনা করতে, ডাউনলোডের অবস্থান পরিবর্তন করতে এবং ডুপ্লিকেট ডাউনলোড সনাক্তকরণ উন্নত করতে দেয়৷ permissions_post_notification_title=বিজ্ঞপ্তি পোস্ট করুন permissions_post_notification_reason=ডাউনলোড পরিচালনা করতে অ্যাপটিকে ব্যাকগ্রাউন্ডে চালাতে হবে। আপনাকে অবগত রাখতে এবং ব্যাকগ্রাউন্ড অপারেশনের অনুমতি দিতে বিজ্ঞপ্তিগুলি ব্যবহার করা হয়৷ permissions_ignore_battery_optimization_title=ব্যাটারি অপ্টিমাইজেশান উপেক্ষা করুন permissions_ignore_battery_optimization_reason=কিছু ডিভাইস ব্যাটারি বাঁচাতে আক্রমনাত্মকভাবে ব্যাকগ্রাউন্ড অ্যাক্টিভিটি সীমিত করে, যা অ্যাপ খোলা না থাকলে ডাউনলোড থামাতে বা বন্ধ করতে পারে। ডাউনলোডগুলি অবিচ্ছিন্নভাবে চলতে থাকে তা নিশ্চিত করতে আপনি বিকল্পভাবে অ্যাপটিকে ব্যাটারি অপ্টিমাইজেশন থেকে বাদ দিতে পারেন open_in_browser=ব্রাউজারে খুলুন browser=ব্রাউজার browser_new_tab=New Tab browser_close_tab=Close Tab browser_open_in_new_tab=নতুন ট্যাবে খুলুন browser_open_in_new_background_tab=ব্যাকগ্রাউন্ডে নতুন ট্যাবে খুলুন browser_no_tab_open=কোন ট্যাব খোলা নেই browser_tabs=ট্যাব browser_paste_and_go=পেস্ট করুন ও এতে যান browser_bookmarks=Bookmarks browser_add_bookmark=Add Bookmark browser_edit_bookmark=Edit Bookmark browser_add_to_bookmarks=Add To Bookmarks browser_remove_from_bookmarks=Remove From Bookmarks ================================================ FILE: shared/resources/src/commonMain/resources/com/abdownloadmanager/resources/locales/bqi_IR.properties ================================================ app_title=دؽوۉداری دانلود کوۊ confirm_auto_categorize_downloads_title=کتن بندی خوتکار دانلودا confirm_auto_categorize_downloads_description=هر موورد بؽ کتن بندی، و جۊر خوتکار و کتن بندی مربۊت ب خوس ٱوورده ابۊ. confirm_reset_to_default_categories_title=وورگندن و کتن بندیا پؽش فرز confirm_reset_to_default_categories_description=ای کار پوی کتن بندیا ن پاک اکونه وو کتن بندیا پؽش فرز ن اوورگنه\! confirm_delete_download_items_title=تاییڌ پاک کردن confirm_delete_download_items_description=الن اخۊین {{count}} موورد ن پاک کۊنین confirm_delete_download_unfinished_items_description=الن اخۊین {{count}} دانلود تموم نوابیڌه ن پاک کۊنین؟ confirm_delete_download_finished_and_unfinished_items_description=الن اخۊین {{finishedCount}} دانلود تموم وابیڌه وو {{unfinishedCount}} دانلود تموم نوابیڌه ن پاک کۊنین؟ also_delete_file_from_disk=هومچیناکو فایلن ز ری دیسک هم پاک کۊنین confirm_delete_category_item_title=کتن بندی {{name}} هونی پاک ابۊ confirm_delete_category_item_description=الن اخۊی کتن بندی "{{value}}" ن پاک کۊنی؟ your_download_will_not_be_deleted=دانلودا ایسا پاک نؽبۊن drag_the_file_to_another_app=فایل ن و برنومه دیری بکشین drop_link_or_file_here=لینگ یا فایل ن ایچو بنین. nothing_will_be_imported=لینگی و من نیا\! n_links_will_be_imported={{count}} لینگ و من ایا n_items_selected={{count}} موورد پسند وابیڌه window_close=بستن window_minimize=کۊچیر کردن window_maximize=گپ کردن window_restore=وورگندن delete=پاک کردن remove=پاک کردن cancel=رڌ کردن close=بستن menu=Menu more_options=More Options ok=خا add=ٱووردن paste=Paste change=آلشت edit=آلشت change_anyway=و هر هال آلشتس بڌه download=دانلود refresh=وانۊ کردن settings=سامووا on_completion=دیندا unknown=ن دیاری unknown_error=ختا ن دیاری download_item_not_found=موورد دانلود ن نجوست name=نوم download_link=لینگ دانلود not_finished=تموم نوابیڌه all=پوی finished=تموم وابی Unfinished=تموم نکرده canceled=رڌ وابیڌه error=ختا paused=واڌاشته downloading=هونی دانلود ابۊ added=ٱوورده وابیڌه idle=بؽ کار preparing_file=هونی فایل ن ٱماڌه اکونه creating_file=هونی فایل ن وورکل اکونه resuming=هونی ز سر اگره retrying=هونی ز نۊ قپ ریت اکونه list_is_empty=نومگه پتی هڌ\! search_in_the_list=پیتینیڌن من نومگه search=پیتینیڌن clear=روفتن general=پوی وولاتی enabled=فعال disabled=قیر فعال default=خوتکار file=فایل tasks=کارا tools=ٱوزارا help=هیاری system=سیستوم all_missing_files=پوی فایلا ز دست رئڌه all_finished=پوی تموم وابیڌه یل all_unfinished=پوی تموم نوابیڌه یل entire_list=پوی نومگه download_browser_integration=ی جۊر کردن دانلود وا گشت گر exit=و در زیڌن show_downloads=نشووݩ داڌن دانلودا new_download=دانلود نۊ stop_all=واڌاشتن پوی import_from_clipboard=و من ٱووردن ز کلیپ بورد batch_download=دانلود کتنی open=گۊشیڌن share=Share open_file=گۊشیڌن فایل open_folder=گۊشیڌن دوبلگه resume=رئڌن وا پؽش pause=واڌاشتن restart_download=ناهاڌن پا دانلود دووارته copy=لف گیری کردن copy_link=لف گیری لینگ copy_as_curl=جۊر cURL لف گیری بۊ show_properties=نشووݩ داڌن ویژیی یل move_to_queue=جا گورو و سف move_to_this_queue=جا گورو و ای سف move_to_category=جا گورو و کتن بندی move_to_this_category=جا گورو و ای کتن بندی categories=کتن بندی یل add_category=ٱووردن کتن بندی edit_category=آلشت کتن بندی delete_category=پاک کردن کتن بندی category_name=نوم کتن بندی category_download_location=جاگه زفت کردن کتن بندی category_download_location_description=هر سا ای کتن بندی من بلگه "ٱووردن دانلود" پسند وابی ای تور ن سی زفت کردن فایل و کار اگره category_file_types=نوء فایلا کتن بندی category_file_types_description=و جۊر خوتکار ای نوع فایلا و ای کتن بندی ازاف ابۊن (هرسا ک ی دانلود نۊ ازاف ابۊ)\n«فاسله» ن سی سوا کردن نوع فایلا و کار بگیرین (ext1 ext2 ext3...) category_url_patterns=اۊلگۊ یل URL category_url_patterns_description=Automatically put download from these URLs to this category. (when you add new download)\nSeparate URLs with space, you can also use * for wildcard auto_categorize_downloads=کتن بندی خوتکار دانلودا restore_defaults=وورگندن پؽش فرزا about=زبار version_n=نوسخه {{value}} developed_with_love_for_you=وا ❤️ سی ایسا وورکل وابیڌه donate=لادراری مالی visit_the_project_website=سایت پوروژه ن بنیرین this_is_a_free_and_open_source_software=ای برنومه مۊفتی وو بونچک واز هڌ view_the_source_code=کود بونچک ن بنیرین third_party_libraries=Third Party Libraries powered_by_open_source_software=جۉݩ گرؽڌه ز برنومه یل بونچک واز view_the_open_source_licenses=نشووݩ داڌن موجوزا بونچک واز support_and_community=لادراری وو بونکۊ telegram=تلگرام channel=تورگه group=بونکۊ add_download=ٱووردن دانلود add_multi_download_page_header=مووردایی ک اخۊین دانلود بۊن پسند کۊنین save_to=زفت کردن من where_should_each_item_saved=هر موورد کوئجه زفت بۊ؟ there_are_multiple_items_please_select_a_way_you_want_to_save_them=چند موورد هڌه\! روش زفت کردن ن پسند کۊنین each_item_on_its_own_category=هر موورد من کتن بندی خوس each_item_on_its_own_category_description=هر موورد من کتن بندی خوس و ری نوء فایل جا اگره all_items_in_one_category=پوی مووردا من ی کتن بندی all_items_in_one_category_description=پوی فایلا من کتن پسند بیڌه زفت ابۊن all_items_in_one_Location=پوی مووردا من ی جاگه all_items_in_one_Location_description=پوی مووردا من دوبلگه پسند بیڌه زفت ابۊن unselected_all_items_in_specific_location_description=پوی فایلا من کتن بندی پسند بیڌه زفت ابۊن no_category_selected=کتن بندی پسند نوابیڌه no_categories_found=کتن بندی نجۊرست download_location=جاگه دانلود location=جاگه select_queue=پسند سف without_queue=بؽ سف use_category=و کار گرؽڌن کتن بندی cant_write_to_this_folder=نتره من ای دوبلگه هؽل کونه file_name_already_exists=نوم فایلن هڌه download_already_exists=دانلود ز زیتر بیڌس invalid_file_name=نوم فایل زبال نؽ show_solutions=نشووݩ داڌن ره هلا... change_solution=آلشت ره هل select_a_solution=پسند ی ره هل select_download_strategy_description=لینگی ک داڌین من نومگه دانلود هڌس، دیاری کۊنین ک اخۊین چ کاری ٱنجوم دین download_strategy_add_a_numbered_file=ٱووردن فایل وا شوماره download_strategy_add_a_numbered_file_description=ٱووردن شوماره دیندا نوم فایل دانلود download_strategy_override_existing_file=ز نۊ هؽل کردن فایلی ک هڌس download_strategy_override_existing_file_description=پاک کردن دانلودی ک هڌس وو هؽل کردن ری اۊ فایل download_strategy_update_download_link=ورۊ رسۊوی دانلودی ک هڌس download_strategy_update_download_link_description=ورۊ کردن لینگ وو موجوزا دانلودی ک هڌس download_strategy_show_downloaded_file=نشووݩ داڌن فایل دانلود وابیڌه download_strategy_show_downloaded_file_description=نشووݩ داڌن موورد دانلودی ک هڌس سی رئڌن وا پؽش یا گۊشیڌن batch_download_link_help=لینگی و من بیارین ک کاراکترا جاگۊزین (wildcard) ن داشته بۊ (* ن و کار بگیرین) invalid_url=نشۊوی زبال نؽ list_is_too_large_maximum_n_items_allowed=نومگه قلوه گپ هڌ\! هدکسر {{count}} موورد موجاز هڌ enter_range=زیڌن تلایه range_from=ز range_to=تا batch_download_wildcard_length=تۊل کاراکتر wildcard first_link=لینگ نیایی last_link=لینگ دیندایی open_source_software_used_in_this_app=برنومه یل بونچک واز و کار گرؽڌه من ای برنومه links=لینگا website=وب سایت developers=وورکل کونووݩ source_code=کود بونچک license=جواز no_license_found=جوازی نجوست organization=سازمووݩ add_new_queue=ٱووردن سف نۊ queue_name=نوم سف queues=سفا stop_queue=واڌاشتن سف start_queue=ر وندن سف clear_queue_items=پتی کردن سف config=سامووݩ items=مووردا move_down=بلم بیڌن move_up=ب روء بیذن remove_queue=پاک کردن سف queue_name_help=نومی سی ای سف دیاری کۊنین queue_name_describe=نوم سف {{value}} هڌ queue_max_concurrent_download=هدکسر دانلود وایکی queue_max_concurrent_download_description=هدکسر دانلود وایکی سی ای سف queue_automatic_stop=واڌاشتن خوتکار queue_automatic_stop_description=واڌاشتن خوتکار سف هر سا ک مووردی منس نؽ queue_scheduler=مجال بندی queue_enable_scheduler=فعال کردن مجال بندی queue_active_days=رۊزا فعال queue_active_days_description=من چ رۊزایی وا مجال بندی فعال بۊوه؟ queue_scheduler_enable_auto_start_time=فعال کردن مجال ر وندن خوتکار queue_scheduler_auto_start_time=مجال ر وندن خوتکار queue_scheduler_enable_auto_stop_time=فعال کردن مجال واڌاشتن خوتکار queue_scheduler_auto_stop_time=مجال واڌاشتن خوتکار queue_shutdown_on_completion=دیندا سیستوم کۊر بۊ queue_shutdown_on_completion_description=کۊر کردن خوتکار سیستوم مجالی ک ای سف تموم بۊ یا زمووݩ تموم بیڌن برنومه ریزی وابیڌس برسه. appearance=شؽوات download_engine=موتور دانلود browser_integration=ی جۊر کردن وا گشت گر settings_download_max_retries_count=هدکسر قپ ریتا دووارته سی دانلود settings_download_max_retries_count_description=هدکسر کرتایی ک برنومه قپ ریت اکونه تا دانلود نامووفق ن ز نۊ ٱنجوم بڌه پؽش ز یو ک تسلیم بۊ settings_download_max_retries_count_describe_no_retries=سی دانلودا نامووفق دووارته قپ ریت نؽکونه settings_download_max_retries_count_describe_n_retries=سی دانلودا نامووفق {{count}} کرت قپ ریت اکونه settings_download_thread_count=تئداد منپیزا settings_download_thread_count_description=هدکسر تئداد منپیزا سی هر موورد دانلود settings_download_thread_count_describe=هر دانلود تره تا {{count}} منپیز داشته بۊ settings_download_thread_count_with_large_value_describe=Warning\: Setting a high thread count may increase system resource usage, reduce performance, or cause connection issues with servers. Use higher values only if you understand the potential impact on your system and network. settings_use_server_last_modified_time=و کار گرؽڌن مجال دیندایی آلشت سرور settings_use_server_last_modified_time_description=مجال دانلود، زمووݩ آلشت دیندایی سرور سی فایل مهلی و کار اگؽره settings_append_extension_to_incomplete_downloads=ٱووردن پسوند و دانلودا تموم نوابیڌه settings_append_extension_to_incomplete_downloads_description=Append ".part" extension to incomplete downloads. This helps to identify unfinished downloads and prevents accidental opening of incomplete files. settings_use_sparse_file_allocation=وورکل فایل و جۊر پۊیا Sparse settings_use_sparse_file_allocation_description=Create files more efficiently, especially on SSDs, by reducing unnecessary data writing. This can speed up download starts and reduce disk usage. If downloads start slowly or you experience unusual download speeds, consider disabling this option, as it may not be fully supported on some devices. settings_ignore_ssl_certificates=نیڌه گرؽڌن گوواهی یل SSL settings_ignore_ssl_certificates_description=واجۊری گوواهی SSL ن قیرفعال اکونه. تینا ٱر لنگس هڌین ای گۊزینه ن و کار بگیرین، چیناکه گاشڌ سی منپیز ایسا خترا ٱمنیتی پؽش بیا. settings_global_speed_limiter=مئدۊد کوݩ ترات کۊلی settings_global_speed_limiter_description=ترات کۊلی دانلود و ای مقدار مئدۊد ابۊ (0 و مئنی بؽ مئدۊدیت) settings_show_average_speed=نشووݩ داڌن ترات هندا منجا settings_show_average_speed_description=ترات دانلود جۊر هندا منجا یا دییق نشووݩ داڌه ابۊ settings_use_category_by_default=و کار گرؽڌن کتن بندی جۊر پؽش فرز settings_use_category_by_default_description=مجال ٱووردن دانلود، هالت پؽش فرز کتن بندی ن و کار بگره. settings_default_download_folder=دوبلگه دانلود پؽش فرز settings_default_download_folder_description=مجالی ک دانلود نۊیی ن ٱوۊردین، ای جاگه سی جاگه پؽش فرز و کار اره settings_default_download_folder_describe="{{folder}}" و کار اروه settings_use_proxy=و کار گرؽڌن پروکسی settings_use_proxy_description=سی دانلود فایلا پروکسی ن و کار بگیرین settings_use_proxy_describe_no_proxy=پروکسی و کار گرؽڌه نؽبۊ settings_use_proxy_describe_system_proxy=پروکسی سیستوم و کار اروه settings_use_proxy_describe_manual_proxy="{{value}}" و کار اروه settings_use_proxy_describe_pac_proxy=فایل pac وا ای نشۊوی و کار اروه\: {{value}} settings_track_deleted_files_on_disk=رئگیری فایلا ز دست رئڌه ز ری ویرگه settings_track_deleted_files_on_disk_description=ٱر فایلا ز تور دانلود پاک یا جا گورو وابین، و جۊر خوتکار ز نومگه دانلود پاک ابۊن. settings_delete_partial_file_on_download_cancellation=پاک کردن فایل تموم نوابیڌه مجال رڌ کردن دانلود settings_delete_partial_file_on_download_cancellation_description=When a download is canceled, the partially downloaded file will be deleted from the disk. This helps keep your download folder clean and reduces unnecessary disk space usage. However, the download will restart from the beginning the next time you start it. settings_default_user_agent=User-Agent پؽش فرز settings_default_user_agent_description=Specify the Default-User Agent string to define how requests identify to servers. This can help in accessing content optimized for particular devices or in circumventing download limitations imposed by certain websites. settings_download_size_unit=واهڌ هندا دانلود settings_download_size_unit_description=واهڌ و کار گرؽڌه سی نشووݩ داڌن هندا دانلود settings_download_speed_unit=واهڌ ترات دانلود settings_download_speed_unit_description=واهڌ و کار گرؽڌه سی نشووݩ داڌن ترات دانلود settings_theme=تم settings_theme_description=پسند تم برنومه settings_default_dark_theme=تم تاریک پؽش فرز settings_default_dark_theme_description=مجالی ک برنومه تم سیستوم ن و دین اکونه وو تم سیستوم تاریک هڌ ای تم و کار اروه settings_default_light_theme=تم رۊشنا پؽش فرز settings_default_light_theme_description=مجالی ک برنومه تم سیستوم ن و دین اکونه وو تم سیستوم رۊشنا هڌ ای تم و کار اروه settings_font=فونت settings_font_description=آلشت فونتی ک من برنومه و کار اروه. ی قرد ز فونتا گاشڌ من ای برنومه و خۊوی نشووݩ داڌه نبۊن. settings_ui_scale=هندا رابت منتوری settings_ui_scale_description=آلشت هندا المان وو هؽلا من بلگه یل settings_language=زووݩ settings_compact_top_bar=نوار رویی جم وو جۊر settings_compact_top_bar_description=شؽونیڌن نوار رویی وا نوار وارسۊوی مجالی ک نیمدری ٱسلی و هندایی ک بس بۊ جا داشته بۊ settings_use_native_menu_bar=و کار گرؽڌن نوار نومگه سیستوم settings_use_native_menu_bar_description=نوار نومگه پؽش فرز سیستوم و کار گرؽڌه بۊ settings_use_relative_date_time=و کار گرؽڌن زمووݩ نسبی settings_use_relative_date_time_description=قالوو زمووݩ/ویرگار نسبی ن سی نشووݩ داڌن ویرگارا من برنومه و کار بگیرین (جۊر «2 رۊز پؽش» و جا ویرگار وو زمووݩ دییق) settings_show_icon_labels=نشووݩ داڌن هؽل آیکونا settings_show_icon_labels_description=لیبلا ٱر ک بۊوه زؽر آیکونا نشووݩ داڌه ابۊن (جۊر دویمه یل نوار ٱوزار بلگه ٱسلی) settings_use_system_tray=و کار گرؽڌن System Tray settings_use_system_tray_description=نشووݩ داڌن System Tray مجالی ک برنومه ر وسته settings_start_on_boot=ر وستن مجال و من ٱووڌن و سیستوم settings_start_on_boot_description=ر وستن خوتکار برنومه مجال و من ٱووڌن منتور settings_notification_sound=دونگ وارسۊوی settings_notification_sound_description=پشک دونگ مجال وارسۊوی نۊ settings_browser_integration=ی جۊر کردن وا گشت گر settings_browser_integration_description=گرؽڌن دانلودا ز گشت گر settings_browser_integration_server_port=پورت سرور settings_browser_integration_server_port_description=پورت سی ی جۊر کردن وا گشت گر settings_browser_integration_server_port_describe=برنومه ز پورت {{port}} گۊش اگره settings_dynamic_part_creation=وورکل پارت و جۊر پۊیا settings_dynamic_part_creation_description=مجالی ک ی پارت کامل وابی، پارت دیری وورکل ابۊ تا ترات دانلود قلوه بۊ settings_show_completion_dialog=نشووݩ داڌن نیمدری کامل وابیڌن دانلود settings_show_completion_dialog_description=هر سا ک ی دانلود تموم وابی و جۊر خوتکار نیمدری تموم وابیڌن دانلود نشووݩ داڌه ابۊ. settings_show_download_progress_dialog=نشووݩ داڌن نیمدری پؽش رئڌن دانلود settings_show_download_progress_dialog_description=هر سا ک ی دانلود ر وست و جۊر خوتکار نیمدری پؽش رئڌن دانلود نشووݩ داڌه ابۊ. settings_per_host_settings=سامووا سی هر هاست settings_per_host_settings_descriptions=ای سامووا و جۊر خوتکار ری دانلودا نۊیی ک ی هاست دیاری ن و کار اگرن ائمال ابۊ. settings_download_max_concurrent_downloads=Maximum Concurrent Downloads settings_download_max_concurrent_downloads_description=The maximum number of files that can be downloaded at the same time (downloads managed by queues are not counted; set to 0 for unlimited) download_item_settings_speed_limit=مئدۊدیت دانلود download_item_settings_speed_limit_description=مئدۊدیت ترات دانلود سی ای موورد download_item_settings_show_download_completion_dialog=نشووݩ داڌن نیمدری کامل وابیڌن دانلود download_item_settings_show_download_completion_dialog_description=هر سا ک ای دانلود تموم وابی و جۊر خوتکار بلگه کامل وابیڌن دانود نشووݩ داڌه ابۊ. download_item_settings_shutdown_on_completion=دیندا سیستوم کۊر بۊ download_item_settings_shutdown_on_completion_description=کۊر کردن خوتکار سیستوم مجالی ک ای دانلود تموم وابی. download_item_settings_thread_count=تئداد منپیزا download_item_settings_thread_count_description=چند تا منپیز سی ای دانلود و کار گرؽڌه بۊ (0 سی پؽش فرز) download_item_settings_thread_count_describe={{count}} منپیز سی ای دانلود download_item_settings_username_description=ٱر لینگ ائراز هۊویت ز ایسا اخو، نوم منتوری ن بڌین download_item_settings_password_description=ٱر لینگ ائراز هۊویت ز ایسا اخو، رزم ن بڌین download_item_settings_download_page=بلگه دانلود download_item_settings_download_page_description=بلگه سایتی ک ای دانلود ز من اوچو وورکل وابیڌه download_item_settings_file_checksum=امزا فایل download_item_settings_file_checksum_description=وا و کار گرؽڌن ای کود ترین واجۊری کۊنین ک فایل و خۊوی دانلود وابیڌه یا ن download_item_settings_user_agent=User-Agent download_item_settings_user_agent_description=ی User-Agent سیخومی سی و کار گرؽڌن من ای دانلود (سی و کار گرؽڌن مقدار پؽش فرز، بؽلینس پتی بۊ) file_checksum=امزا فایل file_checksum_page=واجۊری کوݩ امزا فایل file_checksum_page_file_checksum_default_algorithm=ٱلگوریتم پؽش فرز file_checksum_page_file_checksum_default_algorithm_help=ٱر ک امزا فایلا داڌه نبۊن ز ای ٱلگۊریتم پؽش فرز سی هساو کردن امزا فایل و کار اروه. start=ر وندن calculated_checksum=امزا هساو وابیڌه saved_checksum=امزا زفت وابیڌه checksum_algorithm=ٱلگوریتم file_not_found=فایل ن نجوست download_not_finished=دانلود کامل نوابیڌه done=ٱنجوم وابی waiting=مندیر matches=جۊر یکن not_matches=جۊر یک نؽن copy_to_clipboard=لف گیری من کلیپ بورد username=نوم منتوری password=رزم average_speed=ترات منجا exact_speed=ترات دییق unlimited=نا مئدۊد use_global_settings=و کار گرؽڌن سامووا پوی وولاتی cant_run_browser_integration=نتره ی جۊر کردن گشت گر ن ر ونه cant_open_file=نتره فایل ن بوگوشه cant_open_folder=نتره دوبلگه ن بوگوشه # times for example 2 seconds ago relative_time_long_years={{years}} سال relative_time_long_months={{months}} ما relative_time_long_days={{days}} رۊز relative_time_long_hours={{hours}} ساعت relative_time_long_minutes={{minutes}} دیقه relative_time_long_seconds={{seconds}} سانیه relative_time_short_years={{years}} سال relative_time_short_months={{months}} ما relative_time_short_days={{days}} رۊز relative_time_short_hours={{hours}} ساعت relative_time_short_minutes={{minutes}} دیقه relative_time_short_seconds={{seconds}} سانیه relative_time_left={{time}} منده relative_time_ago={{time}} پؽش auto=خوتکار unspecified=ن دیلری custom=دلخا icon=آیکون author=وورکل کوݩ link=لینگ size=هندا status=وزعیت parts_info_downloaded_size=دانلود وابیڌه parts_info_total_size=پوی speed=ترات time_left=مجال باقی منده date_added=ویرگار ٱووڌن info=جۊزعیات download_page_downloaded_size=دانلود وابیڌه download_page_download_completed=دانلود تموم وابی resume_support=امکووݩ ز سر گرؽڌن yes=هری no=ن parts_info=جۊزعیات پارتا disconnected=قت وابی receiving_data=گرؽڌن داده connecting=هونی منپیز ابۊ warning=بپا unsupported_resume_warning=ای دانلود ن نترین ز سر گیرین وو گاشڌ دیندا تر مجبۊر بۊین هونه ز من نومگه دانلود "ریستارت" کۊنین stop_anyway=و هر هال واسته customize_columns=آلشت سۊتۊنا reset=وورنشۊوی monday=دوشمبه tuesday=سه شمبه wednesday=چار شمبه thursday=پنجشمبه friday=جومه saturday=شمبه sunday=ی شمبه proxy_open_system_proxy_settings=گۊشیڌن سامووا پروکسی سیستوم proxy_type=نوء پروکسی proxy_do_not_use_proxy_for=پروکسی ن سی یونووݩ و کار مگر proxy_do_not_use_proxy_for_description=A list of urls that may not be proxied\nYou can use wildcard with *\nfor example 192.168.1.* example.com (space separated) proxy_change_title=آلشت پروکسی change_proxy=آلشت پروکسی proxy_no=بؽ پروکسی proxy_system=پروکسی سیستوم proxy_manual=پروکسی دستی proxy_pac=کانفیگ خوتکار پروکسی (pac) proxy_pac_url=آدرس فایل کانفیگ خوتکار پروکسی address=آدرس port=پورت address_and_port=آدرس وو پورت use_authentication=و کار گرؽڌن ائراز هۊویت warning_you_may_have_to_restart_the_download_later=ایسا گاشڌ دیندا تر مجبۊر بۊین ای دانلود ن دووارته ر ونین\! edit_download_title=آلشت دانلود edit_download_update_from_download_page=ورۊ رسۊوی ز بلگه دانلود edit_download_update_from_download_page_description=When this window is open, you can go to the Download Page and click the download button. The app will capture and update the new download credentials so you can save them. edit_download_saved_download_item_size_not_match=موورد دانلود وا هندا {{currentSize}} زفت وابیڌه، ک وا هندا نۊ {{newSize}} ی جۊر نؽ. translators_page_thanks=ممنووݩ دار هونووی هڌیم ک من ولرنیڌن ای پوروژه هیاری کردن ❤️ translators=ولرنی کارووݩ language=زووݩ translators_contribute_title=بؽڌر کردن ولرنیڌنا translators_contribute_description=Want to help improve this project? If your language isn't listed or needs some tweaks, you can contribute your translations and make it better\! contribute=هیاری داڌن meet_the_translators=آشنایی وا ولرنی کارووݩ localized_by_translators=بۊمی وابیڌه و دست ولرنی کارووݩ confirm_exit=تاییڌ و در زیڌن confirm_exit_description=الن اخۊی ز AB Download Manager زنی و در؟\nدانلودا وو سفا فعال، واڌاشته ابۊن\! update=ورۊ رسۊوی update_updater=ورۊ کوننده update_available=ورۊ رسۊوی من دسرس هڌ update_error=Update Error update_available_suggest_to_to_update=ایسا وا ورۊ رسۊوی ترین قابلیتا دیندایی، پؽش رئڌنا وو بؽڌر وابیڌنا عملکردی ن و دست یارین. update_release_notes=ویرداشتا تیجنیڌن update_check_for_update=واجۊری سی ورۊ رسۊوی update_checking_for_update=هونی واجۊری اکونه سی ورۊ رسۊوی update_no_update=ایسا نوسخه دیندایی ن و کار گیریڌینه update_check_error=مجال ورۊ رسۊوی ختایی پؽش ٱووڌ update_app_updated_to_version_n=برنومه و نوسخه {{version}} ورۊ رسۊوی وابی create_desktop_entry=وورکل و من ٱووڌنی دسکتاپ shutdown_alert=هوشدار کۊر بیڌن system_shutdown_soon=سیستوم و هیم زی کۊر ابۊ\! system_shutdown_failed=کۊر کردن سیستوم مووفق نبی\! system_shutdown_soon_description=The system will shut down soon. If you're still using the computer, please save your work or cancel the shutdown. system_shutdown_reason_queue_completed=پوی دانلودا سف تموم کردن. system_shutdown_reason_queue_end_time_reached=زمووݩ تموم وابیڌن برنومه ریزی سی سف دانلود رسیڌه. system_shutdown_download_finished=دانلود تموم وابی. shutdown_now=هیم سکو کۊرس کوݩ settings_per_host_settings_new_host=<هاست نۊ> settings_per_host_settings_not_selected=ٱول ی موورد نۊ وورکل یا پسند کۊنین\! settings_per_host_settings_host=هاست settings_per_host_settings_host_description=These settings will be applied to downloads matching this hostname. Wildcards (*) are supported (e.g., example.com, *.example.com — use only one). settings_browser_in_launcher=Browser Icon In Launcher settings_browser_in_launcher_description=Show or hide the browser icon in the launcher (app list). sort_by=Sort By welcome=Welcome new_folder=New Folder skip=Skip lets_go=Let's Go next=Next select_all=Select All select_inside=Select Inside select_invert=Select Invert open_settings=Open Settings back=Back service_is_running=Service is running initial_setup_description=Let’s set things up initial_setup_notice=You can change these settings anytime later permission_granted=Permission granted permission_not_granted=Permission not granted permissions=Permissions give_permission=Allow permission give_storage_permission=Allow storage access storage_roots=Storage Roots permissions_initial_title=Permissions setup permissions_initial_description=To work properly, the app needs a few permissions. On the next screen, you’ll see what each permission is used for and you can decide which ones to allow or skip. permissions_done_title=You’re all set permissions_done_description=Everything is ready. All required permissions have been granted and the app is good to go. permissions_manage_storage_title=Manage storage access permissions_manage_storage_reason=This permission lets the app change the download folder, detect duplicate downloads more accurately, and enable some extra features. It’s optional, but recommended for the best experience. permission_read_write_external_storage_title=Read and write storage permission_read_write_external_storage_reason=This permission allows the app to save and manage downloaded files, change the download location, and improve duplicate download detection. permissions_post_notification_title=Post Notification permissions_post_notification_reason=The app needs to run in the background to manage downloads. Notifications are used to keep you informed and allow background operation. permissions_ignore_battery_optimization_title=Ignore Battery Optimization permissions_ignore_battery_optimization_reason=Some devices aggressively limit background activity to save battery, which can pause or stop downloads when the app isn’t open. You can optionally exclude the app from battery optimization to ensure downloads continue uninterrupted open_in_browser=Open In Browser browser=Browser browser_new_tab=New Tab browser_close_tab=Close Tab browser_open_in_new_tab=Open In New Tab browser_open_in_new_background_tab=Open In New Background Tab browser_no_tab_open=No tabs are open browser_tabs=Tabs browser_paste_and_go=Paste And Go browser_bookmarks=Bookmarks browser_add_bookmark=Add Bookmark browser_edit_bookmark=Edit Bookmark browser_add_to_bookmarks=Add To Bookmarks browser_remove_from_bookmarks=Remove From Bookmarks ================================================ FILE: shared/resources/src/commonMain/resources/com/abdownloadmanager/resources/locales/ckb_IR.properties ================================================ app_title=AB Download Manager confirm_auto_categorize_downloads_title=ڕێکخستنی بەشەکانی داگرتن بە خۆکاری confirm_auto_categorize_downloads_description=ھەر دابەزاندنێکی پۆلێننەکراو خۆکارانە زیاد دەکرێت بۆ پۆلی پەیوەست پێی. confirm_reset_to_default_categories_title=ڕێکیبخەرەوە بۆ پۆلە بنچینەییەکان confirm_reset_to_default_categories_description=ئەمە ھەموو پۆلەکان دەسڕێتەوە و پۆلە بنچینەییەکان دەھێنێتەوە\! confirm_delete_download_items_title=سڕینەوە دووپات بکەرەوە confirm_delete_download_items_description=دڵنیایت لە سڕینەوەی {{count}} شت؟ confirm_delete_download_unfinished_items_description=دڵنیایت لە سڕینەوەی {{count}} داونڵۆدی تەواونەبوو؟ confirm_delete_download_finished_and_unfinished_items_description=دڵنیایت لە سڕینەوەی {{finishedCount}} داونڵۆدی تەواوبوو و {{unfinishedCount}} دانەی تەواونەبوو؟ also_delete_file_from_disk=لەسەر کۆمپیوتەریش بیسڕەوە confirm_delete_category_item_title=سڕینەوەی پۆلی {{name}} confirm_delete_category_item_description=دڵنیایت لە سڕینەوەی {{value}} شت؟ your_download_will_not_be_deleted=داونڵۆدەکانت ناسڕدرێنەوە drag_the_file_to_another_app=پەڕگەکە ڕاکێشە بۆ بەرنامەیەکی تر drop_link_or_file_here=بەستەر یان پەڕگە بخەرە ئێرە. nothing_will_be_imported=ھیچ ھاوردە ناکرێت n_links_will_be_imported={{count}} بەسەر ھاوردە دەکرێت n_items_selected={{count}} شت دیاری کراوە window_close=داخستن window_minimize=بچووککردنەوە window_maximize=گەورەی بکە window_restore=گێڕانەوە delete=سڕینەوە remove=لایببە cancel=ھەڵوەشاندنەوە close=دایبخە menu=Menu more_options=More Options ok=باشە add=زیادکردن paste=Paste change=بیگۆڕە edit=دەستکاری change_anyway=ھەر چۆنێک بێت بیگۆڕە download=دایبەزێنە refresh=نوێکردنەوە settings=ڕێکخستنەکان on_completion=لە تەواوبووندا unknown=نەزاندراو unknown_error=ھەڵەی نەزاندراو download_item_not_found=ستی داونڵۆد نەدۆزرایەوە name=ناو download_link=بەستەری داونڵۆد not_finished=تەواو نەبووە all=ھەموو finished=تەواوبوو Unfinished=تەواونەبوو canceled=ھەڵوەشێندراو error=ھەڵە paused=ڕاگیراو downloading=دادەبەزێت added=زیاد کرا idle=IDLE preparing_file=پەڕگەکە ئامادە دەکرێت creating_file=پەڕگەکە دروست دەکرێت resuming=بەردەوامبوون retrying=ھەوڵدانەوە list_is_empty=پێڕستەکە بەتاڵە\! search_in_the_list=لە پێڕستەکەدا بگەڕێ search=گەڕان clear=سڕینەوە general=گشتی enabled=کارا disabled=ناکارا default=بنچینیەیی file=پەڕگە tasks=ئەرکەکان tools=ئامرازەکان help=یارمەتی system=سیستەم all_missing_files=ھەموو پەڕگە بزرەکان all_finished=ھەموو تەواوبووەکان all_unfinished=ھەموو تەواونەبووەکان entire_list=پێڕستی تەواو download_browser_integration=یەکخستنی وێبگەڕ دابگرە exit=دەرچوون show_downloads=داونڵۆدەکان پیشان بدە new_download=داونڵۆدی نوێ stop_all=ھەمووی ڕابگرە import_from_clipboard=لە کلیپبۆردەوە ھاوردەی بکە batch_download=داونڵۆدی بە کۆمەڵ open=بیکەرەوە share=Share open_file=پەڕگەکە بکەرەوە open_folder=فۆڵدەرەکە بکەرەوە resume=بەردەوامبوون pause=ڕاگرتن restart_download=داونڵۆدەکە دەستپێبکەرەوە copy=لەبەرگرتنەوە copy_link=لەبەرگرتنەوەی بەستەر copy_as_curl=لەبەرگرتنەوە وەک cURL show_properties=تایبەتمەندییەکان پیشان بدە move_to_queue=بیگوازەرەوە بۆ ڕیز move_to_this_queue=بیگوازەرەوە بۆ ئەم ڕیزە move_to_category=بیگوازەوە بۆ پۆل move_to_this_category=بیگوازەرەوە بۆ ئەم پۆلە categories=پۆلەکان add_category=پۆل زیاد بکە edit_category=پۆل دەستکاری بکە delete_category=پۆل بسڕەوە category_name=ناوی پۆل category_download_location=شوێنی داونڵۆدی پۆلەکە category_download_location_description=کاتێک ئەم پۆلە ھەڵدەبژێردرێت لە "زیادکردنی داونڵۆد" ئەمە وەک "شوێنی داونڵۆد" بەکاربێنە category_file_types=جۆری پەڕگەی پۆلەکە category_file_types_description=خۆکارانە ئەم جۆرە پەڕگانە بخەرە ئەم پۆلەوە. (کاتێک داونڵۆدێکی نوێ زیاد دەکەیت)\nپاشگری پەڕگەکان بە بۆشایی جیا بکەرەوە (پاشگری١ پاشگری٢ ...) category_url_patterns=داڕێژەکانی بەستەر category_url_patterns_description=خۆکارانە داونڵۆدەکان لەم بەستەرەوە بخەرە ئەم پۆلەوە. (کاتێک داونڵۆدێکی نوێ زیاد دەکەیت)\nبەستەرەکان بە بۆشایی جیابکەرەوە، دەشتوانیت * بەکاربھێنیت وەک کارەکتەری جێگرەوە auto_categorize_downloads=خۆکارانە دابەزاندنەکان پۆلێن بکە restore_defaults=بیگەڕێنەرەوە سەر بنچینەییەکان about=دەربارە version_n=وەشانی {{value}} developed_with_love_for_you=بە ❤️ەوە پەرەی پێدراوە donate=بەخشین visit_the_project_website=بچۆرە سەر وێبگەی پڕۆژەکە this_is_a_free_and_open_source_software=ئەمە بەرنامەیەکی خۆڕایی و سەرچاوە کراوەیە view_the_source_code=کۆدی سەرچاوە ببینە third_party_libraries=Third Party Libraries powered_by_open_source_software=لەلایەن بەرنامەی سەرچاوە کراوەوە پاڵپشتیی کراوە view_the_open_source_licenses=مۆڵەتەکانی سەرچاوە کراوە ببینە support_and_community=یارمەتی و کۆمەڵگا telegram=تەلەگرام channel=کەناڵ group=کۆمەڵە add_download=داونڵۆد زیاد بکە add_multi_download_page_header=ئەو شتانە هەڵبژێە کە دەتەوێت ھەڵیانبگریت بۆ داونڵۆد save_to=پاشەکەوتی بکە بۆ where_should_each_item_saved=لە کوێ ھەریەک لە شتەکان پاشەکەوت بکرێت؟ there_are_multiple_items_please_select_a_way_you_want_to_save_them=زیاد لە یەک شت ھەیە\! تکایە ڕێگایەک ھەڵبژێرە بۆ پاشەکەوتکردنیان each_item_on_its_own_category=ھەر شتێک لەسەر پۆلی خۆی each_item_on_its_own_category_description=ھەر شتێک دەخرێتە پۆلێکەوە کە ئەو جۆرە پەڕگەیەی تێدایە all_items_in_one_category=ھەموو شتەکان لە یەک پۆل all_items_in_one_category_description=ھەموو پەڕگەکان پاشەکەوت دەکرێنە پۆلە دیاریکراوەکەوە all_items_in_one_Location=ھەموو شتەکان لە یەک شوێن all_items_in_one_Location_description=ھەموو شتەکان پاشەکەوت دەکرێنە پۆلە دیاریکراوەکەوە unselected_all_items_in_specific_location_description=ھەموو پەڕگەکان پاشەکەوت دەکرێنە شوێنی پۆلە دیاریکراوەکەوە no_category_selected=ھیچ پۆلێک ھەڵنەبژێردراوە no_categories_found=ھیچ پۆلێک نەدرۆزرایەوە download_location=شوێنی داونڵۆد location=شوێن select_queue=ڕیز ھەڵبژێرە without_queue=بەبێ ڕیز use_category=پۆل بەکاربێنە cant_write_to_this_folder=ناوتواندرێت لەسەر ئەم فۆڵدەرە بنووسرێت file_name_already_exists=ناوی پەڕگە بوونی ھەیە download_already_exists=داونڵۆدەکە بوونی ھەیە invalid_file_name=ناوی پەڕگەی نادروست show_solutions=چارەسەرەکان پیشان بدە... change_solution=چارەسەر بگۆڕە select_a_solution=چارەسەرێک ھەڵبژێرە select_download_strategy_description=ئەو بەستەرەی دابینت کردووە بوونی ھەیە لە پێڕستەکانی داونڵۆددا، تکایە دیاری بکە کە دەتەوێت چی بکەیت download_strategy_add_a_numbered_file=پەڕگەیەکی ژمارەکراو زیاد بکە download_strategy_add_a_numbered_file_description=نیشایەنەیەک زیاد بکە لە کۆتاییی ناوی پەڕگەی دابەزیوو download_strategy_override_existing_file=جێی پەڕگەی ھەبوو بگرەوە download_strategy_override_existing_file_description=ئەو داونڵۆدەی ھەیە بیسڕەوە و جێگەی بگرەوە download_strategy_update_download_link=داونڵۆدی ھەبوو نوێبکەرەوە download_strategy_update_download_link_description=بەستەری داونڵۆدی ھەبوو و باوەڕنامەکەی نوێ بکەرەوە download_strategy_show_downloaded_file=پەڕگەی داونڵۆدکراو پیشان بدە download_strategy_show_downloaded_file_description=داونڵۆدی لەوەپێش ھەبوو پیشان بدە، تاکوو بتوانیت کلیک لەسەر بەردەوامبوون بکەیت و بیکەیتەوە batch_download_link_help=بەستەرێک بنووسە کە کارەکتەری جێگرەوەی تێدایە (* بەکاربھێنە) invalid_url=بەستەری نادروست list_is_too_large_maximum_n_items_allowed=پێڕستەکە زۆر گەورەیە\! زۆرترین {{count}} ڕێگەپێدراوە enter_range=مەودایەک بنووسە range_from=لە range_to=بۆ batch_download_wildcard_length=درێژیی کارەکتەری جێگرەوە first_link=یەکەم بەستەر last_link=دوایین بەستەر open_source_software_used_in_this_app=بەرنامە سەرچاوە کراوەکانی لەم بەرنامەیەدا بەکارھاتوون links=بەستەرەکان website=وێبگە developers=پەرەپێدەرەکان source_code=کۆدی سەرچاوە license=مۆڵەت no_license_found=ھیچ مۆڵەتێک نەدۆزرایەوە organization=ڕێکخراو add_new_queue=ڕیزێکی نوێ زیاد بکە queue_name=ناوی ڕیز queues=ڕیزەکان stop_queue=ڕیز ڕابگرە start_queue=ڕیز دەستپێبکە clear_queue_items=ڕیز بەتاڵە config=شێوەپێدان items=شتەکان move_down=بیبەرە خوارەوە move_up=بیبەرە سەرەوە remove_queue=ڕیز بسڕەوە queue_name_help=ناوێک دیاری بکە بۆ ئەم ڕیزە queue_name_describe=ناوی ڕیز {{value}}ە queue_max_concurrent_download=زۆرترین داونڵۆدی ھاوکات queue_max_concurrent_download_description=زۆرترین داونڵۆد بۆ ئەم ڕیزە queue_automatic_stop=ڕاگرتنی خۆکار queue_automatic_stop_description=خۆکارانە ڕیز بوەستێنە کە ھیچی تێدا نەبوو queue_scheduler=خشتەکردن queue_enable_scheduler=خشتەدانان کارا بکە queue_active_days=ڕۆژە چالاکەکان queue_active_days_description=خشتەکە لە چ ڕۆژێکدا کار بکات؟ queue_scheduler_enable_auto_start_time=کاتی دەستپێکردنی خۆکارانە کارا بکە queue_scheduler_auto_start_time=کاتی دەستپێکردنی خۆکار queue_scheduler_enable_auto_stop_time=کاتی ڕاگرتنی خۆکارانە کارا بکە queue_scheduler_auto_stop_time=کاتی ڕاگرتنی خۆکار queue_shutdown_on_completion=سیستەمەکە بکوژێنەوە لەگەڵ تەواوبوون queue_shutdown_on_completion_description=خۆکارانە سیستەمەکە بکوژێنەرەوە کاتێک ڕیزەکە تەواو بوو، یان کە گەیشتیتە کاتی دیاریکراو. appearance=دەرکەوتن download_engine=بزوێنەری داونڵۆد browser_integration=یەکخستنی وێبگەڕ settings_download_max_retries_count=زۆرترین ڕێژەی ھەوڵدانەوەکانی داونڵۆد settings_download_max_retries_count_description=زۆرترین ڕێژەی ئەو کاتانەی کە بەرنامەکە ھەوڵی دووبارەکردنەوەی داونڵۆدێکی شکتخواردوو دەدات پێش وازھێنان settings_download_max_retries_count_describe_no_retries=داونڵۆدە شکستخواردووەکان ھەوڵیان لەگەڵدا نادرێتەوە settings_download_max_retries_count_describe_n_retries=داونڵۆدە شکستخواردووەکان {{count}} جار ھەوڵیان لەگەڵدا دەدرێتەوە settings_download_thread_count=ژمارەی تاڵەکان settings_download_thread_count_description=زۆرترین ڕێژەی تاڵەکانی داونڵۆد بۆ ھەر داونڵۆدێک settings_download_thread_count_describe=داونڵۆدێک دەکرێت تا {{count}} تاڵی ھەبێت settings_download_thread_count_with_large_value_describe=ئاگاداری\: دانانی ژمارەی تاڵی زۆر لەوانەیە بەکارھێنانی سەرچاوەکانی سیستەمەکەت زیاد بکات، سیستەمەکەت خاو بکاتەوە، یان تووشی کێشەت بکات لەگەڵ پەیوەستبوون بە سێرڤەرەوە.\nبەھای بەرزتر بەکاربھێنە ئەگەر لەو کاریگەرییانە تێدەگەیت کە لەسەر سیستەم و تۆڕەکەت دەبێت. settings_use_server_last_modified_time=کاتی دوایین گۆڕانکاریی سێرڤەر بەکاربھێنە settings_use_server_last_modified_time_description=کاتێک پەڕگەیەک دادەبەزێت، کاتی دوایین گۆڕانکاریی سێرڤەر بەکاربھێنە بۆ پەڕگەکە settings_append_extension_to_incomplete_downloads=پاشکۆیەک زیاد بکە بۆ داونڵۆدە تەواونەبووەکان settings_append_extension_to_incomplete_downloads_description=پاشکۆی ".part" زیاد بکە بۆ داونڵۆدە تەواونەبووەکان. ئەمە یارمەتیدەر دەبێت لە جیاکردنەوەی داونڵۆدە تەواونەبووەکان و ڕێگری دەکات لە کردنەوەی پەڕگە تەواونەبووەکان. settings_use_sparse_file_allocation=دابەشکردنی پەڕگە شاشەکان settings_use_sparse_file_allocation_description=پەڕگەکان بە شێوەیەکی باشتر دروست بکە، بە تایبەتی لەسەر ھاردی ئێس ئێس دی، بە کەمکردنەوەی نووسینی داتای ناپێویست. ئەمە دەتوانێت خێراییی داونڵۆدەکان باشتر بکات و بەکارھێنانی بیرگە کەم بکاتەوە. ئەگەر داونڵۆدەکە بە خاوی دەستپێدەکات یان خێراییی داونڵۆدەکانت نائاسایین، باشترە کە ئەمە ناچالاک بکەیت، چونکە لەوانەیە ھەموو ئامێرێک پاڵپشتیی نەکەن. settings_ignore_ssl_certificates=مۆڵەتی SSL پشتگوێ بخە settings_ignore_ssl_certificates_description=سەلماندنی مۆڵەتی SSL ناچالاک بکە. تەنیا ئەگەر پێویست بوو بەکاری بھێنە، چونکە دەکرێت پەیوەندییەکەت ڕووبەڕووی کێشەی سەلامەتی بکاتەوە. settings_global_speed_limiter=سنووردارکەری خێرایی سەرتاسەری settings_global_speed_limiter_description=سنووری خێراییی داونڵۆدی سەرتاسەری (٠ واتە بێسنوور) settings_show_average_speed=خێرایی مامناوەند پیشان بدە settings_show_average_speed_description=خێراییی داونڵۆد بە مامناوەند یان بە تەواوەتی settings_use_category_by_default=پۆلەکە بەکاربێنە بە شێوەی بنچینەیی settings_use_category_by_default_description=ئەم پۆلە وەک پۆلی بنچینەیی بەکاربھێنە لەکاتی زیادکردنی داونڵۆد. settings_default_download_folder=فۆڵدەری داونڵۆدەی بنچینەیی settings_default_download_folder_description=کاتێک داونڵۆدێک زیاد دەکەیت ئەم شوێنە وەکوو شوێنی بنچینەیی بەکاردێت settings_default_download_folder_describe="{{folder}}" بەکاردێت settings_use_proxy=پڕۆکسی بەکاربھێنە settings_use_proxy_description=پڕۆکسی بەکاربھێنە بۆ داونڵۆدکردنی پەڕگەکان settings_use_proxy_describe_no_proxy=ھیچ پڕۆکسییەک بەکار نایەت settings_use_proxy_describe_system_proxy=پڕۆکسیی سیستەم بەکاردێت settings_use_proxy_describe_manual_proxy="{{value}}" بەکاردێت settings_use_proxy_describe_pac_proxy=پەڕگەی PACی "{{value}}" بەکاربھێنە settings_track_deleted_files_on_disk=شوێن پەڕگە سڕاوەکانی سەر بیرگە بکە settings_track_deleted_files_on_disk_description=خۆکارانە پەڕگەکان لە پێڕستەکە بسڕەوە کاتێک لە شوێنی داونڵۆدەکەیان دەسڕێنەوە یان دەگوازرێنەوە. settings_delete_partial_file_on_download_cancellation=پەڕگە ناتەواوەکان بسڕەوە لەگەڵ ھەڵوەشاندنەوەی دابەزاندنەکە settings_delete_partial_file_on_download_cancellation_description=کاتێک دابەزاندنێک ھەڵدەوەشێندرێتەوە، پەڕگە دابەزاوە ناتەواوەکان لەسەر ئامێرەکە دەسطدرێنەوە. ئەمە یارمەتیدەر دەبێت لە پاکڕاگرتنی فۆڵدەری دابەزاندنەکان و کەمکردنەوەی بەکارھێنانی ناپێویستی بیرگە. بەڵام دابەزاندنەکە لە سەرەتاوە دەستپێدەکاتەوە کە دووبارە دەستت پێکردەوە. settings_default_user_agent=نوێنەری بنچینەییی بەکارھێنەر settings_default_user_agent_description=زنجیرەنووسەی بنچینەییی بریکاری بەکارھێنەر بنووسە بۆ دیاریکردنی شێوازی ناساندنی داواکارییەکان بۆ سێرڤەرەکان. ئەمە یارمەتیدەر دەبێت لە دەستگەیشتن بەو ناوەڕۆکانەی بۆ ئامێری دیاریکراو باشکراون یان سنووردارکردنی داونڵۆدی فریودەرانە کە لەلایەن وێبگەی دیاریکراوەوە دەسەپێندرێن. settings_download_size_unit=یەکەی قەبارەی داونڵۆد settings_download_size_unit_description=ئەو یەکەیەی بەکاردێت بۆ پیشاندانی قەبارەی داونڵۆد settings_download_speed_unit=یەکەی خێراییی داونڵۆد settings_download_speed_unit_description=ئەو یەکەیەی بەکاردێت بۆ پیشاندانی خێراییی داونڵۆد settings_theme=ڕووکار settings_theme_description=شێوازێک بۆ بەرنامەکە دیاری بکە settings_default_dark_theme=ڕووکاری تاریک وەک بنچینەیی settings_default_dark_theme_description=کاتێک کار دەکات کە بەرنامەکە شوێن شێوازی سیستەم دەکەوێت و ڕووکاری تاریک چالاکە settings_default_light_theme=ڕووکاری ڕووناک وەک بنچینەیی settings_default_light_theme_description=کاتێک کار دەکات کە بەرنامەکە شوێن شێوازی سیستەم دەکەوێت و ڕووکاری ڕووناک چالاکە settings_font=فۆنت settings_font_description=ئەو فۆنتە بگۆڕە کە لە ڕووکاری بەرنامەکەدا بەکاردێت، ھەندێک فۆنت لەوانەیە بە دروستی پیشاننەدرێن لە بەرنامەکەدا. settings_ui_scale=ئەندازەی ڕووکار settings_ui_scale_description=قەبارەی بەشەکانی ڕووکاری بەرنامەکە بگۆڕە settings_language=زمان settings_compact_top_bar=تووڵی سەرەوەی پەستێنراو settings_compact_top_bar_description=تووڵی سەرەوە و تووڵی ناونیشان بکە بە یەک کاتێک پەنجەرەی سەرەکی پانیی تەواوی ھەیە settings_use_native_menu_bar=تووڵی پێڕستی خۆماڵی بەکاربێنە settings_use_native_menu_bar_description=شێوازی تووڵی پێڕستی بنچینەییی سیستەم بەکاربێنە settings_use_relative_date_time=کات/ڕێکەوتی ڕێژەیی بەکاربێنە settings_use_relative_date_time_description=شێوازی ڕێکەوت/کاتی ڕێژەیی بەکاربھێنە لە بەرنامەکەدا (بۆ نموونە "٢ ڕۆژ پێش ئێستا" لەجیاتیی ڕێکەوت/کاتی تەواو) settings_show_icon_labels=نووسینی ئایکۆنەکان پیشان بدە settings_show_icon_labels_description=ناو لەژێر ئایکۆنەکان پیشان بدە کە تواندرا (وەک کردارەکانی تووڵامرازی ماڵەوە) settings_use_system_tray=سندووقی سیستەم بەکاربھێنە settings_use_system_tray_description=ئایکۆنی سندووقی سیستەم پیشان بدە کاتێک بەرنامەکە کارایە settings_start_on_boot=لەگەڵ ھەڵبووندا دەستپێبکە settings_start_on_boot_description=خۆکارانە بەرنامەکە بکەرەوە کاتێک بەکارھێنەر سیستەمەکەی کارپێدەکات settings_notification_sound=دەنگی ئاگادارکەرەوە settings_notification_sound_description=دەنگێک لێبدە لەکاتی ئاگاداریی نوێ settings_browser_integration=یەکخستنی وێبگەڕ settings_browser_integration_description=داونڵۆد لە وێبگەڕەوە قبوڵ بکە settings_browser_integration_server_port=پۆڕتی سێرڤەر settings_browser_integration_server_port_description=پۆرت بۆ یەکخستنی وێبگەڕ settings_browser_integration_server_port_describe=بەرنامەکە گوێ بۆ پۆرتی {{port}} دەگرێت settings_dynamic_part_creation=دروستکەری بەشی بزۆک settings_dynamic_part_creation_description=کاتێک بەشێک تەواو دەبێت بەشێکی تر دروست بکە بە لەتکردنی بەشەکانی تر بۆ باشترکردنی خێراییی داونڵۆد settings_show_completion_dialog=دیالۆگی تەواوبوونی داونڵۆد پیشان بدە settings_show_completion_dialog_description=خۆکارانە دیالۆگی "داونڵۆد تەواو بوو" پیشان بدە کە داونڵۆدێک تەواو بوو. settings_show_download_progress_dialog=دیالۆگی بەرەوپێشچوونی داونڵۆد پیشان بدە settings_show_download_progress_dialog_description=خۆکارانە دیالۆگی "بەرەوپێشچوونی داونڵۆد" پیشان بدە کە داونڵۆدێک دەستی پێکرد. settings_per_host_settings=ڕێکخستنی ڕاژەی تایبەتمەند settings_per_host_settings_descriptions=ئەم ڕێکخستنە بە خۆکاری جێبەجێ دەکرێت بۆ هەر داگرتنێکی نوێ کە هاوشێوەی ڕاژەکە بن. settings_download_max_concurrent_downloads=Maximum Concurrent Downloads settings_download_max_concurrent_downloads_description=The maximum number of files that can be downloaded at the same time (downloads managed by queues are not counted; set to 0 for unlimited) download_item_settings_speed_limit=سنووری خێرایی download_item_settings_speed_limit_description=خێراییی داونڵۆد سنووردار بکە بۆ ئەم شتە download_item_settings_show_download_completion_dialog=دیالۆگی تەواوبوونی داونڵۆد پیشان بدە download_item_settings_show_download_completion_dialog_description=خۆکارانە دیالۆگی "داونڵۆد تەواو بوو" پیشان بدە کە ئەم داونڵۆدە تەواو بوو. download_item_settings_shutdown_on_completion=سیستەمەکە بکوژێنەوە لەگەڵ تەواوبوون download_item_settings_shutdown_on_completion_description=خۆکارانە سیستەمەکە بکوژێنەوە کە ئەم دابەزاندنە تەواو بوو. download_item_settings_thread_count=ژمارەی تاڵەکان download_item_settings_thread_count_description=چەن تاڵ بەکار بێت بۆ ئەم داونڵۆدە (٠ بۆ بنچینەیی) download_item_settings_thread_count_describe={{count}} تاڵ بۆ ئەم داونڵۆدە download_item_settings_username_description=ناوی بەکارھێنەرێک دابین بکە ئەگەر بەستەرەکە پارێزراوە download_item_settings_password_description=تێپەڕوشەیەک دابین بکە ئەگەر بەستەرەکە پارێزراوە download_item_settings_download_page=پەڕەی داونڵۆد download_item_settings_download_page_description=ئەو وێبگەیەی ئەم داونڵۆدەی لێوە دەستپێکرا download_item_settings_file_checksum=چێکسەمی پەڕگە download_item_settings_file_checksum_description=زنجیرەنووسەی وردکردن کە دەتواندرێت بەکاربێت بۆ زانینی ئەوەی کە پەڕگەکە بە دروستی دابەزیوە download_item_settings_user_agent=تایبەت download_item_settings_user_agent_description=ڕێکخستنی تایبەتی بەکارهێنەر بۆ ئەمە (بە بەتاڵی جێی بهێڵە بۆ بنەڕەتی) file_checksum=چێکسەمی پەڕگە file_checksum_page=پشکنەری چێکسەمی پەڕگە file_checksum_page_file_checksum_default_algorithm=ئالگۆریتمی بنچینەیی file_checksum_page_file_checksum_default_algorithm_help=ئالگۆریتمی بنچینەیی کە بەکاردێت بۆ ژمێرکاریی چێکسەمی پەڕگە کاتێک دابین نەکرابن. start=دەستپێکردن calculated_checksum=چێکسەمی ژمێرکاریکراو saved_checksum=چێکسەمی پاشەکەوتکراو checksum_algorithm=ئالگۆریتم file_not_found=پەڕگە نەدۆزرایەوە download_not_finished=داونڵۆدەکە تەواو نەبووە done=تەواوبوو waiting=چاوەڕێبە matches=ھاوتایە not_matches=ھاوتا نییە copy_to_clipboard=لەبەری بگرەوە بۆ کلیپبۆرد username=ناوی بەکارھێنەر password=تێپەڕوشە average_speed=خێرایی مامناوەند exact_speed=خێراییی تەواو unlimited=بێسنوور use_global_settings=ڕێکخستنە سەرتاسەرییەکان بەکاربھێنە cant_run_browser_integration=ناتواندرێت یەکخستنی وێبگەڕ بەکارببرێت cant_open_file=ناتواندرێت پەڕگەکە بکرێتەوە cant_open_folder=ناتواندرێت فۆڵدەرەکە بکرێتەوە # times for example 2 seconds ago relative_time_long_years={{years}} ساك relative_time_long_months={{months}} مانگ relative_time_long_days={{days}} ڕۆژ relative_time_long_hours={{hours}} کاتژمێر relative_time_long_minutes={{minutes}} خولەک relative_time_long_seconds={{seconds}} چرکە relative_time_short_years={{years}} س relative_time_short_months={{months}} م relative_time_short_days={{days}} ڕ relative_time_short_hours={{hours}} ک relative_time_short_minutes={{minutes}} خ relative_time_short_seconds={{seconds}} چ relative_time_left={{time}}ی ماوە relative_time_ago={{time}} پێش ئێستا auto=خۆکارانە unspecified=نادیار custom=ڕاسپێردراو icon=ئایکۆن author=دانەر link=بەستەر size=قەبارە status=دۆخ parts_info_downloaded_size=دابەزێندراو parts_info_total_size=سەرجەم speed=خێرایی time_left=کاتی ماوە date_added=ڕێکەوتی زیادکردن info=زانیاری download_page_downloaded_size=دابەزێندراو download_page_download_completed=داونڵۆدەکە تەواو بوو resume_support=پاڵپشتیی بەردەوامبوون دەکات yes=بەڵێ no=نەخێر parts_info=زانیاریی پارچەکان disconnected=دابڕا receiving_data=داتا وەردەگرێت connecting=پەیوەندی بەستن warning=ئاگاداری unsupported_resume_warning=ئەم داونڵۆدە پاڵپشتیی بەردەوامبوون ناکات\! دوایی لەوانەیە پێویست بکات لە سەرەتاوە دەستی پێبکەیتەوە لە پێڕستی داونڵۆد stop_anyway=ھەر چۆنێک بێت ڕایبگرە customize_columns=ستوونەکان ڕێکبخە reset=ڕێکخستنەوە monday=دووشەممە tuesday=سێشەممە wednesday=چوارشەممە thursday=پێنجشەممە friday=ھەینی saturday=شەممە sunday=یەکشەممە proxy_open_system_proxy_settings=ڕێکخستنەکانی پڕۆکسیی سیستەم بکەرەوە proxy_type=جۆری پڕۆکسی proxy_do_not_use_proxy_for=پڕۆکسی بەکار مەھێنە بۆ proxy_do_not_use_proxy_for_description=پێڕستێک لەو بەستەرانەی لەوانەیە پڕۆکسی نەکرێن\nدەتوانیت * بەکاربێنیت وەک کارەکتەری جێگرەوە\nبۆ نموونە 192.168.1.* example.com (بە بۆشایی جیاکراونەتەوە) proxy_change_title=پڕۆکسی بگۆڕە change_proxy=پڕۆکسی بگۆڕە proxy_no=بێ پڕۆکسی proxy_system=پڕۆکسیی سیستەم proxy_manual=پڕۆکسیی دەستی proxy_pac=شێوەپێدانی خۆکاری پڕۆکسی proxy_pac_url=بەستەری شێوەپێدانی خۆکارانەی پڕۆکسی address=ناونیشان port=پۆرت address_and_port=ناونیشان و پۆرت use_authentication=سەلماندن بەکاربھێنە warning_you_may_have_to_restart_the_download_later=لەوانەیە دواتر پێویست بکات داونڵۆدەکە لە سەرەتاوە دەستپێبکەیتەوە\! edit_download_title=داونڵۆد دەستکاری بکە edit_download_update_from_download_page=وەشانی نوێ لە پەڕەی داونڵۆدەوە بەدەست بھێنە edit_download_update_from_download_page_description=کاتێک ئەم پەنجەرەیە کراوەیە، دەتوانیت بچیتە سەر پەڕەی داونڵۆد و کلیک لەسەر دوگمەی داونڵۆد بکەیت. بەرنامەکە باوەڕنامە نوێکەی داونڵۆدەکە دەگرێت و نوێی دەکاتەوە تاکوو بتوانیت پاشەکەوتی بکەیت. edit_download_saved_download_item_size_not_match=داونڵۆدە پاشەکەوتکراوەکە قەبارەی {{currentSize}} ھەیە، کە ھاوتای قەبارە نوێکە نییە کە {{newSize}}ە. translators_page_thanks=سپاس و پێزانین بۆ ئەوانەی یارمەتیدەر بوون لە وەرگێڕانی ئەم پڕۆژەیە ❤️ translators=وەرگێڕەکان language=زمان translators_contribute_title=وەرگێڕانەکان باشتر بکە translators_contribute_description=دەتەوێت یارمەتیدەر بیت لە باشترکردنی ئەم پڕۆژەیە؟ ئەگەر ئەو زمانەی دەتەوێت لێرەدا نییە یاخود پێویستی بە دەستکاریکردن ھەیە، دەتوانیت بەشدار بیت لە وەرگێڕانەکان و باشتریان بکەیت\! contribute=بەشدار بە meet_the_translators=وەرگێڕەکان بناسە localized_by_translators=لەلایەن وەرگێڕەکانەوە وەرگێڕدراوە confirm_exit=دەرچوون دڵنیابکەرەوە confirm_exit_description=دڵنیایت کە دەتەوێت لە ئەی بی داونڵۆد مەنەجەر دەربچیت؟\nداونڵۆد/ڕیزە چالاکەکان ڕادەگیرێن\! update=نوێکردنەوە update_updater=نوێکەرەوە update_available=وەشانی نوێ بەردەستە update_error=Update Error update_available_suggest_to_to_update=دەتوانیت بەرنامەکە نوێ بکەیتەوە بۆ دوایین وەشان بۆ چێژبینین لە تایبەتمەندی، باشترکردن، و سوودمەندیی نوێ. update_release_notes=تێبینییەکانی بڵاوبوونەوە update_check_for_update=بگەڕێ بۆ وەشانی نوێ update_checking_for_update=گەڕان بۆ وەشانی نوێ update_no_update=تۆ دوایین وەشان بەکار دەھێنیت update_check_error=ھەڵە ھەبوو لە گەڕان بۆ وەشانی نوێ update_app_updated_to_version_n=بەرنامەکە نوێکرایەوە بۆ وەشانی {{version}} create_desktop_entry=قەدبڕی دێسکتۆپ دروست بکە shutdown_alert=ئاگاداریی کوژاندنەوە system_shutdown_soon=سیستەمەکە بەمزووانە دەکوژێتەوە\! system_shutdown_failed=کوژاندنەوەی سیستەمەکە شکتی ھێنا\! system_shutdown_soon_description=سیستەمەکە بەمزووانە دەکوژێتەوە. ئەگەر ھێشتا کۆمپیوتەرەکە بەکار دەھێنیت، تکایە کارەکانت پاشەکەوت بکە یان کوژاندنەوەکە ھەڵبوەشێنەرەوە. system_shutdown_reason_queue_completed=ھەموو دابەزاندنەکانی ڕیزەکە تەواو بوون. system_shutdown_reason_queue_end_time_reached=گەیشتینە کاتی داندراوی کۆتایی بۆ ڕیزی دابەزاندن. system_shutdown_download_finished=داونڵۆدەکە تەواو بوو. shutdown_now=ئێستا بیکوژێنەوە settings_per_host_settings_new_host=<ڕاژەی نوێ> settings_per_host_settings_not_selected=سەرەتا دانەیەکی نوێ دروست بکە یان هەڵبژێرە\! settings_per_host_settings_host=ڕاژە settings_per_host_settings_host_description=ئەم ڕێکخستنە جێبەجێ دەکرێت بۆ ئەو داگرتنانەی کە هەمان ناوی ڕاژەیان هەیە،. کاردەکانی پشتگیریکراون (*) (بۆ نمونە\: example.com, *.example.com — تەنیا یەکێکیان بەکاربێنە). settings_browser_in_launcher=Browser Icon In Launcher settings_browser_in_launcher_description=Show or hide the browser icon in the launcher (app list). sort_by=Sort By welcome=Welcome new_folder=New Folder skip=Skip lets_go=Let's Go next=Next select_all=Select All select_inside=Select Inside select_invert=Select Invert open_settings=Open Settings back=Back service_is_running=Service is running initial_setup_description=Let’s set things up initial_setup_notice=You can change these settings anytime later permission_granted=Permission granted permission_not_granted=Permission not granted permissions=Permissions give_permission=Allow permission give_storage_permission=Allow storage access storage_roots=Storage Roots permissions_initial_title=Permissions setup permissions_initial_description=To work properly, the app needs a few permissions. On the next screen, you’ll see what each permission is used for and you can decide which ones to allow or skip. permissions_done_title=You’re all set permissions_done_description=Everything is ready. All required permissions have been granted and the app is good to go. permissions_manage_storage_title=Manage storage access permissions_manage_storage_reason=This permission lets the app change the download folder, detect duplicate downloads more accurately, and enable some extra features. It’s optional, but recommended for the best experience. permission_read_write_external_storage_title=Read and write storage permission_read_write_external_storage_reason=This permission allows the app to save and manage downloaded files, change the download location, and improve duplicate download detection. permissions_post_notification_title=Post Notification permissions_post_notification_reason=The app needs to run in the background to manage downloads. Notifications are used to keep you informed and allow background operation. permissions_ignore_battery_optimization_title=Ignore Battery Optimization permissions_ignore_battery_optimization_reason=Some devices aggressively limit background activity to save battery, which can pause or stop downloads when the app isn’t open. You can optionally exclude the app from battery optimization to ensure downloads continue uninterrupted open_in_browser=Open In Browser browser=Browser browser_new_tab=New Tab browser_close_tab=Close Tab browser_open_in_new_tab=Open In New Tab browser_open_in_new_background_tab=Open In New Background Tab browser_no_tab_open=No tabs are open browser_tabs=Tabs browser_paste_and_go=Paste And Go browser_bookmarks=Bookmarks browser_add_bookmark=Add Bookmark browser_edit_bookmark=Edit Bookmark browser_add_to_bookmarks=Add To Bookmarks browser_remove_from_bookmarks=Remove From Bookmarks ================================================ FILE: shared/resources/src/commonMain/resources/com/abdownloadmanager/resources/locales/de_DE.properties ================================================ app_title=AB Download Manager confirm_auto_categorize_downloads_title=Downloads automatisch kategorisieren confirm_auto_categorize_downloads_description=Alle nicht kategorisierten Einträge werden automatisch zu der zugehörigen Kategorie hinzugefügt. confirm_reset_to_default_categories_title=Auf Standardkategorien zurücksetzen confirm_reset_to_default_categories_description=dies wird alle Kategorien ENTFERNEN und stellt die Standardkategorien wieder her\! confirm_delete_download_items_title=Löschen bestätigen confirm_delete_download_items_description=Sind Sie sicher, dass Sie {{count}} Einträge löschen möchten? confirm_delete_download_unfinished_items_description=Sind Sie sicher, dass sie {{count}} laufende Downloads entfernen möchten? confirm_delete_download_finished_and_unfinished_items_description=Möchtest du wirklich {{finishedCount}} abgeschlossene und {{unfinishedCount}} nicht abgeschlossene Downloads löschen? also_delete_file_from_disk=Datei auch von der Festplatte löschen confirm_delete_category_item_title=Kategorie {{name}} wird gelöscht confirm_delete_category_item_description=Sind Sie sicher, dass Sie die Kategorie "{{value}}" löschen möchten? your_download_will_not_be_deleted=Ihre Downloads werden nicht gelöscht drag_the_file_to_another_app=Datei in eine andere App ziehen drop_link_or_file_here=Link oder Datei hierher ziehen. nothing_will_be_imported=Es wird nichts importiert n_links_will_be_imported={{count}} Links werden importiert n_items_selected={{count}} Elemente ausgewählt window_close=Schließen window_minimize=Minimieren window_maximize=Maximieren window_restore=Wiederherstellen delete=Löschen remove=Entfernen cancel=Abbrechen close=Schließen menu=Menü more_options=Weitere Optionen ok=OK add=Hinzufügen paste=Paste change=Ändern edit=Bearbeiten change_anyway=Trotzdem ändern download=Download refresh=Aktualisieren settings=Einstellungen on_completion=Nach Abschluss unknown=Unbekannt unknown_error=Unbekannter Fehler download_item_not_found=Download-Element nicht gefunden name=Name download_link=Downloadlink not_finished=Nicht abgeschlossen all=Alle finished=Abgeschlossen Unfinished=Unvollendet canceled=Abgebrochen error=Fehler paused=Pausiert downloading=Wird heruntergeladen added=Hinzugefügt idle=Ruhezustand preparing_file=Datei wird vorbereitet creating_file=Datei wird erstellt resuming=Wird fortgesetzt retrying=Wiederhole list_is_empty=Liste ist leer\! search_in_the_list=In der Liste suchen search=Suchen clear=Leeren general=Allgemein enabled=Aktiviert disabled=Deaktiviert default=Standard file=Datei tasks=Aufgaben tools=Werkzeuge help=Hilfe system=System all_missing_files=Alle fehlenden Dateien all_finished=Alle abgeschlossen all_unfinished=Alle unvollendet entire_list=Gesamte Liste download_browser_integration=Browser-Integration herunterladen exit=Beenden show_downloads=Downloads anzeigen new_download=Neuer Download stop_all=Alle stoppen import_from_clipboard=Aus Zwischenablage importieren batch_download=Batch-Download open=Öffnen share=Teilen open_file=Datei öffnen open_folder=Ordner öffnen resume=Fortsetzen pause=Pause restart_download=Download neu starten copy=Kopieren copy_link=Link kopieren copy_as_curl=Kopieren als cURL show_properties=Eigenschaften anzeigen move_to_queue=In Warteschlange verschieben move_to_this_queue=In diese Warteschlange verschieben move_to_category=In Kategorie verschieben move_to_this_category=In diese Kategorie verschieben categories=Kategorien add_category=Kategorie hinzufügen edit_category=Kategorie bearbeiten delete_category=Kategorie löschen category_name=Kategoriename category_download_location=Kategorie Download-Verzeichnis category_download_location_description=Wenn diese Kategorie unter "Download hinzufügen" ausgewählt wurde, verwende dieses Verzeichnis als "Download-Speicherort" category_file_types=Kategorie-Dateitypen category_file_types_description=Fügen Sie automatisch Dateien dieses Typs in diese Kategorie ein (wenn Sie einen neuen Download starten). Die Endungen sind mit einem Leerzeichen getrennt (ext1 ext2...) category_url_patterns=URL-Muster category_url_patterns_description=Fügen Sie automatisch Dateien dieser Quelle in diese Kategorie ein (wenn Sie einen neuen Download starten). Die Endungen sind mit einem Leerzeichen getrennt und "*" können für Wildcards verwendet werden auto_categorize_downloads=Downloads automatisch kategorisieren restore_defaults=Standard wiederherstellen about=Über version_n=Version {{value}} developed_with_love_for_you=Entwickelt mit ❤️ für dich donate=Spenden visit_the_project_website=Projektwebseite besuchen this_is_a_free_and_open_source_software=Dies ist eine freie & Open Source Software view_the_source_code=Quelltext ansehen third_party_libraries=Drittanbieter-Bibliotheken powered_by_open_source_software=Unterstützt durch Open Source Software view_the_open_source_licenses=Open-Source-Lizenzen anzeigen support_and_community=Support & Community telegram=Telegram channel=Kanal group=Gruppe add_download=Download hinzufügen add_multi_download_page_header=Wählen sie alle Elemente aus, die sie Herunterladen möchten save_to=Speichern unter where_should_each_item_saved=Wo soll jedes Element gespeichert werden? there_are_multiple_items_please_select_a_way_you_want_to_save_them=Es sind multiple Objekte existent\! Wählen sie einen weg diese zu speichern each_item_on_its_own_category=Jede Eintrag in seiner eigenen Kategorie each_item_on_its_own_category_description=Jeder Eintrag wird in einer Kategorie mit diesem Dateityp platziert all_items_in_one_category=Alle Einträge in einer Kategorie all_items_in_one_category_description=Alle Dateien werden in der gewählten Kategorie gespeichert all_items_in_one_Location=Alle Einträge an einem Ort all_items_in_one_Location_description=Alle Einträge werden im ausgewählten Verzeichnis gespeichert unselected_all_items_in_specific_location_description=Alle Dateien werden im Verzeichnis der ausgewählten Kategorie gespeichert no_category_selected=Keine Kategorie ausgewählt no_categories_found=Keine Kategorien gefunden download_location=Downloadverzeichnis location=Dateipfad select_queue=Warteschlange auswählen without_queue=Ohne Warteschlange use_category=Kategorie verwenden cant_write_to_this_folder=In diesen Ordner kann nicht geschrieben werden file_name_already_exists=Dateiname existiert bereits download_already_exists=Download bereits vorhanden invalid_file_name=Ungültiger Dateiname show_solutions=Lösungen anzeigen... change_solution=Lösung ändern select_a_solution=Eine Lösung auswählen select_download_strategy_description=Der von Ihnen angegebene Link ist bereits in den Downloadlisten. Bitte geben Sie an, was Sie tun möchten download_strategy_add_a_numbered_file=Datei nummerieren download_strategy_add_a_numbered_file_description=Index am Ende des Dateinamens hinzufügen download_strategy_override_existing_file=Vorhandene Datei überschreiben download_strategy_override_existing_file_description=Bestehenden Download entfernen und in diese Datei schreiben download_strategy_update_download_link=Vorhandenen Download aktualisieren download_strategy_update_download_link_description=Vorhandenen Download-Link und die zugehörigen Anmeldedaten aktualisieren download_strategy_show_downloaded_file=Heruntergeladene Datei anzeigen download_strategy_show_downloaded_file_description=Zeige bereits vorhandene Download-Elemente an, damit du auf "Fortsetzen" klicken oder sie öffnen kannst. batch_download_link_help=Gib einen Link ein, der Platzhalter enthält (verwende *) invalid_url=Ungültige URL list_is_too_large_maximum_n_items_allowed=Liste ist zu groß\! Maximal {{count}} Einträge erlaubt enter_range=Bereich eingeben range_from=Von range_to=Bis batch_download_wildcard_length=Platzhalterlänge first_link=Erster Link last_link=Letzter Link open_source_software_used_in_this_app=In dieser App verwendete Open-Source-Software links=Links website=Webseite developers=Entwickler source_code=Quelltext license=Lizenz no_license_found=Keine Lizenz gefunden organization=Organisation add_new_queue=Neue Warteschlange hinzufügen queue_name=Name der Warteschlange queues=Warteschlangen stop_queue=Warteschlange stoppen start_queue=Warteschlange starten clear_queue_items=Leere Warteschlange config=Konfiguration items=Elemente move_down=Nach unten move_up=Nach oben remove_queue=Warteschlange entfernen queue_name_help=Namen für diese Warteschlange festlegen queue_name_describe=Name der Warteschlange ist {{value}} queue_max_concurrent_download=Max. gleichzeitiger Downloads queue_max_concurrent_download_description=Max. Download für diese Warteschlange queue_automatic_stop=Automatischer Stopp queue_automatic_stop_description=Die Warteschlange wird automatisch beendet, wenn kein Element mehr vorhanden ist queue_scheduler=Planer queue_enable_scheduler=Planer aktivieren queue_active_days=Aktive Tage queue_active_days_description=An welchen Tagen soll der Planer funktionieren? queue_scheduler_enable_auto_start_time=Auto-Startzeit aktivieren queue_scheduler_auto_start_time=Auto-Startzeit queue_scheduler_enable_auto_stop_time=Auto-Stoppzeit aktivieren queue_scheduler_auto_stop_time=Auto-Stoppzeit queue_shutdown_on_completion=System nach Abschluss Herunterfahren queue_shutdown_on_completion_description=Das System automatisch herunterfahren, wenn diese Warteschlange abgeschlossen ist oder wenn die geplante Endzeit erreicht ist. appearance=Aussehen download_engine=Download-Engine browser_integration=Browserintegration settings_download_max_retries_count=Maximale Download-Wiederholungen settings_download_max_retries_count_description=Die maximale Anzahl von Versuchen, die die App unternimmt, um einen fehlgeschlagenen Download erneut durchzuführen, bevor sie aufgibt settings_download_max_retries_count_describe_no_retries=Fehlgeschlagene Downloads werden nicht erneut versucht settings_download_max_retries_count_describe_n_retries=Fehlgeschlagene Downloads werden {{count}} mal wiederholt settings_download_thread_count=Thread-Anzahl settings_download_thread_count_description=Maximale Download-Threads pro Eintrag settings_download_thread_count_describe=Ein Download kann bis zu {{count}} Threads haben settings_download_thread_count_with_large_value_describe=Warnung\: Das Festlegen einer hohen Thread-Anzahl kann die Systemressourcen stärker beanspruchen, die Leistung verringern oder Verbindungsprobleme mit Servern verursachen. Verwende höhere Werte nur, wenn du die möglichen Auswirkungen auf dein System und Netzwerk kennst. settings_use_server_last_modified_time=Verwende das Datum der letzten Änderung des Servers settings_use_server_last_modified_time_description=Verwende beim Herunterladen einer Datei das Datum der letzten Änderung des Servers für die lokale Datei settings_append_extension_to_incomplete_downloads=Erweiterung an unvollständige Downloads anhängen settings_append_extension_to_incomplete_downloads_description=Hängt die Dateierweiterung ".part" an unvollständige Downloads an. Dies hilft dabei, unvollständige Downloads zu identifizieren und verhindert das versehentliche Öffnen unvollständiger Dateien. settings_use_sparse_file_allocation=Sparse-Datei-Zuweisung settings_use_sparse_file_allocation_description=Erstelle Dateien effizienter, insbesondere auf SSDs, indem unnötiges Schreiben von Daten reduziert wird. Dies kann den Start von Downloads beschleunigen und den Speicherplatzbedarf verringern. Wenn Downloads langsam starten oder ungewöhnliche Downloadgeschwindigkeiten auftreten, solltest du erwägen, diese Option zu deaktivieren, da sie möglicherweise auf einigen Geräten nicht vollständig unterstützt wird. settings_ignore_ssl_certificates=SSL-Zertifikat ignorieren settings_ignore_ssl_certificates_description=Deaktiviert die Überprüfung von SSL-Zertifikaten. Nur bei Bedarf verwenden, da dies Ihre Verbindung Sicherheitsrisiken aussetzen kann. settings_global_speed_limiter=Globale Geschwindigkeitsbegrenzung settings_global_speed_limiter_description=Globales Limit für Downloadgeschwindigkeit (0 bedeutet unbegrenzt) settings_show_average_speed=Durchschnittsgeschwindigkeit anzeigen settings_show_average_speed_description=Durchschnittsgeschwindigkeit oder reale Geschwindigkeit des Downloads settings_use_category_by_default=Kategorie standardmäßig nutzen settings_use_category_by_default_description=Kategorie standardmäßig beim Hinzufügen eines Downloads verwenden. settings_default_download_folder=Standard-Downloadordner settings_default_download_folder_description=Wenn Sie einen neuen Download hinzufügen, wird dieser Ordner standardmäßig verwendet settings_default_download_folder_describe="{{folder}}" wird verwendet settings_use_proxy=Proxy verwenden settings_use_proxy_description=Proxy für das Herunterladen von Dateien verwenden settings_use_proxy_describe_no_proxy=Es wird kein Proxy verwendet settings_use_proxy_describe_system_proxy=System-Proxy wird verwendet settings_use_proxy_describe_manual_proxy="{{value}}" wird verwendet settings_use_proxy_describe_pac_proxy=Die PAC-Datei "{{value}}" wird verwendet settings_track_deleted_files_on_disk=Gelöschte Dateien auf der Festplatte verfolgen settings_track_deleted_files_on_disk_description=Entferne automatisch alle Elemente aus der Liste, welche gelöscht wurden oder sich nicht mehr im Download-Verzeichnis befinden. settings_delete_partial_file_on_download_cancellation=Unvollständige Datei bei Abbruch eines Downloads löschen settings_delete_partial_file_on_download_cancellation_description=Wenn ein Download abgebrochen wird, wird die unvollständig heruntergeladene Datei von der Festplatte gelöscht. Dadurch bleibt der Download-Ordner übersichtlich und unnötiger Speicherplatz wird nicht belegt. Allerdings wird der Download beim nächsten Start wieder von vorne beginnen. settings_default_user_agent=Standard-Benutzeragent settings_default_user_agent_description=Geben Sie den Standard User-Agent String an, um zu bestimmen, wie sich Anfragen gegenüber Servern identifizieren. Dies kann beim Zugriff auf für bestimmte Geräte optimierte Inhalte oder bei der Umgehung von Download-Beschränkungen bestimmter Websites hilfreich sein. settings_download_size_unit=Download-Größeneinheit settings_download_size_unit_description=Einheit zum Anzeigen der Downloadgröße settings_download_speed_unit=Download-Geschwindigkeitseinheit settings_download_speed_unit_description=Einheit zum Anzeigen der Downloadgeschwindigkeit settings_theme=Thema settings_theme_description=Wählen Sie ein Thema für die App settings_default_dark_theme=Dunkles Standarddesign settings_default_dark_theme_description=Gilt, wenn die App dem Systemdesign folgt und der Dunkelmodus aktiv ist settings_default_light_theme=Helles Standarddesign settings_default_light_theme_description=Gilt, wenn die App dem Systemdesign folgt und der helle Modus aktiv ist settings_font=Schriftart settings_font_description=Ändert die Schriftart, die in der App-Oberfläche verwendet wird. Einige Schriftarten werden in der App möglicherweise nicht korrekt angezeigt. settings_ui_scale=UI Skalierung settings_ui_scale_description=Die Größe der Oberflächenelemente der App anpassen settings_language=Sprache settings_compact_top_bar=Kompakte obere Leiste settings_compact_top_bar_description=Die obere Leiste mit der Titelleiste zusammenführen, wenn das Hauptfenster breit genug ist settings_use_native_menu_bar=Native Menüleiste verwenden settings_use_native_menu_bar_description=Standard-Stil der Menüleiste verwenden settings_use_relative_date_time=Verwende relatives Datums-/Zeitformat settings_use_relative_date_time_description=Verwende in der App ein relatives Datums-/Zeitformat (z. B. „vor 2 Tagen“ statt des genauen Datums/der genauen Uhrzeit) settings_show_icon_labels=Symbolbeschriftungen anzeigen settings_show_icon_labels_description=Zeige wenn möglich Beschriftungen unter Symbolen an (z. B. bei Aktionen der Menüleiste) settings_use_system_tray=Systemablage verwenden settings_use_system_tray_description=Systemablage-Icon anzeigen, wenn die App läuft settings_start_on_boot=Beim Systemstart ausführen settings_start_on_boot_description=Anwendung mit Benutzeranmeldung automatisch starten settings_notification_sound=Benachrichtigungston settings_notification_sound_description=Ton bei neuer Benachrichtigung abspielen settings_browser_integration=Browserintegration settings_browser_integration_description=Downloads von Browsern akzeptieren settings_browser_integration_server_port=Server-Port settings_browser_integration_server_port_description=Port für Browserintegration settings_browser_integration_server_port_describe=App wird auf Port {{port}} hören settings_dynamic_part_creation=Dynamische Teileerstellung settings_dynamic_part_creation_description=Wenn ein Teil fertiggestellt ist, einen weiteren Teil erstellen, indem andere Teile aufgeteilt werden, um die Downloadgeschwindigkeit zu verbessern settings_show_completion_dialog=Dialog zur Download-Fertigstellung anzeigen settings_show_completion_dialog_description=Automatisch den 'Download-Fertigstellung'-Dialog anzeigen, wenn ein Download abgeschlossen ist. settings_show_download_progress_dialog=Dialog zum Download-Fortschritt anzeigen settings_show_download_progress_dialog_description=Automatisch den "Download-Fortschritt"-Dialog anzeigen, wenn ein Download gestartet wurde. settings_per_host_settings=Pro Host-Einstellungen settings_per_host_settings_descriptions=Diese Einstellungen werden automatisch auf alle neuen Downloads angewendet, die dem angegebenen Host entsprechen. settings_download_max_concurrent_downloads=Maximum Concurrent Downloads settings_download_max_concurrent_downloads_description=The maximum number of files that can be downloaded at the same time (downloads managed by queues are not counted; set to 0 for unlimited) download_item_settings_speed_limit=Geschwindigkeitslimit download_item_settings_speed_limit_description=Download-Geschwindigkeit für diesen Eintrag begrenzen download_item_settings_show_download_completion_dialog=Dialog zur Download-Fertigstellung anzeigen download_item_settings_show_download_completion_dialog_description=Automatisch den "Download-Fertigstellung"-Dialog anzeigen, wenn dieser Download abgeschlossen ist. download_item_settings_shutdown_on_completion=System nach Abschluss Herunterfahren download_item_settings_shutdown_on_completion_description=Das System automatisch herunterfahren, wenn der Download abgeschlossen ist. download_item_settings_thread_count=Anzahl der Threads download_item_settings_thread_count_description=Wie viel Threads zum Herunterladen dieses Eintrags verwendet wurde (0 für Standard) download_item_settings_thread_count_describe={{count}} Threads für diesen Download download_item_settings_username_description=Geben Sie einen Benutzernamen an, wenn der Link eine geschützte Ressource ist download_item_settings_password_description=Geben Sie ein Passwort an, wenn der Link eine geschützte Ressource ist download_item_settings_download_page=Downloadseite download_item_settings_download_page_description=Die Webseite, von der dieser Download gestartet wurde download_item_settings_file_checksum=Datei-Prüfsumme download_item_settings_file_checksum_description=Eine Hash-Zeichenkette, die verwendet werden kann, um zu überprüfen, ob eine Datei korrekt heruntergeladen wurde download_item_settings_user_agent=User-Agent download_item_settings_user_agent_description=Benutzerdefinierter User-Agent für diesen Download (leer lassen, um die Standardeinstellung zu verwenden) file_checksum=Datei-Prüfsumme file_checksum_page=Datei-Prüfsummen-Prüfer file_checksum_page_file_checksum_default_algorithm=Standardalgorithmus file_checksum_page_file_checksum_default_algorithm_help=Der Standardalgorithmus, der zur Berechnung von Datei-Prüfsummen verwendet wird, wenn diese nicht angegeben werden. start=Starten calculated_checksum=Berechnete Prüfsumme saved_checksum=Gespeicherte Prüfsumme checksum_algorithm=Algorithmus file_not_found=Datei nicht gefunden download_not_finished=Download nicht abgeschlossen done=Erledigt waiting=Warten matches=Treffer not_matches=Keine Übereinstimmungen copy_to_clipboard=In die Zwischenablage kopieren username=Benutzername password=Passwort average_speed=Durchschnittliche Geschwindigkeit exact_speed=Exakte Geschwindigkeit unlimited=Unlimitiert use_global_settings=Globale Einstellungen verwenden cant_run_browser_integration=Browserintegration kann nicht ausgeführt werden cant_open_file=Datei kann nicht geöffnet werden cant_open_folder=Ordner kann nicht geöffnet werden # times for example 2 seconds ago relative_time_long_years={{years}} Jahre relative_time_long_months={{months}} Monate relative_time_long_days={{days}} Tage relative_time_long_hours={{hours}} Stunden relative_time_long_minutes={{minutes}} Minuten relative_time_long_seconds={{seconds}} Sekunden relative_time_short_years={{years}} j relative_time_short_months={{months}} M relative_time_short_days={{days}} t relative_time_short_hours={{hours}} Std relative_time_short_minutes={{minutes}} Min. relative_time_short_seconds={{seconds}} Sek relative_time_left={{time}} verbleibend relative_time_ago={{time}} her auto=Automatisch unspecified=Nicht angegeben custom=Benutzerdefiniert icon=Symbol author=Autor link=Link size=Größe status=Status parts_info_downloaded_size=Heruntergeladen parts_info_total_size=Gesamt speed=Geschwindigkeit time_left=Verbleibende Zeit date_added=Datum hinzugefügt info=Info download_page_downloaded_size=Heruntergeladen download_page_download_completed=Download abgeschlossen resume_support=Unterstützung fortsetzen yes=Ja no=Nein parts_info=Teileinfo disconnected=Getrennt receiving_data=Daten werden empfangen connecting=Verbinde warning=Warnung unsupported_resume_warning=Dieser Download unterstützt keine Fortsetzung\! Möglicherweise musst du ihn später in der Download-Liste neu starten stop_anyway=Trotzdem stoppen customize_columns=Spalten anpassen reset=Zurücksetzen monday=Montag tuesday=Dienstag wednesday=Mittwoch thursday=Donnerstag friday=Freitag saturday=Samstag sunday=Sonntag proxy_open_system_proxy_settings=System-Proxy-Einstellungen öffnen proxy_type=Proxytyp proxy_do_not_use_proxy_for=Proxy nicht verwenden für proxy_do_not_use_proxy_for_description=Eine Liste von URLs, die nicht über Proxys\naufgerufen werden dürfen.\nDu kannst Platzhalter mit * verwenden\nzum Beispiel 192.168.1.* example.com (durch Leerzeichen getrennt) proxy_change_title=Proxy ändern change_proxy=Proxy ändern proxy_no=Kein Proxy proxy_system=System-Proxy proxy_manual=Manueller Proxy proxy_pac=Proxy Auto Konfiguration proxy_pac_url=URL der Proxy Auto-Konfiguration address=Adresse port=Port address_and_port=Adresse & Port use_authentication=Authentifizierung verwenden warning_you_may_have_to_restart_the_download_later=Möglicherweise muss der Download später neu gestartet werden\! edit_download_title=Download bearbeiten edit_download_update_from_download_page=Von Downloadseite aktualisieren edit_download_update_from_download_page_description=Bei geöffnetem Fenster kann die Download-Seite aufgerufen und der Download-Button betätigt werden. Die App erfasst und aktualisiert die neuen Download-Zugangsdaten, um deren Speicherung zu ermöglichen. edit_download_saved_download_item_size_not_match=Das gespeicherte Download-Element hat eine Größe von {{currentSize}}, die nicht mit der neuen Größe von {{newSize}} übereinstimmt. translators_page_thanks=Mit Dankbarkeit an diejenigen, die bei der Übersetzung dieses Projekts geholfen haben ❤️ translators=Übersetzer language=Sprache translators_contribute_title=Übersetzungen verbessern translators_contribute_description=Wollen Sie helfen, dieses Projekt zu verbessern? Wenn Ihre Sprache nicht aufgelistet ist oder es sind Verbesserungen nötig, dann können Sie Ihre Übersetzungen einbringen und es verbessern\! contribute=Mitwirken meet_the_translators=Die Übersetzer kennenlernen localized_by_translators=Lokalisiert von Übersetzern confirm_exit=Beenden bestätigen confirm_exit_description=Sind Sie sicher, dass Sie AB Download Manager beenden möchten?\nAktive Downloads/Warteschlangen werden gestoppt\! update=aktualisieren update_updater=Updater update_available=Neue Version verfügbar update_error=Updatefehler update_available_suggest_to_to_update=Ein Update auf die neueste Version ermöglicht die Nutzung neuer Funktionen, Verbesserungen und Leistungssteigerungen. update_release_notes=Versionshinweise update_check_for_update=Nach neuer Version suchen update_checking_for_update=Es wird nach einer neuen Aktualisierung gesucht update_no_update=Es wird bereits die neuste Version genutzt update_check_error=Beim Suchen einer neuen Version ist ein Fehler aufgetreten update_app_updated_to_version_n=App auf Version {{version}} aktualisiert create_desktop_entry=Desktop-Eintrag erstellen shutdown_alert=Warnung bei Herunterfahren system_shutdown_soon=Herunterfahren wird gestartet\! system_shutdown_failed=Herunterfahren fehlgeschlagen\! system_shutdown_soon_description=Das System wird bald heruntergefahren. Wenn Sie den Computer noch verwenden, speichern Sie bitte Ihre Arbeit oder brechen Sie den Vorgang ab. system_shutdown_reason_queue_completed=Alle Downloads in der Warteschlange sind abgeschlossen. system_shutdown_reason_queue_end_time_reached=Geplante Endzeit für die Download-Warteschlange erreicht. system_shutdown_download_finished=Download abgeschlossen. shutdown_now=Jetzt herunterfahren settings_per_host_settings_new_host= settings_per_host_settings_not_selected=Erstellen oder wählen Sie zuerst ein neues Element\! settings_per_host_settings_host=Host settings_per_host_settings_host_description=Diese Einstellungen werden auf Downloads angewendet, die diesem Hostnamen entsprechen. Wildcards (*) werden unterstützt (z. B. example.com, *.example.com – bitte nur eine verwenden). settings_browser_in_launcher=Browser Icon In Launcher settings_browser_in_launcher_description=Show or hide the browser icon in the launcher (app list). sort_by=Sortieren nach welcome=Willkommen new_folder=Neuer Ordner skip=Überspringen lets_go=Los geht's next=Weiter select_all=Alle auswählen select_inside=Innen auswählen select_invert=Auswahl umkehren open_settings=Einstellungen öffnen back=Zurück service_is_running=Dienst wird ausgeführt initial_setup_description=Let’s set things up initial_setup_notice=Sie können diese Einstellungen jederzeit später ändern permission_granted=Berechtigung gewährt permission_not_granted=Berechtigung verweigert permissions=Berechtigungen give_permission=Zugriff erlauben give_storage_permission=Speicherzugriff erlauben storage_roots=Storage Roots permissions_initial_title=Permissions setup permissions_initial_description=To work properly, the app needs a few permissions. On the next screen, you’ll see what each permission is used for and you can decide which ones to allow or skip. permissions_done_title=You’re all set permissions_done_description=Everything is ready. All required permissions have been granted and the app is good to go. permissions_manage_storage_title=Manage storage access permissions_manage_storage_reason=This permission lets the app change the download folder, detect duplicate downloads more accurately, and enable some extra features. It’s optional, but recommended for the best experience. permission_read_write_external_storage_title=Read and write storage permission_read_write_external_storage_reason=This permission allows the app to save and manage downloaded files, change the download location, and improve duplicate download detection. permissions_post_notification_title=Post Notification permissions_post_notification_reason=The app needs to run in the background to manage downloads. Notifications are used to keep you informed and allow background operation. permissions_ignore_battery_optimization_title=Ignore Battery Optimization permissions_ignore_battery_optimization_reason=Some devices aggressively limit background activity to save battery, which can pause or stop downloads when the app isn’t open. You can optionally exclude the app from battery optimization to ensure downloads continue uninterrupted open_in_browser=Open In Browser browser=Browser browser_new_tab=New Tab browser_close_tab=Close Tab browser_open_in_new_tab=Open In New Tab browser_open_in_new_background_tab=Open In New Background Tab browser_no_tab_open=No tabs are open browser_tabs=Tabs browser_paste_and_go=Paste And Go browser_bookmarks=Bookmarks browser_add_bookmark=Add Bookmark browser_edit_bookmark=Edit Bookmark browser_add_to_bookmarks=Add To Bookmarks browser_remove_from_bookmarks=Remove From Bookmarks ================================================ FILE: shared/resources/src/commonMain/resources/com/abdownloadmanager/resources/locales/en_US.properties ================================================ app_title=AB Download Manager confirm_auto_categorize_downloads_title=Auto categorize downloads confirm_auto_categorize_downloads_description=Any uncategorized item will be automatically added to its related category. confirm_reset_to_default_categories_title=Reset to Default Categories confirm_reset_to_default_categories_description=This will REMOVE all categories and brings backs default categories\! confirm_delete_download_items_title=Confirm Delete confirm_delete_download_items_description=Are you sure you want to delete {{count}} items? confirm_delete_download_unfinished_items_description=Are you sure you want to delete {{count}} unfinished downloads? confirm_delete_download_finished_and_unfinished_items_description=Are you sure you want to delete {{finishedCount}} finished and {{unfinishedCount}} unfinished downloads? also_delete_file_from_disk=Also delete file from disk confirm_delete_category_item_title=Removing {{name}} category confirm_delete_category_item_description=Are you sure you want to delete "{{value}}" Category? your_download_will_not_be_deleted=Your downloads won't be deleted drag_the_file_to_another_app=Drag the file to another app drop_link_or_file_here=Drop link or file here. nothing_will_be_imported=Nothing will be imported n_links_will_be_imported={{count}} links will be imported n_items_selected={{count}} items selected window_close=Close window_minimize=Minimize window_maximize=Maximize window_restore=Restore delete=Delete remove=Remove cancel=Cancel close=Close menu=Menu more_options=More Options ok=OK add=Add paste=Paste change=Change edit=Edit change_anyway=Change Anyway download=Download refresh=Refresh settings=Settings on_completion=On Completion unknown=Unknown unknown_error=Unknown Error download_item_not_found=Download item not found name=Name download_link=Download link not_finished=Not finished all=All finished=Finished Unfinished=Unfinished canceled=Canceled error=Error paused=Paused downloading=Downloading added=Added idle=IDLE preparing_file=Preparing File creating_file=Creating File resuming=Resuming retrying=Retrying list_is_empty=List is empty! search_in_the_list=Search in the List search=Search clear=Clear general=General enabled=Enabled disabled=Disabled default=Default file=File tasks=Tasks tools=Tools help=Help system=System all_missing_files=All Missing Files all_finished=All Finished all_unfinished=All Unfinished entire_list=Entire List download_browser_integration=Download Browser Integration exit=Exit show_downloads=Show Downloads new_download=New Download stop_all=Stop All import_from_clipboard=Import From Clipboard batch_download=Batch Download open=Open share=Share open_file=Open File open_folder=Open Folder resume=Resume pause=Pause restart_download=Restart Download copy=Copy copy_link=Copy link copy_as_curl=Copy as cURL show_properties=Show Properties move_to_queue=Move To Queue move_to_this_queue=Move to this Queue move_to_category=Move To Category move_to_this_category=Move to this category categories=Categories add_category=Add Category edit_category=Edit Category delete_category=Delete Category category_name=Category Name category_download_location=Category Download Location category_download_location_description=When this category chosen in "Add Download" use this directory as "Download Location" category_file_types=Category file types category_file_types_description=Automatically put these file types to this category. (when you add new download)\nSeparate file extensions with space (ext1 ext2 ...) category_url_patterns=URL Patterns category_url_patterns_description=Automatically put download from these URLs to this category. (when you add new download)\nSeparate URLs with space, you can also use * for wildcard auto_categorize_downloads=Auto Categorize Downloads restore_defaults=Restore Defaults about=About version_n=Version {{value}} developed_with_love_for_you=Developed with ❤️ for you donate=Donate visit_the_project_website=Visit the project website this_is_a_free_and_open_source_software=This is a free & Open Source software view_the_source_code=See the Source Code third_party_libraries=Third Party Libraries powered_by_open_source_software=Powered by Open Source Software view_the_open_source_licenses=View the Open-Source licenses support_and_community=Support & Community telegram=Telegram channel=Channel group=Group add_download=Add Download add_multi_download_page_header=Select Items you want to pick up for download save_to=Save To where_should_each_item_saved=Where should each item be saved? there_are_multiple_items_please_select_a_way_you_want_to_save_them=There are multiple items! please select a way you want to save them each_item_on_its_own_category=Each item on its own category each_item_on_its_own_category_description=Each item will be placed in a category that have that file type all_items_in_one_category=All items in one Category all_items_in_one_category_description=All files will be saved in the selected category all_items_in_one_Location=All items in one Location all_items_in_one_Location_description=All items will be saved in the selected directory unselected_all_items_in_specific_location_description=All files will be saved in the selected category location no_category_selected=No Category Selected no_categories_found=No Categories Found download_location=Download Location location=Location select_queue=Select Queue without_queue=Without Queue use_category=Use Category cant_write_to_this_folder=Can't write to this folder file_name_already_exists=File name already exists download_already_exists=Download already exists invalid_file_name=Invalid filename show_solutions=Show solutions... change_solution=Change solution select_a_solution=Select a solution select_download_strategy_description=The link you provided is already in download lists please specify what you want to do download_strategy_add_a_numbered_file=Add a numbered file download_strategy_add_a_numbered_file_description=Add an index after the end of download file name download_strategy_override_existing_file=Override existing file download_strategy_override_existing_file_description=Remove existing download and write to that file download_strategy_update_download_link=Update existing download download_strategy_update_download_link_description=Update the existing download link and its credentials download_strategy_show_downloaded_file=Show downloaded file download_strategy_show_downloaded_file_description=Show already existing download item, so you can press on resume or open it batch_download_link_help=Enter a link that contains wildcards (use *) invalid_url=Invalid URL list_is_too_large_maximum_n_items_allowed=List is too large! maximum {{count}} items allowed enter_range=Enter range range_from=From range_to=To batch_download_wildcard_length=Wildcard length first_link=First Link last_link=Last Link open_source_software_used_in_this_app=Open Source Software used in this App links=Links website=Website developers=Developers source_code=Source Code license=License no_license_found=No license found organization=Organization add_new_queue=Add New Queue queue_name=Queue Name queues=Queues stop_queue=Stop Queue start_queue=Start Queue clear_queue_items=Empty Queue config=Config items=Items move_down=Move down move_up=Move up remove_queue=Remove Queue queue_name_help=Specify a name for this queue queue_name_describe=Queue name is {{value}} queue_max_concurrent_download=Max Concurrent Download queue_max_concurrent_download_description=Max download for this queue queue_automatic_stop=Automatic Stop queue_automatic_stop_description=Automatic stop queue when there is no item in it queue_scheduler=Scheduler queue_enable_scheduler=Enable Scheduler queue_active_days=Active Days queue_active_days_description=Which days schedulers function? queue_scheduler_enable_auto_start_time=Enable Auto Start Time queue_scheduler_auto_start_time=Auto Start Time queue_scheduler_enable_auto_stop_time=Enable Auto Stop Time queue_scheduler_auto_stop_time=Auto Stop Time queue_shutdown_on_completion=Shutdown System On Completion queue_shutdown_on_completion_description=Automatically shutdown the system when this queue is completed. or when the scheduled end time is reached. appearance=Appearance download_engine=Download Engine browser_integration=Browser Integration settings_download_max_retries_count=Maximum Download Retries settings_download_max_retries_count_description=The maximum number of times the app will retry a failed download before giving up settings_download_max_retries_count_describe_no_retries=Failed downloads won't be retried settings_download_max_retries_count_describe_n_retries=Failed downloads will be retried {{count}} time(s) settings_download_thread_count=Thread Count settings_download_thread_count_description=Maximum download thread per download item settings_download_thread_count_describe=A download can have up to {{count}} threads settings_download_thread_count_with_large_value_describe=Warning: Setting a high thread count may increase system resource usage, reduce performance, or cause connection issues with servers. Use higher values only if you understand the potential impact on your system and network. settings_use_server_last_modified_time=Use Server's Last-Modified Time settings_use_server_last_modified_time_description=When downloading a file, use server's last modified time for the local file settings_append_extension_to_incomplete_downloads=Append Extension To Incomplete Downloads settings_append_extension_to_incomplete_downloads_description=Append ".part" extension to incomplete downloads. This helps to identify unfinished downloads and prevents accidental opening of incomplete files. settings_use_sparse_file_allocation=Sparse File Allocation settings_use_sparse_file_allocation_description=Create files more efficiently, especially on SSDs, by reducing unnecessary data writing. This can speed up download starts and reduce disk usage. If downloads start slowly or you experience unusual download speeds, consider disabling this option, as it may not be fully supported on some devices. settings_ignore_ssl_certificates=Ignore SSL Certificates settings_ignore_ssl_certificates_description=Disables SSL certificate verification. Use only if necessary, as it may expose your connection to security risks. settings_global_speed_limiter=Global Speed Limiter settings_global_speed_limiter_description=Global download speed limit (0 means unlimited) settings_show_average_speed=Show Average Speed settings_show_average_speed_description=Download speed in average or precision settings_use_category_by_default=Use Category By Default settings_use_category_by_default_description=Use category by default when adding a download. settings_default_download_folder=Default Download Folder settings_default_download_folder_description=When you add new download this location is used by default settings_default_download_folder_describe="{{folder}}" will be used settings_use_proxy=Use Proxy settings_use_proxy_description=Use proxy for downloading files settings_use_proxy_describe_no_proxy=No Proxy will be used settings_use_proxy_describe_system_proxy=System Proxy will be used settings_use_proxy_describe_manual_proxy="{{value}}" will be used settings_use_proxy_describe_pac_proxy=PAC file "{{value}}" will be used settings_track_deleted_files_on_disk=Track Deleted Files On Disk settings_track_deleted_files_on_disk_description=Automatically remove files from the list when they are deleted or moved from the download directory. settings_delete_partial_file_on_download_cancellation=Delete Partial File On Download Cancellation settings_delete_partial_file_on_download_cancellation_description=When a download is canceled, the partially downloaded file will be deleted from the disk. This helps keep your download folder clean and reduces unnecessary disk space usage. However, the download will restart from the beginning the next time you start it. settings_default_user_agent=Default User-Agent settings_default_user_agent_description=Specify the Default-User Agent string to define how requests identify to servers. This can help in accessing content optimized for particular devices or in circumventing download limitations imposed by certain websites. settings_download_size_unit=Download Size Unit settings_download_size_unit_description=Unit used to display the download size settings_download_speed_unit=Download Speed Unit settings_download_speed_unit_description=Unit used to display the download speed settings_theme=Theme settings_theme_description=Select a theme for the App settings_default_dark_theme=Default Dark Theme settings_default_dark_theme_description=Applies when the app follows the system theme and dark mode is active settings_default_light_theme=Default Light Theme settings_default_light_theme_description=Applies when the app follows the system theme and light mode is active settings_font=Font settings_font_description=Change the font used in the app interface, Some fonts might not display correctly in the app. settings_ui_scale=UI Scale settings_ui_scale_description=Adjust the size of the app's interface elements settings_language=Language settings_compact_top_bar=Compact Top Bar settings_compact_top_bar_description=Merge top bar with title bar when the main window has enough width settings_use_native_menu_bar=Use Native Menu Bar settings_use_native_menu_bar_description=Use the system's default menu bar style settings_use_relative_date_time=Use relative date/time settings_use_relative_date_time_description=Use relative date/time format for dates in the app (e.g., "2 days ago" instead of the exact date/time) settings_show_icon_labels=Show Icon Labels settings_show_icon_labels_description=Show labels under icons when possible ( like home toolbar actions ) settings_use_system_tray=Use System Tray settings_use_system_tray_description=Show system tray icon when the app is running settings_start_on_boot=Start On Boot settings_start_on_boot_description=Auto start application on user logins settings_notification_sound=Notification Sound settings_notification_sound_description=Play sound on new notification settings_browser_integration=Browser Integration settings_browser_integration_description=Accept downloads from browsers settings_browser_integration_server_port=Server Port settings_browser_integration_server_port_description=Port for browser integration settings_browser_integration_server_port_describe=App will listen to port {{port}} settings_dynamic_part_creation=Dynamic Part Creation settings_dynamic_part_creation_description=When a part is finished create another part by splitting other parts to improve download speed settings_show_completion_dialog=Show Download Completion Dialog settings_show_completion_dialog_description=Automatically show "Download Complete" dialog when a download finished. settings_show_download_progress_dialog=Show Download Progress Dialog settings_show_download_progress_dialog_description=Automatically show "Download Progress" dialog when a download started. settings_per_host_settings=Per Host Settings settings_per_host_settings_descriptions=These settings will be automatically applied to any new download that matches the specified host. settings_download_max_concurrent_downloads=Maximum Concurrent Downloads settings_download_max_concurrent_downloads_description=The maximum number of files that can be downloaded at the same time (downloads managed by queues are not counted; set to 0 for unlimited) download_item_settings_speed_limit=Speed Limit download_item_settings_speed_limit_description=Limit download speed for this item download_item_settings_show_download_completion_dialog=Show Download Completion Dialog download_item_settings_show_download_completion_dialog_description=Automatically Show the "Download Complete" dialog when this download is finished. download_item_settings_shutdown_on_completion=Shutdown System On Completion download_item_settings_shutdown_on_completion_description=Automatically shutdown the system when this download is finished. download_item_settings_thread_count=Thread Count download_item_settings_thread_count_description=How much thread used to download this download item (0 for default) download_item_settings_thread_count_describe={{count}} threads for this download download_item_settings_username_description=Provide a username if the link is a protected resource download_item_settings_password_description=Provide a password if the link is a protected resource download_item_settings_download_page=Download Page download_item_settings_download_page_description=The webpage where this download was initiated download_item_settings_file_checksum=File Checksum download_item_settings_file_checksum_description=A hash string which can be used to check if file is downloaded correctly download_item_settings_user_agent=User-Agent download_item_settings_user_agent_description=Custom User-Agent for this item (leave empty to use the default) file_checksum=File Checksum file_checksum_page=File Checksum Checker file_checksum_page_file_checksum_default_algorithm=Default Algorithm file_checksum_page_file_checksum_default_algorithm_help=The default algorithm used to calculate file checksums when they are not provided. start=Start calculated_checksum=Calculated Checksum saved_checksum=Saved Checksum checksum_algorithm=Algorithm file_not_found=File not found download_not_finished=Download not finished done=Done waiting=Waiting matches=Matches not_matches=Not Matches copy_to_clipboard=Copy To Clipboard username=Username password=Password average_speed=Average Speed exact_speed=Exact Speed unlimited=Unlimited use_global_settings=Use Global Settings cant_run_browser_integration=Can't run browser integration cant_open_file=Can't Open File cant_open_folder=Can't Open Folder # times for example 2 seconds ago relative_time_long_years={{years}} years relative_time_long_months={{months}} months relative_time_long_days={{days}} days relative_time_long_hours={{hours}} hours relative_time_long_minutes={{minutes}} minutes relative_time_long_seconds={{seconds}} seconds relative_time_short_years={{years}} y relative_time_short_months={{months}} M relative_time_short_days={{days}} d relative_time_short_hours={{hours}} hr relative_time_short_minutes={{minutes}} min relative_time_short_seconds={{seconds}} sec relative_time_left={{time}} left relative_time_ago={{time}} ago auto=Auto unspecified=Unspecified custom=Custom icon=Icon author=Author link=Link size=Size status=Status parts_info_downloaded_size=Downloaded parts_info_total_size=Total speed=Speed time_left=Time Left date_added=Date Added info=Info download_page_downloaded_size=Downloaded download_page_download_completed=Download Completed resume_support=Resume Support yes=Yes no=No parts_info=Parts Info disconnected=Disconnected receiving_data=Receiving Data connecting=Connecting warning=Warning unsupported_resume_warning=This download doesn't support resuming! You may have to RESTART it later in the Download List stop_anyway=Stop Anyway customize_columns=Customize Columns reset=Reset monday=Monday tuesday=Tuesday wednesday=Wednesday thursday=Thursday friday=Friday saturday=Saturday sunday=Sunday proxy_open_system_proxy_settings=Open System Proxy Settings proxy_type=Proxy type proxy_do_not_use_proxy_for=Don't Use proxy for proxy_do_not_use_proxy_for_description=A list of urls that may not be proxied\nYou can use wildcard with *\nfor example 192.168.1.* example.com (space separated) proxy_change_title=Change Proxy change_proxy=Change Proxy proxy_no=No Proxy proxy_system=System Proxy proxy_manual=Manual Proxy proxy_pac=Proxy Auto Configuration proxy_pac_url=Proxy Auto Configuration URL address=Address port=Port address_and_port=Address & Port use_authentication=Use Authentication warning_you_may_have_to_restart_the_download_later=You may have to restart the download later! edit_download_title=Edit Download edit_download_update_from_download_page=Update from Download Page edit_download_update_from_download_page_description=When this window is open, you can go to the Download Page and click the download button. The app will capture and update the new download credentials so you can save them. edit_download_saved_download_item_size_not_match=The saved download item has a size of {{currentSize}}, which does not match the new size of {{newSize}}. translators_page_thanks=With Gratitude to Those Who Helped Translate This Project ❤️ translators=Translators language=Language translators_contribute_title=Improve Translations translators_contribute_description=Want to help improve this project? If your language isn't listed or needs some tweaks, you can contribute your translations and make it better\! contribute=Contribute meet_the_translators=Meet the Translators localized_by_translators=Localized by Translators confirm_exit=Confirm Exit confirm_exit_description=Are you sure you want to exit AB Download Manager?\nActive downloads/queues will be stopped! update=Update update_updater=Updater update_available=Update Available update_error=Update Error update_available_suggest_to_to_update=You can update to the latest version to enjoy new features, enhancements, and performance improvements. update_release_notes=Release Notes update_check_for_update=Check for Update update_checking_for_update=Checking for Update update_no_update=You are using the latest version update_check_error=Error while checking for update update_app_updated_to_version_n=App updated to version {{version}} create_desktop_entry=Create Desktop Entry shutdown_alert=Shut Down Alert system_shutdown_soon=System Will Shut Down Soon! system_shutdown_failed=System Shut Down Failed! system_shutdown_soon_description=The system will shut down soon. If you're still using the computer, please save your work or cancel the shutdown. system_shutdown_reason_queue_completed=All downloads in the queue are complete. system_shutdown_reason_queue_end_time_reached=Scheduled end time for the download queue reached. system_shutdown_download_finished=Download completed. shutdown_now=Shut Down Now settings_per_host_settings_new_host= settings_per_host_settings_not_selected=Create or select a new item first! settings_per_host_settings_host=Host settings_per_host_settings_host_description=These settings will be applied to downloads matching this hostname. Wildcards (*) are supported (e.g., example.com, *.example.com — use only one). settings_browser_in_launcher=Browser Icon In Launcher settings_browser_in_launcher_description=Show or hide the browser icon in the launcher (app list). sort_by=Sort By welcome=Welcome new_folder=New Folder skip=Skip lets_go=Let's Go next=Next select_all=Select All select_inside=Select Inside select_invert=Select Invert open_settings=Open Settings back=Back service_is_running=Service is running initial_setup_description=Let’s set things up initial_setup_notice=You can change these settings anytime later permission_granted=Permission granted permission_not_granted=Permission not granted permissions=Permissions give_permission=Allow permission give_storage_permission=Allow storage access storage_roots=Storage Roots permissions_initial_title=Permissions setup permissions_initial_description=To work properly, the app needs a few permissions. On the next screen, you’ll see what each permission is used for and you can decide which ones to allow or skip. permissions_done_title=You’re all set permissions_done_description=Everything is ready. All required permissions have been granted and the app is good to go. permissions_manage_storage_title=Manage storage access permissions_manage_storage_reason=This permission lets the app change the download folder, detect duplicate downloads more accurately, and enable some extra features. It’s optional, but recommended for the best experience. permission_read_write_external_storage_title=Read and write storage permission_read_write_external_storage_reason=This permission allows the app to save and manage downloaded files, change the download location, and improve duplicate download detection. permissions_post_notification_title=Post Notification permissions_post_notification_reason=The app needs to run in the background to manage downloads. Notifications are used to keep you informed and allow background operation. permissions_ignore_battery_optimization_title=Ignore Battery Optimization permissions_ignore_battery_optimization_reason=Some devices aggressively limit background activity to save battery, which can pause or stop downloads when the app isn’t open. You can optionally exclude the app from battery optimization to ensure downloads continue uninterrupted open_in_browser=Open In Browser browser=Browser browser_new_tab=New Tab browser_close_tab=Close Tab browser_open_in_new_tab=Open In New Tab browser_open_in_new_background_tab=Open In New Background Tab browser_no_tab_open=No tabs are open browser_tabs=Tabs browser_paste_and_go=Paste And Go browser_bookmarks=Bookmarks browser_add_bookmark=Add Bookmark browser_edit_bookmark=Edit Bookmark browser_add_to_bookmarks=Add To Bookmarks browser_remove_from_bookmarks=Remove From Bookmarks ================================================ FILE: shared/resources/src/commonMain/resources/com/abdownloadmanager/resources/locales/es_ES.properties ================================================ app_title=AB Administrador de descargas confirm_auto_categorize_downloads_title=Clasificar automáticamente descargas confirm_auto_categorize_downloads_description=Cualquier elemento no categorizado se añadirá automáticamente a su categoría correspondiente. confirm_reset_to_default_categories_title=Reiniciar categorías por defecto confirm_reset_to_default_categories_description=¡Esto ELIMINARÁ todas las categorías y restablecerá las predeterminadas\! confirm_delete_download_items_title=Confirmar Eliminación confirm_delete_download_items_description=¿Estás seguro de que quieres eliminar {{count}} elementos? confirm_delete_download_unfinished_items_description=¿Estás seguro de que quieres eliminar {{count}} descargas incompletas? confirm_delete_download_finished_and_unfinished_items_description=¿Estás seguro de que quieres eliminar {{finishedCount}} descargas completadas y {{unfinishedCount}} descargas incompletas? also_delete_file_from_disk=Eliminar también del disco confirm_delete_category_item_title=Eliminando categoría {{name}} confirm_delete_category_item_description=¿Estás seguro de que quieres eliminar la categoría "{{value}}"? your_download_will_not_be_deleted=Sus descargas no serán eliminadas drag_the_file_to_another_app=Arrastra el archivo a otra aplicación drop_link_or_file_here=Suelta el enlace o archivo aquí. nothing_will_be_imported=Nada será importado n_links_will_be_imported={{count}} enlaces serán importados n_items_selected={{count}} elementos seleccionados window_close=Cerrar window_minimize=Minimizar window_maximize=Maximizar window_restore=Restaurar delete=Eliminar remove=Quitar cancel=Cancelar close=Cerrar menu=Menú more_options=Más opciones ok=Aceptar add=Añadir paste=Pegar change=Cambiar edit=Editar change_anyway=Cambiar igualmente download=Descargar refresh=Actualizar settings=Ajustes on_completion=Al finalizar unknown=Desconocido unknown_error=Error desconocido download_item_not_found=No se encuentra el elemento descargado name=Nombre download_link=Enlace de descarga not_finished=Sin completar all=Todo finished=Completado Unfinished=Sin completar canceled=Cancelado error=Error paused=Pausado downloading=Descargando added=Añadido idle=INACTIVO preparing_file=Preparando archivo creating_file=Creando archivo resuming=Resumiendo retrying=Reintentando list_is_empty=¡La lista está vacía\! search_in_the_list=Buscar en la Lista search=Buscar clear=Limpiar general=General enabled=Habilitado disabled=Deshabilitado default=Predeterminado file=Archivo tasks=Tareas tools=Herramientas help=Ayuda system=Sistema all_missing_files=Todos los archivos que faltan all_finished=Todo completado all_unfinished=Sin completar entire_list=Lista completa download_browser_integration=Descargar integración con navegador exit=Salir show_downloads=Mostrar descargas new_download=Nueva descarga stop_all=Parar todo import_from_clipboard=Importar desde el portapapeles batch_download=Descarga por lotes open=Abrir share=Compartir open_file=Abrir archivo open_folder=Abrir carpeta resume=Reanudar pause=Pausar restart_download=Reiniciar descarga copy=Copiar copy_link=Copiar enlace copy_as_curl=Copiar como cURL show_properties=Mostrar propiedades move_to_queue=Mover a la cola move_to_this_queue=Mover a esta cola move_to_category=Mover a la categoría move_to_this_category=Mover a esta categoría categories=Categorías add_category=Añadir categoría edit_category=Modificar categoría delete_category=Eliminar categoría category_name=Nombre de la categoría category_download_location=Ubicación de categoría category_download_location_description=Al eligir esta categoría en "Añadir descarga", usar este directorio como "Ubicación de descarga" category_file_types=Tipos de archivos de categoría category_file_types_description=Poner automáticamente estos tipos de archivos en esta categoría. (al añadir una nueva descarga)\nSeparar las extensiones de archivo con espacio (ext1 ext2...) category_url_patterns=Patrones de URL category_url_patterns_description=Poner automáticamente las descargas de estas URLs en esta categoría. (al añadir una nueva descarga)\nSeparar las URLs con espacios, también puede usar * como comodín auto_categorize_downloads=Categorizar descargas automáticamente restore_defaults=Restaurar valores por defecto about=Acerca de... version_n=Versión {{value}} developed_with_love_for_you=Desarrollado con ❤️ para ti donate=Donar visit_the_project_website=Visitar sitio web del proyecto this_is_a_free_and_open_source_software=Este es un software gratuito y de código abierto view_the_source_code=Ver el código fuente third_party_libraries=Bibliotecas de terceros powered_by_open_source_software=Producido por software de código abierto view_the_open_source_licenses=Ver las licencias de código abierto support_and_community=Soporte & Comunidad telegram=Telegram channel=Canal group=Grupo add_download=Añadir descarga add_multi_download_page_header=Seleccione los elementos que desea tomar para descargar save_to=Guardar en where_should_each_item_saved=¿Dónde debe guardarse cada elemento? there_are_multiple_items_please_select_a_way_you_want_to_save_them=¡Hay varios elementos\! Elegir una forma de guardarlos each_item_on_its_own_category=Cada elemento en su propia categoría each_item_on_its_own_category_description=Cada elemento se colocará en una categoría que tenga ese tipo de archivo all_items_in_one_category=Todos los elementos en una categoría all_items_in_one_category_description=Todos los archivos serán guardados en la categoría destino seleccionada all_items_in_one_Location=Todos los elementos en una sola ubicación all_items_in_one_Location_description=Todos los elementos se guardarán en el directorio seleccionado unselected_all_items_in_specific_location_description=Todos los elementos se guardarán en el directorio seleccionado no_category_selected=Sin categoría no_categories_found=No se encontraron categorías download_location=Ubicación de descarga location=Ubicación select_queue=Seleccionar cola without_queue=Sin cola use_category=Usar categoría cant_write_to_this_folder=No se puede escribir en esta carpeta file_name_already_exists=El nombre de archivo ya existe download_already_exists=La descarga ya existe invalid_file_name=Archivo inválido show_solutions=Mostrar soluciones... change_solution=Cambiar solución select_a_solution=Seleccionar una solución select_download_strategy_description=El enlace ya se encuentra en la lista de descargas, por favor especifique lo que desea hacer download_strategy_add_a_numbered_file=Añadir un archivo numerado download_strategy_add_a_numbered_file_description=Añadir un índice al final del nombre del archivo de descarga download_strategy_override_existing_file=Sobreescribir el archivo existente download_strategy_override_existing_file_description=Quitar la descarga existente y escribir en ese archivo download_strategy_update_download_link=Actualizar descarga existente download_strategy_update_download_link_description=Actualizar el enlace de descarga existente y sus credenciales download_strategy_show_downloaded_file=Mostrar archivo descargado download_strategy_show_downloaded_file_description=Mostrar el elemento de descarga ya existente, para que pueda pulsar en reanudar o abrir batch_download_link_help=Introducir un enlace que contenga comodines (utilizar *) invalid_url=La URL no es válida. list_is_too_large_maximum_n_items_allowed=¡La lista es demasiado grande\! Máximo {{count}} elementos permitidos enter_range=Introducir rango range_from=Desde range_to=Hasta batch_download_wildcard_length=Longitud de comodín first_link=Primer enlace last_link=Último enlace open_source_software_used_in_this_app=Software de código abierto utilizado en esta aplicación links=Enlaces website=Sitio web developers=Desarrolladores source_code=Código fuente license=Licencia no_license_found=No se ha encontrado ninguna Licencia organization=Organización add_new_queue=Añadir nueva cola queue_name=Nombre de cola queues=Colas stop_queue=Detener cola start_queue=Iniciar cola clear_queue_items=Cola vacía config=Ajustes items=Elementos move_down=Mover abajo move_up=Mover arriba remove_queue=Quitar cola queue_name_help=Especificar un nombre para esta cola queue_name_describe=El nombre de la cola es {{value}} queue_max_concurrent_download=Máximas descargas simultáneas queue_max_concurrent_download_description=Máximas descargas para esta cola queue_automatic_stop=Detener automáticamente queue_automatic_stop_description=Detener automáticamente la cola cuando no hay ningún elemento en ella queue_scheduler=Programador queue_enable_scheduler=Activar programador queue_active_days=Días activos queue_active_days_description=¿Qué días funcionan los programadores? queue_scheduler_enable_auto_start_time=Habilitar hora de inicio automática queue_scheduler_auto_start_time=Hora de inicio automático queue_scheduler_enable_auto_stop_time=Activar hora de parada automática queue_scheduler_auto_stop_time=Hora de parada automática queue_shutdown_on_completion=Sistema de apagado al finalizar queue_shutdown_on_completion_description=Apagar automáticamente el sistema cuando se complete esta cola o cuando se alcance la hora de finalización programada. appearance=Apariencia download_engine=Motor de descarga browser_integration=Integración con navegador settings_download_max_retries_count=Reintentos máximos de descarga settings_download_max_retries_count_description=El número máximo de veces que la aplicación reintentará una descarga fallida antes de rendirse settings_download_max_retries_count_describe_no_retries=Las descargas fallidas no se volverán a intentar settings_download_max_retries_count_describe_n_retries=Las descargas fallidas se reintentarán {{count}} vez/veces settings_download_thread_count=Partes settings_download_thread_count_description=Máximo de partes por descarga settings_download_thread_count_describe=Una descarga puede tener hasta {{count}} partes settings_download_thread_count_with_large_value_describe=Advertencia\: Configurar un alto número de hilos puede aumentar el uso de recursos del sistema, reducir el rendimiento o causar problemas de conexión con los servidores. Utilice valores más altos sólo si comprende el impacto potencial en su sistema y su red. settings_use_server_last_modified_time=Usar la última hora de modificación del servidor settings_use_server_last_modified_time_description=Al descargar un archivo, usar la última hora de modificación del servidor para el archivo local settings_append_extension_to_incomplete_downloads=Añadir extensión a las descargas incompletas settings_append_extension_to_incomplete_downloads_description=Añadir extensión ".part" a las descargas incompletas. Esto ayuda a identificar las descargas incompletas y evita la apertura accidental de archivos incompletos. settings_use_sparse_file_allocation=Asignación dispersa de archivos settings_use_sparse_file_allocation_description=Crea archivos de manera mas eficiente, especialmente en SSDs, reduciendo la escritura de datos innecesaria. Esto puede acelerar la velocidad de descarga y reducir el uso de disco. Si la descarga inicia de manera lenta o experimenta una inusual velocidad de descarga, considera deshabilitar esta opción, ya que puede que no sea soportada por algunos dispositivos. settings_ignore_ssl_certificates=Ignorar certificados SSL settings_ignore_ssl_certificates_description=Deshabilita la verificación de certificado SSL. Utilice sólo si es necesario, ya que puede exponer su conexión a riesgos de seguridad. settings_global_speed_limiter=Limitador global de velocidad settings_global_speed_limiter_description=Límite global de velocidad de descarga (0 significa ilimitado) settings_show_average_speed=Mostrar velocidad promedio settings_show_average_speed_description=Velocidad de descarga promedio o precisa settings_use_category_by_default=Usar categoría por defecto settings_use_category_by_default_description=Usar categoría por defecto al añadir una descarga. settings_default_download_folder=Carpeta de descargas por defecto settings_default_download_folder_description=Al añadir una nueva descarga, se usa esta ubicación por defecto settings_default_download_folder_describe=Se usará "{{folder}}" settings_use_proxy=Usar proxy settings_use_proxy_description=Usar proxy para descargar archivos settings_use_proxy_describe_no_proxy=Ningún proxy será utilizado settings_use_proxy_describe_system_proxy=El proxy del sistema será utilizado settings_use_proxy_describe_manual_proxy="{{value}}" será utilizado settings_use_proxy_describe_pac_proxy=Se utilizará el archivo pac "{{value}}" settings_track_deleted_files_on_disk=Rastrear archivos borrados en disco settings_track_deleted_files_on_disk_description=Quitar automáticamente los archivos de la lista cuando se eliminan o se mueven del directorio de descargas. settings_delete_partial_file_on_download_cancellation=Eliminar archivo parcial al cancelar la descarga settings_delete_partial_file_on_download_cancellation_description=Cuando se cancela una descarga, el archivo descargado parcialmente se eliminará del disco. Esto ayuda a mantener limpia la carpeta de descargas y reduce el uso innecesario de espacio en disco. Sin embargo, la descarga se reiniciará desde el principio la próxima vez que la inicie. settings_default_user_agent=Agente de usuario predeterminado settings_default_user_agent_description=Especifique la cadena de agente de usuario predeterminado para definir cómo se identifican las peticiones a los servidores. Esto puede ayudar a acceder a contenidos optimizados para determinados dispositivos o en evitar limitaciones de descarga impuestas por ciertos sitios web. settings_download_size_unit=Unidad de tamaño de descarga settings_download_size_unit_description=Unidad para mostrar el tamaño de la descarga settings_download_speed_unit=Unidad de velocidad de descarga settings_download_speed_unit_description=Unidad usada para mostrar la velocidad de descarga settings_theme=Tema settings_theme_description=Seleccione un tema para la aplicación settings_default_dark_theme=Tema oscuro predeterminado settings_default_dark_theme_description=Se aplica cuando la aplicación sigue el tema del sistema y el modo oscuro está activo settings_default_light_theme=Tema claro predeterminado settings_default_light_theme_description=Se aplica cuando la aplicación sigue el tema del sistema y el modo claro está activo settings_font=Fuente settings_font_description=Cambiar la fuente usada en la interfaz de la aplicación, algunas fuentes podrían no mostrarse correctamente en la aplicación. settings_ui_scale=Escala de la interfaz settings_ui_scale_description=Ajustar el tamaño de los elementos de la interfaz settings_language=Idioma settings_compact_top_bar=Barra superior compacta settings_compact_top_bar_description=Combinar la barra superior con la barra de título cuando la ventana principal tiene suficiente espacio settings_use_native_menu_bar=Usar barra de menú nativa settings_use_native_menu_bar_description=Usar el estilo de barra de menú por defecto del sistema settings_use_relative_date_time=Usar fecha y hora relativa settings_use_relative_date_time_description=Usar formato relativo de fecha/hora para las fechas en la aplicación (por ejemplo, "hace 2 días" en lugar de la fecha / hora exacta) settings_show_icon_labels=Mostrar etiquetas de los iconos settings_show_icon_labels_description=Mostrar etiquetas debajo de los iconos cuando sea posible (en los iconos del lado derecho del botón de nueva descarga) settings_use_system_tray=Usar bandeja del sistema settings_use_system_tray_description=Mostrar el icono en la bandeja del sistema cuando la aplicación esté en ejecución settings_start_on_boot=Iniciar con el equipo settings_start_on_boot_description=Iniciar aplicación al iniciar sesión settings_notification_sound=Sonido de notificación settings_notification_sound_description=Reproducir sonido en una nueva notificación settings_browser_integration=Integración con navegador settings_browser_integration_description=Aceptar descargas desde el navegador settings_browser_integration_server_port=Puerto del servidor settings_browser_integration_server_port_description=Puerto para integración en navegador settings_browser_integration_server_port_describe=La aplicación escuchará el puerto {{port}} settings_dynamic_part_creation=Creación dinámica de partes settings_dynamic_part_creation_description=Cuando una parte está terminada, crear otra dividiendo otras partes para mejorar la velocidad de descarga settings_show_completion_dialog=Mostrar diálogo de descarga completada settings_show_completion_dialog_description=Mostrar automáticamente el diálogo "Descarga completada" cuando haya finalizado una descarga. settings_show_download_progress_dialog=Mostrar diálogo de progreso de descarga settings_show_download_progress_dialog_description=Mostrar automáticamente diálogo de "Progreso de descarga" al iniciar una descarga. settings_per_host_settings=Configuración por host settings_per_host_settings_descriptions=Estos ajustes se aplicarán automáticamente a cualquier nueva descarga que coincida con el host especificado. settings_download_max_concurrent_downloads=Máximo número de descargas simultáneas settings_download_max_concurrent_downloads_description=El número máximo de archivos que se pueden descargar al mismo tiempo (las descargas administradas por las colas no son contadas; se establece en 0 para ilimitado) download_item_settings_speed_limit=Límite de velocidad download_item_settings_speed_limit_description=Limitar velocidad de descarga para este elemento download_item_settings_show_download_completion_dialog=Mostrar diálogo de descarga completada download_item_settings_show_download_completion_dialog_description=Mostrar automáticamente diálogo de "Descarga completada" al finalizar una descarga. download_item_settings_shutdown_on_completion=Sistema de apagado al finalizar download_item_settings_shutdown_on_completion_description=Apagar automáticamente el sistema cuando finalice esta descarga. download_item_settings_thread_count=Partes download_item_settings_thread_count_description=Número de partes usadas para esta descarga (0 por defecto) download_item_settings_thread_count_describe={{count}} partes para esta descarga download_item_settings_username_description=Colocar un nombre de usuario si el enlace está protegido download_item_settings_password_description=Colocar una contraseña si el enlace está protegido download_item_settings_download_page=Página de descarga download_item_settings_download_page_description=La página web donde se inició la descarga download_item_settings_file_checksum=Suma de verificación download_item_settings_file_checksum_description=Una cadena hash que puede ser usada para comprobar si el archivo se ha descargado correctamente download_item_settings_user_agent=Agente de usuario download_item_settings_user_agent_description=Agente de usuario personalizado para este elemento (dejar vacío para usar el predeterminado) file_checksum=Suma de verificación file_checksum_page=Comprobador de suma de verificación de archivo file_checksum_page_file_checksum_default_algorithm=Algoritmo por defecto file_checksum_page_file_checksum_default_algorithm_help=El algoritmo por defecto usado para calcular sumas de verificación de archivos cuando no se proporcionan. start=Iniciar calculated_checksum=Suma de verificación calculada saved_checksum=Suma de verificación guardada checksum_algorithm=Algoritmo file_not_found=Archivo no encontrado download_not_finished=Descarga sin completar done=Hecho waiting=Esperando matches=Coincidencias not_matches=Sin coinciden copy_to_clipboard=Copiar al portapapeles username=Usuario password=Contraseña average_speed=Velocidad promedio exact_speed=Velocidad exacta unlimited=Ilimitado use_global_settings=Usar ajustes globales cant_run_browser_integration=No se puede ejecutar la integración con el navegador cant_open_file=No se puede abrir el archivo cant_open_folder=No se puede abrir la carpeta # times for example 2 seconds ago relative_time_long_years={{years}} años relative_time_long_months={{months}} meses relative_time_long_days={{days}} días relative_time_long_hours={{hours}} horas relative_time_long_minutes={{minutes}} minutos relative_time_long_seconds={{seconds}} segundos relative_time_short_years={{years}} a relative_time_short_months={{months}} M relative_time_short_days={{days}} d relative_time_short_hours={{hours}} hrs relative_time_short_minutes={{minutes}} min relative_time_short_seconds={{seconds}} seg relative_time_left={{time}} quedan relative_time_ago=Hace {{time}} auto=Auto unspecified=Sin especificar custom=Personalizado icon=Icono author=Autor link=Enlace size=Tamaño status=Estado parts_info_downloaded_size=Descargado parts_info_total_size=Total speed=Velocidad time_left=Tiempo restante date_added=Añadido info=Información download_page_downloaded_size=Descargado download_page_download_completed=Descarga completada resume_support=Reanudable yes=Si no=No parts_info=Información de partes disconnected=Desconectado receiving_data=Recibiendo datos connecting=Conectando warning=Advertencia unsupported_resume_warning=¡Esta descarga no se puede reanudar\! Es posible que tenga que reanudarla más tarde en la lista de descargas stop_anyway=Parar igualmente customize_columns=Personalizar columnas reset=Reiniciar monday=Lunes tuesday=Martes wednesday=Miércoles thursday=Jueves friday=Viernes saturday=Sábado sunday=Domingo proxy_open_system_proxy_settings=Abrir ajustes de proxy del sistema proxy_type=Tipo de proxy proxy_do_not_use_proxy_for=No usar proxy para proxy_do_not_use_proxy_for_description=La lista de los enlaces puede no estar en proxy\nPuede usar un comodín con *\npor ejemplo 192.168.1.* example.com (separado con un espacio) proxy_change_title=Cambiar proxy change_proxy=Cambiar proxy proxy_no=Sin proxy proxy_system=Proxy del sistema proxy_manual=Proxy manual proxy_pac=Configuración automática de proxy proxy_pac_url=URL de configuración automática de proxy address=Dirección port=Puerto address_and_port=Dirección & puerto use_authentication=Usar autenticación warning_you_may_have_to_restart_the_download_later=¡Puede que tenga que reiniciar la descarga más tarde\! edit_download_title=Editar descarga edit_download_update_from_download_page=Actualizar desde la página de descarga edit_download_update_from_download_page_description=Cuando esta ventana esté abierta, puede ir a la página de descarga y hacer clic en el botón de descarga. La aplicación capturará y actualizará las nuevas credenciales de descarga para que puedas guardarlas. edit_download_saved_download_item_size_not_match=El elemento de descarga guardado tiene un tamaño de {{currentSize}}, que no coincide con el nuevo tamaño de {{newSize}}. translators_page_thanks=Con gratitud a quienes ayudaron a traducir este proyecto ❤️ translators=Traductores language=Idioma translators_contribute_title=Mejorar traducciones translators_contribute_description=¿Quieres ayudar a mejorar este proyecto? Si tu idioma no está en la lista o necesita algunos retoques, ¡puedes contribuir con tus traducciones y mejorarlo\! contribute=Contribuir meet_the_translators=Conoce a los traductores localized_by_translators=Localizado por traductores confirm_exit=Confirmar salida confirm_exit_description=¿Desea salir de AB Download Manager?\nLas descargas/colas activas se detendrán. update=Actualizar update_updater=Actualizador update_available=Actualización disponible update_error=Error al actualizar update_available_suggest_to_to_update=Puede actualizar a la última versión para disfrutar de nuevas características, mejoras y mejoras de rendimiento. update_release_notes=Notas de lanzamiento update_check_for_update=Buscar actualizaciones update_checking_for_update=Buscando actualizaciones update_no_update=Estás usando la última versión update_check_error=Error al buscar actualización update_app_updated_to_version_n=Aplicación actualizada a la versión {{version}} create_desktop_entry=Crear acceso directo en el escritorio shutdown_alert=Alerta de apagado system_shutdown_soon=¡El sistema se apagará pronto\! system_shutdown_failed=¡Error al apagar el sistema\! system_shutdown_soon_description=El sistema se apagará pronto. Si todavía está utilizando el ordenador, guarde su trabajo o cancele el apagado. system_shutdown_reason_queue_completed=Todas las descargas cola se han completado. system_shutdown_reason_queue_end_time_reached=Se ha alcanzado la hora de finalización programada para la cola de descargas. system_shutdown_download_finished=Descarga completada. shutdown_now=Apagar ahora settings_per_host_settings_new_host= settings_per_host_settings_not_selected=¡Crea o selecciona un nuevo elemento primero\! settings_per_host_settings_host=Host settings_per_host_settings_host_description=Estos ajustes se aplicarán a las descargas que coincidan con este nombre de host. Se admiten comodines (*) (por ejemplo, example.com, *.example.com — usa solo uno). settings_browser_in_launcher=Icono del navegador en el lanzador de aplicaciones settings_browser_in_launcher_description=Mostrar u ocultar el icono del navegador en el lanzador (lista de aplicaciones). sort_by=Ordenar por welcome=Bienvenido new_folder=Nueva carpeta skip=Omitir lets_go=Vamos next=Siguiente select_all=Seleccionar todo select_inside=Seleccione dentro select_invert=Invertir selección open_settings=Abrir configuración back=Atrás service_is_running=El servicio se está ejecutando initial_setup_description=Configuremos las opciones initial_setup_notice=Puedes cambiar esta configuración en cualquier momento más tarde permission_granted=Permiso concedido permission_not_granted=Permiso no concedido permissions=Permisos give_permission=Permitir permisos give_storage_permission=Permitir el acceso al almacenamiento storage_roots=Storage Roots permissions_initial_title=Configuración de permisos permissions_initial_description=Para funcionar correctamente, la aplicación necesita algunos permisos. En la siguiente pantalla, verás para qué se utiliza cada permiso y podrás decidir cuáles permitir o saltar. permissions_done_title=Está todo listo permissions_done_description=Todo está listo. Se han concedido todos los permisos necesarios y la aplicación está lista para funcionar. permissions_manage_storage_title=Administrar acceso de almacenamiento permissions_manage_storage_reason=Este permiso permite que la aplicación cambie la carpeta de descargas, detecte descargas duplicadas con mayor precisión y habilite algunas funciones adicionales. Es opcional, pero recomendado para tener la mejor experiencia. permission_read_write_external_storage_title=Almacenamiento de lectura y escritura permission_read_write_external_storage_reason=Este permiso permite que la aplicación guarde y administre archivos descargados, cambie la ubicación de descarga y mejore la detección de descargas duplicadas. permissions_post_notification_title=Enviar notificación permissions_post_notification_reason=La aplicación necesita ejecutarse en segundo plano para gestionar las descargas. Las notificaciones se utilizan para mantenerte informado y permitir la operación en segundo plano. permissions_ignore_battery_optimization_title=Ignorar optimización de la batería permissions_ignore_battery_optimization_reason=Algunos dispositivos limitan agresivamente la actividad en segundo plano para ahorrar batería, lo que puede pausar o detener las descargas cuando la aplicación no está abierta. Opcionalmente, puedes excluir la aplicación de la optimización de la batería para garantizar que las descargas continúen sin interrupciones. open_in_browser=Abrir en navegador browser=Navegador browser_new_tab=Nueva pestaña browser_close_tab=Cerrar pestaña browser_open_in_new_tab=Abrir en una Nueva Pestaña browser_open_in_new_background_tab=Abrir en nueva pestaña en segundo plano browser_no_tab_open=No hay pestañas abiertas browser_tabs=Pestañas browser_paste_and_go=Pegar e ir browser_bookmarks=Marcadores browser_add_bookmark=Añadir marcador browser_edit_bookmark=Editar marcador browser_add_to_bookmarks=Añadir a marcadores browser_remove_from_bookmarks=Eliminar de marcadores ================================================ FILE: shared/resources/src/commonMain/resources/com/abdownloadmanager/resources/locales/fa_IR.properties ================================================ app_title=مدیریت دانلود آبی confirm_auto_categorize_downloads_title=دسته‌بندی خودکار دانلودها confirm_auto_categorize_downloads_description=هر آیتم بدون دسته‌بندی به‌طور خودکار به دسته‌بندی مرتبط افزوده می‌شود. confirm_reset_to_default_categories_title=بازنشانی به دسته‌بندی‌های پیش‌فرض confirm_reset_to_default_categories_description=این کار تمام دسته‌بندی‌ها را حذف کرده و دسته‌بندی‌های پیش‌فرض را بازمی‌گرداند\! confirm_delete_download_items_title=تأیید حذف confirm_delete_download_items_description=آیا مطمئن هستید که می‌خواهید {{count}} آیتم را حذف کنید؟ confirm_delete_download_unfinished_items_description=آیا واقعا میخواهید {{count}} دانلود ناتمام را حذف کنید؟ confirm_delete_download_finished_and_unfinished_items_description=آیا واقعا میخواهید {{finishedCount}} دانلود تکمیل شده و {{unfinishedCount}} دانلود ناتمام را حذف کنید؟ also_delete_file_from_disk=همچنین فایل را از دیسک حذف کن confirm_delete_category_item_title=حذف دسته‌بندی {{name}} confirm_delete_category_item_description=آیا مطمئن هستید که می‌خواهید دسته‌بندی "{{value}}" را حذف کنید؟ your_download_will_not_be_deleted=دانلودهای شما حذف نخواهند شد drag_the_file_to_another_app=کشیدن فایل به برنامه دیگر drop_link_or_file_here=لینک یا فایل را اینجا رها کنید. nothing_will_be_imported=لینکی وارد نخواهد شد\! n_links_will_be_imported={{count}} لینک وارد خواهد شد n_items_selected={{count}} آیتم انتخاب شده window_close=بستن window_minimize=کمینه window_maximize=بیشینه window_restore=بازگردانی delete=حذف remove=حذف cancel=لغو close=بستن menu=منو more_options=گزینه‌های بیش‌تر ok=تأیید add=افزودن paste=چسباندن change=ویرایش edit=ویرایش change_anyway=به هر حال تغییرش بده download=دانلود refresh=تازه‌سازی settings=تنظیمات on_completion=در پایان unknown=نامشخص unknown_error=خطای نامشخص download_item_not_found=آیتم دانلود یافت نشد name=نام download_link=لینک دانلود not_finished=تمام نشده all=همه finished=تمام شده Unfinished=نا‌تمام canceled=لغو شده error=خطا paused=متوقف شده downloading=در حال دانلود added=افزوده شده idle=بی‌کار preparing_file=در حال آماده‌سازی فایل creating_file=در حال ایجاد فایل resuming=در حال از سرگیری retrying=درحال تلاش مجدد list_is_empty=لیست خالی است\! search_in_the_list=جستجو در لیست search=جستجو clear=پاک‌کردن general=عمومی enabled=فعال disabled=غیرفعال default=پیش‌فرض file=فایل tasks=کارها tools=ابزارها help=راهنما system=سیستم all_missing_files=همه فایل های از دست رفته all_finished=همه تمام شده ها all_unfinished=همه نا‌تمام ها entire_list=کل لیست download_browser_integration=یکپارچه‌سازی دانلود با مرورگر exit=خروج show_downloads=نمایش دانلودها new_download=دانلود جدید stop_all=توقف همه import_from_clipboard=وارد کردن از کلیپ بورد batch_download=دانلود دسته‌ای open=باز کردن share=همرسانی open_file=باز کردن فایل open_folder=باز کردن پوشه resume=ادامه pause=توقف restart_download=شروع مجدد دانلود copy=کپی کردن copy_link=کپی لینک copy_as_curl=به صورت cURL کپی شود show_properties=نمایش ویژگی‌ها move_to_queue=انتقال به صف move_to_this_queue=انتقال به این صف move_to_category=انتقال به دسته‌بندی move_to_this_category=انتقال به این دسته بندی categories=دسته‌بندی ها add_category=افزودن دسته‌بندی edit_category=ویرایش دسته‌بندی delete_category=حذف دسته‌بندی category_name=نام دسته بندی category_download_location=محل ذخیره دسته بندی category_download_location_description=وقتی این دسته بندی در صفحه "افزودن دانلود" انتخاب شد از این مسیر برای ذخیره فایل استفاده میشود category_file_types=نوع فایل های دسته بندی category_file_types_description=به‌صورت خودکار این فایل نوع فایل ها به این دسته بندی اضافه میشوند (وقتی که یک دانلود جدید اضافه میشود)\n از "فاصله" برای جدا کردن نوع فایل ها استفاده کنید (ext1 ext2 ext3...) category_url_patterns=پترن های URL category_url_patterns_description=هنگامی که دانلود جدیدی اضافه میشود به صورت خودکار آن هایی که از این پترن لینک پیروی میکنن به این دسته بندی اضافه خواهند شد\n از فاصله برای جدا کردن الگو ها استفاده کنید\nمیتوانید از * (ستاره) برای wildcard استفاده کنید auto_categorize_downloads=دسته‌بندی خودکار دانلودها restore_defaults=بازگرداندن پیش‌فرض‌ها about=درباره version_n=نسخه {{value}} developed_with_love_for_you=با ❤️ برای شما توسعه داده شده donate=حمایت مالی visit_the_project_website=از وبسایت پروژه بازدید کنید this_is_a_free_and_open_source_software=این نرم‌افزار رایگان و منبع باز است view_the_source_code=کد منبع را ببینید third_party_libraries=کتابخانه‌های شخص ثالث powered_by_open_source_software=قدرت گرفته از نرم‌افزار های منبع باز view_the_open_source_licenses=نمایش مجوزهای منبع باز support_and_community=پشتیبانی و انجمن telegram=تلگرام channel=کانال group=گروه add_download=افزودن دانلود add_multi_download_page_header=آیتم‌هایی که می‌خواهید را برای دانلود انتخاب کنید save_to=ذخیره در where_should_each_item_saved=هر آیتم در کجا ذخیره شود؟ there_are_multiple_items_please_select_a_way_you_want_to_save_them=چندین آیتم موجود است\! لطفاً روش ذخیره را انتخاب کنید each_item_on_its_own_category=هر آیتم در دسته‌بندی خود each_item_on_its_own_category_description=هر آیتم در دسته‌بندی خود بر اساس نوع فایل قرار خواهد گرفت all_items_in_one_category=همه آیتم‌ها در یک دسته‌بندی all_items_in_one_category_description=همه فایل‌ها در دسته‌بندی انتخاب‌شده ذخیره می‌شوند all_items_in_one_Location=همه آیتم‌ها در یک مکان all_items_in_one_Location_description=همه آیتم‌ها در پوشه انتخابی ذخیره خواهند شد unselected_all_items_in_specific_location_description=همه فایل‌ها در مکان دسته‌بندی انتخاب شده ذخیره می‌شوند no_category_selected=دسته‌بندی انتخاب نشده no_categories_found=دسته‌بندی‌ای پیدا نشد download_location=محل دانلود location=مکان select_queue=انتخاب صف without_queue=بدون صف use_category=استفاده از دسته‌بندی cant_write_to_this_folder=نمی‌توان در این پوشه نوشت file_name_already_exists=نام فایل موجود است download_already_exists=دانلود از قبل وجود دارد invalid_file_name=نام فایل نامعتبر است show_solutions=نمایش راه‌حل‌ها... change_solution=تغییر راه‌حل select_a_solution=انتخاب یک راه‌حل select_download_strategy_description=لینکی که ارائه دادید در لیست دانلود موجود است، لطفاً مشخص کنید که می‌خواهید چه کاری انجام دهید download_strategy_add_a_numbered_file=افزودن فایل با شماره download_strategy_add_a_numbered_file_description=اضافه‌کردن شماره در انتهای نام فایل دانلود download_strategy_override_existing_file=بازنویسی فایل موجود download_strategy_override_existing_file_description=حذف دانلود موجود و نوشتن روی آن فایل download_strategy_update_download_link=بروزرسانی دانلود موجود download_strategy_update_download_link_description=بروزرسانی لینک و مجوزهای دانلود موجود download_strategy_show_downloaded_file=نمایش فایل دانلودشده download_strategy_show_downloaded_file_description=نمایش آیتم دانلود موجود برای ادامه دادن یا باز کردن batch_download_link_help=لینکی وارد کنید که شامل کاراکترهای جایگزین (wildcard) باشد (از * استفاده کنید) invalid_url=آدرس نامعتبر است list_is_too_large_maximum_n_items_allowed=لیست خیلی بزرگ است\! حداکثر {{count}} آیتم مجاز است enter_range=وارد کردن محدوده range_from=از range_to=تا batch_download_wildcard_length=طول نویسه‌های wildcard first_link=لینک اول last_link=لینک آخر open_source_software_used_in_this_app=نرم‌افزارهای منبع باز استفاده‌شده در این برنامه links=لینک‌ها website=وبسایت developers=توسعه‌دهندگان source_code=کد منبع license=مجوز no_license_found=مجوزی یافت نشد organization=سازمان add_new_queue=افزودن صف جدید queue_name=نام صف queues=صف‌ها stop_queue=توقف صف start_queue=شروع صف clear_queue_items=خالی کردن صف config=پیکربندی items=آیتم‌ها move_down=پایین بردن move_up=بالا بردن remove_queue=حذف صف queue_name_help=نامی برای این صف مشخص کنید queue_name_describe=نام صف {{value}} است queue_max_concurrent_download=حداکثر دانلود همزمان queue_max_concurrent_download_description=حداکثر دانلود همزمان برای این صف queue_automatic_stop=توقف خودکار queue_automatic_stop_description=توقف خودکار صف وقتی آیتمی در آن وجود ندارد queue_scheduler=زمان‌بندی queue_enable_scheduler=فعال‌سازی زمان‌بندی queue_active_days=روزهای فعال queue_active_days_description=در چه روزهایی باید زمان‌بندی فعال باشد؟ queue_scheduler_enable_auto_start_time=فعالسازی زمان شروع خودکار queue_scheduler_auto_start_time=زمان شروع خودکار queue_scheduler_enable_auto_stop_time=فعال‌سازی زمان توقف خودکار queue_scheduler_auto_stop_time=زمان توقف خودکار queue_shutdown_on_completion=در پایان سیستم خاموش شود queue_shutdown_on_completion_description=خاموش کردن خودکار سیستم هنگامی که این صف به پایان برسد یا زمان پایان برنامه‌ریزی‌شده فرا برسد. appearance=ظاهر download_engine=موتور دانلود browser_integration=یکپارچه‌سازی با مرورگر settings_download_max_retries_count=حداکثر تلاش های مجدد برای دانلود settings_download_max_retries_count_description=حداکثر تعداد دفعاتی که برنامه تلاش می‌کند یک دانلود ناموفق را دوباره انجام دهد پیش از آنکه تسلیم شود settings_download_max_retries_count_describe_no_retries=برای دانلودهای ناموفق دوباره تلاش نخواهد شد settings_download_max_retries_count_describe_n_retries=برای دانلودهای ناموفق {{count}} بار تلاش خواهد شد settings_download_thread_count=تعداد کانکشن ها settings_download_thread_count_description=حداکثر تعداد کانکشن ها برای هر آیتم دانلود settings_download_thread_count_describe=هر دانلود می‌تواند تا {{count}} کانکشن داشته باشد settings_download_thread_count_with_large_value_describe=هشدار\: تنظیم تعداد کانکشن های بالا ممکن است استفاده از منابع سیستم را افزایش دهد، عملکرد سیستم را تضعیف کند یا باعث ایجاد مشکل در اتصال به بعضی از سرورها شود. تنها در صورتی از مقادیر بالاتر استفاده کنید که متوجه تأثیرات منفی احتمالی آن بر سیستم و شبکه خود باشید. settings_use_server_last_modified_time=استفاده از زمان آخرین تغییر سرور settings_use_server_last_modified_time_description=هنگام دانلود، زمان آخرین تغییر سرور برای فایل محلی استفاده می‌شود settings_append_extension_to_incomplete_downloads=افزودن پسوند به دانلود های ناتمام settings_append_extension_to_incomplete_downloads_description=افزودن پسوند ".part" به دانلود های ناتمام. این کار باعث تشخیص بهتر دانلود های ناتمام و جلوگیری از باز کردن آن ها توسط کاربر میشود. settings_use_sparse_file_allocation=ایجاد فایل به‌صورت پویا (Sparse) settings_use_sparse_file_allocation_description=ایجاد فایل به صورت بهینه تر به خصوص برای حافظه های SSD (با کاهش میزان نوشتن روی حافظه\!) این کار میتونه باعث سریع شدن زمان شروع دانلود ها و مصرف کمتر از حافظه بشه. اگر دانلود ها به صورت آهسته شروع شدن و یا سرعت دانلود پایین بود این گزینه را غیر فعال کنید چرا که ممکنه روی همه دستگاه ها پشتیبانی نشه. settings_ignore_ssl_certificates=نادیده گرفتن گواهی های SSL settings_ignore_ssl_certificates_description=بررسی گواهی SSL را غیرفعال می‌کند. فقط در صورت نیاز از این گزینه استفاده کنید، زیرا ممکن است اتصال شما را در معرض خطرات امنیتی قرار دهد. settings_global_speed_limiter=محدودکننده سرعت کلی settings_global_speed_limiter_description=سرعت کلی دانلود به این مقدار محدود می شود (0 به معنای بدون محدودیت) settings_show_average_speed=نمایش سرعت میانگین settings_show_average_speed_description=سرعت دانلود به‌صورت میانگین یا دقیق نمایش داده شود settings_use_category_by_default=استفاده از دسته‌بندی به‌صورت پیشفرض settings_use_category_by_default_description=هنگام افزودن دانلود به طور پیش‌فرض از دسته‌بندی استفاده شود. settings_default_download_folder=پوشه دانلود پیش‌فرض settings_default_download_folder_description=هنگامی که دانلود جدیدی اضافه می‌کنید، این مکان به‌طور پیش‌فرض استفاده می‌شود settings_default_download_folder_describe="{{folder}}" استفاده خواهد شد settings_use_proxy=استفاده از پروکسی settings_use_proxy_description=برای دانلود فایل‌ها از پروکسی استفاده شود settings_use_proxy_describe_no_proxy=پروکسی استفاده نخواهد شد settings_use_proxy_describe_system_proxy=پروکسی سیستم استفاده خواهد شد settings_use_proxy_describe_manual_proxy="{{value}}" استفاده خواهد شد settings_use_proxy_describe_pac_proxy=فایل pac با این آدرس استفاده خواهد شد\: {{value}} settings_track_deleted_files_on_disk=رهگیری فایل‌های از دست‌رفته از روی حافظه settings_track_deleted_files_on_disk_description=اگر فایل ها از مسیر دانلود حذف یا جابجا شوند، به‌صورت خودکار از لیست دانلود پاک خواهند شد. settings_delete_partial_file_on_download_cancellation=پاک کردن فایل ناتمام هنگام لغو دانلود settings_delete_partial_file_on_download_cancellation_description=وقتی یک دانلود لغو می‌شود، فایل نیمه‌دانلودشده از روی دیسک حذف خواهد شد. این کار به تمیز نگه داشتن پوشه‌ی دانلود و کاهش استفاده‌ی بیهوده از فضای دیسک کمک می‌کند. با این حال، در صورت شروع مجدد، دانلود از ابتدا آغاز خواهد شد. settings_default_user_agent=User-Agent پیشفرض settings_default_user_agent_description=می‌توانید یک User Agent پیش‌فرض را مشخص کنید تا نحوه شناسایی درخواست‌ها برای سرورها تعیین شود. این‌کار می‌تواند در دسترسی به محتوای بهینه‌شده برای دستگاه‌های خاص یا دور زدن محدودیت‌های دانلود برخی وب‌سایت‌ها مفید باشد. settings_download_size_unit=واحد حجم دانلود settings_download_size_unit_description=واحد مورد استفاده برای نمایش حجم دانلود settings_download_speed_unit=واحد سرعت دانلود settings_download_speed_unit_description=واحد مورد استفاده برای نمایش سرعت دانلود settings_theme=تم settings_theme_description=انتخاب تم برای برنامه settings_default_dark_theme=تم تیره پیش‌فرض settings_default_dark_theme_description=هنگامی که برنامه از تم سیستم پیروی می‌کند و تم سیستم تیره هست از این تم استفاده می‌شود settings_default_light_theme=تم روشن پیش‌فرض settings_default_light_theme_description=هنگامی که برنامه از تم سیستم پیروی می‌کند و تم سیستم روشن هست از این تم استفاده می‌شود settings_font=فونت settings_font_description=تغییر فونتی که در برنامه استفاده میشود. بعضی از فونت ها ممکن است در این برنامه به درستی نمایش داده نشوند. settings_ui_scale=اندازه رابط کاربری settings_ui_scale_description=تغییر اندازه المان و متن ها در صفحات settings_language=زبان - Language settings_compact_top_bar=نوار بالای جمع‌وجور settings_compact_top_bar_description=ترکیب کردن نوار بالا با نوار عنوان هنگامی که پنجره اصلی به اندازه کافی فضا داشته باشد settings_use_native_menu_bar=استفاده از نوار منوی سیستم settings_use_native_menu_bar_description=از نوار منوی پیشفرض سیستم استفاده شود settings_use_relative_date_time=استفاده از زمان نسبی settings_use_relative_date_time_description=از قالب زمان/تاریخ نسبی برای نمایش تاریخ‌ها در برنامه استفاده کنید (مثلاً «2 روز پیش» به جای تاریخ و زمان دقیق) settings_show_icon_labels=نمایش متن آیکون ها settings_show_icon_labels_description=لیبل ها در صورت امکان زیر آیکون‌ها نمایش داده میشوند (مانند دکمه‌های نوار ابزار صفحه اصلی) settings_use_system_tray=استفاده از System Tray settings_use_system_tray_description=نمایش System Tray هنگامی که برنامه در حال اجراست settings_start_on_boot=شروع هنگام ورود به سیستم settings_start_on_boot_description=اجرای خودکار برنامه هنگام لاگین شدن کاربر settings_notification_sound=صدای اعلان settings_notification_sound_description=پخش صدا هنگام اعلان جدید settings_browser_integration=یکپارچه‌سازی با مرورگر settings_browser_integration_description=دریافت دانلودها از مرورگر settings_browser_integration_server_port=پورت سرور settings_browser_integration_server_port_description=پورت برای یکپارچه‌سازی با مرورگر settings_browser_integration_server_port_describe=برنامه به پورت {{port}} گوش می‌دهد settings_dynamic_part_creation=ایجاد پارت به‌صورت پویا settings_dynamic_part_creation_description=هنگامی که یک پارت کامل شد، پارت دیگری ایجاد میشود تا سرعت دانلود افزایش یابد settings_show_completion_dialog=نمایش پنجره تکمیل دانلود settings_show_completion_dialog_description=وقتی که یک دانلود تمام شد به‌صورت خودکار صفحه تکمیل دانلود نمایش داده شود. settings_show_download_progress_dialog=نمایش پنجره پیشرفت دانلود settings_show_download_progress_dialog_description=وقتی که یک دانلود شروع شد به‌صورت خودکار پنجره پیشرفت دانلود نمایش داده شود. settings_per_host_settings=تنظیمات برای هر هاست settings_per_host_settings_descriptions=این تنظیمات به‌صورت خودکار روی دانلود های جدیدی که از یک هاست مشخص استفاده می‌کنند اعمال میشود. settings_download_max_concurrent_downloads=حداکثر دانلود همزمان settings_download_max_concurrent_downloads_description=حداکثر تعداد فایل‌هایی که می‌توانند به‌صورت هم‌زمان دانلود شوند (دانلودهایی که توسط صف‌ها مدیریت می‌شوند محاسبه نمی‌شوند؛ برای نامحدود بودن مقدار را روی 0 قرار دهید) download_item_settings_speed_limit=محدودیت سرعت download_item_settings_speed_limit_description=محدودیت سرعت دانلود برای این آیتم download_item_settings_show_download_completion_dialog=نمایش پنجره تکمیل دانلود download_item_settings_show_download_completion_dialog_description=وقتی که این دانلود تمام شد به‌صورت خودکار صفحه تکمیل دانلود نمایش داده شود. download_item_settings_shutdown_on_completion=در پایان سیستم خاموش شود download_item_settings_shutdown_on_completion_description=خاموش کردن خودکار سیستم هنگامی که این دانلود به پایان برسد. download_item_settings_thread_count=تعداد کانکشن ها download_item_settings_thread_count_description=چند کانکشن برای دانلود این آیتم استفاده شود (0 برای پیش‌فرض) download_item_settings_thread_count_describe={{count}} کانکشن برای این دانلود download_item_settings_username_description=اگر لینک نیازمند احراز هویت است، نام کاربری ارائه کنید download_item_settings_password_description=اگر لینک یک نیازمند احراز هویت است، رمز عبور ارائه کنید download_item_settings_download_page=صفحه دانلود download_item_settings_download_page_description=صفحه وبی که این دانلود در آن ایجاد شده download_item_settings_file_checksum=امضای فایل download_item_settings_file_checksum_description=درهَمَک (هش) به شما کمک می‌کند تا صحت فایل دانلود شده را بررسی کنید download_item_settings_user_agent=عامل کاربر download_item_settings_user_agent_description=یک User-Agent اختصاصی برای استفاده در این دانلود (برای استفاده از مقدار پیشفرض مقدار را خالی بگذارید) file_checksum=جمع‌آزمای فایل file_checksum_page=بررسی‌کننده‌ی جمع‌آزمای فایل file_checksum_page_file_checksum_default_algorithm=الگوریتم پیش‌فرض file_checksum_page_file_checksum_default_algorithm_help=در صورتی که جمع‌آزمای فایل‌ها ارائه نشده باشد از این الگوریتم پیش‌فرض برای محاسبه جمع‌آزمای فایل استفاده می‌شود. start=شروع calculated_checksum=امضای محاسبه شده saved_checksum=جمع‌آزمای ذخیره شده checksum_algorithm=الگوریتم file_not_found=فایل پیدا نشد download_not_finished=دانلود تکمیل نشده done=انجام شد waiting=در انتظار matches=مطابقت دارد not_matches=مطابقت ندارد copy_to_clipboard=کپی در کلیپ‌بورد username=نام کاربری password=رمز عبور average_speed=سرعت میانگین exact_speed=سرعت دقیق unlimited=نامحدود use_global_settings=استفاده از تنظیمات عمومی cant_run_browser_integration=نمی‌توان یکپارچه‌سازی مرورگر را اجرا کرد cant_open_file=نمی‌توان فایل را باز کرد cant_open_folder=نمی‌توان پوشه را باز کرد # times for example 2 seconds ago relative_time_long_years={{years}} سال relative_time_long_months={{months}} ماه relative_time_long_days={{days}} روز relative_time_long_hours={{hours}} ساعت relative_time_long_minutes={{minutes}} دقیقه relative_time_long_seconds={{seconds}} ثانیه relative_time_short_years={{years}} سال relative_time_short_months={{months}} ماه relative_time_short_days={{days}} روز relative_time_short_hours={{hours}} ساعت relative_time_short_minutes={{minutes}} دقیقه relative_time_short_seconds={{seconds}} ثانیه relative_time_left={{time}} مانده relative_time_ago={{time}} پیش auto=خودکار unspecified=نامشخص custom=دلخواه icon=آیکون author=سازنده link=لینک size=حجم status=وضعیت parts_info_downloaded_size=دانلود شده parts_info_total_size=کل speed=سرعت time_left=زمان باقیمانده date_added=تاریخ اضافه شدن info=جزئیات download_page_downloaded_size=دانلود شده download_page_download_completed=دانلود پایان یافت resume_support=امکان از سرگیری yes=بله no=خیر parts_info=جزئیات پارت ها disconnected=قطع شد receiving_data=دریافت دیتا connecting=در حال اتصال warning=هشدار unsupported_resume_warning=این دانلود امکان از سرگیری ندارد و ممکن است بعدا مجبور شوید آن را در لیست دانلود ها "ریستارت" کنید stop_anyway=به هرحال متوقف شود customize_columns=ویرایش ستون ها reset=بازنشانی monday=دوشنبه tuesday=سه‌شنبه wednesday=چهارشنبه thursday=پنجشنبه friday=جمعه saturday=شنبه sunday=یکشنبه proxy_open_system_proxy_settings=باز کردن تنظیمات پروکسی سیستم proxy_type=نوع پروکسی proxy_do_not_use_proxy_for=برای این ها از پروکسی استفاده نکن proxy_do_not_use_proxy_for_description=لیستی از لینک هایی که نباید برای آن ها از پروکسی استفاده شود\nشما میتونین از * بعنوان wildcard استفاده کنید\nبرای مثال 192.168.1.* example.com (با فاصله از هم جدا شوند) proxy_change_title=ویرایش پروکسی change_proxy=ویرایش پروکسی proxy_no=بدون پروکسی proxy_system=پروکسی سیستم proxy_manual=پروکسی دستی proxy_pac=کانفیگ خودکار پروکسی (pac) proxy_pac_url=آدرس فایل کانفیگ خودکار پروکسی address=آدرس port=پورت address_and_port=آدرس و پورت use_authentication=استفاده از احراز هویت warning_you_may_have_to_restart_the_download_later=شما ممکنه بعدا مجبور بشید این دانلود را ریستارت کنید\! edit_download_title=ویرایش دانلود edit_download_update_from_download_page=بروزرسانی از صفحه دانلود edit_download_update_from_download_page_description=مادامی که این صفحه باز است شما میتونانید به صفحه دانلود بروید و از آنجا روی دکمه دانلود کلیک کنید برناامه به‌صورت خودکار اطلاعات دانلود را دریافت و اپدیت میکند که شما میتوانید آن را ذخیره کنید. edit_download_saved_download_item_size_not_match=آیتم دانلود با سایز {{currentSize}} ذخیره شده است، که با سایز جدید {{newSize}} مطابقت ندارد. translators_page_thanks=با سپاس و قدردانی از کسانی که به ترجمه این پروژه کمک کردند ❤️ translators=مترجم ها language=زبان translators_contribute_title=بهبود ترجمه ها translators_contribute_description=میخواهید این پروژه را بهبود بدید؟ اگر زبان شما در لیست نیست یا به یک سری تغییرات نیاز دارد میتوانید ترجمه های خود را اضافه کنید و آن را بهتر کنید\! contribute=مشارکت meet_the_translators=آشنایی با مترجمان localized_by_translators=بومی سازی شده توسط مترجمان confirm_exit=تایید خروج confirm_exit_description=آیا مطمئن هستید که میخواهید از AB Download Manager خارج شوید ؟\nدانلود ها و صف های فعال متوقف خواهند شد\! update=بروزرسانی update_updater=بروز کننده update_available=بروزرسانی دردسترس است update_error=خطای بروزرسانی update_available_suggest_to_to_update=شما با بروزرسانی میتوانید از آخرین قابلیت ها، پیشرفت ها بهبود های عملکردی بهره‌مند شوید. update_release_notes=یادداشت‌های انتشار update_check_for_update=بررسی برای بروزرسانی update_checking_for_update=درحال بررسی برای بروزرسانی update_no_update=شما از آخرین نسخه استفاده میکنید update_check_error=خطایی هنگام بررسی بروزرسانی رخ داد update_app_updated_to_version_n=برنامه به نسخه {{version}} بروزرسانی شد create_desktop_entry=ساخت ورودی دسکتاپ shutdown_alert=هشدار خاموشی system_shutdown_soon=سیستم به‌زودی خاموش می‌شود\! system_shutdown_failed=خاموش کردن سیستم ناموفق بود\! system_shutdown_soon_description=سیستم به‌زودی خاموش می‌شود. اگر هنوز در حال استفاده از رایانه هستید، لطفاً کارهای خود را ذخیره کنید یا خاموش شدن را لغو نمایید. system_shutdown_reason_queue_completed=تمام دانلودهای صف به پایان رسیده‌اند. system_shutdown_reason_queue_end_time_reached=زمان پایان برنامه‌ریزی‌شده برای صف دانلود فرا رسیده است. system_shutdown_download_finished=دانلود به پایان رسید. shutdown_now=همین حالا خاموش کن settings_per_host_settings_new_host=<هاست جدید> settings_per_host_settings_not_selected=ابتدا یک مورد جدید ایجاد یا انتخاب کنید\! settings_per_host_settings_host=هاست settings_per_host_settings_host_description=این تنظیمات برای دانلودهایی که با این نام هاست (میزبان یا دامنه) مطابقت دارند اعمال می‌شوند.\nکاراکترهای جایگزین (*) پشتیبانی می‌شوند (مثلاً\: example.com, *.example.com — فقط یکی را استفاده کنید). settings_browser_in_launcher=آیکون مرورگر در لانچر settings_browser_in_launcher_description=نمایش یا عدم نمایش آیکون مرورگر در صفحه برنامه ها (لانچر). sort_by=مرتب‌سازی بر اساس welcome=خوش آمدید new_folder=پوشه جدید skip=رد شدن lets_go=بزن بریم next=بعدی select_all=انتخاب همه select_inside=انتخاب داخل select_invert=انتخاب معکوس open_settings=باز کردن تنظیمات back=برگشت service_is_running=سرویس فعال است initial_setup_description=بیا یکسری چیز هارو تنظیم کنیم initial_setup_notice=شما میتوانید این تنظیمات را بعدا تغییر دهید permission_granted=اجازه داده شد permission_not_granted=اجازه داده نشد permissions=دسترسی ها give_permission=اجازه دادن give_storage_permission=اجازه دسترسی به حافظه storage_roots=ریشه‌های فضای ذخیره‌سازی permissions_initial_title=تنظیم مجوز ها permissions_initial_description=برای اینکه برنامه به درستی کار کنه. برنامه به یکسری دسترسی ها نیاز داره. در صفحه بعدی خواهی دید هر دسترسی برای چه کاری استفاده میشه و میتونی تصمیم بگیری کدوم رو میخوای اجازه بدی یا رد بشی. permissions_done_title=همه چی آماده ست permissions_done_description=همه چیز آماده ست. به دسترسی های مورد نیاز اجازه داده شده و برنامه آماده استفاده ست. permissions_manage_storage_title=دسترسی مدیریت حافظه permissions_manage_storage_reason=این دسترسی به برنامه اجازه میده بتونه مسیر دانلود رو تغییر بده، دانلود های تکراری رو با دقت بیشتری تشخیص بده و یک سری قابلیت های دیگه. این دسترسی اختیاریه ولی برای تجربه بهتر، فعال بودنش پیشنهاد میشه. permission_read_write_external_storage_title=خواندن و نوشتن در حافظه permission_read_write_external_storage_reason=این دسترسی به برنامه اجازه میده دانلود هارو روی حافظه ذخیره و مدیریت کنه، مسیر دانلود رو تغییر بده و دانلود های تکراری رو بهتر تشخیص بده. permissions_post_notification_title=ارسال اعلان ها permissions_post_notification_reason=برای مدیریت دانلودها، برنامه باید در پس‌زمینه اجرا شود. اعلان‌ها برای اطلاع‌رسانی به شما و امکان اجرای برنامه در پس‌زمینه استفاده می‌شوند. permissions_ignore_battery_optimization_title=نادیده گرفتن بهینه‌سازی باتری permissions_ignore_battery_optimization_reason=برخی دستگاه‌ها فعالیت برنامه‌ها در پس‌زمینه را محدود می‌کنند و ممکن است دانلودها هنگام بسته بودن برنامه متوقف شوند. به‌صورت اختیاری می‌توانید برنامه را از بهینه‌سازی باتری مستثنا کنید تا دانلودها بدون وقفه ادامه داشته باشند open_in_browser=باز کردن در مرورگر browser=مرورگر browser_new_tab=زبانه جدید browser_close_tab=بستن زبانه browser_open_in_new_tab=باز کردن در زبانه جدید browser_open_in_new_background_tab=باز کردن در زبانه جدید (پس زمینه) browser_no_tab_open=هیچ زبانه‌ای باز نیست browser_tabs=زبانه‌ها browser_paste_and_go=بچسبون و بریم browser_bookmarks=نشانک ها browser_add_bookmark=افزودن نشانک browser_edit_bookmark=ویرایش نشانک browser_add_to_bookmarks=افزودن به نشانک ها browser_remove_from_bookmarks=حذف از نشانک‌ ها ================================================ FILE: shared/resources/src/commonMain/resources/com/abdownloadmanager/resources/locales/fi_FI.properties ================================================ app_title=AB Download Manager confirm_auto_categorize_downloads_title=Luokittele lataukset kategorioihin automaattisesti confirm_auto_categorize_downloads_description=Kaikki luokittelemattomat kohteet lisätään automaattisesti niitä vastaaviin kategorioihin. confirm_reset_to_default_categories_title=Palauta oletuskategoriat confirm_reset_to_default_categories_description=Tämä POISTAA kaikki kategoriat ja palauttaa oletukset\! confirm_delete_download_items_title=Vahvista poisto confirm_delete_download_items_description=Haluatko varmasti poistaa {{count}} kohdetta? confirm_delete_download_unfinished_items_description=Haluatko varmasti poistaa {{count}} keskeneräistä latausta? confirm_delete_download_finished_and_unfinished_items_description=Haluatko varmasti poistaa {{finishedCount}} valmistunutta ja {{unfinishedCount}} keskeneräistä latausta? also_delete_file_from_disk=Poista tiedosto myös levyltä confirm_delete_category_item_title=Poistetaan kategoriaa {{name}} confirm_delete_category_item_description=Haluatko varmasti poistaa kategorian "{{value}}"? your_download_will_not_be_deleted=Latauksiasi ei poisteta drag_the_file_to_another_app=Vedä tiedosto toiseen sovellukseen drop_link_or_file_here=Pudota linkki tai tiedosto tähän. nothing_will_be_imported=Mitään ei tuoda n_links_will_be_imported={{count}} linkkiä tuodaan n_items_selected={{count}} kohdetta valittu window_close=Sulje window_minimize=Pienennä window_maximize=Suurenna window_restore=Palauta delete=Poista remove=Poista cancel=Peru close=Sulje menu=Valikko more_options=Lisää vaihtoehtoja ok=Ok add=Lisää paste=Liitä change=Vaihda edit=Muokkaa change_anyway=Vaihda silti download=Lataa refresh=Päivitä settings=Asetukset on_completion=Valmistuminen unknown=Tuntematon unknown_error=Tuntematon virhe download_item_not_found=Latauskohdetta ei löytynyt name=Nimi download_link=Latauslinkki not_finished=Kesken all=Kaikki finished=Valmistunut Unfinished=Keskeneräiset canceled=Peruttu error=Virhe paused=Pysäytetty downloading=Ladataan added=Lisätty idle=TOIMETON preparing_file=Valmistellaan tiedostoa creating_file=Luodaan tiedostoa resuming=Jatketaan retrying=Yritetään uudelleen list_is_empty=Lista on tyhjä\! search_in_the_list=Etsi listalta search=Haku clear=Tyhjennä general=Yleiset enabled=Käytössä disabled=Ei käytössä default=Oletus file=Tiedosto tasks=Tehtävät tools=Työkalut help=Tuki system=Järjestelmä all_missing_files=Kaikki puuttuvat tiedostot all_finished=Kaikki valmistuneet all_unfinished=Kaikki keskeneräiset entire_list=Koko lista download_browser_integration=Lataa selainintegraatio exit=Sulje show_downloads=Näytä lataukset new_download=Lisää lataus stop_all=Pysäytä kaikki import_from_clipboard=Tuo leikepöydältä batch_download=Joukkolataus open=Avaa share=Jaa open_file=Avaa tiedosto open_folder=Avaa kansio resume=Jatka pause=Pysäytä restart_download=Aloita lataus uudelleen copy=Kopioi copy_link=Kopioi linkki copy_as_curl=Kopioi cURL show_properties=Näytä ominaisuudet move_to_queue=Siirrä jonoon move_to_this_queue=Siirrä tähän jonoon move_to_category=Siirrä kategoriaan move_to_this_category=Siirrä tähän kategoriaan categories=Kategoriat add_category=Lisää kategoria edit_category=Muokkaa kategoriaa delete_category=Poista kategoria category_name=Kategorian nimi category_download_location=Kategorian lataussijainti category_download_location_description=Kun tämä kategoria valitaan "Lisää lataus" -ikkunasta, korvaa yleinen "Lataussijainti" tällä kansiolla. category_file_types=Kategorian tiedostotyypit category_file_types_description=Määritä uutta latausta lisättäessä automaattisesti tähän kategoriaan luokiteltavat tiedostotyypit. Erottele tiedostopäätteet välilyönneillä (ext1 ext2 ...). category_url_patterns=URL-osoitesäännöt category_url_patterns_description=Määritä uutta latausta lisättäessä automaattisesti tähän kategoriaan luokiteltavat URL-osoitteet. Erottele osoitteet välilyönneillä ja käytä * (tähti) jokerimerkkinä. auto_categorize_downloads=Luokittele lataukset kategorioihin automaattisesti restore_defaults=Palauta oletukset about=Tietoja version_n=Versio {{value}} developed_with_love_for_you=Kehitetty ❤️ sinulle donate=Lahjoita visit_the_project_website=Avaa projektin verkkosivusto this_is_a_free_and_open_source_software=Ilmainen avoimen lähdekoodin ohjelmisto view_the_source_code=Tarkastele lähdekoodia third_party_libraries=Ulkopuoliset lisenssit powered_by_open_source_software=Toimii avoimen lähdekoodin voimalla view_the_open_source_licenses=Näytä avoimen lähdekoodin lisenssit support_and_community=Tuki ja yhteisö telegram=Telegram channel=Kanava group=Ryhmä add_download=Lisää lataus add_multi_download_page_header=Valitse kohteet, jotka haluat ladata save_to=Tallennuskohde where_should_each_item_saved=Mihin kohteet tulee tallentaa? there_are_multiple_items_please_select_a_way_you_want_to_save_them=Kohteita on useita\! Valitse tapa, jolla haluat tallentaa ne. each_item_on_its_own_category=Jokainen kohde sitä vastaavaan kategoriaan each_item_on_its_own_category_description=Tiedostot tallennetaan niiden tyyppejä vastaaviin kategorioihin. all_items_in_one_category=Kaikki kohteet samaan kategoriaan all_items_in_one_category_description=Kaikki tiedostot tallennetaan valittuun kategoriaan. all_items_in_one_Location=Kaikki kohteet samaan sijaintiin all_items_in_one_Location_description=Kaikki tiedostot tallennetaan valittuun kansioon. unselected_all_items_in_specific_location_description=Kaikki tiedostot tallennetaan valitun kategorian sijaintiin. no_category_selected=Kategoriaa ei valittu no_categories_found=Kategorioita ei ole download_location=Lataussijainti location=Sijainti select_queue=Valitse jono without_queue=Ilman jonoa use_category=Käytä kategoriaa cant_write_to_this_folder=Tähän kansioon ei voida tallentaa file_name_already_exists=Tiedostonimi on jo olemassa download_already_exists=Lataus on jo olemassa invalid_file_name=Virheellinen tiedostonimi show_solutions=Näytä ratkaisut... change_solution=Vaihda ratkaisu select_a_solution=Valitse ratkaisu select_download_strategy_description=Lisättävä linkki löytyy jo latauslistoilta. Valitse mitä tehdään. download_strategy_add_a_numbered_file=Luo numeroitu tiedosto download_strategy_add_a_numbered_file_description=Lisää lataustiedoston nimeen järjestysnumero. download_strategy_override_existing_file=Korvaa olemassa oleva tiedosto download_strategy_override_existing_file_description=Poista aiempi lataus ja korvaa se. download_strategy_update_download_link=Päivitä olemassa oleva lataus download_strategy_update_download_link_description=Päivitä olemassa oleva latauslinkki ja sen käyttäjätiedot. download_strategy_show_downloaded_file=Näytä ladattu tiedosto download_strategy_show_downloaded_file_description=Näytä aiempi lataus, jotta voit jatkaa sen latausta tai avata sen. batch_download_link_help=Syötä jokerimerkkejä sisältävä osoite (käytä *) invalid_url=Virheellinen URL-osoite list_is_too_large_maximum_n_items_allowed=Lista on liian suuri\! Enintään {{count}} kohdetta sallitaan. enter_range=Syötä alue range_from=Alkaen range_to=Päättyen batch_download_wildcard_length=Jokerimerkin muuttujan pituus first_link=Ensimmäinen osoite last_link=Viimeinen osoite open_source_software_used_in_this_app=Sovelluksessa käytetyt avoimen lähdekoodin projektit links=Linkit website=Verkkosivusto developers=Kehittäjät source_code=Lähdekoodi license=Lisenssi no_license_found=Lisenssiä ei löytynyt organization=Organisaatio add_new_queue=Lisää uusi jono queue_name=Jonon nimi queues=Jonot stop_queue=Pysäytä jono start_queue=Käynnistä jono clear_queue_items=Tyhjennä jono config=Määritykset items=Kohteet move_down=Siirrä alemmas move_up=Siirrä ylemmäs remove_queue=Poista jono queue_name_help=Anna jonolle tunnistettava nimi queue_name_describe=Jonon nimi on {{value}} queue_max_concurrent_download=Samanaikaisten latausten määrä queue_max_concurrent_download_description=Samanaikaisten latausten määrä tässä jonossa. queue_automatic_stop=Automaattinen pysäytys queue_automatic_stop_description=Pysäytä jono automaattisesti, kun siinä ei ole kohteita. queue_scheduler=Ajoitus queue_enable_scheduler=Käytä ajoitusta queue_active_days=Aktiiviset päivät queue_active_days_description=Päivät, joina ajoituksia käytetään. queue_scheduler_enable_auto_start_time=Käytä automaattista käynnistystä queue_scheduler_auto_start_time=Automaattisen käynnistyksen aika queue_scheduler_enable_auto_stop_time=Käytä automaattista pysäytystä queue_scheduler_auto_stop_time=Automaattisen pysäytyksen aika queue_shutdown_on_completion=Sammuta järjestelmä jonon valmistuessa queue_shutdown_on_completion_description=Sammuta järjestelmä automaattisesti, kun tämä jono valmistuu tai ajoitettu pysäytysaika saavutetaan. appearance=Ulkoasu download_engine=Latausmoottori browser_integration=Selainintegraatio settings_download_max_retries_count=Latausyritysten enimmäismäärä settings_download_max_retries_count_description=Määritä miten monta kertaa epäonnistunutta latausta yritetään uudelleen ennen luovuttamista. settings_download_max_retries_count_describe_no_retries=Epäonnistuneita latauksia ei yritetä uudelleen settings_download_max_retries_count_describe_n_retries=Epäonnistuneita latauksia yritetään uudelleen {{count}} kerran/kertaa settings_download_thread_count=Säiemäärä settings_download_thread_count_description=Säikeiden enimmäismäärä latausta kohden. settings_download_thread_count_describe=Latauksella voi olla enintään {{count}} säiettä settings_download_thread_count_with_large_value_describe=Varoitus\: Korkea säiemäärä voi lisätä järjestelmän kuormitusta, heikentää suorituskykyä tai aiheuttaa yhteysongelmia palvelimien kanssa. Käytä korkeampia arvoja vain, jos ymmärrät miten ne voivat vaikuttaa järjestelmääsi ja yhteyteesi. settings_use_server_last_modified_time=Käytä palvelimen viimeisintä muokkausaikaa settings_use_server_last_modified_time_description=Käytä paikalliselle tiedostolle palvelimen viimeisintä muokkausaikaa. settings_append_extension_to_incomplete_downloads=Lisää keskeneräisiin latauksiin tiedostopääte settings_append_extension_to_incomplete_downloads_description=Lisää keskeneräisiin latauksiin ".part"-tiedostopääte. Tämä auttaa tunnistamaan keskeneräiset lataukset ja estää keskeneräisten tiedostojen avaamisen tai käsittelyn vahingossa. settings_use_sparse_file_allocation=Tiedostojen sparse-varaus settings_use_sparse_file_allocation_description=Luo tiedostot tehokkaammin ja vähennä erityisesti SSD-laitteissa tarpeetonta tiedontallennusta. Tämä voi nopeuttaa latauksia ja vähentää levyn käyttöä. Jos lataukset käynnistyvät hitaasti tai koet poikkeavia latausnopeuksia, harkitse tämän käytöstä poistoa, koska sitä ei välttämättä tueta täysin joissakin laitteissa. settings_ignore_ssl_certificates=Älä huomioi SSL-varmenteita settings_ignore_ssl_certificates_description=Poistaa SSL-varmenteiden vahvistuksen käytöstä. Altistaa yhteytesi tietoturvariskeille, joten käytä vain tarvittaessa. settings_global_speed_limiter=Yleinen nopeusrajoitin settings_global_speed_limiter_description=Yleinen latausnopeuden rajoitus (0 on rajoittamaton). settings_show_average_speed=Näytä keskinopeus settings_show_average_speed_description=Näytä keskimääräinen tai tarkka latausnopeus. settings_use_category_by_default=Käytä kategorioita oletusarvoisesti settings_use_category_by_default_description=Luokittele lataukset oletusarvoisesti kategorioihin latauksia lisättäessä. settings_default_download_folder=Oletusarvoinen latauskansio settings_default_download_folder_description=Kun lisäät uuden latauksen, tallennetaan se oletusarvoisesti tähän sijaintiin. settings_default_download_folder_describe=Käytetään sijaintia "{{folder}}". settings_use_proxy=Käytä välityspalvelinta settings_use_proxy_description=Lataa tiedostot välityspalvelimen kautta. settings_use_proxy_describe_no_proxy=Välityspalvelinta ei käytetä. settings_use_proxy_describe_system_proxy=Käytetään järjestelmän välityspalvelinta. settings_use_proxy_describe_manual_proxy=Käytetään välityspalvelinta "{{value}}". settings_use_proxy_describe_pac_proxy=Käytetään PAC-tiedostoa "{{value}}". settings_track_deleted_files_on_disk=Valvo tiedostojen poistumista levyltä settings_track_deleted_files_on_disk_description=Poista tiedostot latauslistalta automaattisesti, kun ne poistetaan tai siirretään latauskansiosta. settings_delete_partial_file_on_download_cancellation=Poista osittainen tiedosto kun lataus perutaan settings_delete_partial_file_on_download_cancellation_description=Osittain ladattu tiedosto poistetaan levyltä, kun lataus perutaan. Tämä pitää latauskansion siistinä ja vähentää tallennustilan tarpeetonta varausta, joskin lataus aloitetaan alusta kun se seuraavan kerran käynnistetään. settings_default_user_agent=Oletusarvoinen käyttäjäagentti settings_default_user_agent_description=Määritä oletusarvoinen merkkijono palvelimille lähetettävälle käyttäjäagentille. Tämä voi auttaa käyttämään tietyille laitteille optimoitua sisältöä tai kiertämään joidenkin verkkosivustojen asettamia latausrajoituksia. settings_download_size_unit=Latauskoon yksikkö settings_download_size_unit_description=Yksikkö, jota käytetään latauksen koon esitykseen. settings_download_speed_unit=Latausnopeuden yksikkö settings_download_speed_unit_description=Latausnopeuksien esitykseen käytettävä yksikkö. settings_theme=Teema settings_theme_description=Valitse sovelluksen ulkoasuteema. settings_default_dark_theme=Oletusarvoinen tumma teema settings_default_dark_theme_description=Käytetään, kun sovellus seuraa järjestelmän teemaa ja se on tummassa tilassa. settings_default_light_theme=Oletusarvoinen vaalea teema settings_default_light_theme_description=Käytetään, kun sovellus seuraa järjestelmän teemaa ja se on vaaleassa tilassa. settings_font=Fontti settings_font_description=Muuta sovelluksen käyttöliittymän kirjasin. Jotkin kirjasimet eivät välttämättä näy sovelluksessa oikein. settings_ui_scale=Käyttöliittymän skaalaus settings_ui_scale_description=Säädä sovelluksen käyttöliittymän elementtien kokoa. settings_language=Kieli settings_compact_top_bar=Kompakti yläpalkki settings_compact_top_bar_description=Yhdistä yläpalkki otsikkopalkkiin pääikkunan ollessa riittävän leveä. settings_use_native_menu_bar=Käytä natiivia valikkopalkkia settings_use_native_menu_bar_description=Käytä järjestelmän oletusarvoista valikkopalkin tyyliä. settings_use_relative_date_time=Käytä suhteellista päiväystä/aikaa settings_use_relative_date_time_description=Näytä päiväykset suhteellisessa muodossa, eli tarkan päiväyksen sijaan näytetään esim. "2 päivää sitten". settings_show_icon_labels=Näytä kuvakkeiden tekstit settings_show_icon_labels_description=Näytä tekstit kuvakkeiden alla mikäli mahdollista (työkalupalkin toimintojen tapaan). settings_use_system_tray=Käytä järjestelmän ilmaisinaluetta settings_use_system_tray_description=Näytä kuvake ilmaisinalueella sovelluksen ollessa käynnissä. settings_start_on_boot=Käynnistä automaattisesti settings_start_on_boot_description=Käynnistä sovellus automaattisesti käyttäjän kirjautuessa. settings_notification_sound=Ilmoitusääni settings_notification_sound_description=Toista ääni uusille ilmoituksille. settings_browser_integration=Selainintegraatio settings_browser_integration_description=Vastaanota latauksia selaimista. settings_browser_integration_server_port=Palvelimen portti settings_browser_integration_server_port_description=Selainintegrointiin käytettävä portti. settings_browser_integration_server_port_describe=Sovellus kuuntelee porttia {{port}}. settings_dynamic_part_creation=Dynaaminen osiointi settings_dynamic_part_creation_description=Kun osan lataus valmistuu, paranna latausnopeutta jakamalla jäljellä olevat osat uudelleen. settings_show_completion_dialog=Näytä ilmoitus latauksen valmistuessa settings_show_completion_dialog_description=Ilmoita latauksen valmistumisesta avaamalla "Lataus on valmistunut" -ikkuna. settings_show_download_progress_dialog=Näytä latauskohtaiset ikkunat settings_show_download_progress_dialog_description=Näytä latauksen tilan kertova ikkuna kun lataus alkaa. settings_per_host_settings=Isäntäkohtaiset asetukset settings_per_host_settings_descriptions=Näitä asetuksia sovelletaan automaattisesti uusiin latauksiin, jotka vastaavat määritettyä isäntää. settings_download_max_concurrent_downloads=Samanaikaisten latausten määrä settings_download_max_concurrent_downloads_description=Enimmäismäärä tiedostoja, jotka voidaan ladata samaan aikaan (jonojen hallinnoimia latauksia ei lasketa; poista rajoitus asettamalla arvoksi 0). download_item_settings_speed_limit=Nopeusrajoitus download_item_settings_speed_limit_description=Rajoita tämän kohteen latausnopeutta. download_item_settings_show_download_completion_dialog=Näytä ilmoitus latauksen valmistuessa download_item_settings_show_download_completion_dialog_description=Ilmoita latauksen valmistumisesta avaamalla "Lataus on valmistunut" -ikkuna. download_item_settings_shutdown_on_completion=Sammuta järjestelmä latauksen valmistuessa download_item_settings_shutdown_on_completion_description=Sammuta järjestelmä automaattisesti, kun tämä lataus valmistuu. download_item_settings_thread_count=Säiemäärä download_item_settings_thread_count_description=Montaako säiettä kohteen lataukseen käytetään (0 \= oletus). download_item_settings_thread_count_describe={{count}} säiettä tälle lataukselle download_item_settings_username_description=Syötä käyttäjätunnus, jos lähde edellyttää tunnistautumista. download_item_settings_password_description=Syötä salasana, jos lähde edellyttää tunnistautumista. download_item_settings_download_page=Lataussivu download_item_settings_download_page_description=Verkkosivu, jolta tämä lataus lisättiin. download_item_settings_file_checksum=Tiedoston tarkistussumma download_item_settings_file_checksum_description=Hajautusarvo, jonka avulla voidaan tarkastaa onko tiedosto ladattu oikein. download_item_settings_user_agent=Käyttäjäagentti download_item_settings_user_agent_description=Mukautettu käyttäjä-agentti tälle kohteelle (käytä oletusta jättämällä tyhjäksi). file_checksum=Tiedoston tarkistussumma file_checksum_page=Tiedoston tarkistussumman tarkistin file_checksum_page_file_checksum_default_algorithm=Oletusarvoinen algoritmi file_checksum_page_file_checksum_default_algorithm_help=Oletusalgoritmi, jota käytetään tarkistussumman laskentaan, kun tiedostokohtaista summaa ei ole ennalta ilmoitettu. start=Käynnistä calculated_checksum=Laskettu tarkistussumma saved_checksum=Tallennettu tarkistussumma checksum_algorithm=Algoritmi file_not_found=Tiedostoa ei löytynyt download_not_finished=Lataus on vielä kesken done=Suoritettu waiting=Odottaa matches=Vastaavuudet not_matches=Vastaavuuksia ei ole copy_to_clipboard=Kopioi leikepöydälle username=Käyttäjätunnus password=Salasana average_speed=Keskinopeus exact_speed=Tarkka nopeus unlimited=Rajoittamaton use_global_settings=Käytetään yleistä asetusta cant_run_browser_integration=Selainintegrointiin käytettävä portti cant_open_file=Tiedostoa ei voida avata cant_open_folder=Kansiota ei voida avata # times for example 2 seconds ago relative_time_long_years={{years}} vuotta relative_time_long_months={{months}} kuukautta relative_time_long_days={{days}} päivää relative_time_long_hours={{hours}} tuntia relative_time_long_minutes={{minutes}} minuuttia relative_time_long_seconds={{seconds}} sekuntia relative_time_short_years={{years}} v relative_time_short_months={{months}} kk relative_time_short_days={{days}} pv relative_time_short_hours={{hours}} t relative_time_short_minutes={{minutes}} min relative_time_short_seconds={{seconds}} s relative_time_left={{time}} jäljellä relative_time_ago={{time}} sitten auto=Automaattinen unspecified=Ei määritetty custom=Mukautettu icon=Kuvake author=Tekijä link=Osoite size=Koko status=Tila parts_info_downloaded_size=Ladattu parts_info_total_size=Koko speed=Nopeus time_left=Aikaa jäljellä date_added=Lisäysaika info=Tiedot download_page_downloaded_size=Ladattu download_page_download_completed=Lataus on valmistunut resume_support=Tukee tauotusta yes=Kyllä no=Ei parts_info=Osien tiedot disconnected=Ei yhteyttä receiving_data=Vastaanotetaan connecting=Yhdistetään warning=Varoitus unsupported_resume_warning=Latauksen palvelin ei tue keskeytetyn latauksen jatkamista\! Joudut myöhemmin aloittamaan sen uudelleen alusta. stop_anyway=Pysäytä silti customize_columns=Muokkaa sarakkeita reset=Palauta monday=Maanantai tuesday=Tiistai wednesday=Keskiviikko thursday=Torstai friday=Perjantai saturday=Lauantai sunday=Sunnuntai proxy_open_system_proxy_settings=Avaa järjestelmän välityspalvelinasetukset proxy_type=Välityspalvelimen tyyppi proxy_do_not_use_proxy_for=Älä käytä välityspalvelinta seuraaville proxy_do_not_use_proxy_for_description=Luettelo URL-osoitteista, joille ei käytetä välityspalvelinta.\nErottele osoitteet välilyönneillä ja käytä * (tähti) jokerimerkkinä,\nesim. 192.168.1.* esimerkki.fi. proxy_change_title=Määritä välityspalvelin change_proxy=Määritä välityspalvelin proxy_no=Ei välityspalvelinta proxy_system=Järjestelmän välityspalvelin proxy_manual=Määritä välityspalvelin itse proxy_pac=Automaattinen välityspalvelin proxy_pac_url=Välityspalvelimen automaattimäärityksen URL-osoite address=Osoite port=Portti address_and_port=Osoite ja portti use_authentication=Käytä tunnistautumista warning_you_may_have_to_restart_the_download_later=Saatat joutua aloittamaan latauksen myöhemmin uudelleen\! edit_download_title=Muokkaa latausta edit_download_update_from_download_page=Päivitä lataussivulta edit_download_update_from_download_page_description=Tämän ikkunan ollessa avoinna voit avata lataussivun ja painaa latauspainiketta, jolloin sovellus kaappaa ja päivittää uudet käyttäjätiedot, jotta voit tallentaa ne. edit_download_saved_download_item_size_not_match=Tallennetun latauskohteen koko on {{currentSize}}, joka ei vastaa uutta kokoa {{newSize}}. translators_page_thanks=Kiitos projektin käännökseen osallistuneille ❤️ translators=Kääntäjät language=Kieli translators_contribute_title=Paranna käännöksiä translators_contribute_description=Haluatko auttaa projektissa? Mikäli kieltäsi ei ole listattu tai se kaipaa korjausta, voit osallistua käännökseen ja parantaa sitä\! contribute=Osallistu meet_the_translators=Tutustu kääntäjiin localized_by_translators=Kääntäjien lokalisoima confirm_exit=Vahvista sulku confirm_exit_description=Haluatko varmasti sulkea AB Download Managerin?\nAktiiviset lataukset/jonot pysäytetään\! update=Päivitä update_updater=Päivittäjä update_available=Päivitys saatavilla update_error=Päivitysvirhe update_available_suggest_to_to_update=Päivitä uusimpaan versioon nauttiaksesi uusista ominaisuuksista, korjauksista ja suorituskykyparannuksista. update_release_notes=Muutoshistoria update_check_for_update=Tarkista päivitykset update_checking_for_update=Tarkistetaan päivityksiä update_no_update=Käytössäsi on uusin versio update_check_error=Virhe tarkistettaessa päivityksiä update_app_updated_to_version_n=Sovellus päivitettiin versioon {{version}} create_desktop_entry=Luo kuvake työpöydälle shutdown_alert=Sammutusilmoitus system_shutdown_soon=Järjestelmä sammuu pian\! system_shutdown_failed=Järjestelmän sammutus epäonnistui\! system_shutdown_soon_description=Järjestelmä sammuu pian. Jos käytät laitetta vielä, tallenna työsi tai peru sammutus. system_shutdown_reason_queue_completed=Kaikki jonon lataukset ovat valmistuneet. system_shutdown_reason_queue_end_time_reached=Jonolle asetettu pysäytysaika on saavutettu. system_shutdown_download_finished=Lataus on valmistunut. shutdown_now=Sammuta nyt settings_per_host_settings_new_host= settings_per_host_settings_not_selected=Luo tai valitse uusi kohde ensin\! settings_per_host_settings_host=Isäntä settings_per_host_settings_host_description=Näitä asetuksia sovelletaan tätä isäntää vastaaviin latauksiin. Jokerimerkkejä (*) tuetaan (esim. esimerkki.fi, *.esimerkki.fi — käytä vain yhtä). settings_browser_in_launcher=Selaimen kuvake käynnistimessä settings_browser_in_launcher_description=Näytä selaimen kuvake käynnistimessä tai piilota se (sovelluslista). sort_by=Järjestä welcome=Tervetuloa new_folder=Uusi kansio skip=Ohita lets_go=Aloitetaan next=Seuraava select_all=Valitse kaikki select_inside=Valitse sisältö select_invert=Käännä valinta open_settings=Avaa asetukset back=Takaisin service_is_running=Palvelu on käynnissä initial_setup_description=Määritetään ominaisuudet initial_setup_notice=Voit muuttaa näitä asetuksia milloin tahansa permission_granted=Käyttöoikeus on myönnetty permission_not_granted=Käyttöoikeutta ei ole myönnetty permissions=Käyttöoikeudet give_permission=Myönnä käyttöoikeus give_storage_permission=Myönnä tallennustilan käyttöoikeus storage_roots=Storage Roots permissions_initial_title=Määritetään asetukset permissions_initial_description=Jotta sovellus toimii oikein, tarvitsee se muutamia käyttöoikeuksia. Seuraavalla näytöllä näet miten kutakin käyttöoikeutta käytetään ja voit valita, mitkä sallitaan ja mitkä ohitetaan. permissions_done_title=Kaikki kunnossa permissions_done_description=Määritykset on tehty, kaikki tarvittavat käyttöoikeudet on myönnetty ja sovellus on valmis käyttöön. permissions_manage_storage_title=Hallitse tallennustilan käyttöoikeutta permissions_manage_storage_reason=Tällä käyttöoikeudella sovellus voi vaihtaa latauskohdetta, tunnistaa latausten kaksoiskappaleet paremmin ja käyttää joitakin lisäominaisuuksia. Tämä on valinnainen, mutta suositeltava parasta käyttökokemusta varten. permission_read_write_external_storage_title=Tiedostojen luku- ja tallennusoikeus permission_read_write_external_storage_reason=Tämä käyttöoikeus sallii sovelluksen tallentaa ja hallita ladattuja tiedostoja, vaihtaa latauskohdetta ja parantaa latausten kaksoiskappaleiden tunnistusta. permissions_post_notification_title=Ilmoitusoikeus permissions_post_notification_reason=Latausten hallinta edellyttää, että sovellus suoritetaan taustalla ja ilmoitusten avulla taustatoiminnot sallitaan, ja sinut pidetään ajan tasalla. permissions_ignore_battery_optimization_title=Älä huomioi akkuvirran säästöä permissions_ignore_battery_optimization_reason=Jotkin laiteet rajoittavat taustatoimintoja aggressiivisesti akun säästämiseksi, jonka seurauksena lataukset saattavat pysähtyä kun sovellus ei ole avoinna. Halutessasi voit sulkea sovelluksen optimoinnin ulkopuolelle, jotta lataukset jatkuvat keskeytyksettä. open_in_browser=Avaa selaimessa browser=Selain browser_new_tab=Uusi välilehti browser_close_tab=Sulje välilehti browser_open_in_new_tab=Avaa uudella välilehdellä browser_open_in_new_background_tab=Avaa uudella taustavälilehdellä browser_no_tab_open=Välilehtiä ei ole avoinna browser_tabs=Välilehdet browser_paste_and_go=Liitä ja avaa browser_bookmarks=Kirjamerkit browser_add_bookmark=Lisää kirjanmerkki browser_edit_bookmark=Muokkaa kirjanmerkkiä browser_add_to_bookmarks=Lisää kirjanmerkkeihin browser_remove_from_bookmarks=Poista kirjanmerkeistä ================================================ FILE: shared/resources/src/commonMain/resources/com/abdownloadmanager/resources/locales/fr_FR.properties ================================================ app_title=AB Download Manager confirm_auto_categorize_downloads_title=Catégoriser automatiquement les téléchargements confirm_auto_categorize_downloads_description=Tout élément non classé sera automatiquement ajouté à sa catégorie correspondante. confirm_reset_to_default_categories_title=Rétablir les catégories par défaut confirm_reset_to_default_categories_description=Ceci SUPPRIMERA toutes vos catégories et rétablira les catégories par défaut \! confirm_delete_download_items_title=Confirmer la suppression confirm_delete_download_items_description=Êtes-vous sûr de vouloir supprimer {{count}} éléments ? confirm_delete_download_unfinished_items_description=Êtes-vous sûr de vouloir supprimer {{count}} téléchargements non terminés ? confirm_delete_download_finished_and_unfinished_items_description=Êtes-vous sûr de vouloir supprimer les {{finishedCount}} téléchargements terminés et {{unfinishedCount}} non terminés ? also_delete_file_from_disk=Supprimer également du disque confirm_delete_category_item_title=Suppression de la catégorie {{name}} confirm_delete_category_item_description=Êtes-vous sûr de vouloir supprimer la catégorie "{{value}}" ? your_download_will_not_be_deleted=Vos téléchargements ne seront pas supprimés drag_the_file_to_another_app=Glissez le fichier vers une autre application drop_link_or_file_here=Déposez un lien ou un fichier ici. nothing_will_be_imported=Rien ne sera importé n_links_will_be_imported={{count}} liens seront importés n_items_selected={{count}} éléments sélectionnés window_close=Fermer window_minimize=Réduire window_maximize=Agrandir window_restore=Rétablir delete=Supprimer remove=Retirer cancel=Annuler close=Fermer menu=Menu more_options=Plus d'options ok=D'accord add=Ajouter paste=Coller change=Changer edit=Modifier change_anyway=Changer quand même download=Télécharger refresh=Actualiser settings=Paramètres on_completion=À l'achèvement unknown=Inconnu unknown_error=Erreur inconnue download_item_not_found=L'élément à télécharger n'a pas été trouvé name=Nom download_link=Lien de téléchargement not_finished=Pas terminé all=Tout finished=Terminé Unfinished=Incomplet canceled=Annulé error=Erreur paused=Suspendu downloading=Téléchargement en cours added=Ajouté idle=INACTIF preparing_file=Préparation du fichier creating_file=Création du fichier resuming=Reprise en cours retrying=Nouvelle tentative list_is_empty=Rien ici pour l'instant \! search_in_the_list=Rechercher search=Rechercher clear=Vider general=Général enabled=Activé disabled=Désactivé default=Par défaut file=Fichier tasks=Tâches tools=Outils help=Aide system=Système all_missing_files=Tous les fichiers manquants all_finished=Terminés all_unfinished=Incomplets entire_list=Tout download_browser_integration=Télécharger l'extension du navigateur exit=Quitter show_downloads=Afficher les téléchargements new_download=Nouveau téléchargement stop_all=Arrêter tout import_from_clipboard=Importer depuis le presse-papiers batch_download=Téléchargement groupé open=Ouvrir share=Partager open_file=Ouvrir le fichier open_folder=Ouvrir le dossier resume=Reprendre pause=Suspendre restart_download=Redémarrer le téléchargement copy=Copier copy_link=Copier le lien copy_as_curl=Copier comme cURL show_properties=Propriétés move_to_queue=Déplacer vers une file d'attente move_to_this_queue=Déplacer vers cette file d'attente move_to_category=Déplacer vers une catégorie move_to_this_category=Déplacer vers cette catégorie categories=Catégories add_category=Ajouter une catégorie edit_category=Modifier la catégorie delete_category=Supprimer la catégorie category_name=Nom de la catégorie category_download_location=Emplacement de téléchargement de la catégorie category_download_location_description=Lorsque cette catégorie est choisie dans "Ajouter un téléchargement", utilisez ce répertoire comme "Emplacement de téléchargement" category_file_types=Types de fichiers de la catégorie category_file_types_description=Placez automatiquement ces types de fichiers dans cette catégorie. (lors de l'ajout d'un nouveau téléchargement)\nSéparez les extensions de fichiers par un espace (ext1 ext2 ...) category_url_patterns=Modèles d'URL category_url_patterns_description=Placez automatiquement le téléchargement à partir de ces URL dans cette catégorie. (lors de l'ajout d'un nouveau téléchargement)\nSéparez les URL par un espace, vous pouvez également utiliser * comme caractère générique auto_categorize_downloads=Catégoriser automatiquement les téléchargements restore_defaults=Rétablir les valeurs par défaut about=À propos version_n=v{{value}} developed_with_love_for_you=Développé avec ❤️ pour vous donate=Contribuer visit_the_project_website=Visiter le site web du projet this_is_a_free_and_open_source_software=Ceci est un logiciel libre et gratuit view_the_source_code=Voir le code source third_party_libraries=Bibliothèques tierces powered_by_open_source_software=Propulsé par des logiciels libres view_the_open_source_licenses=Voir les licences Open-Source support_and_community=Assistance et Communauté telegram=Telegram channel=Chaîne group=Groupe add_download=Ajouter un téléchargement add_multi_download_page_header=Sélectionnez les éléments que vous souhaitez récupérer pour le téléchargement save_to=Enregistrer dans where_should_each_item_saved=Où chaque élément doit-il être sauvegardé ? there_are_multiple_items_please_select_a_way_you_want_to_save_them=Il y a plusieurs éléments \! Veuillez sélectionner la manière dont vous souhaitez les enregistrer each_item_on_its_own_category=Chaque élément dans sa propre catégorie each_item_on_its_own_category_description=Chaque élément sera placé dans une catégorie qui contient ce type de fichier all_items_in_one_category=Tous les éléments dans une catégorie all_items_in_one_category_description=Tous les fichiers seront enregistrés à l'emplacement de la catégorie sélectionnée all_items_in_one_Location=Tous les éléments dans un seul emplacement all_items_in_one_Location_description=Tous les éléments seront enregistrés dans le répertoire sélectionné unselected_all_items_in_specific_location_description=Tous les fichiers seront enregistrés à l'emplacement de la catégorie sélectionnée no_category_selected=Aucune catégorie sélectionnée no_categories_found=Aucune catégorie trouvée download_location=Emplacement du téléchargement location=Emplacement select_queue=Sélectionner la file d’attente without_queue=Sans file d'attente use_category=Catégoriser cant_write_to_this_folder=Impossible d'écrire dans ce dossier file_name_already_exists=Le nom du fichier existe déjà download_already_exists=Le téléchargement existe déjà invalid_file_name=Nom de fichier invalide show_solutions=Afficher les solutions... change_solution=Changer la solution select_a_solution=Sélectionnez une solution select_download_strategy_description=Le lien que vous avez fourni est déjà dans les listes de téléchargement, veuillez préciser ce que vous souhaitez faire download_strategy_add_a_numbered_file=Ajouter un fichier numéroté download_strategy_add_a_numbered_file_description=Ajouter un index à la fin du nom du fichier de téléchargement download_strategy_override_existing_file=Écraser le fichier existant download_strategy_override_existing_file_description=Supprimer le téléchargement existant et écrire dans ce fichier download_strategy_update_download_link=Mettre à jour le téléchargement existant download_strategy_update_download_link_description=Mettre à jour le lien de téléchargement existant et ses identifiants download_strategy_show_downloaded_file=Afficher le fichier téléchargé download_strategy_show_downloaded_file_description=Afficher l'élément de téléchargement déjà existant, afin que vous puissiez le reprendre ou l'ouvrir batch_download_link_help=Entrez un lien contenant des caractères génériques (utilisez *) invalid_url=URL invalide list_is_too_large_maximum_n_items_allowed=La liste est trop longue \! {{count}} éléments maximum autorisés enter_range=Entrez la plage range_from=De range_to=à batch_download_wildcard_length=Longueur du caractère générique first_link=Premier lien last_link=Dernier lien open_source_software_used_in_this_app=Logiciels Open-Source utilisés dans cette application links=Liens website=Site web developers=Développeurs source_code=Code source license=Licence no_license_found=Aucune licence trouvée organization=Organisation add_new_queue=Ajouter une nouvelle file d'attente queue_name=Nom de la file d'attente queues=Files d'attente stop_queue=Arrêter la file d'attente start_queue=Démarrer la file d'attente clear_queue_items=Vider la file d'attente config=Configuration items=Éléments move_down=Descendre move_up=Monter remove_queue=Supprimer la file d'attente queue_name_help=Spécifiez un nom pour cette file d'attente queue_name_describe=Le nom de la file d’attente est {{value}} queue_max_concurrent_download=Téléchargements simultanés max queue_max_concurrent_download_description=Téléchargements maximum pour cette file d'attente queue_automatic_stop=Arrêt automatique queue_automatic_stop_description=Arrêt automatique de la file d'attente lorsqu'elle ne contient plus d'éléments queue_scheduler=Planificateur queue_enable_scheduler=Activer le planificateur queue_active_days=Jours d'activité queue_active_days_description=Quels sont les jours de fonctionnement du planificateur ? queue_scheduler_enable_auto_start_time=Activer le temps de démarrage automatique queue_scheduler_auto_start_time=Temps de démarrage automatique queue_scheduler_enable_auto_stop_time=Activer le temps d'arrêt automatique queue_scheduler_auto_stop_time=Temps d'arrêt automatique queue_shutdown_on_completion=Éteindre le système à l'achèvement queue_shutdown_on_completion_description=Arrêtez automatiquement le système lorsque cette file d'attente est terminée ou lorsque l'heure de fin prévue est atteinte. appearance=Apparence download_engine=Moteur de téléchargement browser_integration=Intégration du navigateur settings_download_max_retries_count=Nombre maximum de tentatives de téléchargement settings_download_max_retries_count_description=Le nombre maximum de fois que l'application tentera à nouveau un téléchargement échoué avant d'abandonner settings_download_max_retries_count_describe_no_retries=Les téléchargements qui ont échoué ne seront pas réessayés settings_download_max_retries_count_describe_n_retries=Les téléchargements qui ont échoué seront réessayés {{count}} fois settings_download_thread_count=Nombre de threads settings_download_thread_count_description=Nombre maximal de threads de téléchargement par élément settings_download_thread_count_describe=Un téléchargement peut utiliser jusqu''à {{count}} threads settings_download_thread_count_with_large_value_describe=Avertissement \: La définition d'un nombre de threads élevé peut augmenter l'utilisation des ressources système, réduire les performances ou provoquer des problèmes de connexion avec les serveurs. Utilisez des valeurs plus élevées uniquement si vous comprenez l'impact potentiel sur votre système et votre réseau. settings_use_server_last_modified_time=Utiliser l'heure de dernière modification du serveur settings_use_server_last_modified_time_description=Lors du téléchargement d'un fichier, utiliser l'heure de dernière modification du serveur pour le fichier local settings_append_extension_to_incomplete_downloads=Ajouter une extension aux téléchargements incomplets settings_append_extension_to_incomplete_downloads_description=Ajouter l'extension ".part" aux téléchargements incomplets. Cela permet d'identifier les téléchargements inachevés et d'éviter l'ouverture accidentelle de fichiers incomplets. settings_use_sparse_file_allocation=Allocation de fichier partiellement alloué settings_use_sparse_file_allocation_description=Créez des fichiers plus efficacement, en particulier sur les disques SSD, en réduisant l'écriture de données inutiles. Cela peut accélérer le démarrage des téléchargements et réduire l'utilisation du disque. Si les téléchargements démarrent lentement ou si vous constatez des vitesses de téléchargement inhabituelles, envisagez de désactiver cette option, car il se peut qu'elle ne soit pas entièrement prise en charge sur certains appareils. settings_ignore_ssl_certificates=Ignorer les certificats SSL settings_ignore_ssl_certificates_description=Désactive la vérification des certificats SSL. À n'utiliser qu'en cas de nécessité, car cela peut exposer votre connexion à des risques de sécurité. settings_global_speed_limiter=Limiteur de vitesse global settings_global_speed_limiter_description=Limite globale de vitesse de téléchargement (0 \= illimité) settings_show_average_speed=Afficher la vitesse moyenne settings_show_average_speed_description=Vitesse de téléchargement en moyenne ou exacte settings_use_category_by_default=Catégoriser par défaut settings_use_category_by_default_description=Catégoriser par défaut lors de l'ajout d'un fichier de téléchargement. settings_default_download_folder=Dossier de téléchargement par défaut settings_default_download_folder_description=Lorsque vous ajoutez un nouveau téléchargement, cet emplacement est utilisé par défaut settings_default_download_folder_describe="{{folder}}" sera utilisé settings_use_proxy=Utiliser un proxy settings_use_proxy_description=Utiliser un proxy pour télécharger des fichiers settings_use_proxy_describe_no_proxy=Aucun proxy ne sera utilisé settings_use_proxy_describe_system_proxy=Le proxy système sera utilisé settings_use_proxy_describe_manual_proxy="{{value}}" sera utilisé settings_use_proxy_describe_pac_proxy=Le fichier pac "{{value}}" sera utilisé settings_track_deleted_files_on_disk=Suivre les fichiers supprimés sur le disque settings_track_deleted_files_on_disk_description=Supprimer automatiquement les fichiers de la liste lorsqu'ils sont supprimés ou déplacés du répertoire de téléchargement. settings_delete_partial_file_on_download_cancellation=Supprimer le fichier partiel lors de l'annulation du téléchargement settings_delete_partial_file_on_download_cancellation_description=Lorsqu'un téléchargement est annulé, le fichier partiellement téléchargé sera supprimé du disque. Cela aide à garder votre dossier de téléchargement propre et réduit l'utilisation inutile de l'espace disque. Cependant, le téléchargement redémarrera depuis le début la prochaine fois que vous le démarrez. settings_default_user_agent=Agent utilisateur par défaut settings_default_user_agent_description=Spécifiez la chaîne User-Agent par défaut pour définir la manière dont les requêtes s'identifient aux serveurs. Cela peut permettre d'accéder à des contenus optimisés pour des appareils particuliers ou de contourner les limitations de téléchargement imposées par certains sites web. settings_download_size_unit=Unité de téléchargement settings_download_size_unit_description=Unité utilisée pour afficher la taille du téléchargement settings_download_speed_unit=Unité de vitesse de téléchargement settings_download_speed_unit_description=Unité utilisée pour afficher la vitesse de téléchargement settings_theme=Thème settings_theme_description=Sélectionnez un thème pour l'application settings_default_dark_theme=Thème sombre par défaut settings_default_dark_theme_description=S'applique lorsque l'application suit le thème du système et que le mode sombre est activé settings_default_light_theme=Thème clair par défaut settings_default_light_theme_description=S'applique lorsque l'application suit le thème du système et que le mode clair est activé settings_font=Police settings_font_description=Modifier la police utilisée dans l'interface de l'application. Certaines polices peuvent ne pas s'afficher correctement dans l'application. settings_ui_scale=Échelle de l'interface settings_ui_scale_description=Ajuster la taille des éléments de l'interface de l'application settings_language=Langue settings_compact_top_bar=Barre supérieure compacte settings_compact_top_bar_description=Fusionner la barre supérieure avec la barre de titre lorsque la fenêtre principale a suffisamment de largeur settings_use_native_menu_bar=Utiliser la barre de menu native settings_use_native_menu_bar_description=Utiliser le style de barre de menu par défaut du système settings_use_relative_date_time=Utiliser la date/heure relative settings_use_relative_date_time_description=Utiliser le format date/heure relatif pour les dates dans l'application (par exemple, "il y a 2 jours" au lieu de la date/heure exacte) settings_show_icon_labels=Afficher les libellés des icônes settings_show_icon_labels_description=Afficher les étiquettes sous les icônes lorsque possible (comme les actions de la barre d'outils) settings_use_system_tray=Utiliser la barre d'état système settings_use_system_tray_description=Afficher l'icône dans la barre d'état système lorsque l'application est en cours d'exécution settings_start_on_boot=Lancer au démarrage settings_start_on_boot_description=Démarrer automatiquement l'application lors de la connexion de l'utilisateur settings_notification_sound=Son de notification settings_notification_sound_description=Jouer un son lors d'une nouvelle notification settings_browser_integration=Intégration du navigateur settings_browser_integration_description=Accepter les téléchargements depuis le navigateur settings_browser_integration_server_port=Port du serveur settings_browser_integration_server_port_description=Port pour l'intégration du navigateur settings_browser_integration_server_port_describe=L''application écoutera le port {{port}} settings_dynamic_part_creation=Création dynamique de segments settings_dynamic_part_creation_description=Lorsqu'un segment est terminé, créez un nouveau segment en divisant d'autres segments pour améliorer la vitesse de téléchargement settings_show_completion_dialog=Fenêtre de fin de téléchargement settings_show_completion_dialog_description=Afficher automatiquement la fenêtre "Téléchargement terminé" lorsqu'un téléchargement est terminé. settings_show_download_progress_dialog=Fenêtre de progression du téléchargement settings_show_download_progress_dialog_description=Afficher automatiquement la fenêtre "Téléchargement en cours" lorsqu'un téléchargement a commencé. settings_per_host_settings=Paramètres par hôte settings_per_host_settings_descriptions=Ces paramètres seront automatiquement appliqués à tout nouveau téléchargement qui correspond à l'hôte spécifié. settings_download_max_concurrent_downloads=Téléchargements simultanés max. settings_download_max_concurrent_downloads_description=Le nombre maximum de fichiers pouvant être téléchargés en même temps (les téléchargements gérés par les files d'attente ne sont pas comptés ; réglez sur 0 pour illimité) download_item_settings_speed_limit=Limite de vitesse download_item_settings_speed_limit_description=Limiter la vitesse de téléchargement pour cet élément download_item_settings_show_download_completion_dialog=Fenêtre de fin de téléchargement download_item_settings_show_download_completion_dialog_description=Afficher automatiquement la fenêtre "Téléchargement terminé" lorsque ce téléchargement est terminé. download_item_settings_shutdown_on_completion=Éteindre le système à l'achèvement download_item_settings_shutdown_on_completion_description=Éteindre automatiquement le système lorsque ce téléchargement est terminé. download_item_settings_thread_count=Nombre de threads download_item_settings_thread_count_description=Nombre de threads utilisés pour télécharger cet élément (0 \= valeur par défaut) download_item_settings_thread_count_describe={{count}} threads pour ce téléchargement download_item_settings_username_description=Fournissez un nom d'utilisateur si le lien est une ressource protégée download_item_settings_password_description=Fournissez un mot de passe si le lien est une ressource protégée download_item_settings_download_page=Page de téléchargement download_item_settings_download_page_description=La page web où ce téléchargement a été lancé download_item_settings_file_checksum=Somme de contrôle du fichier download_item_settings_file_checksum_description=Une chaîne de hachage qui peut être utilisée pour vérifier si le fichier a été téléchargé correctement download_item_settings_user_agent=Agent utilisateur download_item_settings_user_agent_description=Agent utilisateur personnalisé pour cet élément (laisser vide pour utiliser la valeur par défaut) file_checksum=Somme de contrôle du fichier file_checksum_page=Vérificateur de somme de contrôle du fichier file_checksum_page_file_checksum_default_algorithm=Algorithme par défaut file_checksum_page_file_checksum_default_algorithm_help=L'algorithme utilisé par défaut pour calculer les sommes de contrôle des fichiers lorsqu'elles ne sont pas fournies. start=Démarrer calculated_checksum=Somme de contrôle calculée saved_checksum=Somme de contrôle enregistrée checksum_algorithm=Algorithme file_not_found=Fichier introuvable download_not_finished=Téléchargement incomplet done=Terminé waiting=En attente matches=Correspond not_matches=Ne correspond pas copy_to_clipboard=Copier dans le presse-papiers username=Nom d’utilisateur password=Mot de passe average_speed=Vitesse moyenne exact_speed=Vitesse exacte unlimited=Illimité use_global_settings=Utiliser les paramètres globaux cant_run_browser_integration=Impossible d'exécuter l'intégration du navigateur cant_open_file=Impossible d'ouvrir le fichier cant_open_folder=Impossible d'ouvrir le dossier # times for example 2 seconds ago relative_time_long_years={{years}} ans relative_time_long_months={{months}} mois relative_time_long_days={{days}} jours relative_time_long_hours={{hours}} heures relative_time_long_minutes={{minutes}} minutes relative_time_long_seconds={{seconds}} secondes relative_time_short_years={{years}} a relative_time_short_months={{months}} M relative_time_short_days={{days}} j relative_time_short_hours={{hours}} h relative_time_short_minutes={{minutes}} min relative_time_short_seconds={{seconds}} s relative_time_left={{time}} restant relative_time_ago=Il y a {{time}} auto=Auto unspecified=Non spécifié custom=Personnalisé icon=Icône author=Auteur link=Lien size=Taille status=État parts_info_downloaded_size=Téléchargé parts_info_total_size=Total speed=Vitesse time_left=Temps restant date_added=Date d'ajout info=Informations download_page_downloaded_size=Téléchargé download_page_download_completed=Téléchargement terminé resume_support=Capacité de reprise yes=Oui no=Non parts_info=Plus de détails disconnected=Déconnecté receiving_data=Réception de données connecting=Connexion en cours warning=Avertissement unsupported_resume_warning=Ce téléchargement ne prend pas en charge la reprise \! Vous devrez peut-être le redémarrer plus tard dans la liste des téléchargements stop_anyway=Arrêter quand même customize_columns=Personnaliser les colonnes reset=Réinitialiser monday=Lundi tuesday=Mardi wednesday=Mercredi thursday=Jeudi friday=Vendredi saturday=Samedi sunday=Dimanche proxy_open_system_proxy_settings=Ouvrir les paramètres du proxy système proxy_type=Type de proxy proxy_do_not_use_proxy_for=Ne pas utiliser le proxy pour proxy_do_not_use_proxy_for_description=Une liste d'URL qui ne peuvent pas être mandatées\nVous pouvez utiliser des caractères génériques avec *\nPar exemple \: 192.168.1.* exemple.com (séparés par des espaces) proxy_change_title=Changer de proxy change_proxy=Changer de proxy proxy_no=Pas de proxy proxy_system=Proxy du système proxy_manual=Proxy manuel proxy_pac=Configuration automatique du proxy proxy_pac_url=URL de configuration automatique du proxy address=Adresse port=Port address_and_port=Adresse et port use_authentication=Utiliser l'authentification warning_you_may_have_to_restart_the_download_later=Vous devrez peut-être redémarrer le téléchargement plus tard \! edit_download_title=Modifier le téléchargement edit_download_update_from_download_page=Mise à jour depuis la page de téléchargement edit_download_update_from_download_page_description=Lorsque cette fenêtre est ouverte, vous pouvez accéder à la page de téléchargement et cliquer sur le bouton de téléchargement. L'application capturera et mettra à jour les nouvelles références de téléchargement afin que vous puissiez les enregistrer. edit_download_saved_download_item_size_not_match=L''élément téléchargé a une taille de {{currentSize}}, qui ne correspond pas à la nouvelle taille de {{newSize}}. translators_page_thanks=Avec gratitude à ceux qui ont aidé à traduire ce projet ❤️ translators=Traducteurs language=Langue translators_contribute_title=Améliorer les traductions translators_contribute_description=Vous souhaitez contribuer à l'amélioration de ce projet ? Si votre langue n'est pas listée ou a besoin de quelques ajustements, vous pouvez contribuer avec vos traductions \! contribute=Contribuer meet_the_translators=Rencontrez les traducteurs localized_by_translators=Localisé par des traducteurs confirm_exit=Confirmer la fermeture confirm_exit_description=Êtes-vous sûr de vouloir quitter AB Download Manager ?\nLes téléchargements/files d'attente actifs seront arrêtés \! update=Mettre à jour update_updater=Mises à jour update_available=Mise à jour disponible update_error=Erreur lors de la mise à jour update_available_suggest_to_to_update=Vous pouvez mettre à jour vers la dernière version pour profiter de nouvelles fonctionnalités, améliorations et gains de performances. update_release_notes=Notes de version update_check_for_update=Vérifier les mises à jour update_checking_for_update=Vérification de la mise à jour update_no_update=Vous utilisez la dernière version update_check_error=Erreur lors de la vérification de la mise à jour update_app_updated_to_version_n=Application mise à jour vers la version {{version}} create_desktop_entry=Créer un raccourci bureau shutdown_alert=Avertissement d'arrêt system_shutdown_soon=Le système va bientôt s'éteindre \! system_shutdown_failed=L'arrêt du système a échoué \! system_shutdown_soon_description=Le système va bientôt s'éteindre. Si vous utilisez toujours l'ordinateur, veuillez enregistrer votre travail ou annuler l'arrêt. system_shutdown_reason_queue_completed=Tous les téléchargements dans la file d'attente sont terminés. system_shutdown_reason_queue_end_time_reached=L'heure de fin prévue pour la file d'attente de téléchargement atteint. system_shutdown_download_finished=Téléchargement terminé. shutdown_now=Éteindre maintenant settings_per_host_settings_new_host= settings_per_host_settings_not_selected=Créez ou sélectionnez d'abord un nouvel élément \! settings_per_host_settings_host=Hôte settings_per_host_settings_host_description=Ces paramètres seront appliqués aux téléchargements correspondant à ce nom d'hôte. Les astérisques (*) sont pris en charge (par ex \: example.com, *.example.com - n'utilisez qu'un seul). settings_browser_in_launcher=Icône du navigateur dans le lanceur settings_browser_in_launcher_description=Afficher/Masquer l'icône du navigateur dans le lanceur (liste d'applications). sort_by=Trier par welcome=Bienvenue new_folder=Nouveau dossier skip=Ignorer lets_go=C'est parti next=Suivant select_all=Sélectionner tout select_inside=Sélectionner l'intérieur select_invert=Sélectionner l'inverse open_settings=Ouvrir les paramètres back=Précédent service_is_running=Le service est en cours d'exécution initial_setup_description=Mettons les choses en place initial_setup_notice=Vous pouvez modifier ces paramètres à tout moment plus tard permission_granted=Autorisation accordée permission_not_granted=Autorisation non accordée permissions=Autorisations give_permission=Accorder les autorisations give_storage_permission=Accorder l'accès au stockage storage_roots=Racines de stockage permissions_initial_title=Mettons les choses en place permissions_initial_description=Pour fonctionner correctement, l'application a besoin de quelques autorisations. Sur l’écran suivant, vous verrez à quoi sert chaque permission et vous pouvez décider laquelle accorder ou ignorer. permissions_done_title=Tout est prêt permissions_done_description=Tout est prêt. Toutes les autorisations requises ont été accordées et l'application est prête à fonctionner. permissions_manage_storage_title=Gérer l'accès au stockage permissions_manage_storage_reason=Cette autorisation permet à l'application de modifier le dossier de téléchargement, de détecter plus précisément les téléchargements en double et d'activer certaines fonctionnalités supplémentaires. C’est facultatif, mais recommandé pour une meilleure expérience. permission_read_write_external_storage_title=Stockage en lecture et écriture permission_read_write_external_storage_reason=Cette autorisation permet à l'application d'enregistrer et de gérer les fichiers téléchargés, de modifier l'emplacement de téléchargement et d'améliorer la détection des téléchargements en double. permissions_post_notification_title=Accès aux notifications permissions_post_notification_reason=L'application doit s'exécuter en arrière-plan pour gérer les téléchargements. Les notifications sont utilisées pour vous tenir informé et permettre des opérations en arrière-plan. permissions_ignore_battery_optimization_title=Ignorer l'optimisation de la batterie permissions_ignore_battery_optimization_reason=Certains appareils limitent agressivement l'activité en arrière-plan pour économiser de la batterie, ce qui peut mettre en pause ou arrêter les téléchargements lorsque l'application n'est pas ouverte. Vous pouvez éventuellement exclure l'application de l'optimisation de la batterie pour vous assurer que les téléchargements se poursuivent sans interruption open_in_browser=Ouvrir dans le navigateur browser=Navigateur browser_new_tab=Nouvel onglet browser_close_tab=Fermer l'onglet browser_open_in_new_tab=Ouvrir dans un nouvel onglet browser_open_in_new_background_tab=Ouvrir dans un nouvel onglet en arrière-plan browser_no_tab_open=Aucun onglet n'est ouvert browser_tabs=Onglets browser_paste_and_go=Coller et accéder browser_bookmarks=Signets browser_add_bookmark=Ajouter un signet browser_edit_bookmark=Modifier le signet browser_add_to_bookmarks=Ajouter aux signets browser_remove_from_bookmarks=Retirer des signets ================================================ FILE: shared/resources/src/commonMain/resources/com/abdownloadmanager/resources/locales/hu_HU.properties ================================================ app_title=AB Letöltéskezelő confirm_auto_categorize_downloads_title=Automatikusan kategorizálja a letöltéseket confirm_auto_categorize_downloads_description=Bármely nem kategorizálatlan elem automatikusan hozzáadódik a kapcsolódó kategóriához. confirm_reset_to_default_categories_title=Alapértelmezett kategóriákra állítsa vissza confirm_reset_to_default_categories_description=Ez eltávolítja az összes kategóriát, és alapértelmezett kategóriákat hoz vissza\! confirm_delete_download_items_title=Erősítse meg a törlést confirm_delete_download_items_description=Biztos benne, hogy törölni akarja a {{{count}} elemeket? confirm_delete_download_unfinished_items_description=Biztos benne, hogy törölni akarja a {{{count}} befejezetlen letöltéseket? confirm_delete_download_finished_and_unfinished_items_description=Biztos benne, hogy törölni szeretné a {{finishedCount}} kész és a {{unfinishedCount}} befejezetlen letöltéseket? also_delete_file_from_disk=Törölje a fájlt a lemezről is confirm_delete_category_item_title=A {{name}} kategória eltávolítása confirm_delete_category_item_description=Biztos benne, hogy törölni szeretné a "{{value}}" kategóriát? your_download_will_not_be_deleted=A letöltések nem kerülnek törlésre drag_the_file_to_another_app=Húzza a fájlt egy másik alkalmazásba drop_link_or_file_here=Dobjon egy linket vagy egy fájlt ide. nothing_will_be_imported=Semmi sem lesz importálva n_links_will_be_imported={{count}} linkek importálásra kerülnek n_items_selected={{count}} kiválasztott elemek window_close=Bezárás window_minimize=Tálcára window_maximize=Teljes méret window_restore=Visszaállítás delete=Törlés remove=Eltávolítás cancel=Mégse close=Bezárás menu=Menü more_options=További lehetőségek ok=Ok add=Hozzáadás paste=Beillesztés change=Csere edit=Szerkesztés change_anyway=Mindenképpen változtasson download=Letöltés refresh=Frissítés settings=Beállítások on_completion=Befejezéskor unknown=Ismeretlen unknown_error=Ismeretlen hiba download_item_not_found=Letölthető elem nem található name=Név download_link=Letöltési link not_finished=Befejezetlen all=Mind finished=Befejezett Unfinished=Befejezetlen canceled=Törölt error=Hiba paused=Szüneteltetve downloading=Letöltés added=Hozzáadva idle=TÉTLEN preparing_file=Fájl előkészítése creating_file=Fájl létrehozása resuming=Folytatás retrying=Újrapróbálkozás list_is_empty=A lista üres\! search_in_the_list=Keresés a listában search=Keresés clear=Tisztítás general=Általános enabled=Engedélyezve disabled=Letiltva default=Alapértelmezett file=Fájl tasks=Feladatok tools=Eszközök help=Súgó system=Rendszer all_missing_files=Minden hiányzó fájl all_finished=Minden befejezett all_unfinished=Minden befejezetlen entire_list=Teljes lista download_browser_integration=Töltse le a böngészőbővítményt exit=Kilépés show_downloads=Letöltések megjelenítése new_download=Új letöltés stop_all=Összes leállítása import_from_clipboard=Importálás vágólapról batch_download=Kötegelt letöltés open=Megnyitás share=Megosztás open_file=Fájl megnyitása open_folder=Mappa megnyitása resume=Folytatás pause=Szünet restart_download=Letöltés újraindítása copy=Másolás copy_link=Link másolása copy_as_curl=Másolás cURL-ként show_properties=Tulajdonságok megjelenítése move_to_queue=Mozgatás a várólistára move_to_this_queue=Áthelyezés ebbe a várólistába move_to_category=Mozgatás a kategóriába move_to_this_category=Mozgatás ebbe a kategóriába categories=Kategóriák add_category=Kategória hozzáadása edit_category=Kategória szerkesztése delete_category=Kategória törlése category_name=Kategória neve category_download_location=Kategória letöltési helye category_download_location_description=Ha ezt a kategóriát választja a „Letöltés hozzáadása” menüpontban, használja ezt a könyvtárat „Letöltési helyként” category_file_types=Kategória fájltípusok category_file_types_description=Ezeket a fájltípusokat automatikusan ebbe a kategóriába helyezi. (új letöltés hozzáadásakor)\\nA fájlkiterjesztések különválasztása szóközzel (ext1 ext2 ...) category_url_patterns=URL -minták category_url_patterns_description=Automatikusan tegye a letöltést ezekről az URL-címekről ebbe a kategóriába. (amikor új letöltést adsz hozzá)\\nTávolítsd el az URL-eket szóközzel, használhatsz * karaktert is, mint dzsóker auto_categorize_downloads=Letöltések automatikus kategorizálása restore_defaults=Alapértelmezések visszaállítása about=Névjegy version_n=Verzió {{value}} developed_with_love_for_you=Fejlesztve ❤️ az Ön számára donate=Támogatás visit_the_project_website=Látogasson el a projekt weboldalára this_is_a_free_and_open_source_software=Ez egy ingyenes és nyílt forráskódú szoftver view_the_source_code=Lásd a forráskódot third_party_libraries=Harmadik fél könyvtárai powered_by_open_source_software=Open Source Software által üzemeltetett view_the_open_source_licenses=Tekintse meg a Open Source Software licenszeket support_and_community=Támogatás és közösség telegram=Telegram channel=Csatorna group=Csoport add_download=Letöltés hozzáadása add_multi_download_page_header=Válassza ki az elemeket, amelyeket letölteni szeretne save_to=Mentés ide where_should_each_item_saved=Hová kell menteni az egyes elemeket? there_are_multiple_items_please_select_a_way_you_want_to_save_them=Több elem van\! kérjük, válassza ki, hogyan szeretné elmenteni őket each_item_on_its_own_category=Minden elem saját kategóriában each_item_on_its_own_category_description=Minden elem egy olyan kategóriába kerül, amely az adott fájltípussal rendelkezik all_items_in_one_category=Minden elem egy kategóriában all_items_in_one_category_description=Minden fájl a kiválasztott kategóriába kerül mentésre all_items_in_one_Location=Minden elem egy helyen all_items_in_one_Location_description=Az összes elemet a kiválasztott könyvtárba menti unselected_all_items_in_specific_location_description=Minden fájl a kiválasztott kategória helyére kerül mentésre no_category_selected=Nincs kiválasztott kategória no_categories_found=Nem találhatók kategóriák download_location=Letöltési hely location=Hely select_queue=Várólista kiválasztása without_queue=Várólista nélkül use_category=Kategória használata cant_write_to_this_folder=Nem lehet írni erre a mappába file_name_already_exists=A fájlnév már létezik download_already_exists=A letöltés már létezik invalid_file_name=Érvénytelen fájlnév show_solutions=Megoldások megjelenítése... change_solution=Megoldás módosítása select_a_solution=Válasszon ki egy megoldást select_download_strategy_description=A megadott link már a letöltési listákban található, kérjük, adja meg, mit szeretne csinálni download_strategy_add_a_numbered_file=Adjon hozzá egy számozott fájlt download_strategy_add_a_numbered_file_description=Adjon hozzá egy indexet a letöltési fájl nevének vége után download_strategy_override_existing_file=Meglévő fájl felülírása download_strategy_override_existing_file_description=Távolítsa el a meglévő letöltést, és írjon ehhez a fájlhoz download_strategy_update_download_link=A meglévő letöltés frissítése download_strategy_update_download_link_description=Frissítse a meglévő letöltési linket és annak hitelesítő adatait download_strategy_show_downloaded_file=A letöltött fájl megjelenítése download_strategy_show_downloaded_file_description=A már meglévő letöltési elem megjelenítése, majd megnyomhatja a folytatás vagy a megnyitás gombot batch_download_link_help=Írjon be egy linket, amely helyettesítő karaktereket tartalmaz (használja a *-t) invalid_url=Érvénytelen URL list_is_too_large_maximum_n_items_allowed=A lista túl nagy\! maximálisan megengedett {{count}} tételek száma} enter_range=Tartomány megadása range_from=ettől range_to=eddig batch_download_wildcard_length=Helyettesítő karakterek hossza first_link=Első link last_link=Utolsó link open_source_software_used_in_this_app=Ebben az alkalmazásban használt nyílt forráskódú szoftver links=Linkek website=Webhely developers=Fejlesztők source_code=Forráskód license=Licensz no_license_found=Nem található licensz organization=Szervezet add_new_queue=Új várólista hozzáadása queue_name=Várólista neve queues=Várólisták stop_queue=Várólista leállítása start_queue=Várólista indítása clear_queue_items=Üres várólista config=Konfiguráció items=Elemek move_down=Lejjebb move_up=Feljebb remove_queue=Várólista eltávolítása queue_name_help=Adja meg a várólista nevét queue_name_describe=A várólista neve {{value}} queue_max_concurrent_download=Egyidejű letöltések maximális száma queue_max_concurrent_download_description=Maximális letöltés ehhez a várólistához queue_automatic_stop=Automatikus leállítás queue_automatic_stop_description=Automatikusan leállítja a várólistát, ha nincs benne elem queue_scheduler=Ütemező queue_enable_scheduler=Ütemező engedélyezése queue_active_days=Aktív napok queue_active_days_description=Mely napokon működjenek az ütemezők? queue_scheduler_enable_auto_start_time=Automatikus indítási idő engedélyezése queue_scheduler_auto_start_time=Automatikus indítási idő queue_scheduler_enable_auto_stop_time=Automatikus leállítási idő engedélyezése queue_scheduler_auto_stop_time=Automatikus leállítási idő queue_shutdown_on_completion=A rendszer leállítása a rendszer befejezésekor queue_shutdown_on_completion_description=Automatikusan leállítja a rendszert, amikor ez a várólista befejeződik, vagy amikor a tervezett befejezési idő elérkezik. appearance=Megjelenés download_engine=Letöltő motor browser_integration=Böngésző integráció settings_download_max_retries_count=Maximális letöltési újrapróbálkozások száma settings_download_max_retries_count_description=Az alkalmazás legfeljebb annyi alkalommal próbálkozik újra egy sikertelen letöltéssel, mielőtt feladja settings_download_max_retries_count_describe_no_retries=A sikertelen letöltéseket nem próbálja meg újra settings_download_max_retries_count_describe_n_retries=A sikertelen letöltés(eke)t újra megpróbáljuk {{count}} alkalommal settings_download_thread_count=Szálszám settings_download_thread_count_description=Letöltési szál maximális száma letöltési elemenként settings_download_thread_count_describe=Egy letöltésnek legfeljebb {{count}} szála lehet settings_download_thread_count_with_large_value_describe=Figyelmeztetés\: A magas szálszám beállítása növelheti a rendszer erőforrás-felhasználását, csökkentheti a teljesítményt, vagy kapcsolódási problémákat okozhat a kiszolgálókkal. Csak akkor használjon magasabb értékeket, ha tisztában van a rendszerre és a hálózatra gyakorolt lehetséges hatással. settings_use_server_last_modified_time=A kiszolgáló utolsó módosítási idejének használata settings_use_server_last_modified_time_description=Fájl letöltésekor használja a kiszolgáló utolsó módosítási idejét a helyi fájlhoz settings_append_extension_to_incomplete_downloads=Bővítmény hozzáadása a befejezetlen letöltésekhez settings_append_extension_to_incomplete_downloads_description=A ".part" kiterjesztés hozzáadása a nem teljes letöltésekhez. Ez segít azonosítani a befejezetlen letöltéseket, és megakadályozza a befejezetlen fájlok véletlen megnyitását. settings_use_sparse_file_allocation=Apró fájlok helyfoglalása settings_use_sparse_file_allocation_description=Hatékonyabban hozhat létre fájlokat, különösen SSD lemezeken, a felesleges adatírás csökkentésével. Ez felgyorsíthatja a letöltések indítását és csökkentheti a lemezhasználatot. Ha a letöltések lassan indulnak, vagy szokatlan letöltési sebességet tapasztal, fontolja meg ennek az opciónak a letiltását, mivel előfordulhat, hogy egyes eszközök nem támogatják teljes mértékben. settings_ignore_ssl_certificates=Figyelmen kívül hagyja az SSL tanúsítványokat settings_ignore_ssl_certificates_description=Letiltja az SSL tanúsítvány ellenőrzését. Csak szükség esetén használja, mert ez biztonsági kockázatoknak teheti ki a kapcsolatot. settings_global_speed_limiter=Globális sebességkorlátozó settings_global_speed_limiter_description=Globális letöltési sebességkorlátozás (0 azt jelenti, hogy korlátlan) settings_show_average_speed=Átlagos sebesség megjelenítése settings_show_average_speed_description=Letöltési sebesség átlagban vagy pontosságban settings_use_category_by_default=Kategória használata alapértelmezés szerint settings_use_category_by_default_description=Alapértelmezés szerint használja a kategóriát a letöltés hozzáadásakor. settings_default_download_folder=Alapértelmezett letöltési mappa settings_default_download_folder_description=Új letöltés hozzáadásakor a rendszer alapértelmezés szerint ezt a helyet használja settings_default_download_folder_describe=A(z) "{{folder}}" lesz használva\nsettings_use_proxy\=Proxy használata settings_use_proxy=Proxy használata settings_use_proxy_description=Proxy használata fájlok letöltéséhez settings_use_proxy_describe_no_proxy=Proxy nem lesz használva settings_use_proxy_describe_system_proxy=A rendszer proxyját fogjuk használni settings_use_proxy_describe_manual_proxy="{{value}}" lesz használva settings_use_proxy_describe_pac_proxy=\ "{{value}}" PAC fájlt használja a rendszer settings_track_deleted_files_on_disk=Kövesse nyomon a törölt fájlokat a lemezen settings_track_deleted_files_on_disk_description=Fájlok automatikus eltávolítása a listáról, amikor törlik vagy áthelyezik őket a letöltési könyvtárból. settings_delete_partial_file_on_download_cancellation=Részleges fájl törlése a letöltés törlésekor settings_delete_partial_file_on_download_cancellation_description=A letöltés törlésekor a részben letöltött fájl törlődik a lemezről. Ez segít tisztán tartani a letöltési mappát, és csökkenti a felesleges lemezterület-használatot. A letöltés azonban a következő indításkor újraindul az elejéről. settings_default_user_agent=Alapértelmezett felhasználói ügynök settings_default_user_agent_description=Adja meg az Alapértelmezett felhasználói ügynök karakterláncot, hogy meghatározza, hogyan azonosítják a kérelmeket a kiszolgálók felé. Ez segíthet az egyes eszközökre optimalizált tartalmak elérésében, vagy az egyes webhelyek által előírt letöltési korlátozások megkerülésében. settings_download_size_unit=Letöltési méret egység settings_download_size_unit_description=A letöltési méret megjelenítésére használt egység settings_download_speed_unit=Letöltési sebesség egység settings_download_speed_unit_description=A letöltési sebesség megjelenítéséhez használt egység settings_theme=Téma settings_theme_description=Válasszon egy témát az alkalmazáshoz settings_default_dark_theme=Alapértelmezett sötét téma settings_default_dark_theme_description=Akkor alkalmazandó, ha az alkalmazás a rendszer témáját követi, és a sötét üzemmód aktív settings_default_light_theme=Alapértelmezett világos téma settings_default_light_theme_description=Akkor alkalmazandó, ha az alkalmazás a rendszer témáját követi, és a világos üzemmód aktív settings_font=Betűtípus settings_font_description=Az alkalmazás felületén használt betűtípus megváltoztatása, Egyes betűtípusok esetleg nem jelennek meg helyesen az alkalmazásban. settings_ui_scale=UI -skála settings_ui_scale_description=Az alkalmazás kezelőfelületi elemek méretének beállítása settings_language=Nyelv settings_compact_top_bar=Kompakt felső sáv settings_compact_top_bar_description=Egyesítse a felső sávot a címsorral, ha a főablak elég széles settings_use_native_menu_bar=Natív menüsor használata settings_use_native_menu_bar_description=A rendszer alapértelmezett menüsor stílusának használata settings_use_relative_date_time=Relatív dátum/idő használata settings_use_relative_date_time_description=Relatív dátum/idő formátum használata az alkalmazásban a dátumokhoz (pl.\: "2 nappal ezelőtt" a pontos dátum/idő helyett) settings_show_icon_labels=Az ikoncímkék megjelenítése settings_show_icon_labels_description=A címkék megjelenítése az ikonok alatt, ha lehetséges (mint például a kezdőlap eszköztár műveletei) settings_use_system_tray=Használja a rendszer tálcát settings_use_system_tray_description=A rendszer tálca ikon megjelenítése, amikor az alkalmazás fut settings_start_on_boot=Indítás a rendszerrel settings_start_on_boot_description=Alkalmazás automatikus indítása felhasználói bejelentkezéskor settings_notification_sound=Értesítési hang settings_notification_sound_description=Hang lejátszása új értesítéskor settings_browser_integration=Böngésző integráció settings_browser_integration_description=Letöltések elfogadása böngészőkből settings_browser_integration_server_port=Szerverport settings_browser_integration_server_port_description=Port a böngésző integrációjához settings_browser_integration_server_port_describe=Az alkalmazás figyelni fogja a(z) {{port}} portot settings_dynamic_part_creation=Dinamikus darabok létrehozása settings_dynamic_part_creation_description=Amikor egy darab elkészült, hozzon létre egy másik darabot a többi rész felosztásával a letöltési sebesség javítása érdekében settings_show_completion_dialog=Letöltés befejezése párbeszédpanel megjelenítése settings_show_completion_dialog_description=A "Letöltés befejeződött" párbeszédpanel automatikus megjelenítése a letöltés befejezésekor. settings_show_download_progress_dialog=Letöltési folyamat párbeszédpanel megjelenítése settings_show_download_progress_dialog_description=A "Letöltés folyamata" párbeszédpanel automatikus megjelenítése a letöltés megkezdésekor. settings_per_host_settings=Gazdagépenkénti beállítások settings_per_host_settings_descriptions=Ezek a beállítások automatikusan alkalmazásra kerülnek minden olyan új letöltésre, amely megfelel a megadott Gazdagépnek. settings_download_max_concurrent_downloads=Maximális egyidejű letöltések száma settings_download_max_concurrent_downloads_description=Az egyszerre letölthető fájlok maximális száma (a várólisták által kezelt letöltések nem számítanak bele; korlátlan letöltéshez állítsa 0-ra) download_item_settings_speed_limit=Sebességhatár download_item_settings_speed_limit_description=Korlátozza a letöltési sebességet ehhez az elemhez download_item_settings_show_download_completion_dialog=Letöltés befejezése párbeszédpanel megjelenítése download_item_settings_show_download_completion_dialog_description=A "Letöltés befejeződött" párbeszédpanel automatikus megjelenítése a letöltés befejezése után. download_item_settings_shutdown_on_completion=A rendszer leállítása befejezéskor download_item_settings_shutdown_on_completion_description=Automatikusan leállítja a rendszert, amikor a letöltés befejeződik. download_item_settings_thread_count=Szálszám download_item_settings_thread_count_description=Mennyi szálat használtak a letöltési elem letöltéséhez (0 alapértelmezett) download_item_settings_thread_count_describe={{count}} szál ehhez a letöltéshez download_item_settings_username_description=Adjon meg egy felhasználónevet, ha a hivatkozás védett erőforrás download_item_settings_password_description=Adjon meg egy felhasználónevet, ha a hivatkozás védett erőforrás download_item_settings_download_page=Letöltési oldal download_item_settings_download_page_description=Az a weboldal, ahol ezt a letöltést kezdeményezték download_item_settings_file_checksum=Fájl ellenőrző összeg download_item_settings_file_checksum_description=Egy hash karakterlánc, amelynek segítségével ellenőrizhető, hogy a fájl megfelelően lett-e letöltve. download_item_settings_user_agent=Felhasználói ügynök download_item_settings_user_agent_description=Egyéni felhasználói ügynök ehhez az elemhez (hagyja üresen az alapértelmezett használatához) file_checksum=Fájl ellenőrző összeg file_checksum_page=Fájl ellenőrzőösszeg-ellenőrző file_checksum_page_file_checksum_default_algorithm=Alapértelmezett algoritmus file_checksum_page_file_checksum_default_algorithm_help=A fájlellenőrző összegek kiszámítására használt alapértelmezett algoritmus, ha azok nincsenek megadva. start=Indítás calculated_checksum=Számított ellenőrzőösszeg saved_checksum=Mentett ellenőrzőösszeg checksum_algorithm=Algoritmus file_not_found=A fájl nem található download_not_finished=A letöltés nem fejeződött be done=Kész waiting=Várakozás matches=Egyezések not_matches=Nincsenek egyezések copy_to_clipboard=Másolás vágólapra username=Felhasználónév password=Jelszó average_speed=Átlagsebesség exact_speed=Pontos sebesség unlimited=Korlátlan use_global_settings=Használja a globális beállításokat cant_run_browser_integration=Nem lehet futtatni a böngésző integrációját cant_open_file=Nem lehet megnyitni a fájlt cant_open_folder=Nem lehet megnyitni a mappát # times for example 2 seconds ago relative_time_long_years={{years}} év relative_time_long_months={{months}} hónap relative_time_long_days={{days}} nap relative_time_long_hours={{hours}} óra relative_time_long_minutes={{minutes}} perc relative_time_long_seconds={{seconds}} másodperc relative_time_short_years={{years}} é relative_time_short_months={{months}} H relative_time_short_days={{days}} n relative_time_short_hours={{hours}} óra relative_time_short_minutes={{minutes}} perc relative_time_short_seconds={{seconds}} másodperc relative_time_left={{time}} maradt relative_time_ago={{time}} eltelt auto=Automatikus unspecified=Meghatározatlan custom=Egyéni icon=Ikon author=Szerző link=Hivatkozás size=Méret status=Állapot parts_info_downloaded_size=Letöltött parts_info_total_size=Teljes speed=Sebesség time_left=Hátralévő idő date_added=Dátum hozzáadva info=Információ download_page_downloaded_size=Letöltött download_page_download_completed=Letöltés befejeződött resume_support=Támogatás folytatása yes=Igen no=Nem parts_info=Részletek… disconnected=Szétkapcsolt receiving_data=Adatok fogadása connecting=Csatlakozás warning=Figyelmeztetés unsupported_resume_warning=Ez a letöltés nem támogatja a folytatást\! Lehet, hogy később újra kell indítania a letöltési listában stop_anyway=Mindenképpen álljon meg customize_columns=Oszlopok testreszabása reset=Visszaállítás monday=hétfő tuesday=kedd wednesday=szerda thursday=csütörtök friday=péntek saturday=szombat sunday=vasárnap proxy_open_system_proxy_settings=Nyissa meg a rendszer proxy beállításait proxy_type=Proxy típus proxy_do_not_use_proxy_for=Ne használjon proxyt proxy_do_not_use_proxy_for_description=Azoknak az url címeknek a listája, amelyeket nem lehet proxyzni\\nHasználhat * karaktert,\\ például 192.168.1.* example.com (szóközzel elválasztva) proxy_change_title=Változtassa meg a proxyt change_proxy=Változtassa meg a proxyt proxy_no=Nincs proxy proxy_system=Rendszer proxy proxy_manual=Kézi proxy proxy_pac=Proxy automatikus konfigurálása proxy_pac_url=Proxy automatikus konfigurációs URL-címe address=Cím port=Port address_and_port=Cím és port use_authentication=Hitelesítés használata warning_you_may_have_to_restart_the_download_later=Lehet, hogy később újra kell indítania a letöltést\! edit_download_title=Letöltés szerkesztése edit_download_update_from_download_page=Frissítés a letöltési oldalról edit_download_update_from_download_page_description=Ha ez az ablak meg van nyitva, akkor lépjen a Letöltés oldalra, és kattintson a letöltés gombra. Az alkalmazás rögzíti és frissíti az új letöltési hitelesítő adatokat, így elmentheti azokat. edit_download_saved_download_item_size_not_match=A mentett letöltési elem mérete {{currentSize}}, amely nem felel meg az új méretnek {{newSize}}. translators_page_thanks=Hálával azoknak, akik segítették a projekt lefordítását ❤️ translators=Fordítók language=Nyelv translators_contribute_title=Fordítások javítása translators_contribute_description=Szeretne segíteni a projekt fejlesztésében? Ha a te nyelved nem szerepel a listán, vagy ha szükséged van néhány javításra, akkor járulj hozzá a fordításaiddal, és tedd jobbá\! contribute=Hozzájárul meet_the_translators=Ismerje meg a fordítókat localized_by_translators=Fordítók által lokalizálva confirm_exit=Erősítse meg a kilépést confirm_exit_description=Biztos, hogy ki akar lépni az AB Letöltéskezelőből?\\nAz aktív letöltések/várólisták leállnak\! update=Frissítés update_updater=Frissítő update_available=Frissítés érhető el update_error=Frissítési hiba update_available_suggest_to_to_update=A legújabb verzióra frissítve élvezheti az új funkciókat, fejlesztéseket és teljesítményjavításokat. update_release_notes=Kiadási megjegyzések update_check_for_update=Frissítés keresése update_checking_for_update=A frissítés ellenőrzése update_no_update=A legújabb verziót használja update_check_error=Hiba a frissítés keresése közben update_app_updated_to_version_n=Az alkalmazás frissítve a(z) {{{version}} verzióra create_desktop_entry=Asztali bejegyzés létrehozása shutdown_alert=Leállítási riasztás system_shutdown_soon=A rendszer hamarosan leáll\! system_shutdown_failed=A rendszer leállítása sikertelen\! system_shutdown_soon_description=A rendszer hamarosan leáll. Ha még használja a számítógépet, kérjük, mentse el a munkáját, vagy törölje a leállítást. system_shutdown_reason_queue_completed=A várólistán lévő összes letöltés elkészült. system_shutdown_reason_queue_end_time_reached=Elérte a letöltési sor ütemezett befejezési időpontját. system_shutdown_download_finished=Letöltés befejezve. shutdown_now=Leállítás most settings_per_host_settings_new_host=<Új Gazdagép> settings_per_host_settings_not_selected=Először hozzon létre vagy válasszon ki egy új elemet\! settings_per_host_settings_host=Gazdagép settings_per_host_settings_host_description=Ezek a beállítások az adott gazdagépnévvel rendelkező letöltésekre lesznek alkalmazva. A helyettesítő karakterek (*) támogatottak (így\: példa.com, *.példa.com – csak egyet használjon). settings_browser_in_launcher=Böngésző ikon az indítóban settings_browser_in_launcher_description=A böngésző ikonjának megjelenítése vagy elrejtése az indítóban (alkalmazások listája). sort_by=Rendezés welcome=Üdvözöljük new_folder=Új mappa skip=Kihagy lets_go=Menjünk next=Következő select_all=Mind kiválaszt select_inside=Belső kijelölés select_invert=Kiválasztás megfordítása open_settings=Beállítások megnyitása back=Vissza service_is_running=A szolgáltatás fut initial_setup_description=Állítsuk be a dolgokat initial_setup_notice=Ezeket a beállításokat később bármikor módosíthatja permission_granted=Engedély megadva permission_not_granted=Engedély megtagadva permissions=Engedélyek give_permission=Engedélyezés give_storage_permission=Tárhely-hozzáférés engedélyezése storage_roots=Fő tárolási helyek permissions_initial_title=Engedélyek beállítása permissions_initial_description=A megfelelő működéshez az alkalmazásnak néhány engedélyre van szüksége. A következő képernyőn látni fogja, hogy az egyes engedélyeket mire használják, és eldöntheti, hogy melyiket engedélyezi vagy hagyja ki. permissions_done_title=Minden készen áll permissions_done_description=Minden készen áll. Minden szükséges engedélyt megadtunk, és az alkalmazás készen áll. permissions_manage_storage_title=Tárhely-hozzáférés kezelése permissions_manage_storage_reason=Ez az engedély lehetővé teszi az alkalmazás számára, hogy módosítsa a letöltési mappát, pontosabban észlelje a duplikált letöltéseket, és engedélyezzen néhány extra funkciót. Nem kötelező, de a legjobb élmény érdekében ajánlott. permission_read_write_external_storage_title=Tárhely olvasása és írása permission_read_write_external_storage_reason=Ez az engedély lehetővé teszi az alkalmazás számára, hogy mentse és kezelje a letöltött fájlokat, módosítsa a letöltési helyet, és javítsa az ismétlődő letöltések észlelését. permissions_post_notification_title=Értesítés küldése permissions_post_notification_reason=Az alkalmazásnak a háttérben kell futnia a letöltések kezeléséhez. Az értesítések tájékoztatást adnak, és lehetővé teszik a háttérben történő működést. permissions_ignore_battery_optimization_title=Az akkumulátoroptimalizálás figyelmen kívül hagyása permissions_ignore_battery_optimization_reason=Egyes eszközök agresszíven korlátozzák a háttértevékenységet az akkumulátor kímélése érdekében, ami szüneteltetheti vagy leállíthatja a letöltéseket, ha az alkalmazás nincs megnyitva. Opcionálisan kizárhatja az alkalmazást az akkumulátor-optimalizálásból, hogy a letöltések megszakítás nélkül folytatódjanak open_in_browser=Megnyitás böngészőben browser=Böngésző browser_new_tab=Új lap browser_close_tab=Lap bezárása browser_open_in_new_tab=Megnyitás új lapon browser_open_in_new_background_tab=Megnyitás új háttérlapon browser_no_tab_open=Nincs megnyitott lap browser_tabs=Lapok browser_paste_and_go=Beillesztés és tovább browser_bookmarks=Könyvjelzők browser_add_bookmark=Könyvjelző hozzáadása browser_edit_bookmark=Könyvjelző szerkesztése browser_add_to_bookmarks=Hozzáadás a könyvjelzőkhöz browser_remove_from_bookmarks=Eltávolítás a könyvjelzők közül ================================================ FILE: shared/resources/src/commonMain/resources/com/abdownloadmanager/resources/locales/id_ID.properties ================================================ app_title=Manajer Unduhan AB confirm_auto_categorize_downloads_title=Mengelompokkan unduhan secara otomatis confirm_auto_categorize_downloads_description=Setiap item yang belum memiliki kategori akan otomatis dimasukkan ke kategori yang sesuai. confirm_reset_to_default_categories_title=Mengembalikan ke Kategori Bawaan confirm_reset_to_default_categories_description=Ini akan MENGHAPUS semua kategori dan mengembalikan kategori bawaan\! confirm_delete_download_items_title=Konfirmasi Penghapusan confirm_delete_download_items_description=Apakah kamu yakin untuk menghapus {{count}} item? confirm_delete_download_unfinished_items_description=Apakah Anda yakin ingin menghapus {{count}} unduhan yang belum selesai? confirm_delete_download_finished_and_unfinished_items_description=Apakah Anda yakin ingin menghapus {{finishedCount}} unduhan yang telah selesai dan {{unfinishedCount}} unduhan yang belum selesai? also_delete_file_from_disk=Juga menghapus berkas di dalam penyimpanan confirm_delete_category_item_title=Menghapus kategori {{name}} confirm_delete_category_item_description=Apakah kamu yakin untuk menghapus "{{value}}" Kategori? your_download_will_not_be_deleted=Unduhan Anda tidak akan dihapus drag_the_file_to_another_app=Seret berkas ke aplikasi lain drop_link_or_file_here=Letakkan tautan atau berkas di sini. nothing_will_be_imported=Tidak ada yang akan diimpor n_links_will_be_imported={{count}} tautan akan diimpor n_items_selected={{count}} butir dipilih window_close=Keluar window_minimize=Meminimalkan window_maximize=Diperbesar window_restore=Mengembalikan delete=Hapus remove=Buang cancel=Batal close=Tutup menu=Menu more_options=Lebih Banyak Opsi ok=Oke add=Tambah paste=Tempel change=Ubah edit=Sunting change_anyway=Ganti Saja download=Unduh refresh=Muat Ulang settings=Setelan on_completion=Ketika Selesai unknown=Tidak diketahui unknown_error=Kesalahan yang tidak diketahui download_item_not_found=Butir unduhan tidak ditemukan name=Nama download_link=Tautan unduhan not_finished=Tidak selesai all=Semua finished=Selesai Unfinished=Belum Selesai canceled=Dibatalkan error=Kesalahan paused=Ditunda downloading=Mengunduh added=Ditambahkan idle=Tidak Aktif preparing_file=Menyiapkan berkas creating_file=Membuat berkas resuming=Melanjutkan retrying=Mencoba kembali list_is_empty=Daftar kosong\! search_in_the_list=Mencari dalam Daftar search=Cari clear=Kosongkan general=Umum enabled=Diaktifkan disabled=Nonaktif default=Bawaan file=Berkas tasks=Tugas-tugas tools=Peralatan help=Bantuan system=Sistem all_missing_files=Semua Berkas Hilang all_finished=Semuanya selesai all_unfinished=Semuanya belum selesai entire_list=Seluruh Daftar download_browser_integration=Unduh Integrasi Browser exit=Keluar show_downloads=Tampilkan Unduhan new_download=Unduhan Baru stop_all=Stop Semua import_from_clipboard=Impor dari Papan Klip batch_download=Unduh Berkelompok open=Buka share=Bagikan open_file=Buka Berkas open_folder=Buka Folder resume=Lanjut pause=Jeda restart_download=Mulai Ulang Unduhan copy=Salin copy_link=Salin tautan copy_as_curl=Salin sebagai cURL show_properties=Tampilkan Properti move_to_queue=Pindahkan ke Antrean move_to_this_queue=Pindahkan ini ke Antrean move_to_category=Pindahkan ke Kategori move_to_this_category=Pindahkan ke kategori ini categories=Kategori add_category=Tambah Kategori edit_category=Sunting Kategori delete_category=Hapus Kategori category_name=Nama Kategori category_download_location=Lokasi Pengunduhan Kategori category_download_location_description=Ketika kategori ini dipilih dalam “Tambahkan Unduhan”, gunakan direktori ini sebagai “Lokasi Unduhan” category_file_types=Jenis berkas kategori category_file_types_description=Secara otomatis masukkan jenis file ini ke dalam kategori ini. (saat Anda menambahkan unduhan baru)\nPisahkan ekstensi file dengan spasi (ext1 ext2 ...) category_url_patterns=Pola URL category_url_patterns_description=Secara otomatis masukkan unduhan dari URL ini ke dalam kategori ini. (saat Anda menambahkan unduhan baru)\nPisahkan URL dengan spasi, Anda juga dapat menggunakan * sebagai wildcard auto_categorize_downloads=Kategorikan Otomatis Unduhan restore_defaults=Pulihkan Kondisi Bawaan about=Tentang version_n=Versi {{value}} developed_with_love_for_you=Dikembangkan dengan ❤️ untukmu donate=Donasi visit_the_project_website=Kunjungi situs web proyek this_is_a_free_and_open_source_software=Ini adalah perangkat lunak gratis dan bersifat Sumber Terbuka view_the_source_code=Lihat Kode Sumber third_party_libraries=Perpustakaan Pihak Ketiga powered_by_open_source_software=Didukung oleh Perangkat Lunak Sumber Terbuka view_the_open_source_licenses=Lihat lisensi Sumber Terbuka support_and_community=Dukungan dan Komunitas telegram=Telegram channel=Saluran group=Grup add_download=Tambahkan Unduh add_multi_download_page_header=Pilih butir yang ingin Anda ambil untuk diunduh save_to=Simpan ke where_should_each_item_saved=Setiap butir yang ada mau disimpan ke mana? there_are_multiple_items_please_select_a_way_you_want_to_save_them=Ada beberapa butir\! Pilihlah cara penyimpanan yang kamu mau each_item_on_its_own_category=Setiap butir dalam kategorinya masing-masing each_item_on_its_own_category_description=Setiap butir akan ditempatkan dalam kategori yang memiliki tipe berkas tersebut all_items_in_one_category=Semua butir dalam satu Kategori all_items_in_one_category_description=Semua berkas akan disimpan dalam kategori yang dipilih all_items_in_one_Location=Semua butir dalam satu Lokasi all_items_in_one_Location_description=Semua butir akan disimpan dalam kategori yang dipilih unselected_all_items_in_specific_location_description=Semua berkas akan disimpan di lokasi kategori yang dipilih no_category_selected=Tidak ada Kategori yang dipilih no_categories_found=Tidak ditemukan kategori download_location=Lokasi Unduhan location=Lokasi select_queue=Pilih Antrean without_queue=Tanpa Antrean use_category=Gunakan Kategori cant_write_to_this_folder=Tidak dapat menulis ke folder ini file_name_already_exists=Nama yang sama sudah ada download_already_exists=Unduhan sudah ada invalid_file_name=Nama berkas tidak sah show_solutions=Tampilkan solusi... change_solution=Ganti solusi select_a_solution=Pilih solusi select_download_strategy_description=Tautan yang Anda berikan sudah ada dalam daftar unduhan, silakan tentukan apa yang ingin Anda lakukan download_strategy_add_a_numbered_file=Menambahkan berkas bernomor download_strategy_add_a_numbered_file_description=Tambahkan indeks setelah akhir nama berkas unduhan download_strategy_override_existing_file=Mengganti berkas yang ada download_strategy_override_existing_file_description=Buang unduhan yang ada dan tulis ke berkas tersebut download_strategy_update_download_link=Perbarui unduhan yang ada download_strategy_update_download_link_description=Perbarui tautan unduhan yang ada beserta kredensialnya download_strategy_show_downloaded_file=Tampilkan file yang telah diunduh download_strategy_show_downloaded_file_description=Tampilkan butir unduhan yang sudah ada, sehingga Anda dapat menekan lanjutkan atau membukanya batch_download_link_help=Masukkan tautan yang berisi wildcard (gunakan *) invalid_url=URL tidak sah list_is_too_large_maximum_n_items_allowed=Daftar terlalu besar\! Maksimal {{count}} butir yang diizinkan enter_range=Masukkan rentang nilai range_from=Dari range_to=Sampai batch_download_wildcard_length=Panjang wildcard first_link=Tautan Pertama last_link=Tautan Terakhir open_source_software_used_in_this_app=Perangkat Lunak Sumber Terbuka yang digunakan dalam Aplikasi ini links=Tautan website=Situs Web developers=Pengembang source_code=Kode Sumber license=Lisensi no_license_found=Tidak ada lisensi yang ditemukan organization=Organisasi add_new_queue=Tambahkan Antrean Baru queue_name=Nama Antrean queues=Antrean stop_queue=Henti Antrean start_queue=Mulai Antrean clear_queue_items=Antrean Kosong config=Konfigurasi items=Butir move_down=Pindah ke bawah move_up=Pindah ke atas remove_queue=Hapus Antrean queue_name_help=Tentukan nama untuk antrean ini queue_name_describe=Nama antrean adalah {{value}} queue_max_concurrent_download=Jumlah Unduhan Bersamaan Maksimum queue_max_concurrent_download_description=Unduhan maksimal untuk antrean ini queue_automatic_stop=Penghentian otomatis queue_automatic_stop_description=Antrean berhenti otomatis ketika tidak ada butir di dalamnya queue_scheduler=Penjadwal queue_enable_scheduler=Aktifkan Penjadwal queue_active_days=Hari Aktif queue_active_days_description=Pada hari apa penjadwal berfungsi? queue_scheduler_enable_auto_start_time=Aktifkan waktu mulai otomatis queue_scheduler_auto_start_time=Waktu Mulai Otomatis queue_scheduler_enable_auto_stop_time=Aktifkan Waktu Berhenti Otomatis queue_scheduler_auto_stop_time=Waktu Berhenti Otomatis queue_shutdown_on_completion=Matikan Sistem Ketika Selesai queue_shutdown_on_completion_description=Mematikan sistem secara otomatis saat antrean ini selesai. atau ketika waktu akhir yang dijadwalkan tercapai. appearance=Tampilan download_engine=Mesin Unduhan browser_integration=Integrasi Peramban settings_download_max_retries_count=Jumlah Maksimum Ulangi Unduhan settings_download_max_retries_count_description=Jumlah maksimum kali aplikasi akan mencoba mengunduh ulang file yang gagal sebelum menyerah settings_download_max_retries_count_describe_no_retries=Unduhan yang gagal tidak akan dicoba lagi settings_download_max_retries_count_describe_n_retries=Unduhan yang gagal akan dicoba ulang {{count}} kali settings_download_thread_count=Jumlah Utas settings_download_thread_count_description=Utas unduhan maksimum per butir unduhan settings_download_thread_count_describe=Unduhan dapat memiliki hingga {{count}} utas settings_download_thread_count_with_large_value_describe=Peringatan\: Memakai jumlah unit proses yang banyak, berpotensi meningkatkan pemakaian sumber daya komputer, mengurangi performa komputer atau menyebabkan masalah koneksi pada komputer penyedia. Gunakan konfigurasi ini jika anda memahami kemungkinan masalah di atas. settings_use_server_last_modified_time=Gunakan Waktu Terakhir Diubah Server settings_use_server_last_modified_time_description=Saat mengunduh file, gunakan waktu modifikasi terakhir server untuk file lokal settings_append_extension_to_incomplete_downloads=Tambahkan Ekstensi ke Unduhan yang Belum Selesai settings_append_extension_to_incomplete_downloads_description=Tambahkan ekstensi ".part" ke unduhan yang belum selesai. Hal ini membantu mengidentifikasi unduhan yang belum selesai dan mencegah pembukaan file yang belum selesai secara tidak sengaja. settings_use_sparse_file_allocation=Alokasi Berkas Renggang settings_use_sparse_file_allocation_description=Buat berkas lebih efisien, terutama pada SSD, dengan mengurangi penulisan data yang tidak perlu. Ini dapat mempercepat pengunduhan dimulai dan mengurangi penggunaan penyimpanan. Jika unduhan dimulai dengan lambat atau Anda mengalami kecepatan unduh yang tidak biasa, pertimbangkan untuk nonaktifkan opsi ini, karena mungkin tidak didukung sepenuhnya di beberapa perangkat. settings_ignore_ssl_certificates=Abaikan sertifikat SSL settings_ignore_ssl_certificates_description=Nonaktifkan verifikasi sertifikat SSL. Gunakan hanya jika diperlukan, karena hal ini dapat membuat koneksi Anda rentan terhadap risiko keamanan. settings_global_speed_limiter=Pembatas Kecepatan Global settings_global_speed_limiter_description=Batas kecepatan unduh global (0 berarti tidak terbatas) settings_show_average_speed=Tampilkan Kecepatan Rata-rata settings_show_average_speed_description=Kecepatan unduh rata-rata atau presisi settings_use_category_by_default=Gunakan Kategori Secara Bawaan settings_use_category_by_default_description=Gunakan kategori secara bawaan saat menambahkan unduhan. settings_default_download_folder=Folder Unduhan Bawaan settings_default_download_folder_description=Saat kamu menambahkan unduhan baru, lokasi ini digunakan secara bawaan settings_default_download_folder_describe="{{folder}}" akan digunakan settings_use_proxy=Gunakan Proxy settings_use_proxy_description=Gunakan proxy untuk mengunduh berkas settings_use_proxy_describe_no_proxy=Tidak akan menggunakan proxy settings_use_proxy_describe_system_proxy=Proxy Sistem akan digunakan settings_use_proxy_describe_manual_proxy="{{value}}" akan digunakan settings_use_proxy_describe_pac_proxy=Berkas PAC "{{value}} " akan digunakan settings_track_deleted_files_on_disk=Lacak Berkas yang Dihapus Di Penyimpanan settings_track_deleted_files_on_disk_description=Hapus secara otomatis file dari daftar saat file tersebut dihapus atau dipindahkan dari direktori unduhan. settings_delete_partial_file_on_download_cancellation=Hapus File Sebagian Saat Pembatalan Unduhan settings_delete_partial_file_on_download_cancellation_description=Ketika unduhan dibatalkan, file yang telah diunduh sebagian akan dihapus dari cakram. Hal ini membantu menjaga folder unduhan tetap rapi dan mengurangi penggunaan ruang cakram yang tidak perlu. Namun, unduhan akan dimulai dari awal lagi saat Anda memulainya kembali. settings_default_user_agent=Agen Pengguna Bawaan settings_default_user_agent_description=Tentukan string User Agent Default untuk menentukan cara permintaan diidentifikasi oleh server. Hal ini dapat membantu dalam mengakses konten yang dioptimalkan untuk perangkat tertentu atau dalam menghindari batasan unduhan yang diberlakukan oleh beberapa situs web. settings_download_size_unit=Satuan Ukuran Unduhan settings_download_size_unit_description=Satuan yang digunakan untuk menampilkan ukuran unduhan settings_download_speed_unit=Unit Kecepatan Unduhan settings_download_speed_unit_description=Satuan yang digunakan untuk menampilkan kecepatan unduh settings_theme=Tema settings_theme_description=Pilih tema untuk Aplikasi settings_default_dark_theme=Tema Gelap Bawaan settings_default_dark_theme_description=Terapkan ketika aplikasi mengikuti tema sistem dan mode gelap diaktifkan settings_default_light_theme=Tema Terang Bawaan settings_default_light_theme_description=Terapkan ketika aplikasi mengikuti tema sistem dan mode terang diaktifkan settings_font=Font settings_font_description=Ubah font yang digunakan dalam antarmuka aplikasi. Beberapa font mungkin tidak ditampilkan dengan benar di aplikasi. settings_ui_scale=Skala UI settings_ui_scale_description=Sesuaikan ukuran elemen antarmuka aplikasi settings_language=Bahasa settings_compact_top_bar=Bilah Atas Ringkas settings_compact_top_bar_description=Gabungkan bilah atas dengan bilah judul saat jendela utama memiliki lebar yang cukup settings_use_native_menu_bar=Gunakan Bilah Menu Asli settings_use_native_menu_bar_description=Gunakan gaya bilah menu sistem secara bawaan settings_use_relative_date_time=Gunakan tanggal/waktu secara relatif settings_use_relative_date_time_description=Gunakan format tanggal/waktu secara relatif untuk tanggal aplikasi (misalnya., "2 hari yang lalu" bukan tanggal/waktu) settings_show_icon_labels=Tampilkan Label Ikon settings_show_icon_labels_description=Tampilkan label di bawah ikon jika memungkinkan (seperti tindakan pada bilah alat beranda) settings_use_system_tray=Gunakan Baki Sistem settings_use_system_tray_description=Tampilkan ikon baki sistem saat aplikasi sedang berjalan settings_start_on_boot=Jalankan Pada Saat Boot settings_start_on_boot_description=Aplikasi mulai otomatis saat masuk pengguna settings_notification_sound=Suara Notifikasi settings_notification_sound_description=Putar suara pada notifikasi baru settings_browser_integration=Integrasi Peramban settings_browser_integration_description=Terima unduhan dari peramban settings_browser_integration_server_port=Server Port settings_browser_integration_server_port_description=Port untuk integrasi peramban settings_browser_integration_server_port_describe=Aplikasi akan memperhatikan port {{port}} settings_dynamic_part_creation=Pembuatan Bagian Dinamis settings_dynamic_part_creation_description=Saat satu bagian selesai buat bagian lain dengan memisahkan bagian lain untuk meningkatkan kecepatan unduh settings_show_completion_dialog=Tampilkan Dialog Penyelesaian Pengunduhan settings_show_completion_dialog_description=Secara otomatis menampilkan dialog "Unduh Selesai" saat unduhan selesai. settings_show_download_progress_dialog=Tampilkan Dialog Progres Unduhan settings_show_download_progress_dialog_description=Tampilkan secara otomatis dialog "Progres Unduhan" saat unduhan dimulai. settings_per_host_settings=Per Pengaturan Tuan Rumah settings_per_host_settings_descriptions=Pengaturan ini akan diterapkan secara otomatis pada setiap unduhan baru yang sesuai dengan host yang ditentukan. settings_download_max_concurrent_downloads=Jumlah Unduhan Bersamaan Maksimum settings_download_max_concurrent_downloads_description=Jumlah maksimum file yang dapat diunduh secara bersamaan (unduhan yang dikelola oleh antrian tidak dihitung; atur ke 0 untuk tak terbatas) download_item_settings_speed_limit=Batas Kecepatan download_item_settings_speed_limit_description=Batasi kecepatan unduh untuk item ini download_item_settings_show_download_completion_dialog=Tampilkan dialog Penyelesaian Unduhan download_item_settings_show_download_completion_dialog_description=Secara otomatis menampilkan dialog "Penyelesaian Unduhan" saat pengunduhan selesai. download_item_settings_shutdown_on_completion=Sistem Matikan Setelah Selesai download_item_settings_shutdown_on_completion_description=Matikan sistem secara otomatis setelah unduhan ini selesai. download_item_settings_thread_count=Jumlah Utas download_item_settings_thread_count_description=Berapa banyak benang yang digunakan untuk mengunduh butir unduhan ini (0 untuk default) download_item_settings_thread_count_describe={{count}} utas untuk unduhan ini download_item_settings_username_description=Berikan nama pengguna jika tautan tersebut merupakan sumber daya yang dilindungi download_item_settings_password_description=Masukkan kata sandi jika tautan tersebut merupakan sumber daya yang dilindungi download_item_settings_download_page=Halaman Unduhan download_item_settings_download_page_description=Halaman web tempat unduhan ini dimulai download_item_settings_file_checksum=Berkas Integritas Data download_item_settings_file_checksum_description=String hash yang dapat digunakan untuk memeriksa apakah file telah diunduh dengan benar download_item_settings_user_agent=Agen-Pengguna download_item_settings_user_agent_description=User-Agent khusus untuk butir ini (biarkan kosong untuk menggunakan default) file_checksum=Berkas pemeriksa integritas data file_checksum_page=Pemeriksa Berkas Integritas Data file_checksum_page_file_checksum_default_algorithm=Algoritma Default file_checksum_page_file_checksum_default_algorithm_help=Algoritma default yang digunakan untuk menghitung checksum file ketika checksum tersebut tidak disediakan. start=Mulai calculated_checksum=Checksum yang dihitung saved_checksum=Integritas Data Tersimpan checksum_algorithm=Algoritma file_not_found=Berkas tidak Ditemukan download_not_finished=Unduh belum selesai done=Selesai waiting=Menunggu matches=Cocok not_matches=Tidak Cocok copy_to_clipboard=Salin Ke Papan Klip username=Nama Pengguna password=Kata Sandi average_speed=Kecepatan Rata-Rata exact_speed=Kecepatan Akurat unlimited=Tidak Terbatas use_global_settings=Gunakan Setelan Global cant_run_browser_integration=Tidak dapat menjalankan integrasi browser cant_open_file=Tidak Dapat Membuka Berkas cant_open_folder=Tidak dapat membuka folder # times for example 2 seconds ago relative_time_long_years={{years}} tahun relative_time_long_months={{months}} bulan relative_time_long_days={{days}} hari relative_time_long_hours={{hours}} jam relative_time_long_minutes={{minutes}} menit relative_time_long_seconds={{seconds}} detik relative_time_short_years={{years}} t relative_time_short_months={{months}} b relative_time_short_days={{days}} h relative_time_short_hours={{hours}} j relative_time_short_minutes={{minutes}} m relative_time_short_seconds={{seconds}} d relative_time_left={{time}} tersisa relative_time_ago={{time}} lalu auto=Otomatis unspecified=Tidak ditentukan custom=Sesuaikan icon=Ikon author=Pembuat link=Tautan size=Ukuran status=Status parts_info_downloaded_size=Diunduh parts_info_total_size=Total speed=Kecepatan time_left=Waktu Tersisa date_added=Tanggal Ditambahkan info=Info download_page_downloaded_size=Diunduh download_page_download_completed=Unduhan Selesai resume_support=Dukungan Melanjutkan yes=Ya no=Tidak parts_info=Info Suku Cadang disconnected=Terputus receiving_data=Menerima Data connecting=Menyambung warning=Peringatan unsupported_resume_warning=Unduhan ini tidak mendukung melanjutkan\! Anda mungkin harus MEMULAI ULANG nanti di Daftar Unduhan stop_anyway=Berhenti Pula customize_columns=Sesuaikan Kolom reset=Setel ulang monday=Senin tuesday=Selasa wednesday=Rabu thursday=Kamis friday=Jumat saturday=Sabtu sunday=Minggu proxy_open_system_proxy_settings=Buka Setelan Proxy Sistem proxy_type=Tipe proxy proxy_do_not_use_proxy_for=Jangan Gunakan proxy untuk proxy_do_not_use_proxy_for_description=Daftar url yang mungkin tidak diproksi\nAnda dapat menggunakan wildcard dengan *\nmisalnya 192.168.1.* example.com (dipisahkan spasi) proxy_change_title=Ganti Proxy change_proxy=Ganti Proxy proxy_no=Tanpa Proksi proxy_system=Proxy Sistem proxy_manual=Proxy Manual proxy_pac=Konfigurasi Perantara Otomatis proxy_pac_url=URL untuk Konfigurasi Perantara Otomatis address=Alamat port=Port address_and_port=Alamat & Port use_authentication=Gunakan Autentikasi warning_you_may_have_to_restart_the_download_later=Anda mungkin harus memulai ulang unduhan nanti\! edit_download_title=Sunting Unduhan edit_download_update_from_download_page=Pembaruan dari Halaman Unduhan edit_download_update_from_download_page_description=Saat jendela ini terbuka, Anda dapat pergi ke Halaman Unduh dan klik tombol unduh. Aplikasi akan menangkap dan memperbarui kredensial unduh baru sehingga Anda dapat menyimpannya. edit_download_saved_download_item_size_not_match=Butir unduhan yang disimpan memiliki ukuran {{currentSize}}, yang tidak sesuai dengan ukuran baru {{newSize}}. translators_page_thanks=Dengan Rasa Terima Kasih Kepada Mereka Yang Membantu Menerjemahkan Proyek Ini ❤️ translators=Penerjemah language=Bahasa translators_contribute_title=Tingkatkan Terjemahan translators_contribute_description=Ingin membantu meningkatkan proyek ini? Jika bahasa Anda tidak terdaftar atau memerlukan beberapa penyesuaian, Anda dapat menyumbangkan terjemahan Anda dan membuatnya lebih baik\! contribute=Berpartisipasi meet_the_translators=Sapa para penerjemah localized_by_translators=Diterjemahkan oleh Penerjemah confirm_exit=Konfirmasi untuk keluar confirm_exit_description=Apakah Anda yakin ingin keluar dari Manajer Unduhan AB?\nUnduhan/Antrean yang sedang aktif akan dihentikan\! update=Pembaruan update_updater=Pembaru update_available=Pembaruan Tersedia update_error=Kesalahan pada Pembaruan update_available_suggest_to_to_update=Anda dapat memperbarui ke versi terbaru untuk menikmati fitur baru, peningkatan, dan perbaikan kinerja. update_release_notes=Catatan Rilis update_check_for_update=Cek untuk Pembaruan update_checking_for_update=Mengecek untuk Pembaruan update_no_update=Kamu menggunakan versi terbaru update_check_error=Ada kesalahan saat mengecek pembaruan update_app_updated_to_version_n=Aplikasi diperbarui ke versi {{version}} create_desktop_entry=Buat Entri Desktop shutdown_alert=Matikan Peringatan system_shutdown_soon=Sistem Akan Segera Dimatikan\! system_shutdown_failed=Sistem Dimatikan Gagal\! system_shutdown_soon_description=Sistem akan segera dimatikan. Jika Anda masih menggunakan komputer, silakan simpan pekerjaan Anda atau batalkan proses pematian sistem. system_shutdown_reason_queue_completed=Semua unduhan dalam antrean selesai. system_shutdown_reason_queue_end_time_reached=Waktu akhir yang dijadwalkan untuk antrean unduhan tercapai. system_shutdown_download_finished=Pengunduhan selesai. shutdown_now=Matikan Sekarang settings_per_host_settings_new_host= settings_per_host_settings_not_selected=Buat atau pilih butir baru terlebih dahulu\! settings_per_host_settings_host=Tuan Rumah settings_per_host_settings_host_description=Pengaturan ini akan diterapkan pada unduhan yang sesuai dengan nama host ini. Karakter wildcard (*) didukung (misalnya, example.com, *.example.com — gunakan hanya salah satu). settings_browser_in_launcher=Ikon Browser Di Peluncur settings_browser_in_launcher_description=Tampilkan atau sembunyikan ikon browser di peluncur (daftar aplikasi). sort_by=Urut berdasarkan welcome=Selamat Datang new_folder=Folder Baru skip=Lewati lets_go=Ayo Mulai next=Berikutnya select_all=Pilih Semua select_inside=Pilih select_invert=Pilih di Dalam open_settings=Buka Pengaturan back=Kembali service_is_running=Layanan sedang berjalan initial_setup_description=Mari kita atur semuanya initial_setup_notice=Anda dapat mengubah pengaturan ini kapan saja nanti permission_granted=Izin sudah diberikan permission_not_granted=Izin tidak diberikan permissions=Izin give_permission=Beri izin give_storage_permission=Izinkan akses penyimpanan storage_roots=Akar Penyimpanan permissions_initial_title=Pengaturan izin permissions_initial_description=Agar dapat berfungsi dengan baik, aplikasi ini memerlukan beberapa izin. Pada layar berikutnya, Anda akan melihat tujuan dari setiap izin dan dapat memutuskan izin mana yang ingin Anda izinkan atau lewati. permissions_done_title=Anda sudah siap permissions_done_description=Semua sudah siap. Semua izin yang diperlukan telah diberikan dan aplikasi siap digunakan. permissions_manage_storage_title=Kelola akses penyimpanan permissions_manage_storage_reason=Izin ini memungkinkan aplikasi untuk mengubah folder unduhan, mendeteksi unduhan ganda dengan lebih akurat, dan mengaktifkan beberapa fitur tambahan. Izin ini bersifat optional, tetapi disarankan untuk pengalaman terbaik. permission_read_write_external_storage_title=Baca dan tulis penyimpanan permission_read_write_external_storage_reason=Izin ini memungkinkan aplikasi untuk menyimpan dan mengelola berkas yang diunduh, mengubah lokasi unduhan, serta meningkatkan deteksi unduhan ganda. permissions_post_notification_title=Notifikasi Pos permissions_post_notification_reason=Aplikasi ini perlu berjalan di latar belakang untuk mengelola unduhan. Pemberitahuan digunakan untuk memberitahu Anda dan memungkinkan operasi di latar belakang. permissions_ignore_battery_optimization_title=Abaikan Optimalisasi Baterai permissions_ignore_battery_optimization_reason=Beberapa perangkat secara agresif membatasi aktivitas latar belakang untuk menghemat baterai, yang dapat menghentikan atau menunda unduhan saat aplikasi tidak terbuka. Anda dapat secara opsional mengecualikan aplikasi dari pengoptimalan baterai untuk memastikan unduhan terus berlanjut tanpa terputus. open_in_browser=Buka Di Browser browser=Browser browser_new_tab=Tab Baru browser_close_tab=Tutup Tab browser_open_in_new_tab=Buka di Tab Baru browser_open_in_new_background_tab=Buka di Tab Latar Belakang Baru browser_no_tab_open=Tidak ada tab yang terbuka browser_tabs=Tab browser_paste_and_go=Tempel dan Jalankan browser_bookmarks=Markah browser_add_bookmark=Tambahkan Markah browser_edit_bookmark=Sunting Markah browser_add_to_bookmarks=Tambah Ke Penanda browser_remove_from_bookmarks=Hapus Dari Penanda ================================================ FILE: shared/resources/src/commonMain/resources/com/abdownloadmanager/resources/locales/it_IT.properties ================================================ app_title=AB Download Manager confirm_auto_categorize_downloads_title=Categorizzazione automatica download confirm_auto_categorize_downloads_description=Qualsiasi elemento non categorizzato sarà automaticamente aggiunto alla categoria correlata. confirm_reset_to_default_categories_title=Ripristina categorie predefinite confirm_reset_to_default_categories_description=Questo RIMUOVERÀ tutte le categorie e ripristinerà le categorie predefinite\! confirm_delete_download_items_title=Conferma eliminazione confirm_delete_download_items_description=Vuoi eliminare {{count}} elementi? confirm_delete_download_unfinished_items_description=Vuoi eliminare {{count}} download non completati? confirm_delete_download_finished_and_unfinished_items_description=Vuoi eliminare {{finishedCount}} download completati e {{unfinishedCount}} download non completati? also_delete_file_from_disk=Elimina anche il file dal disco confirm_delete_category_item_title=Rimozione categoria {{name}} confirm_delete_category_item_description=Vuoi eliminare la categoria "{{value}}"? your_download_will_not_be_deleted=I download non verranno eliminati drag_the_file_to_another_app=Trascina il file su un'altra applicazione drop_link_or_file_here=Trascina il collegamento o il file qui. nothing_will_be_imported=Niente sarà importato n_links_will_be_imported=Saranno importati {{count}} collegamenti n_items_selected={{count}} elementi selezionati window_close=Chiudi window_minimize=Minimizza window_maximize=Ingrandisci window_restore=Ripristina delete=Elimina remove=Rimuovi cancel=Annulla close=Chiudi menu=Menù more_options=Altre opzioni ok=Ok add=Aggiungi paste=Incolla change=Modifica edit=Modifica change_anyway=Cambia Comunque download=Download refresh=Aggiorna settings=Impostazioni on_completion=In Completamento unknown=Sconosciuto unknown_error=Errore sconosciuto download_item_not_found=Elemento download non trovato name=Nome download_link=Collegamento download not_finished=Non completato all=Tutti finished=Completati Unfinished=Non completati canceled=Annullato error=Errore paused=In pausa downloading=Download added=Aggiunto idle=NON ATTIVO preparing_file=Preparazione file creating_file=Creazione file resuming=Ripresa retrying=Nuovo tentativo list_is_empty=L'elenco è vuoto\! search_in_the_list=Cerca nell'elenco search=Cerca clear=Azzera general=Generale enabled=Abilitato disabled=Disabilitato default=Predefinito file=File tasks=Attività tools=Strumenti help=Aiuto system=Sistema all_missing_files=Tutti i file mancanti all_finished=Tutti completati all_unfinished=Tutti non completati entire_list=Intero elenco download_browser_integration=Download estensione browser exit=Esci show_downloads=Visualizza download new_download=Aggiungi nuovo download stop_all=Interrompi tutto import_from_clipboard=Importa dagli appunti batch_download=Download batch open=Apri share=Condividi open_file=Apri file open_folder=Apri cartella resume=Riprendi pause=Metti in pausa restart_download=Riavvia download copy=Copia copy_link=Copia collegamento copy_as_curl=Copia come cURL show_properties=Visualizza proprietà move_to_queue=Sposta nella coda move_to_this_queue=Sposta in questa coda move_to_category=Muovi nella categoria move_to_this_category=Sposta in questa categoria categories=Categorie add_category=Aggiungi categoria edit_category=Modifica categoria delete_category=Elimina categoria category_name=Nome categoria category_download_location=Percorso download categoria category_download_location_description=Quando in "Aggiungi download" è scelta questa categoria, usa questo cartella come "Percorso salvataggio" category_file_types=Tipi di file categoria category_file_types_description=Metti automaticamente questi tipi di file in questa categoria (quando aggiungi un nuovo download).\nSepara le estensioni dei file con uno spazio (ext1 ext2 ...) category_url_patterns=Modelli URL category_url_patterns_description=Metti automaticamente i download da queste URL in questa categoria (quando aggiungi un nuovo download).\nSepara gli URL con uno spazio, puoi anche usare * come carattere jolly auto_categorize_downloads=Categorizza automaticamente i download restore_defaults=Ripristina predefiniti about=Info programma version_n=Versione {{value}} developed_with_love_for_you=Sviluppato con ❤️ per te donate=Dona visit_the_project_website=Visita il sito del progetto this_is_a_free_and_open_source_software=Questo è un software gratuito e open source view_the_source_code=Guarda il codice sorgente third_party_libraries=Librerie di terze parti powered_by_open_source_software=Supportato da Open Software Source view_the_open_source_licenses=Visualizza le licenze open source support_and_community=Supporto e comunità telegram=Telegram channel=Canale group=Gruppo add_download=Aggiungi download add_multi_download_page_header=Seleziona gli elementi che vuoi scaricare save_to=Salva in where_should_each_item_saved=Dove vuoi salvare ciascun elemento? there_are_multiple_items_please_select_a_way_you_want_to_save_them=Ci sono più elementi\!\nSeleziona un modo in cui salvarli each_item_on_its_own_category=Ogni elemento nella propria categoria each_item_on_its_own_category_description=Ogni elemento sarà collocato in una categoria corrispondente al tipo di file all_items_in_one_category=Tutti gli elementi in un'unica categoria all_items_in_one_category_description=Tutti i file saranno salvati nel percorso della categoria selezionata all_items_in_one_Location=Tutti gli elementi in un unico percorso all_items_in_one_Location_description=Tutti gli elementi saranno salvati nella cartella selezionata unselected_all_items_in_specific_location_description=Tutti i file verranno salvati nel percorso della categoria selezionata no_category_selected=Nessuna categoria selezionata no_categories_found=Nessuna categoria trovata download_location=Percorso download location=Percorso select_queue=Seleziona coda without_queue=Nessuna coda use_category=Usa categoria cant_write_to_this_folder=Impossibile scrivere in questa cartella file_name_already_exists=Il nome del file esiste già download_already_exists=Il download esiste già invalid_file_name=Nome file non valido show_solutions=Visualizza soluzioni... change_solution=Cambia soluzione select_a_solution=Seleziona una soluzione select_download_strategy_description=Il collegamento fornito è già presente nell'elenco download, specifica cosa vuoi fare download_strategy_add_a_numbered_file=Aggiungi un file numerato download_strategy_add_a_numbered_file_description=Aggiungi un indice alla fine del nome file scaricato download_strategy_override_existing_file=Sovrascrivi file esistente download_strategy_override_existing_file_description=Rimuovi il download esistente e scrivi in quel file download_strategy_update_download_link=Aggiorna il download esistente download_strategy_update_download_link_description=Aggiornare il link di download esistente e le sue credenziali download_strategy_show_downloaded_file=Visualizza file scaricato download_strategy_show_downloaded_file_description=Visualizza l'elemento download già esistente, così puoi scegliere se riprenderlo o aprirlo batch_download_link_help=Inserisci un collegamento che contiene caratteri jolly (usa *) invalid_url=URL non valida list_is_too_large_maximum_n_items_allowed=L''elenco è troppo grande - consentiti massimo {{count}} elementi enter_range=Inserisci intervallo range_from=Da range_to=A batch_download_wildcard_length=Lunghezza carattere jolly first_link=Primo collegamento last_link=Ultimo collegamento open_source_software_used_in_this_app=Software open source usato in questa app links=Collegamento website=Sito web developers=Sviluppatori source_code=Codice sorgente license=Licenza no_license_found=Nessuna licenza trovata organization=Organizzazione add_new_queue=Aggiungi nuova coda queue_name=Nome coda queues=Code stop_queue=Ferma coda start_queue=Avvia coda clear_queue_items=Svuota Coda config=Configurazione items=Elementi move_down=Sposta giù move_up=Sposta su remove_queue=Rimuovi coda queue_name_help=Specifica un nome per questa coda queue_name_describe=Il nome della coda è {{value}} queue_max_concurrent_download=Download simultanei massimi queue_max_concurrent_download_description=Download max per questa coda queue_automatic_stop=Interruzione automatica queue_automatic_stop_description=Interrompi automaticamente la coda quando non ci sono elementi queue_scheduler=Pianificazione queue_enable_scheduler=Abilita pianificazione queue_active_days=Giorni pianificazione queue_active_days_description=In quali giorni è attiva la pianificazione? queue_scheduler_enable_auto_start_time=Abilita Ora Di Avvio Automatico queue_scheduler_auto_start_time=Orario avvio automatico queue_scheduler_enable_auto_stop_time=Abilita orario arresto automatico queue_scheduler_auto_stop_time=Orario arresto automatico queue_shutdown_on_completion=Arresto del sistema al completamento queue_shutdown_on_completion_description=Spegni automaticamente il sistema quando questa coda è completata o quando l'ora di fine pianificata è raggiunta. appearance=Aspetto download_engine=Motore download browser_integration=Integrazione browser settings_download_max_retries_count=N. max ritentativi settings_download_max_retries_count_description=Numero massimo di volte che l'app riproverà un download non riuscito prima di arrendersi settings_download_max_retries_count_describe_no_retries=Non verranno effettuati ritentativi per i download non riusciti settings_download_max_retries_count_describe_n_retries=Per i download falliti verranno effettuati {{count}} ritentativi settings_download_thread_count=Numero thread settings_download_thread_count_description=Numero max thread per ogni elemento download settings_download_thread_count_describe=Un download può avere fino a {{count}} thread settings_download_thread_count_with_large_value_describe=Avviso\: l'impostazione di un numero elevato di thread può aumentare l'uso delle risorse di sistema, ridurre le prestazioni o causare problemi di connessione con i server. \nUsa valori più alti solo se comprendi il potenziale impatto sul sistema e sulla rete. settings_use_server_last_modified_time=Usa l'ora dell'ultima modifica nel server settings_use_server_last_modified_time_description=Quando scarichi un file, usa per il file locale l'orario dell'ultima modifica nel server settings_append_extension_to_incomplete_downloads=Aggiungi Estensione al Download Incompleto settings_append_extension_to_incomplete_downloads_description=Aggiunge l'estensione ".part" ai download incompleti. Questo aiuta a identificare i download incompleti e impedisce l'apertura accidentale di file incompleti. settings_use_sparse_file_allocation=Allocazione segmenti file settings_use_sparse_file_allocation_description=Crea file in modo più efficiente, specialmente su SSD, riducendo la scrittura di dati non necessari.\nQuesto può accelerare l'avvio dei download e ridurre l'uso del disco.\nSe i download iniziano lentamente o riscontri velocità di download insolite, considera di disabilitare questa opzione, poiché potrebbe non essere completamente supportata in alcuni dispositivi. settings_ignore_ssl_certificates=Ignora certificati SSL settings_ignore_ssl_certificates_description=Disabilita la verifica del certificato SSL. \nUsa questa opzione solo se necessario, in quanto può esporre la connessione ai rischi per la sicurezza. settings_global_speed_limiter=Limitatore velocità globale settings_global_speed_limiter_description=Limite la velocità di download globale (0 significa illimitata) settings_show_average_speed=Visualizza velocità media settings_show_average_speed_description=Velocità download media o precisa settings_use_category_by_default=Usa la categoria per impostazione predefinita settings_use_category_by_default_description=Quando si aggiunge un download usa la categoria per impostazione predefinita. settings_default_download_folder=Cartella download predefinita settings_default_download_folder_description=Quando aggiungi un nuovo download, per impostazione predefinita è usato questa percorso settings_default_download_folder_describe=Sara usata "{{folder}}" settings_use_proxy=Usa proxy settings_use_proxy_description=Per scaricare i file usa un proxy settings_use_proxy_describe_no_proxy=Non verrà usato alcun proxy settings_use_proxy_describe_system_proxy=Verrà usato il proxy di sistema settings_use_proxy_describe_manual_proxy=Sarà usato "{{value}}" settings_use_proxy_describe_pac_proxy=verrà usato il file pac "{{value}}" settings_track_deleted_files_on_disk=Tieni traccia dei file eliminati dal disco settings_track_deleted_files_on_disk_description=Rimuovi automaticamente i file dall'elenco quando vengono eliminati o spostati nella cartella download. settings_delete_partial_file_on_download_cancellation=Elimina il file parziale all'annullamento del download settings_delete_partial_file_on_download_cancellation_description=Quando un download viene annullato, il file parzialmente scaricato verrà eliminato dal disco. Questo aiuta a mantenere pulita la cartella di download e riduce l'utilizzo non necessario dello spazio su disco. Tuttavia, il download ricomincerà dall'inizio al successivo avvio. settings_default_user_agent=User agent predefinito settings_default_user_agent_description=Specificare la stringa dello user agent predefinito per definire come identificare le richieste ai server. \nCiò può aiutare ad accedere ai contenuti ottimizzati per dispositivi particolari o ad eludere le limitazioni di download imposte da alcuni siti web. settings_download_size_unit=Unità velocità download settings_download_size_unit_description=Unità usata per visualizzare la velocità di download settings_download_speed_unit=Unità velocità download settings_download_speed_unit_description=Unità usata per visualizzare la velocità di download settings_theme=Tema settings_theme_description=Seleziona un tema per l'app settings_default_dark_theme=Tema Scuro Predefinito settings_default_dark_theme_description=Si applica quando l'applicazione segue il tema di sistema e la modalità scura è attiva settings_default_light_theme=Tema Chiaro Predefinito settings_default_light_theme_description=Si applica quando l'applicazione segue il tema di sistema e la modalità chiara è attiva settings_font=Tipo di carattere settings_font_description=Cambia il carattere usato nell'interfaccia dell'app, alcuni caratteri potrebbero non essere visualizzati correttamente nell'app. settings_ui_scale=Scala UI settings_ui_scale_description=Regola la dimensione degli elementi dell'interfaccia dell'app settings_language=Lingua UI settings_compact_top_bar=Barra superiore compatta settings_compact_top_bar_description=Quando la finestra principale ha abbastanza larghezza unisci la barra superiore con la barra del titolo settings_use_native_menu_bar=Usa la Barra dei Menu Nativa settings_use_native_menu_bar_description=Utilizzare lo stile predefinito della barra dei menu del sistema settings_use_relative_date_time=Usa data/ora relativa settings_use_relative_date_time_description=Usa il formato relativo di data/ora per le date nell'applicazione (ad esempio, "2 giorni fa" invece della data/ora esatta) settings_show_icon_labels=Visualizza etichette icone settings_show_icon_labels_description=Visualizza etichette nelle icone quando possibile (come le azioni della barra strumenti home) settings_use_system_tray=Usa icona barra sistema settings_use_system_tray_description=Quando l'app è in esecuzione visualizza l'icona nella barra sistema settings_start_on_boot=Esegui ad avvio sistema settings_start_on_boot_description=Esegue automaticamente l'applicazione all'avvio del sistema settings_notification_sound=Suono notifica settings_notification_sound_description=Riproduci suono per una nuova notifica settings_browser_integration=Integrazione browser settings_browser_integration_description=Accetta download dai browser settings_browser_integration_server_port=Porta server settings_browser_integration_server_port_description=Porta integrazione browser settings_browser_integration_server_port_describe=L''app rimarrà in ascolto sulla porta {{port}} settings_dynamic_part_creation=Creazione dinamica blocchi settings_dynamic_part_creation_description=Per aumentare la velocità in download quando un blocco è completato, crea un'altro blocco dividendo gli altri blocchi settings_show_completion_dialog=Visualizza finestra "Download completato" settings_show_completion_dialog_description=A download completato visualizza automaticamente la finestra "Download completato". settings_show_download_progress_dialog=Visualizza finestra "Avanzamento download" settings_show_download_progress_dialog_description=All'avvio di un download visualizza automaticamente la finestra "Avanzamento download". settings_per_host_settings=Impostazioni Per Host settings_per_host_settings_descriptions=Queste impostazioni verranno applicate automaticamente a qualsiasi nuovo download che corrisponda all'host specificato. settings_download_max_concurrent_downloads=Numero massimo di Download simultanei settings_download_max_concurrent_downloads_description=Il numero massimo di file che possono essere scaricati contemporaneamente (i download gestiti dalle code non sono conteggiati; imposta a 0 per illimitati) download_item_settings_speed_limit=Limite di velocità download_item_settings_speed_limit_description=Limita la velocità in download per questo elemento download_item_settings_show_download_completion_dialog=Visualizza finestra "Download completato" download_item_settings_show_download_completion_dialog_description=A download completato visualizza automaticamente la finestra "Download completato". download_item_settings_shutdown_on_completion=Arresto del sistema al completamento download_item_settings_shutdown_on_completion_description=Spegni automaticamente il sistema quando il download è terminato. download_item_settings_thread_count=Numero di thread download_item_settings_thread_count_description=N. thread usati per scaricare questo elemento (0 \= predefinito) download_item_settings_thread_count_describe={{count}} thread per questo download download_item_settings_username_description=Se il collegamento è una risorsa protetta inserisci il nome utente download_item_settings_password_description=Se il collegamento è una risorsa protetta inserisci la password download_item_settings_download_page=Pagina web download download_item_settings_download_page_description=La pagina web da cui è stato avviato questo download download_item_settings_file_checksum=Checksum file download_item_settings_file_checksum_description=Una stringa hash che può essere usata per verificare se il file è stato scaricato correttamente download_item_settings_user_agent=User Agent download_item_settings_user_agent_description=User-Agent personalizzato per questo elemento (lasciare vuoto per usare il predefinito) file_checksum=Checksum file file_checksum_page=Controllo checksum file file_checksum_page_file_checksum_default_algorithm=Algoritmo predefinito file_checksum_page_file_checksum_default_algorithm_help=L'algoritmo predefinito usato per calcolare i checksum dei file quando non vengono forniti. start=Avvia calculated_checksum=Checksum calcolato saved_checksum=Checksum salvato checksum_algorithm=Algoritmo file_not_found=File non trovato download_not_finished=Download non completato done=Completato waiting=Attendi matches=Corrispondenze not_matches=Non corrispondenze copy_to_clipboard=Copia negli appunti username=Nome utente password=Password average_speed=Velocità media exact_speed=Velocità esatta unlimited=Illimitata use_global_settings=Usa impostazioni globali cant_run_browser_integration=Impossibile eseguire l'integrazione nel browser cant_open_file=Impossibile aprire il file cant_open_folder=Impossibile aprire la cartella # times for example 2 seconds ago relative_time_long_years={{years}} anni relative_time_long_months={{months}} mesi relative_time_long_days={{days}} giorni relative_time_long_hours={{hours}} ore relative_time_long_minutes={{minutes}} minuti relative_time_long_seconds={{seconds}} secondi relative_time_short_years={{years}} a relative_time_short_months={{months}} m relative_time_short_days={{days}} g relative_time_short_hours={{hours}} h relative_time_short_minutes={{minutes}} min relative_time_short_seconds={{seconds}} sec relative_time_left={{time}} rimanente relative_time_ago={{time}} fa auto=Auto unspecified=Non specificato custom=Personalizzato icon=Icona author=Autore link=Collegamento size=Dimensione status=Stato parts_info_downloaded_size=Scaricato parts_info_total_size=Totale speed=Velocità time_left=Tempo rimanente date_added=Data aggiunta info=Info download_page_downloaded_size=Scaricato download_page_download_completed=Download completato resume_support=Supporta ripresa yes=Sì no=No parts_info=Informazioni sui blocchi disconnected=Disconnesso receiving_data=Ricezione dati connecting=Connessione warning=Avviso unsupported_resume_warning=Questo download non supporta la ripresa\!\nPotresti dover RIAVVIARLO più tardi nell'elenco download stop_anyway=Ferma comunque customize_columns=Personalizza colonne reset=Ripristina monday=Lunedì tuesday=Martedì wednesday=Mercoledì thursday=Giovedì friday=Venerdì saturday=Sabato sunday=Domenica proxy_open_system_proxy_settings=Apri impostazioni proxy sistema proxy_type=Tipo di proxy proxy_do_not_use_proxy_for=Non usare proxy per proxy_do_not_use_proxy_for_description=Un elenco di URL che potrebbero non supportare il proxy\nPuoi usare caratteri jolly con *\nper esempio 192.168.1.* example.com (separati da spazi) proxy_change_title=Modifica proxy change_proxy=Modifica proxy proxy_no=Nessun proxy proxy_system=Proxy di sistema proxy_manual=Proxy manuale proxy_pac=Configurazione automatica proxy proxy_pac_url=URL configurazione automatica proxy address=Indirizzo port=Porta address_and_port=Indirizzo e porta use_authentication=Usa autenticazione warning_you_may_have_to_restart_the_download_later=Potresti dover riavviare il download più tardi\! edit_download_title=Modifica download edit_download_update_from_download_page=Aggiorna dalla pagina di download edit_download_update_from_download_page_description=Quando questa finestra è aperta, puoi andare alla pagina di download e selezionare il pulsante di download.\nL'app catturerà e aggiornerà le nuove credenziali di download in modo da poterle salvare. edit_download_saved_download_item_size_not_match=L''elemento di download salvato ha una dimensione di {{currentSize}}, che non corrisponde alla nuova dimensione di {{newSize}}. translators_page_thanks=Con gratitudine a coloro che ci hanno aiutato a tradurre questo progetto ❤️ translators=Traduttori language=Lingua translators_contribute_title=Migliora le traduzioni translators_contribute_description=Vuoi aiutare a migliorare questo progetto?\nSe una lingua non è elencata o ha bisogno di alcune modifiche, puoi contribuire e renderla migliore con le tue traduzioni\! contribute=Contribuisci meet_the_translators=Incontra i traduttori localized_by_translators=Localizzato dai traduttori confirm_exit=Conferma uscita confirm_exit_description=Vuoi uscire da AB Download Manager?\nI download/code attivi verranno interrotti\! update=Aggiorna update_updater=Aggiornamento update_available=Aggiornamento disponibile update_error=Errore durante l'aggiornamento update_available_suggest_to_to_update=Puoi aggiornare alla versione più recente per usufruire di nuove funzionalità, miglioramenti e miglioramenti delle prestazioni. update_release_notes=Note sulla versione update_check_for_update=Controlla aggiornamenti update_checking_for_update=Controllo aggiornamenti update_no_update=Questa versione è aggiornata update_check_error=Errore durante il controllo degli aggiornamenti update_app_updated_to_version_n=App aggiornata lla versione {{version}} create_desktop_entry=Crea Elemento Desktop shutdown_alert=Avviso Di Arresto system_shutdown_soon=Il sistema si spegnerà presto\! system_shutdown_failed=Arresto del sistema non riuscito\! system_shutdown_soon_description=Il sistema si spegnerà presto. Se stai ancora usando il computer, per favore salva il tuo lavoro o annulla l'arresto. system_shutdown_reason_queue_completed=Tutti i download nella coda sono stati completati. system_shutdown_reason_queue_end_time_reached=Ora di fine pianificata per la coda di download raggiunta. system_shutdown_download_finished=Download completato. shutdown_now=Spegni Ora settings_per_host_settings_new_host= settings_per_host_settings_not_selected=Crea o seleziona un nuovo elemento\! settings_per_host_settings_host=Host settings_per_host_settings_host_description=Queste impostazioni verranno applicate ai download che corrispondono a questo nome host. I caratteri jolly (*) sono supportati (ad esempio, example.com, *.example.com — utilizzarne solo uno). settings_browser_in_launcher=Icona del browser nel Launcher settings_browser_in_launcher_description=Mostra o nasconde l'icona del browser nel launcher (elenco app). sort_by=Ordina per welcome=Benvenuto new_folder=Nuova cartella skip=Salta lets_go=Si comincia next=Avanti select_all=Seleziona Tutto select_inside=Seleziona Dentro select_invert=Seleziona Inverti open_settings=Apri Impostazioni back=Indietro service_is_running=Il servizio è in esecuzione initial_setup_description= initial_setup_notice=Puoi modificare queste impostazioni in qualsiasi momento permission_granted=Autorizzazione concessa permission_not_granted=Autorizzazione non concessa permissions=Autorizzazioni give_permission=Consenti autorizzazioni give_storage_permission=Consenti l'accesso allo spazio di archiviazione storage_roots=Storage Roots permissions_initial_title=Configurazione autorizzazioni permissions_initial_description=Per funzionare correttamente, l'app necessita di alcuni permessi. Nella schermata successiva, vedrai per cosa viene utilizzato ogni permesso e potrai decidere quali permessi permettere o saltare. permissions_done_title=È tutto pronto permissions_done_description=Tutto è pronto. Tutti i permessi richiesti sono stati concessi e l'app è pronta per l'uso. permissions_manage_storage_title=Gestisci l'accesso all'archivio permissions_manage_storage_reason=Questa autorizzazione consente all'app di cambiare la cartella di download, rilevare i download duplicati in modo più accurato e abilitare alcune funzionalità aggiuntive. È opzionale, ma consigliata per la migliore esperienza. permission_read_write_external_storage_title=Lettura e scrittura memoria permission_read_write_external_storage_reason=Questa autorizzazione consente all'app di salvare e gestire i file scaricati, modificare la posizione di download e migliorare il rilevamento dei download duplicati. permissions_post_notification_title=Pubblica una notifica permissions_post_notification_reason=L'applicazione deve essere eseguita in background per gestire i download. Le notifiche vengono utilizzate per tenerti informato e consentire operazioni in background. permissions_ignore_battery_optimization_title=Ignora Ottimizzazioni Batteria permissions_ignore_battery_optimization_reason=Alcuni dispositivi limitano aggressivamente l'attività in background per risparmiare batteria, che può mettere in pausa o fermare i download quando l'applicazione non è aperta. È possibile escludere l'app dall'ottimizzazione della batteria per garantire che i download continuino ininterrotti open_in_browser=Apri nel browser browser=Browser browser_new_tab=Nuova Scheda browser_close_tab=Chiudi scheda browser_open_in_new_tab=Apri in una nuova scheda browser_open_in_new_background_tab=Apri In Nuova Scheda In Secondo Piano browser_no_tab_open=Nessuna scheda aperta browser_tabs=Schede browser_paste_and_go=Incolla e vai browser_bookmarks=Segnalibri browser_add_bookmark=Aggiungi segnalibro browser_edit_bookmark=Modifica Segnalibro browser_add_to_bookmarks=Aggiungi ai segnalibri browser_remove_from_bookmarks=Rimuovi dai Segnalibri ================================================ FILE: shared/resources/src/commonMain/resources/com/abdownloadmanager/resources/locales/ja_JP.properties ================================================ app_title=AB Download Manager confirm_auto_categorize_downloads_title=ダウンロードの自動分類 confirm_auto_categorize_downloads_description=カテゴリ未設定の項目は、自動的に関連するカテゴリに追加されます。 confirm_reset_to_default_categories_title=既定のカテゴリにリセット confirm_reset_to_default_categories_description=すべてのカテゴリを削除し、既定のカテゴリを復元します\! confirm_delete_download_items_title=削除の確認 confirm_delete_download_items_description={{count}} 個の項目を削除しますか? confirm_delete_download_unfinished_items_description={{count}} 個の未完了のダウンロードを削除してもよろしいですか? confirm_delete_download_finished_and_unfinished_items_description={{finishedCount}} 個の完了と {{unfinishedCount}} 個の未完了のダウンロードを削除してもよろしいですか? also_delete_file_from_disk=ディスク上のファイルも削除 confirm_delete_category_item_title={{name}} カテゴリを削除 confirm_delete_category_item_description=カテゴリ "{{value}}" を削除しますか? your_download_will_not_be_deleted=ダウンロードは削除されません drag_the_file_to_another_app=ファイルを別のアプリにドラッグ drop_link_or_file_here=ここにリンクまたはファイルをドロップします。 nothing_will_be_imported=インポートされるものはありません n_links_will_be_imported={{count}} 個のリンクをインポートします n_items_selected={{count}} 個の項目を選択 window_close=閉じる window_minimize=最小化 window_maximize=最大化 window_restore=復元 delete=削除 remove=消去 cancel=キャンセル close=閉じる menu=メニュー more_options=その他のオプション ok=はい add=追加 paste=貼り付け change=変更 edit=編集 change_anyway=変更を適用 download=ダウンロード refresh=更新 settings=設定 on_completion=完了時 unknown=不明 unknown_error=不明なエラー download_item_not_found=ダウンロード項目が見つかりません name=名前 download_link=ダウンロード リンク not_finished=未完了 all=全て finished=完了 Unfinished=未完了 canceled=キャンセル error=エラー paused=一時停止中 downloading=ダウンロード中 added=追加済み idle=待機中 preparing_file=ファイルの準備中 creating_file=ファイルの作成中 resuming=再開中 retrying=再試行中 list_is_empty=リストは空です\! search_in_the_list=リスト内を検索 search=検索 clear=クリア general=全般 enabled=有効 disabled=無効 default=既定 file=ファイル tasks=タスク tools=ツール help=ヘルプ system=システム all_missing_files=すべての不足しているファイル all_finished=すべて完了 all_unfinished=すべての未完了 entire_list=リスト全体 download_browser_integration=ブラウザー統合をダウンロード exit=終了 show_downloads=ダウンロードを表示 new_download=新しいダウンロード stop_all=すべて停止 import_from_clipboard=クリップボードから取り込み batch_download=一括ダウンロード open=開く share=共有 open_file=ファイルを開く open_folder=フォルダーを開く resume=再開 pause=一時停止 restart_download=ダウンロードを最初からやり直す copy=コピー copy_link=リンクをコピー copy_as_curl=cURL としてコピー show_properties=プロパティを表示 move_to_queue=キューへ移動 move_to_this_queue=このキューへ移動 move_to_category=カテゴリへ移動 move_to_this_category=このカテゴリへ移動 categories=カテゴリ add_category=カテゴリを追加 edit_category=カテゴリの編集 delete_category=カテゴリの削除 category_name=カテゴリ名 category_download_location=カテゴリの保存先 category_download_location_description="ダウンロードの追加" でこのカテゴリを選択した場合、このディレクトリを "保存先" として使用します category_file_types=カテゴリのファイルの種類 category_file_types_description=新しいダウンロードを追加するとき、これらのファイルの種類は自動的にこのカテゴリに入ります。\n拡張子はスペースで区切って指定します(ext1 ext2 ...)。 category_url_patterns=URL パターン category_url_patterns_description=新しいダウンロードを追加するとき、これらの URL からのダウンロードは自動的にこのカテゴリに入ります。\nURL はスペースで区切って指定できます。ワイルドカードとして * を使用できます。 auto_categorize_downloads=自動でカテゴリ分け restore_defaults=既定に戻す about=概要 version_n=バージョン {{value}} developed_with_love_for_you=開発者\: ❤️ donate=寄付 visit_the_project_website=プロジェクトのウェブサイトを開く this_is_a_free_and_open_source_software=これは無料のオープン ソース ソフトウェアです view_the_source_code=ソース コードを見る third_party_libraries=サードパーティ ライブラリ powered_by_open_source_software=オープン ソース ソフトウェアを利用しています view_the_open_source_licenses=オープン ソース ライセンスを表示 support_and_community=サポートとコミュニティ telegram=Telegram channel=チャンネル group=グループ add_download=ダウンロードを追加 add_multi_download_page_header=ダウンロードする項目を選択してください save_to=保存先 where_should_each_item_saved=各項目の保存先を指定しますか? there_are_multiple_items_please_select_a_way_you_want_to_save_them=複数の項目があります。保存方法を選択してください。 each_item_on_its_own_category=項目ごとにカテゴリに分ける each_item_on_its_own_category_description=各項目は、そのファイルの種類に対応するカテゴリに分類されます。 all_items_in_one_category=すべてを 1 つのカテゴリに保存 all_items_in_one_category_description=すべてのファイルを選択したカテゴリに保存します。 all_items_in_one_Location=すべてを 1 つの場所に保存 all_items_in_one_Location_description=すべての項目を選択したディレクトリに保存します。 unselected_all_items_in_specific_location_description=すべてのファイルを選択したカテゴリの保存先に保存します。 no_category_selected=カテゴリが選択されていません no_categories_found=カテゴリが見つかりません download_location=保存先 location=場所 select_queue=キューを選択 without_queue=キューがありません use_category=カテゴリを使用 cant_write_to_this_folder=このフォルダーに書き込めません file_name_already_exists=同じ名前のファイルが既にあります download_already_exists=同じダウンロードが既にあります invalid_file_name=無効なファイル名 show_solutions=解決策を表示... change_solution=解決策を変更 select_a_solution=解決策を選択 select_download_strategy_description=指定したリンクは既にダウンロード一覧にあります。行う操作を選択してください。 download_strategy_add_a_numbered_file=番号付きファイルを追加 download_strategy_add_a_numbered_file_description=ファイル名の末尾に連番を付けて追加します。 download_strategy_override_existing_file=既存のファイルを上書き download_strategy_override_existing_file_description=既存のダウンロードを削除し、そのファイルに書き込みます。 download_strategy_update_download_link=既存のダウンロードを更新 download_strategy_update_download_link_description=既存のダウンロード リンクと資格情報を更新します。 download_strategy_show_downloaded_file=既存の項目を表示 download_strategy_show_downloaded_file_description=既存のダウンロード項目を表示します。再開や開く操作ができます。 batch_download_link_help=ワイルドカード(*)を使用したリンクを入力します。 invalid_url=無効な URL list_is_too_large_maximum_n_items_allowed=リストが大きすぎます。最大 {{count}} 件までです enter_range=範囲を入力 range_from=開始 range_to=終了 batch_download_wildcard_length=ワイルドカードの長さ first_link=最初のリンク last_link=最後のリンク open_source_software_used_in_this_app=このアプリで使用されているオープン ソース ソフトウェア links=リンク website=Web サイト developers=開発者 source_code=ソース コード license=ライセンス no_license_found=ライセンスが見つかりません organization=組織 add_new_queue=新しいキューを追加 queue_name=キュー名 queues=キュー stop_queue=キューを停止 start_queue=キューを開始 clear_queue_items=キューを空にする config=設定 items=項目 move_down=下へ移動 move_up=上へ移動 remove_queue=キューを削除 queue_name_help=このキューの名前を指定します queue_name_describe=キュー名\: {{value}} queue_max_concurrent_download=最大同時ダウンロード数 queue_max_concurrent_download_description=このキューで同時に実行できるダウンロードの最大数です。 queue_automatic_stop=自動停止 queue_automatic_stop_description=キューに項目がない場合、このキューを自動的に停止します。 queue_scheduler=スケジューラ queue_enable_scheduler=スケジューラを有効にする queue_active_days=稼働日 queue_active_days_description=スケジューラを実行する曜日を選択します。 queue_scheduler_enable_auto_start_time=自動開始時刻を有効にする queue_scheduler_auto_start_time=自動開始時刻 queue_scheduler_enable_auto_stop_time=自動停止時刻を有効にする queue_scheduler_auto_stop_time=自動停止時刻 queue_shutdown_on_completion=完了時にシステムをシャットダウン queue_shutdown_on_completion_description=このキューが完了したとき、またはスケジュールの終了時刻に達したときにシステムを自動的にシャットダウンします。 appearance=外観 download_engine=ダウンロード エンジン browser_integration=ブラウザー統合 settings_download_max_retries_count=最大再試行回数 settings_download_max_retries_count_description=失敗したダウンロードを中止するまでに再試行する最大回数です。 settings_download_max_retries_count_describe_no_retries=失敗したダウンロードは再試行しません settings_download_max_retries_count_describe_n_retries=失敗したダウンロードは {{count}} 回再試行します settings_download_thread_count=スレッド数 settings_download_thread_count_description=各ダウンロード項目で使用する最大スレッド数です。 settings_download_thread_count_describe=ダウンロードは最大 {{count}} スレッドまで使用できます settings_download_thread_count_with_large_value_describe=警告\: スレッド数を高く設定すると、システム リソースの使用量が増え、性能が低下したり、サーバーとの接続に問題が発生したりする場合があります。システムやネットワークへの影響を理解している場合のみ、高い値を使用してください。 settings_use_server_last_modified_time=サーバーの最終更新日時を使用 settings_use_server_last_modified_time_description=ファイルをダウンロードするとき、ローカル ファイルの日時にサーバーの最終更新日時を使用します。 settings_append_extension_to_incomplete_downloads=未完了のダウンロードに拡張子を付ける settings_append_extension_to_incomplete_downloads_description=完了のダウンロードに ".part" 拡張子を付けます。未完了のダウンロードを識別しやすくし、誤って開いてしまうのを防ぎます。 settings_use_sparse_file_allocation=スパース ファイル割り当て settings_use_sparse_file_allocation_description=不要な書き込みを減らしてファイルを効率的に作成します(特に SSD で効果があります)。これによりダウンロード開始が速くなり、ディスク使用量も抑えられます。ダウンロードの開始が遅い場合や、速度が不安定な場合は、このオプションを無効にしてください。一部のデバイスでは完全にサポートされない場合があります。 settings_ignore_ssl_certificates=SSL 証明書を無視 settings_ignore_ssl_certificates_description=SSL 証明書の検証を無効にします。必要な場合にのみ使用してください。接続がセキュリティ リスクにさらされる可能性があります。 settings_global_speed_limiter=グローバル速度制限 settings_global_speed_limiter_description=全体のダウンロード速度の上限(0 は無制限) settings_show_average_speed=平均速度を表示 settings_show_average_speed_description=ダウンロード速度を平均または高精度で表示します。 settings_use_category_by_default=既定でカテゴリを使用 settings_use_category_by_default_description=ダウンロードを追加するとき、既定でカテゴリを使用します。 settings_default_download_folder=既定のダウンロード フォルダー settings_default_download_folder_description=新しいダウンロードを追加するとき、既定でこの場所を使用します。 settings_default_download_folder_describe="{{folder}}" を使用します settings_use_proxy=プロキシを使用 settings_use_proxy_description=ファイルのダウンロードにプロキシを使用します。 settings_use_proxy_describe_no_proxy=プロキシは使用しません settings_use_proxy_describe_system_proxy=システムのプロキシを使用します settings_use_proxy_describe_manual_proxy="{{value}}" を使用します settings_use_proxy_describe_pac_proxy=PAC ファイル "{{value}}" を使用します settings_track_deleted_files_on_disk=ディスク上の削除を追跡 settings_track_deleted_files_on_disk_description=ダウンロード ディレクトリからファイルが削除または移動された場合、一覧から自動的に削除します。 settings_delete_partial_file_on_download_cancellation=キャンセル時に部分ファイルを削除 settings_delete_partial_file_on_download_cancellation_description=ダウンロードをキャンセルしたとき、途中までダウンロードされたファイルをディスクから削除します。これによりダウンロード フォルダーをきれいに保ち、不要なディスク使用量を減らせます。ただし、次回開始するとダウンロードは最初からやり直されます。 settings_default_user_agent=既定の User-Agent settings_default_user_agent_description=既定の User-Agent 文字列を指定し、要求がサーバーにどう識別されるかを決めます。特定デバイス向けのコンテンツにアクセスしたり、一部サイトの制限を回避したりするのに役立つ場合があります。 settings_download_size_unit=ダウンロード サイズの単位 settings_download_size_unit_description=ダウンロード サイズの表示に使用する単位 settings_download_speed_unit=ダウンロード速度の単位 settings_download_speed_unit_description=ダウンロード速度の表示に使用する単位 settings_theme=テーマ settings_theme_description=アプリのテーマを選択します。 settings_default_dark_theme=既定のダーク テーマ settings_default_dark_theme_description=アプリがシステム テーマに従い、ダーク モードが有効な場合に適用されます。 settings_default_light_theme=既定のライト テーマ settings_default_light_theme_description=アプリがシステム テーマに従い、ライト モードが有効な場合に適用されます。 settings_font=フォント settings_font_description=アプリのインターフェイスで使用するフォントを変更します。一部のフォントは正しく表示されない場合があります。 settings_ui_scale=UI スケール settings_ui_scale_description=アプリのインターフェイス要素のサイズを調整します。 settings_language=言語 settings_compact_top_bar=コンパクトなトップ バー settings_compact_top_bar_description=メイン ウィンドウの幅が十分にある場合、トップ バーをタイトル バーに統合します。 settings_use_native_menu_bar=ネイティブ メニュー バーを使用 settings_use_native_menu_bar_description=システム既定のメニュー バー スタイルを使用します。 settings_use_relative_date_time=相対的な日時を使用 settings_use_relative_date_time_description=アプリ内の日付を相対表示にします(例\: "2 日前" など)。 settings_show_icon_labels=アイコンのラベルを表示 settings_show_icon_labels_description=可能な場合、アイコンの下にラベルを表示します(ホームのツールバー操作など)。 settings_use_system_tray=システム トレイを使用 settings_use_system_tray_description=アプリの実行中にシステム トレイ アイコンを表示します。 settings_start_on_boot=システム起動時に開始 settings_start_on_boot_description=ユーザー ログイン時にアプリを自動起動します。 settings_notification_sound=通知音 settings_notification_sound_description=新しい通知で音を再生します。 settings_browser_integration=ブラウザー統合 settings_browser_integration_description=ブラウザーからのダウンロードを受け付けます。 settings_browser_integration_server_port=サーバー ポート settings_browser_integration_server_port_description=ブラウザー統合で使用するポートです。 settings_browser_integration_server_port_describe=アプリはポート {{port}} を監視します settings_dynamic_part_creation=動的分割パーツ生成 settings_dynamic_part_creation_description=パーツが完了したら、他のパーツを分割して新しいパーツを作成し、ダウンロード速度を改善します。 settings_show_completion_dialog=ダウンロード完了ダイアログを表示 settings_show_completion_dialog_description=ダウンロードが完了時に "ダウンロード完了" ダイアログを自動的に表示します。 settings_show_download_progress_dialog=ダウンロード進行状況ダイアログを表示 settings_show_download_progress_dialog_description=ダウンロード開始時に "ダウンロードの進行状況" ダイアログを自動的に表示します。 settings_per_host_settings=ホスト別設定 settings_per_host_settings_descriptions=指定したホストに一致する新しいダウンロードに、これらの設定が自動的に適用されます。 settings_download_max_concurrent_downloads=Maximum Concurrent Downloads settings_download_max_concurrent_downloads_description=The maximum number of files that can be downloaded at the same time (downloads managed by queues are not counted; set to 0 for unlimited) download_item_settings_speed_limit=速度制限 download_item_settings_speed_limit_description=この項目のダウンロード速度を制限します。 download_item_settings_show_download_completion_dialog=ダウンロード完了ダイアログを表示 download_item_settings_show_download_completion_dialog_description=このダウンロードが完了したときに "ダウンロード完了" ダイアログを自動的に表示します。 download_item_settings_shutdown_on_completion=完了時にシステムをシャットダウン download_item_settings_shutdown_on_completion_description=このダウンロードが完了したときにシステムを自動的にシャットダウンします。 download_item_settings_thread_count=スレッド数 download_item_settings_thread_count_description=この項目のダウンロードに使用するスレッド数(既定は 0) download_item_settings_thread_count_describe=このダウンロードに {{count}} スレッドを使用 download_item_settings_username_description=リンクが保護されたリソースの場合はユーザー名を入力します。 download_item_settings_password_description=リンクが保護されたリソースの場合はパスワードを入力します。 download_item_settings_download_page=ダウンロード ページ download_item_settings_download_page_description=このダウンロードを開始した Web ページです。 download_item_settings_file_checksum=ファイル チェックサム download_item_settings_file_checksum_description=ファイルが正しくダウンロードされたか確認するためのハッシュ文字列です。 download_item_settings_user_agent=User-Agent download_item_settings_user_agent_description=この項目のカスタム User-Agent (空欄の場合は既定を使用) file_checksum=ファイル チェックサム file_checksum_page=チェックサム チェッカー file_checksum_page_file_checksum_default_algorithm=既定のアルゴリズム file_checksum_page_file_checksum_default_algorithm_help=チェックサムが指定されていない場合に使用する、チェックサム計算の既定アルゴリズムです。 start=開始 calculated_checksum=計算されたチェックサム saved_checksum=保存されたチェックサム checksum_algorithm=アルゴリズム file_not_found=ファイルが見つかりません download_not_finished=ダウンロードが完了していません done=完了 waiting=待機中 matches=一致 not_matches=不一致 copy_to_clipboard=クリップボードにコピー username=ユーザー名 password=パスワード average_speed=平均速度 exact_speed=正確な速度 unlimited=無制限 use_global_settings=グローバル設定を使用 cant_run_browser_integration=ブラウザー統合を実行できません cant_open_file=ファイルを開けません cant_open_folder=フォルダーを開けません # times for example 2 seconds ago relative_time_long_years={{years}} 年 relative_time_long_months={{months}} か月 relative_time_long_days={{days}}日 relative_time_long_hours={{hours}} 時間 relative_time_long_minutes={{minutes}} 分 relative_time_long_seconds={{seconds}} 秒 relative_time_short_years={{years}} 年 relative_time_short_months={{months}} 月 relative_time_short_days={{days}} 日 relative_time_short_hours={{hours}} 時 relative_time_short_minutes={{minutes}} 分 relative_time_short_seconds={{seconds}} 秒 relative_time_left=残り {{time}} relative_time_ago={{time}} 前 auto=自動 unspecified=未指定 custom=カスタム icon=アイコン author=作成者 link=リンク size=サイズ status=状態 parts_info_downloaded_size=ダウンロード済み parts_info_total_size=合計 speed=速度 time_left=残り時間 date_added=追加日時 info=情報 download_page_downloaded_size=ダウンロード済み download_page_download_completed=ダウンロード完了 resume_support=サポートを再開 yes=はい no=いいえ parts_info=パーツ情報 disconnected=切断されました receiving_data=データ受信中 connecting=接続中 warning=警告 unsupported_resume_warning=このダウンロードは再開に対応していません。後でダウンロード一覧から最初からやり直す必要がある場合があります。 stop_anyway=このまま停止 customize_columns=列をカスタマイズ reset=リセット monday=月曜日 tuesday=火曜日 wednesday=水曜日 thursday=木曜日 friday=金曜日 saturday=土曜日 sunday=日曜日 proxy_open_system_proxy_settings=システムのプロキシ設定を開く proxy_type=プロキシの種類 proxy_do_not_use_proxy_for=次の URL ではプロキシを使用しない proxy_do_not_use_proxy_for_description=プロキシを使用しない URL の一覧です。\nワイルドカード(*)を使用できます。\n例\: 192.168.1.* example.com (スペース区切り) proxy_change_title=プロキシの変更 change_proxy=プロキシの変更 proxy_no=プロキシなし proxy_system=システム プロキシ proxy_manual=手動プロキシ proxy_pac=プロキシ自動構成 proxy_pac_url=プロキシ自動構成URL address=アドレス port=ポート address_and_port=アドレスとポート use_authentication=認証を使用 warning_you_may_have_to_restart_the_download_later=後でダウンロードを最初からやり直す必要がある場合があります\! edit_download_title=ダウンロードを編集 edit_download_update_from_download_page=ダウンロード ページから更新 edit_download_update_from_download_page_description=このウィンドウを開いている間にダウンロード ページへ移動し、ダウンロード ボタンをクリックできます。アプリが新しい資格情報を取得して更新するため、保存できます。 edit_download_saved_download_item_size_not_match=保存されたダウンロード項目のサイズは {{currentSize}} ですが、新しいサイズ {{newSize}} と一致しません。 translators_page_thanks=このプロジェクトの翻訳に協力してくれた皆さんに感謝します ❤️ translators=翻訳者 language=言語 translators_contribute_title=翻訳の改善に協力する translators_contribute_description=このプロジェクトの改善に協力しませんか? 言語が一覧にない場合や調整が必要な場合は、翻訳を投稿してより良くできます。 contribute=翻訳に貢献する meet_the_translators=翻訳者の紹介 localized_by_translators=翻訳\: 翻訳コミュニティ confirm_exit=終了の確認 confirm_exit_description=AB Download Manager を終了しますか?\n実行中のダウンロードやキューは停止します。 update=アップデート update_updater=アップデーター update_available=更新があります update_error=更新エラー update_available_suggest_to_to_update=最新バージョンに更新して、新機能、改善、性能向上を利用できます。 update_release_notes=リリース ノート update_check_for_update=更新を確認 update_checking_for_update=更新を確認中 update_no_update=最新バージョンを使用しています update_check_error=更新の確認中にエラーが発生しました update_app_updated_to_version_n=アプリをバージョン {{version}} に更新しました create_desktop_entry=デスクトップ エントリを作成 shutdown_alert=シャットダウンの警告 system_shutdown_soon=まもなくシステムがシャットダウンします\! system_shutdown_failed=システムのシャットダウンに失敗しました\! system_shutdown_soon_description=まもなくシステムがシャットダウンします。まだコンピューターを使用している場合は、作業を保存するか、シャットダウンをキャンセルしてください。 system_shutdown_reason_queue_completed=キュー内のすべてのダウンロードが完了しました。 system_shutdown_reason_queue_end_time_reached=ダウンロード キューのスケジュール終了時刻に達しました。 system_shutdown_download_finished=ダウンロードが完了しました。 shutdown_now=今すぐシャットダウン settings_per_host_settings_new_host=<新しいホスト> settings_per_host_settings_not_selected=最初に新しい項目を作成するか、既存の項目を選択してください。 settings_per_host_settings_host=ホスト settings_per_host_settings_host_description=このホスト名に一致するダウンロードに、これらの設定が適用されます。ワイルドカード(*)を使用できます(例\: example.com、*.example.com。どちらか 1 つのみ使用)。 settings_browser_in_launcher=ランチャーにブラウザー アイコンを表示 settings_browser_in_launcher_description=ランチャー(アプリ一覧)に表示されるブラウザー アイコンを表示または非表示にします。 sort_by=並べ替え welcome=ようこそ new_folder=新しいフォルダー skip=スキップ lets_go=はじめる next=次へ select_all=すべて選択 select_inside=内部を選択 select_invert=選択を反転 open_settings=設定を開く back=戻る service_is_running=サービスは実行中です initial_setup_description=初期設定を行いましょう initial_setup_notice=これらの設定は後からいつでも変更できます permission_granted=権限が許可されました permission_not_granted=権限が許可されていません permissions=権限 give_permission=権限を許可 give_storage_permission=ストレージへのアクセスを許可 storage_roots=Storage Roots permissions_initial_title=初期設定を行いましょう permissions_initial_description=アプリを正しく動作させるには、いくつかの権限が必要です。次の画面では、それぞれの権限の用途を確認し、許可するかスキップするかを選択できます。 permissions_done_title=設定が完了しました permissions_done_description=すべての準備が整いました。必要な権限はすべて許可され、アプリを利用できます。 permissions_manage_storage_title=ストレージ アクセスの管理 permissions_manage_storage_reason=この権限を許可すると、ダウンロード フォルダーの変更、重複ダウンロードのより正確な検出、追加機能の利用が可能になります。必須ではありませんが、快適に使うために推奨されます。 permission_read_write_external_storage_title=ストレージの読み取りと書き込み permission_read_write_external_storage_reason=この権限により、ダウンロードしたファイルの保存と管理、保存先の変更、重複ダウンロード検出の精度向上が可能になります。 permissions_post_notification_title=通知の送信 permissions_post_notification_reason=ダウンロードを管理するため、アプリはバックグラウンドで動作する必要があります。通知は状況をお知らせし、バックグラウンドでの動作を可能にします。 permissions_ignore_battery_optimization_title=バッテリー最適化を無視 permissions_ignore_battery_optimization_reason=一部の端末では、バッテリーを節約するためにバックグラウンドでの動作が厳しく制限され、アプリを開いていないとダウンロードが一時停止または停止することがあります。必要に応じて、このアプリをバッテリー最適化の対象外にすると、ダウンロードを中断せずに続行できます。 open_in_browser=ブラウザーで開く browser=ブラウザー browser_new_tab=新しいタブ browser_close_tab=タブを閉じる browser_open_in_new_tab=新しいタブで開く browser_open_in_new_background_tab=新しいバックグラウンド タブで開く browser_no_tab_open=開いているタブはありません browser_tabs=タブ browser_paste_and_go=貼り付けて移動 browser_bookmarks=ブックマーク browser_add_bookmark=ブックマークを追加 browser_edit_bookmark=ブックマークを編集 browser_add_to_bookmarks=ブックマークに追加 browser_remove_from_bookmarks=ブックマークから削除 ================================================ FILE: shared/resources/src/commonMain/resources/com/abdownloadmanager/resources/locales/ka_GE.properties ================================================ app_title=AB ჩამოტვირთვების მენეჯერი confirm_auto_categorize_downloads_title=ჩამოტვირთვების ავტომატური კატეგორიზება confirm_auto_categorize_downloads_description=ნებისმიერი უკატეგორიო ნივთი ავტომატურად იქნება დამატებული მასთან დაკავშირებულ კადეგორიაში. confirm_reset_to_default_categories_title=ქარხნული კატეგორიების დაბრუნება confirm_reset_to_default_categories_description=ეს მოაშორებს ყველა კატეგორიას და დააბრუნებს ქარხნულ კატეგორიებს\! confirm_delete_download_items_title=წაშლის დადასტურება confirm_delete_download_items_description=დარწმუნებული ხარ რომ გინდა წაშალო {{count}} ნივთი? confirm_delete_download_unfinished_items_description=დარწმუნებული ხარ რომ გინდა წაშალო {{count}} დაუსრულებელი ჩამოტვირთვა? confirm_delete_download_finished_and_unfinished_items_description=დარწმუნებული ხარ რომ გინდა წაშალო {{finishedCount}} დასრულებული და {{unfinishedCount}} დაუსრულებელი ჩამოტვირთვა? also_delete_file_from_disk=ასევე წაშალე ფაილი დისკიდან confirm_delete_category_item_title=ვშლი {{name}} კატეგორიას confirm_delete_category_item_description=დარწმუნებული ხარ რომ გინდა წაშალო "{{value}}" კატეგორია? your_download_will_not_be_deleted=შენი ჩამოტვირთვები არ წაიშლება drag_the_file_to_another_app=Drag the file to another app drop_link_or_file_here=ჩააგდე ლინკი ან ფაილი აქ. nothing_will_be_imported=არაფერი იქნება იმპორტირებული n_links_will_be_imported={{count}} ლინკი იქნება იმპორტირებული n_items_selected={{count}} ნივთი არჩეული window_close=Close window_minimize=Minimize window_maximize=Maximize window_restore=Restore delete=წაშლა remove=წაშლა cancel=გაუქმება close=დახურვა menu=Menu more_options=More Options ok=კარგი add=დამატება paste=Paste change=შეცვლა edit=შეცვლა change_anyway=მაინც შეცვლა download=ჩამოტვირთვა refresh=განახლება settings=პარამეტრები on_completion=On Completion unknown=უცნობი unknown_error=უცნობი შეცდომა download_item_not_found=ჩამოტვირთვის ნივთი არაა ნაპოვნი name=სახელი download_link=ჩამოტვირთე ლინკი not_finished=დაუსრულებელი all=ყველა finished=დასრულებული Unfinished=დაუსრულებელი canceled=გაუქმებული error=შეცდომა paused=დაპაუზებული downloading=იტვირთება added=დამატებული idle=მოლოდინში preparing_file=მზადდება ფაილი creating_file=იქმნება ფაილი resuming=ვაგრძელებ retrying=Retrying list_is_empty=სია ცარიელია\! search_in_the_list=მოძებნა სიაში search=ძიება clear=გასუფთავება general=ზოგადი enabled=ჩართული disabled=გამორთული default=Default file=ფაილი tasks=დავალებები tools=ხელსაწყოები help=დახმარება system=სისტემა all_missing_files=ყველა დაკარგული ფაილი all_finished=ყველა დასრულებული all_unfinished=ყველა დაუსრულებელი entire_list=მთლიანი სია download_browser_integration=ჩამოტვირთვა ბრაუზერის ინტეგრაციის exit=გასვლა show_downloads=ჩამოტვირთვების ჩვენება new_download=ახალი ჩამოტვირთვა stop_all=ყველას შეჩერება import_from_clipboard=იმპორტირება დაკოპირებულიდან batch_download=ჯგუფური ჩამოტვირთვა open=გახსნა share=Share open_file=ფაილის გახსნა open_folder=საქაღალდის გახსნა resume=გაგრძელება pause=შეჩერება restart_download=ჩამოტვირთვის თავიდან დაწყება copy=Copy copy_link=ბმულის კოპირება copy_as_curl=Copy as cURL show_properties=თვისებების ჩვენება move_to_queue=რიგში გადაყვანა move_to_this_queue=Move to this Queue move_to_category=კატეგორიაში გადაყვანა move_to_this_category=Move to this category categories=Categories add_category=კატეგორიის დამატება edit_category=კატეგორიის რედაქტირება delete_category=კატეგორიის წაშლა category_name=კატეგორიის სახელი category_download_location=კატეგორიის ჩამოტვირთვის ადგილი category_download_location_description=როდესაც ეს კატეგორია არჩეულია "ჩამოტვირთვის დამატებისას" მაშინ მოიხმარება ეს საქაღალდე როგორც "ჩამოტვირთვის ადგილი" category_file_types=კატეგორიის ფაილის სახეობები category_file_types_description=ავტომატურად ჩამატება ამ ფაილის სახეობების ამ კატეგორიაში (ახალი ჩამოტვირთვის დამატებისას)\nგანასხვავე ფაილის გაფართოებები სფეისით (ext1 ext2 ...) category_url_patterns=ლინკების ნიმუშები category_url_patterns_description=ავტომატურად ჩამატება გადმოწერების ამ ლინკებიდან ამ კატეგორიაში (ახალი ჩამოტვირთვის დამატებისას)\nგანასხვავეთ ლინკები სფეისით, ასევე შეგიძლიათ იხმაროთ * როგორც ზოგადი auto_categorize_downloads=ჩამოტვირთვების ავტომატური კატეგორიზება restore_defaults=ნაგულისხმევის აღდგენა about=შესახებ version_n=ვერსია {{value}} developed_with_love_for_you=შექმნილია ❤️-ით შენთვის donate=Donate visit_the_project_website=პროექტის საიტის ნახვა this_is_a_free_and_open_source_software=ეს არის უფასო და ღია კოდის წყაროს მქონე პროგრამა view_the_source_code=ნახვა კოდის წყაროს third_party_libraries=Third Party Libraries powered_by_open_source_software= view_the_open_source_licenses=ნახვა ღია კოდის წყაროს ლიცენზიების support_and_community=დახმარება და საზოგადოება telegram=ტელეგრამი channel=არხი group=ჯგუფი add_download=ჩამოტვირთვის დამატება add_multi_download_page_header=აირჩიე ნივთები რისი აღებაც გსურს ჩამოსატვირთად save_to=შენახვა where_should_each_item_saved=სად უნდა იყოს შენახული თითო ნივთი? there_are_multiple_items_please_select_a_way_you_want_to_save_them=აქ არის მრავალი ნივთი\! გთხოვთ აირჩიოთ გზა რითიც გინდათ რომ შეინახოთ ისინი each_item_on_its_own_category=თითო ნივთი თავის კატეგორიაში each_item_on_its_own_category_description=თითო ნივთი იქნება ჩასმული იმ კატეგორიაში რომელსაც ეგ ფაილის სახეობა აქვს all_items_in_one_category=ყველა ნივთი ერთ კატეგორიაში all_items_in_one_category_description=ყველა ფაილი შეინახება არჩეული კატეგორიის ადგილას all_items_in_one_Location=ყველა ნივთი ერთ ადგილში all_items_in_one_Location_description=ყველა ნივთი შეინახება არჩეულ საქაღალდეში unselected_all_items_in_specific_location_description=All files will be saved in the selected category location no_category_selected=კატეგორია არჩეული არარის no_categories_found=No Categories Found download_location=ჩამოტვირთვის ადგილი location=ადგილი select_queue=რიგის არჩევა without_queue=რიგის გარეშე use_category=მოიხმარე კატეგორია cant_write_to_this_folder=აღნიშნულ საქაღალდეში ჩაწერა ვერ ხერხდება file_name_already_exists=ფაილის სახელი უკვე არსებობს download_already_exists=Download already exists invalid_file_name=მიუღებელი ფაილის სახელი show_solutions=გამოსავლების ჩვენება... change_solution=გამოსავლის შეცვლა select_a_solution=გამოსავლის არჩევა select_download_strategy_description=ლინკი რომელიც მომაწოდე უკვე ჩამოტვირთვების სიაშია გთხოვთ მიუთითეთ რისი გაკეთება გსურთ download_strategy_add_a_numbered_file=ნომერიანი ფაილის დამატება download_strategy_add_a_numbered_file_description=ინდექსის დამატება ჩამოტვირთვის ფაილის სახელის ბოლოში download_strategy_override_existing_file=არსებულ ფაილზე გადაწერა download_strategy_override_existing_file_description=არსებული ჩამოტვირთვის მოშორება და მაგ ფაილში ჩაწერა download_strategy_update_download_link=Update existing download download_strategy_update_download_link_description=Update the existing download link and its credentials download_strategy_show_downloaded_file=ჩამოტვირთვული ფაილის ჩვენება download_strategy_show_downloaded_file_description=არსებული ჩამოტვირთვის ჩვენება, რადგან შეცძლო გაგრძელების დაჭერაზე ან გახსნა ის batch_download_link_help=შეიყვანეთ ლინკი რომელიც შეიცავს ზოგადებს (მოიხმარეთ *) invalid_url=მიუღებელი ლინკი list_is_too_large_maximum_n_items_allowed=სია ზედმეტად დიდია\! დაშვებულია არაუმეტეს {{count}} ნივთი enter_range=შეიყვანეთ დიაპაზონი range_from=დან range_to=მდე batch_download_wildcard_length=ზოგადის სიგრძე first_link=პირველი ლინკი last_link=ბოლო ლინკი open_source_software_used_in_this_app=ღია კოდის წყაროს მქონე პროგრამები ნახმარი ამ აპში links=ლინკები website=ვებსაიტი developers=დეველოპერები source_code=კოდის წყარო license=ლიცენზია no_license_found=არანაირი ლიცენზია არაა ნაპოვნი organization=ორგანიზაცია add_new_queue=ახალი რიგის დამატება queue_name=რიგის სახელი queues=რიგები stop_queue=რიგის შეჩერება start_queue=რიგის დაწყება clear_queue_items=Empty Queue config=კონფიგურაცია items=ნივთები move_down=ქვემოთ ჩამოტანა move_up=ზემოთ ატანა remove_queue=რიგის წაშლა queue_name_help=მიუთითეთ სახელი ამ რიგისთვის queue_name_describe=რიგის სახელია {{value}} queue_max_concurrent_download=მაქსიმუმი ერთდროული ჩამოტვირთვა queue_max_concurrent_download_description=მაქსიმუმი ერთდროული ჩამოტვირთვა ამ რიგისთვის queue_automatic_stop=ავტომატური შეჩერება queue_automatic_stop_description=რიგის ავტომატური შეჩერება როდესაც არაფერია შიგნით queue_scheduler=დამგეგმი queue_enable_scheduler=დამგეგმის ჩართვა queue_active_days=აქტიური დღეები queue_active_days_description=რომელ დღეებში მუშაობს დამგეგმი? queue_scheduler_enable_auto_start_time=Enable Auto Start Time queue_scheduler_auto_start_time=ავტომატური დაწყების დრო queue_scheduler_enable_auto_stop_time=ავტომატური დაწყების დროის ჩართვა queue_scheduler_auto_stop_time=ავტომატური შეჩერების დრო queue_shutdown_on_completion=Shutdown System On Completion queue_shutdown_on_completion_description=Automatically shutdown the system when this queue is completed. or when the scheduled end time is reached. appearance=შეხედულობა download_engine=ჩამოტვირთვების ძრავა browser_integration=ბრაუზერთან ინტეგრაცია settings_download_max_retries_count=Maximum Download Retries settings_download_max_retries_count_description=The maximum number of times the app will retry a failed download before giving up settings_download_max_retries_count_describe_no_retries=Failed downloads won't be retried settings_download_max_retries_count_describe_n_retries=Failed downloads will be retried {{count}} time(s) settings_download_thread_count=ძაფების რაოდენობა settings_download_thread_count_description=მაქსიმუმი ჩამოტვირთვის ძაფი თითო ჩამოტვირთვის ნივთისთვის settings_download_thread_count_describe=ჩამოტვირთვას შეიძლება ჰქონდეს {{count}} ძაფები settings_download_thread_count_with_large_value_describe=Warning\: Setting a high thread count may increase system resource usage, reduce performance, or cause connection issues with servers. Use higher values only if you understand the potential impact on your system and network. settings_use_server_last_modified_time=სერვერის "ბოლოს მოდიფიცირებული" დროის მოხმარება settings_use_server_last_modified_time_description=ფაილის ჩამოტვირთვისას, მოხმარება სერვერის ბოლოს მოდიფიცირებული დროის ადიგლობრივი ფაილისთვის settings_append_extension_to_incomplete_downloads=Append Extension To Incomplete Downloads settings_append_extension_to_incomplete_downloads_description=Append ".part" extension to incomplete downloads. This helps to identify unfinished downloads and prevents accidental opening of incomplete files. settings_use_sparse_file_allocation=Sparse File Allocation settings_use_sparse_file_allocation_description=ფაილების უფრო დამზოგველად შექმნა, განსაკუთრებით SSD-ებზე, არა აუცილებელი მონაცემთა ჩაწერის შემცირებით. ამას შეუძლია ჩამოტვირთვის დაწყების აჩქარება და დისკის მოხმარების დაკლება, გაითვალისწინეთ ამ პარამეტრის გამორთვა, რადგან შეიძლება ზოგ მოწყობილობაზე მთლიანად არ იყოს მხარდაჭერილი. settings_ignore_ssl_certificates=Ignore SSL Certificates settings_ignore_ssl_certificates_description=Disables SSL certificate verification. Use only if necessary, as it may expose your connection to security risks. settings_global_speed_limiter=გლობალური სიჩქარის ლიმიტი settings_global_speed_limiter_description=გლობალური ჩამოტვირთვის სიჩქარის ლიმიტი (0 ნიშვანს ულიმიტოს) settings_show_average_speed=საშუალო სიჩქარის ჩვენება settings_show_average_speed_description=ჩამოტვირთვის სიჩქარე საშუალო ან ზუსტი settings_use_category_by_default=Use Category By Default settings_use_category_by_default_description=Use category by default when adding a download. settings_default_download_folder=ნაგულისხმევი ჩამოტვირთვების საქაღალდე settings_default_download_folder_description=როდესაც დაამატებ ახალ ჩამოტვირთვას ეს საქაღალდეა მოხმარებული ნაგულისხმევად settings_default_download_folder_describe=მოიხმარება {{folder}} settings_use_proxy=პროქსის მოხმარება settings_use_proxy_description=პროქსის მოხმარება ფაილების გადმოწერისთვის settings_use_proxy_describe_no_proxy=არ მოიხმარება პროქსი settings_use_proxy_describe_system_proxy=მოიხმარება სისტემის პროქსი settings_use_proxy_describe_manual_proxy=მოიხმარება {{value}} settings_use_proxy_describe_pac_proxy=PAC file "{{value}}" will be used settings_track_deleted_files_on_disk=Track Deleted Files On Disk settings_track_deleted_files_on_disk_description=Automatically remove files from the list when they are deleted or moved from the download directory. settings_delete_partial_file_on_download_cancellation=Delete Partial File On Download Cancellation settings_delete_partial_file_on_download_cancellation_description=When a download is canceled, the partially downloaded file will be deleted from the disk. This helps keep your download folder clean and reduces unnecessary disk space usage. However, the download will restart from the beginning the next time you start it. settings_default_user_agent=Default User-Agent settings_default_user_agent_description=Specify the Default-User Agent string to define how requests identify to servers. This can help in accessing content optimized for particular devices or in circumventing download limitations imposed by certain websites. settings_download_size_unit=Download Size Unit settings_download_size_unit_description=Unit used to display the download size settings_download_speed_unit=Download Speed Unit settings_download_speed_unit_description=Unit used to display the download speed settings_theme=თემა settings_theme_description=აირჩიეთ თემა აპისთვის settings_default_dark_theme=Default Dark Theme settings_default_dark_theme_description=Applies when the app follows the system theme and dark mode is active settings_default_light_theme=Default Light Theme settings_default_light_theme_description=Applies when the app follows the system theme and light mode is active settings_font=Font settings_font_description=Change the font used in the app interface, Some fonts might not display correctly in the app. settings_ui_scale=ინტერფეისის ზომა settings_ui_scale_description=აპის ინტერფეისის ელემენტების ზომის შეცვლა settings_language=ენა settings_compact_top_bar=კომპაქტური ზედა ზოლი settings_compact_top_bar_description=Merge top bar with title bar when the main window has enough width settings_use_native_menu_bar=Use Native Menu Bar settings_use_native_menu_bar_description=Use the system's default menu bar style settings_use_relative_date_time=Use relative date/time settings_use_relative_date_time_description=Use relative date/time format for dates in the app (e.g., "2 days ago" instead of the exact date/time) settings_show_icon_labels=Show Icon Labels settings_show_icon_labels_description=Show labels under icons when possible ( like home toolbar actions ) settings_use_system_tray=Use System Tray settings_use_system_tray_description=Show system tray icon when the app is running settings_start_on_boot=კომპიუტერთან ერთად ჩართვა settings_start_on_boot_description=ავტომატურად ჩართვა როდესაც კომპიუტერი ჩაირთვება settings_notification_sound=შეტყობინების ხმა settings_notification_sound_description=Play sound on new notification settings_browser_integration=ბრაუზერთან ინტეგრაცია settings_browser_integration_description=ჩამოტვირთვების წამოყება ბრაუზერებიდან settings_browser_integration_server_port=სერვერის პორტი settings_browser_integration_server_port_description=პორტი ბრაუზერთან ინტეგრაციისთვის settings_browser_integration_server_port_describe=აპი მოუსმენს პორტ {{port}}-ს settings_dynamic_part_creation=Dynamic Part Creation settings_dynamic_part_creation_description=როდესაც ნაწილი დამთავრებულია შექმენი ახალი ნაწილი სხვა ნაწილების გაყოფით რადგან სიჩქარემ მოიმატოს settings_show_completion_dialog=Show Download Completion Dialog settings_show_completion_dialog_description=Automatically show "Download Complete" dialog when a download finished. settings_show_download_progress_dialog=ჩამოტვირთვის პროგრესის დიალოგის ჩვენება settings_show_download_progress_dialog_description=ავტომატური ჩვენება "ჩამოტვირთვის პროგრესის" დიალოგის როდესაც ეს ჩამოტვირთვა დაიწყება. settings_per_host_settings=Per Host Settings settings_per_host_settings_descriptions=These settings will be automatically applied to any new download that matches the specified host. settings_download_max_concurrent_downloads=Maximum Concurrent Downloads settings_download_max_concurrent_downloads_description=The maximum number of files that can be downloaded at the same time (downloads managed by queues are not counted; set to 0 for unlimited) download_item_settings_speed_limit=სიჩქარის ლიმიტი download_item_settings_speed_limit_description=ჩამოტვირთვის სიჩქარის ლიმიტი ამ ნივთისთვის download_item_settings_show_download_completion_dialog=ჩამოტვირთვა დასრულებულია დიალოგის ჩვენება download_item_settings_show_download_completion_dialog_description=ავტომატური ჩვენება "ჩამოტვირთვა დასრლებულია" დიალოგის როდესაც ეს ჩამოტვირთვა მორჩება. download_item_settings_shutdown_on_completion=Shutdown System On Completion download_item_settings_shutdown_on_completion_description=Automatically shutdown the system when this download is finished. download_item_settings_thread_count=ძაფების რაოდენობა download_item_settings_thread_count_description=რამდენი ძაფი არის მოხმარებული ამ ნივთის გადმოსაწერად (0 რომ დარჩეს ნაგულისხმევი) download_item_settings_thread_count_describe={{count}} ძაფი ამ ჩამოტვირთვისთვის download_item_settings_username_description=მიუთითეთ მომხმარებელი თუ ლინკი არის დაცული რესურსი download_item_settings_password_description=მიუთითეთ პაროლი თუ ლინკი არის დაცული რესურსი download_item_settings_download_page=ჩამოტვირთვის გვერდი download_item_settings_download_page_description=ვებგვერდი სადაც ჩამოტვირთვა წამოიწყო download_item_settings_file_checksum=File Checksum download_item_settings_file_checksum_description=A hash string which can be used to check if file is downloaded correctly download_item_settings_user_agent=User-Agent download_item_settings_user_agent_description=Custom User-Agent for this item (leave empty to use the default) file_checksum=File Checksum file_checksum_page=File Checksum Checker file_checksum_page_file_checksum_default_algorithm=Default Algorithm file_checksum_page_file_checksum_default_algorithm_help=The default algorithm used to calculate file checksums when they are not provided. start=Start calculated_checksum=Calculated Checksum saved_checksum=Saved Checksum checksum_algorithm=Algorithm file_not_found=File not found download_not_finished=Download not finished done=Done waiting=Waiting matches=Matches not_matches=Not Matches copy_to_clipboard=Copy To Clipboard username=მომხმარებელი password=პაროლი average_speed=საშუალო სიჩქარე exact_speed=ზუსტი სიჩქარე unlimited=ულიმიტო use_global_settings=გლობალური პარამეტრების მოხმარება cant_run_browser_integration=ვერ ვუშვებ ბრაუზერთან ინტეგრაციას cant_open_file=ფაილის გახსნა ვერ ხერხდება cant_open_folder=საქაღალდის გახსნა ვერ ხერხდება # times for example 2 seconds ago relative_time_long_years={{years}} წელი relative_time_long_months={{months}} თვე relative_time_long_days={{days}} დღე relative_time_long_hours={{hours}} საათი relative_time_long_minutes={{minutes}} წუთი relative_time_long_seconds={{seconds}} წამი relative_time_short_years={{years}} წ relative_time_short_months={{months}} თ relative_time_short_days={{days}} დ relative_time_short_hours={{hours}} სთ relative_time_short_minutes={{minutes}} წთ relative_time_short_seconds={{seconds}} წმ relative_time_left={{time}} დარჩენილი relative_time_ago={{time}} წინ auto=ავტომატური unspecified=დაუზუსტებელი custom=ხელით შეყვანა icon=ხატულა author=ავტორი link=ლინკი size=ზომა status=სტატუსი parts_info_downloaded_size=ჩამოტვირთული parts_info_total_size=ჯამი speed=სიჩქარე time_left=დარჩენილი დრო date_added=დამატების თარიღი info=ინფორმაცია download_page_downloaded_size=ჩამოტვირთული download_page_download_completed=ჩამოტვირთვა დამთავრებულია resume_support=გაგრძელების მხარდაჭერა yes=დიახ no=არა parts_info=ნაწილების ინფორმაცია disconnected=კავშირი გამწყდარია receiving_data=მონაცემების მიღება connecting=კავშირდება warning=გაფრთხილება unsupported_resume_warning=ამ ჩამოტვირთვას არ აქვს გაგრძელების მხარდაჭერა\! შეიძლება თავიდან დაწყება დაგჭირდეთ მოგვიანებით ჩამოტვირთვების სიიდან stop_anyway=მაინც შეჩერება customize_columns=Customize Columns reset=განულება monday=ორშაბათი tuesday=სამშაბათი wednesday=ოთხშაბათი thursday=ხუთშაბათი friday=პარასკევი saturday=შაბათი sunday=კვირა proxy_open_system_proxy_settings=სისტემის პროქსის პარამეტრების გახსნა proxy_type=პროქსის ტიპი proxy_do_not_use_proxy_for=არ იხმარო პროქსი ამისთვის proxy_do_not_use_proxy_for_description=ლინკების სია რისთვისაც პროქსი არ მოიხმარება, შეგიძლიათ ზოგადად იხმაროთ *\nმაგალითად 192.168.1.* example.com (სფეისით გაცალკევებული) proxy_change_title=პროქსის შეცვლა change_proxy=პროქსის შეცვლა proxy_no=არანაირი პროქსი proxy_system=სისტემის პროქსი proxy_manual=პროქსის შეყვანა proxy_pac=Proxy Auto Configuration proxy_pac_url=Proxy Auto Configuration URL address=მისამართი port=პორტი address_and_port=მისამართი და პორტი use_authentication=ავთენტიფიკაციის მოხმარება warning_you_may_have_to_restart_the_download_later=შეიძლება მოგვიანებით ჩამოტვირთვის თავიდან დაწყება დაგჭირდეს\! edit_download_title=ჩამოტვირთვის შეცვლა edit_download_update_from_download_page=განახლება ჩამოტვირთვის გვერდიდან edit_download_update_from_download_page_description=როდესაც ეს ფანჯარაა გახსნილი, შეგიძლიათ ჩამოტვირთვის გვერძე გადახვიდეთ და ჩამოტვირთვის ღილაკს დააჭიროთ. აპი დაიჭერს და განაახლებს ჩამოტვირთვის მონაცემებს რადგან შეგეძლოთ მათი შენახვა. edit_download_saved_download_item_size_not_match=შენახულ ჩამოტვირთვას აქვს {{currentSize}} ზომა, რომელიც არ ემთხვევა ახალ {{newSize}} ზომას. translators_page_thanks=მადლობით მათ ვინც დაგვეხმარა ამ პროექტის თარგმნაში ❤️ translators=მთარგმნელები language=ენა translators_contribute_title=თარგმნების გაუმჯობესება translators_contribute_description=გსურთ გააუმჯობესოთ ეს პროექტი? თუ თქვენი ენა არარის ჩამონათვალში ან ჩასწორებები სჭირდება, შეგიძლიათ თვენც გადათარგმნოთ და გააუმჯობესოთ\! contribute=მონაწილეობა meet_the_translators=გაიცანი მთარგმნელები localized_by_translators=ლოკალიზირებული მთარგმნელების მიერ confirm_exit=გამოსვლის დადასტურება confirm_exit_description=დარწმუნებული ხართ რომ გინდათ გამოხვიდეთ AB ჩამოტვირთვების მენეჯერიდან? აქტიური ჩამოტვირთვები/რიგები შეჩერებული იქნება\! update=განახლება update_updater=გამნახლებელი update_available=განახლება ხელმისაწვდომია update_error=Update Error update_available_suggest_to_to_update=შეგიძლიათ უახლეს ვერსიაზე განახლება რათა ისიამოვნოთ ახალი ფუნქციებით, გაუმჯობესებებით და აჩქარებებით. update_release_notes=გამოშვების შენიშვნები update_check_for_update=განახლების შემოწმება update_checking_for_update=მოწმდება განახლება update_no_update=თქვენ ხმარობთ უახლეს ვერსიას update_check_error=მოხდა შეცდომა განახლების შემოწმებისას update_app_updated_to_version_n=აპი განახლდა ვერსია {{version}}ზე create_desktop_entry=Create Desktop Entry shutdown_alert=Shut Down Alert system_shutdown_soon=System Will Shut Down Soon\! system_shutdown_failed=System Shut Down Failed\! system_shutdown_soon_description=The system will shut down soon. If you're still using the computer, please save your work or cancel the shutdown. system_shutdown_reason_queue_completed=All downloads in the queue are complete. system_shutdown_reason_queue_end_time_reached=Scheduled end time for the download queue reached. system_shutdown_download_finished=Download completed. shutdown_now=Shut Down Now settings_per_host_settings_new_host= settings_per_host_settings_not_selected=Create or select a new item first\! settings_per_host_settings_host=Host settings_per_host_settings_host_description=These settings will be applied to downloads matching this hostname. Wildcards (*) are supported (e.g., example.com, *.example.com — use only one). settings_browser_in_launcher=Browser Icon In Launcher settings_browser_in_launcher_description=Show or hide the browser icon in the launcher (app list). sort_by=Sort By welcome=Welcome new_folder=New Folder skip=Skip lets_go=Let's Go next=Next select_all=Select All select_inside=Select Inside select_invert=Select Invert open_settings=Open Settings back=Back service_is_running=Service is running initial_setup_description=Let’s set things up initial_setup_notice=You can change these settings anytime later permission_granted=Permission granted permission_not_granted=Permission not granted permissions=Permissions give_permission=Allow permission give_storage_permission=Allow storage access storage_roots=Storage Roots permissions_initial_title=Permissions setup permissions_initial_description=To work properly, the app needs a few permissions. On the next screen, you’ll see what each permission is used for and you can decide which ones to allow or skip. permissions_done_title=You’re all set permissions_done_description=Everything is ready. All required permissions have been granted and the app is good to go. permissions_manage_storage_title=Manage storage access permissions_manage_storage_reason=This permission lets the app change the download folder, detect duplicate downloads more accurately, and enable some extra features. It’s optional, but recommended for the best experience. permission_read_write_external_storage_title=Read and write storage permission_read_write_external_storage_reason=This permission allows the app to save and manage downloaded files, change the download location, and improve duplicate download detection. permissions_post_notification_title=Post Notification permissions_post_notification_reason=The app needs to run in the background to manage downloads. Notifications are used to keep you informed and allow background operation. permissions_ignore_battery_optimization_title=Ignore Battery Optimization permissions_ignore_battery_optimization_reason=Some devices aggressively limit background activity to save battery, which can pause or stop downloads when the app isn’t open. You can optionally exclude the app from battery optimization to ensure downloads continue uninterrupted open_in_browser=Open In Browser browser=Browser browser_new_tab=New Tab browser_close_tab=Close Tab browser_open_in_new_tab=Open In New Tab browser_open_in_new_background_tab=Open In New Background Tab browser_no_tab_open=No tabs are open browser_tabs=Tabs browser_paste_and_go=Paste And Go browser_bookmarks=Bookmarks browser_add_bookmark=Add Bookmark browser_edit_bookmark=Edit Bookmark browser_add_to_bookmarks=Add To Bookmarks browser_remove_from_bookmarks=Remove From Bookmarks ================================================ FILE: shared/resources/src/commonMain/resources/com/abdownloadmanager/resources/locales/ko_KR.properties ================================================ app_title=AB Download Manager confirm_auto_categorize_downloads_title=다운로드 자동 분류 confirm_auto_categorize_downloads_description=분류되지 않은 항목은 자동으로 관련 범주에 추가됩니다. confirm_reset_to_default_categories_title=기본 범주로 재설정 confirm_reset_to_default_categories_description=이렇게 하면 모든 범주가 제거되고 기본값 범주가 다시 표시됩니다\! confirm_delete_download_items_title=삭제 확인 confirm_delete_download_items_description={{count}}개의 항목을 삭제하시겠습니까? confirm_delete_download_unfinished_items_description=완료되지 않은 다운로드 {{count}}개를 삭제하시겠습니까? confirm_delete_download_finished_and_unfinished_items_description=완료된 다운로드 {{finishedCount}}개와 완료되지 않은 다운로드 {{unfinishedCount}}개를 삭제하시겠습니까? also_delete_file_from_disk=디스크에서도 파일 삭제 confirm_delete_category_item_title={{name}} 범주 제거 confirm_delete_category_item_description="{{value}}" 범주를 삭제하시겠습니까? your_download_will_not_be_deleted=다운로드한 것은 삭제되지 않습니다 drag_the_file_to_another_app=파일을 다른 앱으로 끌어 놓기 drop_link_or_file_here=링크나 파일을 여기에 놓아주세요. nothing_will_be_imported=아무것도 가져오지 않았습니다 n_links_will_be_imported={{count}}개의 링크를 가져옴 n_items_selected={{count}}개의 항목이 선택됨 window_close=닫기 window_minimize=최소화 window_maximize=최대화 window_restore=복원 delete=삭제 remove=제거 cancel=취소 close=닫기 menu=메뉴 more_options=추가 옵션 ok=확인 add=추가 paste=붙여넣기 change=변경 edit=편집 change_anyway=어쨌든 변경 download=다운로드 refresh=새로 고침 settings=설정 on_completion=완료 시 unknown=알 수 없음 unknown_error=알 수 없는 오류 download_item_not_found=다운로드 항목을 찾을 수 없음 name=이름 download_link=다운로드 링크 not_finished=완료되지 않음 all=모두 finished=완료됨 Unfinished=미완료 canceled=취소됨 error=오류 paused=일시중지됨 downloading=다운로드 중 added=추가됨 idle=유휴 preparing_file=파일 준비 중 creating_file=파일 생성 중 resuming=재개 중 retrying=다시 시도 중 list_is_empty=목록이 비어있습니다\! search_in_the_list=목록에서 검색 search=검색 clear=지우기 general=일반 enabled=사용함 disabled=사용 안 함 default=기본값 file=파일 tasks=작업 tools=도구 help=도움말 system=시스템 all_missing_files=모든 누락된 파일 all_finished=모든 완료 all_unfinished=모든 미완료 entire_list=전체 목록 download_browser_integration=브라우저 통합 다운로드 exit=종료 show_downloads=다운로드 표시 new_download=새 다운로드 stop_all=모두 중지 import_from_clipboard=클립보드에서 가져오기 batch_download=일괄 다운로드 open=열기 share=공유 open_file=파일 열기 open_folder=폴더 열기 resume=재개 pause=일시 중지 restart_download=다운로드 다시 시작 copy=복사 copy_link=링크 복사 copy_as_curl=cURL로 복사 show_properties=속성 표시 move_to_queue=대기열로 이동 move_to_this_queue=이 대기열로 이동 move_to_category=범주로 이동 move_to_this_category=이 범주로 이동 categories=범주 add_category=범주 추가 edit_category=범주 편집 delete_category=범주 삭제 category_name=범주 이름 category_download_location=범주 다운로드 위치 category_download_location_description="다운로드 추가"에서 이 범주를 선택한 경우 이 디렉터리를 "다운로드 위치"로 사용합니다 category_file_types=범주 파일 형식 category_file_types_description=이러한 파일 유형을 이 범주에 자동으로 추가합니다. (새 다운로드를 추가할 때)\n파일 확장자는 공백으로 구분하세요 (예\: ext1, ext2 ...) category_url_patterns=URL 패턴 category_url_patterns_description=이러한 URL에서 이 범주에 자동으로 다운로드를 추가합니다. (새 다운로드를 추가할 때)\n공백이 있는 별도의 URL을 사용할 수 있으며, 와일드카드에는 *을 사용할 수도 있습니다 auto_categorize_downloads=다운로드 자동 분류 restore_defaults=기본값으로 복원 about=정보 version_n=버전 {{value}} developed_with_love_for_you=당신을 위해 ❤️으로 개발되었습니다 donate=기부 visit_the_project_website=프로젝트 웹사이트 방문 this_is_a_free_and_open_source_software=이것은 무료 및 오픈 소스 소프트웨어입니다 view_the_source_code=소스 코드 보기 third_party_libraries=타사 라이브러리 powered_by_open_source_software=오픈 소스 소프트웨어 기반 view_the_open_source_licenses=오픈 소스 라이선스 보기 support_and_community=지원 및 커뮤니티 telegram=Telegram channel=채널 group=그룹 add_download=다운로드 추가 add_multi_download_page_header=다운로드할 항목 선택 save_to=저장 위치 where_should_each_item_saved=각 항목을 어디에 저장하시겠습니까? there_are_multiple_items_please_select_a_way_you_want_to_save_them=여러 항목이 있습니다\! 저장할 방법을 선택해 주세요 each_item_on_its_own_category=각 항목을 자체 범주에 each_item_on_its_own_category_description=각 항목을 해당 파일 형식을 가진 범주에 배치 all_items_in_one_category=하나의 범주에 모든 항목 all_items_in_one_category_description=모든 파일이 선택한 범주에 저장됩니다 all_items_in_one_Location=모든 항목을 하나의 위치에 all_items_in_one_Location_description=모든 항목이 선택된 디렉터리에 저장됩니다 unselected_all_items_in_specific_location_description=모든 파일은 선택한 범주 위치에 저장됩니다 no_category_selected=선택한 범주 없음 no_categories_found=범주를 찾을 수 없음 download_location=다운로드 위치 location=위치 select_queue=대기열 선택 without_queue=대기열 없음 use_category=범주 사용 cant_write_to_this_folder=이 폴더에 쓸 수 없습니다 file_name_already_exists=파일 이름이 이미 존재합니다 download_already_exists=다운로드가 이미 존재합니다 invalid_file_name=잘못된 파일 이름 show_solutions=해결책 표시... change_solution=해결책 변경 select_a_solution=해결책 선택 select_download_strategy_description=제공한 링크가 이미 다운로드 목록에 있습니다. 원하는 작업을 지정해 주세요 download_strategy_add_a_numbered_file=번호가 매겨진 파일 추가 download_strategy_add_a_numbered_file_description=다운로드 파일 이름이 끝난 후 인덱스 추가 download_strategy_override_existing_file=기존 파일 덮어쓰기 download_strategy_override_existing_file_description=기존 다운로드를 제거하고 해당 파일에 쓰기 download_strategy_update_download_link=기존 다운로드 업데이트 download_strategy_update_download_link_description=기존 다운로드 링크와 자격 증명 업데이트 download_strategy_show_downloaded_file=다운로드된 파일 표시 download_strategy_show_downloaded_file_description=이미 존재하는 다운로드 항목을 표시하여 재개하거나 열 수 있습니다 batch_download_link_help=와일드카드가 포함된 링크를 입력하세요 (* 사용) invalid_url=잘못된 URL list_is_too_large_maximum_n_items_allowed=목록이 너무 큽니다\! 최대 {{count}}개 항목 허용 enter_range=범위 입력 range_from=시작 range_to=끝 batch_download_wildcard_length=와일드카드 길이 first_link=첫 번째 링크 last_link=마지막 링크 open_source_software_used_in_this_app=이 앱에서 사용되는 오픈 소스 소프트웨어 links=링크 website=웹 사이트 developers=개발자 source_code=소스 코드 license=라이선스 no_license_found=라이선스를 찾을 수 없음 organization=조직 add_new_queue=새 대기열 추가 queue_name=대기열 이름 queues=대기열 stop_queue=대기열 중지 start_queue=대기열 시작 clear_queue_items=빈 대기열 config=구성 items=항목 move_down=아래로 이동 move_up=위로 이동 remove_queue=대기열 제거 queue_name_help=이 대기열의 이름 지정 queue_name_describe=대기열 이름은 {{value}} queue_max_concurrent_download=최대 동시 다운로드 queue_max_concurrent_download_description=이 대기열의 최대 다운로드 수 queue_automatic_stop=자동 중지 queue_automatic_stop_description=항목이 없을 때 자동으로 대기열 중지 queue_scheduler=스케줄러 queue_enable_scheduler=스케줄러 사용 queue_active_days=활동일 queue_active_days_description=어떤 요일에 스케줄러가 작동하나요? queue_scheduler_enable_auto_start_time=자동 시작 시간 사용 queue_scheduler_auto_start_time=자동 시작 시간 queue_scheduler_enable_auto_stop_time=자동 중지 시간 사용 queue_scheduler_auto_stop_time=자동 중지 시간 queue_shutdown_on_completion=완료 시 시스템 종료 queue_shutdown_on_completion_description=이 대기열이 완료되거나 예정된 종료 시간에 도달하면 시스템을 자동으로 종료합니다. appearance=모양 download_engine=다운로드 엔진 browser_integration=브라우저 통합 settings_download_max_retries_count=최대 다운로드 재시도 횟수 settings_download_max_retries_count_description=앱이 실패한 다운로드를 포기하기 전에 다시 시도하는 최대 횟수 settings_download_max_retries_count_describe_no_retries=실패한 다운로드는 재시도되지 않습니다 settings_download_max_retries_count_describe_n_retries=실패한 다운로드를 {{count}}번 재시도합니다 settings_download_thread_count=스레드 수 settings_download_thread_count_description=다운로드 항목당 최대 다운로드 스레드 수 settings_download_thread_count_describe=다운로드에는 최대 {{count}}개의 스레드가 포함될 수 있습니다 settings_download_thread_count_with_large_value_describe=경고\: 스레드 수를 높게 설정하면 시스템 리소스 사용량이 증가하거나 성능이 저하되거나 서버 연결 문제가 발생할 수 있습니다. 시스템과 네트워크에 미칠 수 있는 잠재적 영향을 이해하는 경우에만 더 높은 값을 사용하세요. settings_use_server_last_modified_time=서버의 마지막 수정 시간 사용 settings_use_server_last_modified_time_description=파일을 다운로드할 때 로컬 파일에 대해 서버의 마지막 수정 시간을 사용합니다 settings_append_extension_to_incomplete_downloads=완료되지 않은 다운로드에 확장자 추가 settings_append_extension_to_incomplete_downloads_description=완료되지 않은 다운로드에는 ".part" 확장자를 추가합니다. 이렇게 하면 완료되지 않은 다운로드를 식별하고 완료되지 않은 파일을 실수로 여는 것을 방지할 수 있습니다. settings_use_sparse_file_allocation=희소 파일 할당 settings_use_sparse_file_allocation_description=불필요한 데이터 쓰기를 줄임으로써 특히 SSD에서 파일을 더 효율적으로 생성할 수 있습니다. 이렇게 하면 다운로드 시작 속도를 높이고 디스크 사용량을 줄일 수 있습니다. 다운로드가 느리게 시작되거나 비정상적인 다운로드 속도가 발생하는 경우 일부 디바이스에서 완전히 지원되지 않을 수 있으므로 이 옵션을 비활성화하는 것을 고려해 보세요. settings_ignore_ssl_certificates=SSL 인증서 무시 settings_ignore_ssl_certificates_description=SSL 인증서 인증을 비활성화합니다. 필요한 경우에만 사용하면 연결이 보안 위험에 노출될 수 있으므로 사용하세요. settings_global_speed_limiter=전역 속도 제한 settings_global_speed_limiter_description=전역 다운로드 속도 제한 (0은 무제한을 의미함) settings_show_average_speed=평균 속도 표시 settings_show_average_speed_description=다운로드 속도 평균 또는 정밀도 settings_use_category_by_default=기본값으로 범주 사용 settings_use_category_by_default_description=다운로드를 추가할 때 기본값으로 범주를 사용합니다. settings_default_download_folder=기본 다운로드 폴더 settings_default_download_folder_description=새 다운로드를 추가할 때 이 위치는 기본적으로 사용됩니다 settings_default_download_folder_describe="{{folder}}"가 사용됩니다 settings_use_proxy=프록시 사용 settings_use_proxy_description=파일 다운로드에 프록시 사용 settings_use_proxy_describe_no_proxy=프록시 사용 안 함 settings_use_proxy_describe_system_proxy=시스템 프록시가 사용됩니다 settings_use_proxy_describe_manual_proxy="{{value}}"가 사용됩니다 settings_use_proxy_describe_pac_proxy=PAC 파일 "{{value}}"이 사용됩니다 settings_track_deleted_files_on_disk=디스크에서 삭제된 파일 추적 settings_track_deleted_files_on_disk_description=다운로드 디렉터리에서 파일이 삭제되거나 이동되면 파일을 목록에서 자동으로 제거합니다. settings_delete_partial_file_on_download_cancellation=다운로드 취소 시 부분 파일 삭제 settings_delete_partial_file_on_download_cancellation_description=다운로드가 취소되면 부분적으로 다운로드된 파일이 디스크에서 삭제됩니다. 이렇게 하면 다운로드 폴더를 깨끗하게 유지하고 불필요한 디스크 공간 사용을 줄일 수 있습니다. 그러나 다음에 다운로드를 시작할 때 처음부터 다운로드가 다시 시작됩니다. settings_default_user_agent=기본 사용자 에이전트 settings_default_user_agent_description=기본 사용자 에이전트 문자열을 지정하여 요청이 서버에 식별되는 방식을 정의합니다. 이는 특정 장치에 최적화된 콘텐츠에 액세스하거나 특정 웹사이트에서 부과하는 다운로드 제한을 우회하는 데 도움이 될 수 있습니다. settings_download_size_unit=다운로드 크기 단위 settings_download_size_unit_description=다운로드 크기를 표시하는 데 사용되는 단위 settings_download_speed_unit=다운로드 속도 단위 settings_download_speed_unit_description=다운로드 속도를 표시하는 데 사용되는 단위 settings_theme=테마 settings_theme_description=앱의 테마 선택 settings_default_dark_theme=기본 어두운 테마 settings_default_dark_theme_description=앱이 시스템 테마를 따르고 어두운 모드가 활성화된 경우 적용됩니다 settings_default_light_theme=기본 밝은 테마 settings_default_light_theme_description=앱이 시스템 테마를 따르고 밝은 모드가 활성화된 경우 적용됩니다 settings_font=글꼴 settings_font_description=앱 인터페이스에서 사용되는 글꼴을 변경하면 일부 글꼴이 앱에 올바르게 표시되지 않을 수 있습니다. settings_ui_scale=UI 크기 settings_ui_scale_description=앱의 인터페이스 요소 크기 조정 settings_language=언어 settings_compact_top_bar=조밀한 상단 표시줄 settings_compact_top_bar_description=기본 창의 너비가 충분할 때 상단 막대를 제목 막대와 병합 settings_use_native_menu_bar=원래 메뉴 표시줄 사용 settings_use_native_menu_bar_description=시스템의 기본 메뉴 표시줄 스타일을 사용합니다 settings_use_relative_date_time=상대적 날짜/시간 사용 settings_use_relative_date_time_description=앱의 날짜에 대해 상대적 날짜/시간 형식 사용 (예\: 정확한 날짜/시간 대신 "2일 전") settings_show_icon_labels=아이콘 레이블 표시 settings_show_icon_labels_description=가능한 경우 아이콘 아래에 레이블 표시 (예\: 홈 도구모음 작업) settings_use_system_tray=시스템 트레이 사용 settings_use_system_tray_description=앱이 실행 중일 때 시스템 트레이 아이콘 표시 settings_start_on_boot=부팅할 때 시작 settings_start_on_boot_description=사용자 로그인 시 응용 프로그램 자동 시작 settings_notification_sound=알림 소리 settings_notification_sound_description=새 알림에서 소리 재생 settings_browser_integration=브라우저 통합 settings_browser_integration_description=브라우저에서 다운로드 수락 settings_browser_integration_server_port=서버 포트 settings_browser_integration_server_port_description=브라우저 통합을 위한 포트 settings_browser_integration_server_port_describe=앱이 {{port}} 포트를 수신할 것입니다 settings_dynamic_part_creation=동적 부분 생성 settings_dynamic_part_creation_description=부분이 완료되면 다른 부분을 분할하여 새 부분을 생성하여 다운로드 속도를 개선합니다 settings_show_completion_dialog=다운로드 완료 표시 대화 상자 settings_show_completion_dialog_description=다운로드가 완료되면 자동으로 "다운로드 완료" 대화 상자를 표시합니다. settings_show_download_progress_dialog=다운로드 진행 상황 표시 대화 상자 settings_show_download_progress_dialog_description=다운로드가 시작되면 자동으로 "다운로드 진행 상황" 대화 상자를 표시합니다. settings_per_host_settings=호스트별 설정 settings_per_host_settings_descriptions=이 설정은 지정된 호스트와 일치하는 새 다운로드에 자동으로 적용됩니다. settings_download_max_concurrent_downloads=최대 동시 다운로드 수 settings_download_max_concurrent_downloads_description=동시에 다운로드할 수 있는 최대 파일 수 (대기열로 관리되는 다운로드는 포함되지 않음, 무제한의 경우 0으로 설정됨) download_item_settings_speed_limit=속도 제한 download_item_settings_speed_limit_description=이 항목의 다운로드 속도 제한 download_item_settings_show_download_completion_dialog=다운로드 완료 대화 상자 표시 download_item_settings_show_download_completion_dialog_description=이 다운로드가 완료되면 자동으로 "다운로드 완료" 대화 상자를 표시합니다. download_item_settings_shutdown_on_completion=완료 시 시스템 종료 download_item_settings_shutdown_on_completion_description=이 다운로드가 완료되면 시스템을 자동으로 종료합니다. download_item_settings_thread_count=스레드 수 download_item_settings_thread_count_description=이 다운로드 항목을 다운로드하는 데 사용된 스레드 수 (기본값은 0) download_item_settings_thread_count_describe=이 다운로드를 위한 {{count}}개의 스레드 download_item_settings_username_description=링크가 보호된 리소스인 경우 사용자 이름 제공 download_item_settings_password_description=링크가 보호된 리소스인 경우 비밀번호 제공 download_item_settings_download_page=다운로드 페이지 download_item_settings_download_page_description=이 다운로드가 시작된 웹페이지 download_item_settings_file_checksum=파일 체크섬 download_item_settings_file_checksum_description=파일이 올바르게 다운로드되었는지 확인하는 데 사용할 수 있는 해시 문자열 download_item_settings_user_agent=사용자 에이전트 download_item_settings_user_agent_description=이 항목에 대한 사용자 지정 사용자 에이전트 (기본값을 사용하려면 비워 두세요) file_checksum=파일 체크섬 file_checksum_page=파일 체크섬 검사기 file_checksum_page_file_checksum_default_algorithm=기본 알고리즘 file_checksum_page_file_checksum_default_algorithm_help=파일 체크섬이 제공되지 않을 때 파일 체크섬을 계산하는 데 사용되는 기본 알고리즘입니다. start=시작 calculated_checksum=계산된 체크섬 saved_checksum=저장된 체크섬 checksum_algorithm=알고리즘 file_not_found=파일을 찾을 수 없음 download_not_finished=다운로드가 완료되지 않음 done=완료 waiting=대기 중 matches=일치 not_matches=일치하지 않음 copy_to_clipboard=클립보드에 복사 username=사용자 이름 password=비밀번호 average_speed=평균 속도 exact_speed=정확한 속도 unlimited=무제한 use_global_settings=전역 설정 사용 cant_run_browser_integration=브라우저 통합을 실행할 수 없습니다 cant_open_file=파일을 열 수 없습니다 cant_open_folder=폴더를 열 수 없습니다 # times for example 2 seconds ago relative_time_long_years={{years}}년 relative_time_long_months={{months}}개월 relative_time_long_days={{days}}일 relative_time_long_hours={{hours}}시간 relative_time_long_minutes={{minutes}}분 relative_time_long_seconds={{seconds}}초 relative_time_short_years={{years}}년 relative_time_short_months={{months}}개월 relative_time_short_days={{days}}일 relative_time_short_hours={{hours}}시간 relative_time_short_minutes={{minutes}}분 relative_time_short_seconds={{seconds}}초 relative_time_left={{time}} 남음 relative_time_ago={{time}} 전 auto=자동 unspecified=지정되지 않음 custom=사용자 지정 icon=아이콘 author=작성자 link=링크 size=크기 status=상태 parts_info_downloaded_size=다운로드 parts_info_total_size=전체 speed=속도 time_left=남은 시간 date_added=추가된 날짜 info=정보 download_page_downloaded_size=다운로드 download_page_download_completed=다운로드 완료 resume_support=재개 지원 yes=예 no=아니요 parts_info=부분 정보 disconnected=연결 끊김 receiving_data=데이터 수신 중 connecting=연결 중 warning=경고 unsupported_resume_warning=이 다운로드는 재개를 지원하지 않습니다\! 나중에 다운로드 목록에서 다시 시작해야 할 수도 있습니다 stop_anyway=그래도 중지 customize_columns=열 사용자 지정 reset=재설정 monday=월요일 tuesday=화요일 wednesday=수요일 thursday=목요일 friday=금요일 saturday=토요일 sunday=일요일 proxy_open_system_proxy_settings=시스템 프록시 설정 열기 proxy_type=프록시 유형 proxy_do_not_use_proxy_for=프록시 사용하지 않을 대상 proxy_do_not_use_proxy_for_description=프록시로 사용할 수 없는 URL 목록\n*로 와일드카드를 사용할 수 있음\n예\: 192.168.1.* example.com (공백으로 구분) proxy_change_title=프록시 변경 change_proxy=프록시 변경 proxy_no=프록시 없음 proxy_system=시스템 프록시 proxy_manual=수동 프록시 proxy_pac=프록시 자동 구성 proxy_pac_url=프록시 자동 구성 URL address=주소 port=포트 address_and_port=주소 및 포트 use_authentication=인증 사용 warning_you_may_have_to_restart_the_download_later=나중에 다운로드를 다시 시작해야 할 수도 있습니다\! edit_download_title=다운로드 편집 edit_download_update_from_download_page=다운로드 페이지에서 업데이트 edit_download_update_from_download_page_description=이 창이 열리면 다운로드 페이지로 이동하여 다운로드 버튼을 클릭할 수 있습니다. 앱에서 새 다운로드 자격 증명을 캡처하고 업데이트하여 저장할 수 있습니다. edit_download_saved_download_item_size_not_match=저장된 다운로드 항목의 크기가 {{currentSize}}인데, 이는 새 크기인 {{newSize}}와 일치하지 않습니다. translators_page_thanks=이 프로젝트 번역에 도움을 주신 분들께 감사드립니다 ❤️ translators=번역가 language=언어 translators_contribute_title=번역 개선 translators_contribute_description=이 프로젝트를 개선하는 데 도움을 주고 싶으신가요? 귀하의 언어가 목록에 없거나 일부 수정이 필요하다면, 귀하의 번역을 기여하여 더 나은 프로젝트로 만들 수 있습니다\! contribute=기여하기 meet_the_translators=한국어 번역\: 비너스걸 localized_by_translators=번역가들에 의해 현지화 confirm_exit=종료 확인 confirm_exit_description=AB Download Manager를 종료하시겠습니까?\n활성 다운로드/대기열이 중지됩니다\! update=업데이트 update_updater=업데이터 update_available=업데이트 사용 가능 update_error=업데이트 오류 update_available_suggest_to_to_update=최신 버전으로 업데이트하여 새로운 기능, 향상된 기능 및 성능 향상을 즐길 수 있습니다. update_release_notes=릴리스 노트 update_check_for_update=업데이트 확인 update_checking_for_update=업데이트 확인 중 update_no_update=최신 버전을 사용하고 있습니다 update_check_error=업데이트 확인 중 오류 발생 update_app_updated_to_version_n=앱이 {{version}} 버전으로 업데이트되었습니다 create_desktop_entry=바탕 화면 바로 가기 만들기 shutdown_alert=종료 알림 system_shutdown_soon=시스템이 곧 종료됩니다\! system_shutdown_failed=시스템 종료 실패\! system_shutdown_soon_description=시스템이 곧 종료됩니다. 컴퓨터를 계속 사용 중이라면 작업을 저장하거나 종료를 취소해 주세요. system_shutdown_reason_queue_completed=대기열의 모든 다운로드가 완료되었습니다. system_shutdown_reason_queue_end_time_reached=다운로드 대기열의 예정된 종료 시간에 도달했습니다. system_shutdown_download_finished=다운로드가 완료되었습니다. shutdown_now=지금 종료 settings_per_host_settings_new_host=<새 호스트> settings_per_host_settings_not_selected=먼저 새 항목을 만들거나 선택하세요\! settings_per_host_settings_host=호스트 settings_per_host_settings_host_description=이 설정은 이 호스트 이름과 일치하는 다운로드에 적용됩니다. 와일드카드 (*)가 지원됩니다 (예\: example.com, *.example.com — 하나만 사용). settings_browser_in_launcher=런처의 브라우저 아이콘 settings_browser_in_launcher_description=런처 (앱 목록)에서 브라우저 아이콘을 표시하거나 숨깁니다. sort_by=정렬 기준 welcome=환영합니다 new_folder=새 폴더 skip=건너뛰기 lets_go=시작하기 next=다음 select_all=모두 선택 select_inside=내부 선택 select_invert=반전 선택 open_settings=설정 열기 back=뒤로 service_is_running=서비스가 실행 중입니다 initial_setup_description=설정해 보겠습니다 initial_setup_notice=이 설정은 나중에 언제든지 변경할 수 있습니다 permission_granted=권한이 부여됨 permission_not_granted=권한이 부여되지 않음 permissions=권한 give_permission=권한 허용 give_storage_permission=저장소 액세스 허용 storage_roots=저장소 루트 permissions_initial_title=설정해 보겠습니다 permissions_initial_description=제대로 작동하려면 앱에 몇 가지 권한이 필요합니다. 다음 화면에서 각 권한이 무엇에 사용되는지 확인하고 어떤 권한을 허용할지 건너뛸지 결정할 수 있습니다. permissions_done_title=모두 준비되었습니다 permissions_done_description=모든 준비가 완료되었습니다. 필요한 모든 권한이 부여되었으며 앱은 바로 사용할 수 있습니다. permissions_manage_storage_title=저장소 액세스 관리 permissions_manage_storage_reason=이 권한을 통해 앱은 다운로드 폴더를 변경하고 중복 다운로드를 더 정확하게 감지하며 몇 가지 추가 기능을 활성화할 수 있습니다. 선택 사항이지만 최상의 경험을 위해 권장됩니다. permission_read_write_external_storage_title=저장소 읽기 및 쓰기 permission_read_write_external_storage_reason=이 권한을 통해 앱은 다운로드된 파일을 저장하고 관리하며, 다운로드 위치를 변경하고 중복 다운로드 감지를 개선할 수 있습니다. permissions_post_notification_title=알림 액세스 permissions_post_notification_reason=다운로드를 관리하려면 앱이 백그라운드에서 실행되어야 합니다. 알림은 사용자에게 정보를 제공하고 백그라운드 작업을 허용하는 데 사용됩니다. permissions_ignore_battery_optimization_title=배터리 최적화 무시 permissions_ignore_battery_optimization_reason=일부 기기는 배터리 소모를 줄이기 위해 백그라운드 활동을 적극적으로 제한하는데, 이로 인해 앱이 실행 중이 아닐 때 다운로드가 일시 중단되거나 중단될 수 있습니다. 다운로드가 중단 없이 계속되도록 하려면 앱을 배터리 최적화 대상에서 제외할 수 있습니다 open_in_browser=브라우저에서 열기 browser=브라우저 browser_new_tab=새 탭 browser_close_tab=탭 닫기 browser_open_in_new_tab=새 탭에서 열기 browser_open_in_new_background_tab=새 백그라운드 탭에서 열기 browser_no_tab_open=열려 있는 탭이 없습니다 browser_tabs=탭 browser_paste_and_go=붙여넣고 이동 browser_bookmarks=북마크 browser_add_bookmark=북마크 추가 browser_edit_bookmark=북마크 편집 browser_add_to_bookmarks=북마크에 추가 browser_remove_from_bookmarks=북마크에서 제거 ================================================ FILE: shared/resources/src/commonMain/resources/com/abdownloadmanager/resources/locales/lt_LT.properties ================================================ app_title=AB Atsisiuntimų Tvarkyklė confirm_auto_categorize_downloads_title=Automatiškai suskirstyti atsisiuntimus į kategorijas confirm_auto_categorize_downloads_description=Bet koks nekategorizuotas elementas bus automatiškai pridėtas prie susijusios kategorijos. confirm_reset_to_default_categories_title=Atkurti numatytąsias kategorijas confirm_reset_to_default_categories_description=Tai PAŠALINS visas kategorijas ir atkurs numatytąsias kategorijas\! confirm_delete_download_items_title=Patvirtinti ištrynimą confirm_delete_download_items_description=Ar tikrai norite ištrinti {{count}} elementus? confirm_delete_download_unfinished_items_description=Ar tikrai norite ištrinti {{count}} nebaigtus atsisiuntimus? confirm_delete_download_finished_and_unfinished_items_description=Ar tikrai norite ištrinti {{finishedCount}} baigtus ir {{unfinishedCount}} nebaigtus atsisiuntimus? also_delete_file_from_disk=Taip pat ištrinti failą iš disko confirm_delete_category_item_title=Šalinama kategorija {{name}} confirm_delete_category_item_description=Ar tikrai norite ištrinti kategoriją "{{value}}"? your_download_will_not_be_deleted=Jūsų atsisiuntimai nebus ištrinti drag_the_file_to_another_app=Nuvilkite failą į kitą programėlę drop_link_or_file_here=Įkelkite nuorodą arba failą čia. nothing_will_be_imported=Niekas nebus importuojama n_links_will_be_imported=Bus importuota {{count}} nuorodų n_items_selected=Pasirinkta {{count}} elementų window_close=Uždaryti window_minimize=Sumažinti window_maximize=Maksimizuoti window_restore=Atkurti delete=Ištrinti remove=Pašalinti cancel=Atšaukti close=Uždaryti menu=Menu more_options=More Options ok=Gerai add=Pridėti paste=Paste change=Keisti failą edit=Redaguoti change_anyway=Keisti vis tiek download=Atsisiųsti refresh=Atnaujinti settings=Nustatymai on_completion=Užbaigus unknown=Nežinomas unknown_error=Nežinoma klaida download_item_not_found=Atsisiuntimo elementas nerastas name=Pavadinimas download_link=Atsisiuntimo nuoroda not_finished=Neužbaigta all=Visi finished=Baigta Unfinished=Nebaigtas canceled=Atšauktas error=Klaida paused=Pristabdyta downloading=Siunčiama\: added=Pridėta idle=Neaktyvus preparing_file=Ruošiamas failas creating_file=Kuriamas failas resuming=Tęsiama retrying=Bandoma iš naujo list_is_empty=Sąrašas yra tuščias. search_in_the_list=Ieškoti sąraše search=Ieškoti clear=Išvalyti general=Bendrieji nustatymai enabled=Įjungta disabled=Išjungta default=Numatytasis file=Failas tasks=Užduotys tools=Įrankiai help=Pagalba system=Sistema all_missing_files=Visi trūkstami failai all_finished=Visi baigti all_unfinished=Visi nebaigti entire_list=Visas sąrašas download_browser_integration=Atsisiuntimo naršyklės integracija exit=Išeiti show_downloads=Rodyti atsisiuntimus new_download=Naujas atsisiuntimas stop_all=Stabdyti visus import_from_clipboard=Importuoti iš iškarpinės batch_download=Grupinis atsisiuntimas open=Atidaryti share=Share open_file=Atidaryti failą open_folder=Atidaryti aplanką resume=Pratęsti pause=Pristabdyti restart_download=Paleisti atsisiuntimą iš naujo copy=Kopijuoti copy_link=Kopijuoti nuorodą copy_as_curl=Kopijuoti kaip cURL show_properties=Rodyti savybes move_to_queue=Perkelti į eilę move_to_this_queue=Perkelti į šią eilę move_to_category=Perkelti į kategoriją move_to_this_category=Perkelti į šią kategoriją categories=Kategorijos add_category=Pridėti kategoriją edit_category=Redaguoti kategoriją delete_category=Ištrinti kategoriją category_name=Kategorijos pavadinimas category_download_location=Kategorijos atsisiuntimo vieta category_download_location_description=Kai ši kategorija pasirenkama "Pridėti atsisiuntimą", naudoti šį aplanką kaip "Atsisiuntimo vietą" category_file_types=Kategorijos failų tipai category_file_types_description=Automatiškai įdėti šiuos failų tipus į šią kategoriją. (kai pridedate naują atsisiuntimą)\nAtskirinkite failų plėtines tarpais (ext1 ext2 ...) category_url_patterns=URL adresų modeliai category_url_patterns_description=Automatiškai įdėti atsisiuntimus iš šių URL adresų į šią kategoriją. (kai pridedate naują atsisiuntimą)\nAtskirinkite URL adresus tarpais, galite taip pat naudoti * kaip pakaitos simbolį auto_categorize_downloads=Automatiškai suskirstyti atsisiuntimus į kategorijas restore_defaults=Atkurti numatytuosius about=Apie version_n=Versija {{value}} developed_with_love_for_you=Sukurta su ❤️ jums donate=Paremti visit_the_project_website=Aplankyti projekto svetainę this_is_a_free_and_open_source_software=Tai yra nemokama ir atvirojo kodo programėlė view_the_source_code=Rodyti pirminį kodą third_party_libraries=Third Party Libraries powered_by_open_source_software=Sukurta naudojant atvirojo kodo programinę įrangą view_the_open_source_licenses=Rodyti atvirojo kodo licencijas support_and_community=Pagalba ir bendruomenė telegram=Telegram channel=Kanalas group=Grupė add_download=Pridėti atsisiuntimą add_multi_download_page_header=Pasirinkite elementus, kuriuos norite atsisiųsti save_to=Išsaugoti į where_should_each_item_saved=Kur turėtų būti išsaugotas kiekvienas elementas? there_are_multiple_items_please_select_a_way_you_want_to_save_them=Yra daug elementų\! Pasirinkite, kaip norite juos išsaugoti each_item_on_its_own_category=Kiekvienas elementas savoje kategorijoje each_item_on_its_own_category_description=Kiekvienas elementas bus įdėtas į kategoriją, kuri turi šį failų tipą all_items_in_one_category=Visi elementai vienoje kategorijoje all_items_in_one_category_description=Visi failai bus išsaugoti pasirinktoje kategorijoje all_items_in_one_Location=Visi elementai vienoje vietoje all_items_in_one_Location_description=Visi elementai bus išsaugoti pasirinktoje direktorijoje unselected_all_items_in_specific_location_description=Visi failai bus išsaugoti pasirinktos kategorijos vietoje no_category_selected=Kategorija nepasirinkta no_categories_found=Nerasta jokių kategorijų download_location=Atsisiuntimo vieta location=Vieta select_queue=Pasirinkite eilę without_queue=Be eilės use_category=Naudoti kategoriją cant_write_to_this_folder=Negalima rašyti į šį aplanką file_name_already_exists=Failo pavadinimas jau egzistuoja download_already_exists=Atsisiuntimas jau egzistuoja invalid_file_name=Neteisingas failo pavadinimas show_solutions=Rodyti sprendimus... change_solution=Keisti sprendimą select_a_solution=Pasirinkite sprendimą select_download_strategy_description=Pateikta nuoroda jau yra atsisiuntimų sąraše, nurodykite, ką norite padaryti download_strategy_add_a_numbered_file=Pridėti sunumeruotą failą download_strategy_add_a_numbered_file_description=Pridėti indeksą prie atsisiuntimo failo pavadinimo pabaigos download_strategy_override_existing_file=Pakeisti esamą failą download_strategy_override_existing_file_description=Pašalinti esamą atsisiuntimą ir rašyti į tą failą download_strategy_update_download_link=Atnaujinti esamą atsisiuntimą download_strategy_update_download_link_description=Atnaujinti esamą atsisiuntimo nuorodą ir jos prisijungimo duomenis download_strategy_show_downloaded_file=Rodyti atsisiųstą failą download_strategy_show_downloaded_file_description=Rodyti jau esamą atsisiuntimo elementą, kad galėtumėte spustelėti tęsti arba atidaryti batch_download_link_help=Įveskite nuorodą su pakaitos simboliais (naudokite *) invalid_url=Neteisingas URL list_is_too_large_maximum_n_items_allowed=Sąrašas per didelis\! leidžiama daugiausia {{count}} elementų enter_range=Įveskite diapazoną range_from=Nuo range_to=Iki batch_download_wildcard_length=Pakaitos simbolių ilgis first_link=Pirmoji nuoroda last_link=Paskutinė nuoroda open_source_software_used_in_this_app=Šioje programoje naudojama atvirojo kodo programinė įranga links=Nuorodos website=Svetainė developers=Kūrėjai source_code=Pirminis kodas license=Licencija no_license_found=Licencija nerasta organization=Organizacija add_new_queue=Pridėti naują eilę queue_name=Eilės pavadinimas queues=Eilės stop_queue=Stabdyti eilę start_queue=Pradėti eilę clear_queue_items=Išvalyti eilės elementus config=Konfigūracija items=Elementai move_down=Perkelti žemyn move_up=Perkelti aukštyn remove_queue=Pašalinti eilę queue_name_help=Nurodykite šios eilės pavadinimą queue_name_describe=Eilės pavadinimas yra {{value}} queue_max_concurrent_download=Maksimalus vienu metu atsisiuntimų skaičius queue_max_concurrent_download_description=Maksimalus atsisiuntimų skaičius šioje eilėje queue_automatic_stop=Automatinis sustabdymas queue_automatic_stop_description=Automatiškai sustabdyti eilę, kai joje nėra elementų queue_scheduler=Tvarkaraštis queue_enable_scheduler=Įjungti tvarkaraštį queue_active_days=Aktyvios dienos queue_active_days_description=Kuriomis dienomis veikia tvarkaraštis? queue_scheduler_enable_auto_start_time=Įjungti automatinį pradžios laiką queue_scheduler_auto_start_time=Automatinis pradžios laikas queue_scheduler_enable_auto_stop_time=Įjungti automatinį sustabdymo laiką queue_scheduler_auto_stop_time=Automatinis sustabdymo laikas queue_shutdown_on_completion=Išjungti sistemą užbaigus queue_shutdown_on_completion_description=Automatiškai išjungti sistemą, kai ši eilė bus užbaigta arba pasieks suplanuotą pabaigos laiką. appearance=Išvaizda download_engine=Atsisiuntimo variklis browser_integration=Naršyklės integracija settings_download_max_retries_count=Maksimalus atsisiuntimo bandymų skaičius settings_download_max_retries_count_description=Didžiausias kartų skaičius, kiek programa bandys iš naujo atsisiųsti nepavykusį atsisiuntimą prieš pasiduodant settings_download_max_retries_count_describe_no_retries=Nepavykę atsisiuntimai nebus kartojami settings_download_max_retries_count_describe_n_retries=Nepavykę atsisiuntimai bus kartojami {{count}} kartą(-us) settings_download_thread_count=Gijų skaičius settings_download_thread_count_description=Maksimalus atsisiuntimo gijų skaičius vienam atsisiuntimui settings_download_thread_count_describe=Atsisiuntimui galima naudoti iki {{count}} gijų settings_download_thread_count_with_large_value_describe=Įspėjimas\: Nustačius didelį gijų skaičių, gali padidėti sistemos išteklių naudojimas, sumažėti našumas arba kilti ryšio problemų su serveriais. Naudokite didesnes reikšmes tik suprasdami galimą poveikį sistemai ir tinklui. settings_use_server_last_modified_time=Naudoti serverio paskutinio keitimo laiką settings_use_server_last_modified_time_description=Atsisiunčiant failą, naudoti serverio paskutinio keitimo laiką vietiniam failui settings_append_extension_to_incomplete_downloads=Pridėti plėtinį nebaigtiems atsisiuntimams settings_append_extension_to_incomplete_downloads_description=Pridėti „.part“ plėtinį nebaigtiems atsisiuntimams. Tai padeda atpažinti nebaigtus atsisiuntimus ir apsaugo nuo netyčinio jų atidarymo. settings_use_sparse_file_allocation=Naudoti retą failų paskirstymą settings_use_sparse_file_allocation_description=Kurti failus efektyviau, ypač SSD diskuose, sumažinant nereikalingą duomenų rašymą. Tai gali pagreitinti atsisiuntimo pradžią ir sumažinti disko naudojimą. Jei atsisiuntimai prasideda lėtai arba pastebite neįprastą greitį, išjunkite šią parinktį, nes ji gali būti nepalaikoma kai kuriuose įrenginiuose. settings_ignore_ssl_certificates=Nepaisyti SSL sertifikatų settings_ignore_ssl_certificates_description=Išjungia SSL sertifikatų tikrinimą. Naudokite tik esant būtinybei, nes tai gali kelti saugumo riziką. settings_global_speed_limiter=Visuotinis greičio ribotuvas settings_global_speed_limiter_description=Bendras atsisiuntimo greičio limitas (0 reiškia neribojama) settings_show_average_speed=Rodyti vidutinį greitį settings_show_average_speed_description=Atsisiuntimo greitis – vidutinis arba tikslus settings_use_category_by_default=Pagal nutylėjimą naudoti kategoriją settings_use_category_by_default_description=Pagal nutylėjimą naudoti kategoriją pridedant atsisiuntimą. settings_default_download_folder=Numatytasis atsisiuntimų aplankas settings_default_download_folder_description=Pridedant naują atsisiuntimą, ši vieta bus naudojama pagal nutylėjimą settings_default_download_folder_describe="{}" bus naudojama settings_use_proxy=Naudoti tarpinį serverį settings_use_proxy_description=Naudoti tarpinį serverį failų atsisiuntimui settings_use_proxy_describe_no_proxy=Tarpių serverių nebus naudojama settings_use_proxy_describe_system_proxy=Bus naudojamas sistemos tarpinis serveris settings_use_proxy_describe_manual_proxy=Bus naudojamas „{{value}}“ settings_use_proxy_describe_pac_proxy=Bus naudojamas PAC failas „{{value}}“ settings_track_deleted_files_on_disk=Stebėti ištrintus failus diske settings_track_deleted_files_on_disk_description=Automatiškai pašalinti failus iš sąrašo, kai jie ištrinami arba perkeliami iš atsisiuntimų katalogo. settings_delete_partial_file_on_download_cancellation=Ištrinti dalinį failą atšaukus atsisiuntimą settings_delete_partial_file_on_download_cancellation_description=Kai atsisiuntimas atšaukiamas, dalinai atsisiųstas failas bus ištrintas iš disko. Tai padeda išlaikyti tvarką atsisiuntimų aplanke ir sumažina nereikalingą disko vietos naudojimą. Tačiau kitą kartą pradėjus, atsisiuntimas prasidės nuo pradžių. settings_default_user_agent=Numatytasis naudotojo agentas settings_default_user_agent_description=Nurodykite numatytąją naudotojo agento eilutę, kad apibrėžtumėte, kaip užklausos bus atpažįstamos serveriuose. Tai gali padėti pasiekti turinį, optimizuotą tam tikriems įrenginiams, arba apeiti kai kurių svetainių atsisiuntimo apribojimus. settings_download_size_unit=Download Size Unit settings_download_size_unit_description=Unit used to display the download size settings_download_speed_unit=Atsisiuntimo greičio vienetas settings_download_speed_unit_description=Vienetas, kuriuo rodomas atsisiuntimo greitis settings_theme=Tema settings_theme_description=Pasirinkite programos temą settings_default_dark_theme=Numatytoji tamsi tema settings_default_dark_theme_description=Taikoma, kai programa seka sistemos temą ir įjungtas tamsus režimas settings_default_light_theme=Numatytoji šviesi tema settings_default_light_theme_description=Taikoma, kai programa seka sistemos temą ir įjungtas šviesus režimas settings_font=Šriftas settings_font_description=Keisti programos sąsajos šriftą. Kai kurie šriftai gali būti netinkamai rodomi programoje. settings_ui_scale=Vartotojo sąsajos mastelis settings_ui_scale_description=Reguliuoti programos sąsajos elementų dydį settings_language=Kalba settings_compact_top_bar=Kompaktiška viršutinė juosta settings_compact_top_bar_description=Sujungti viršutinę juostą su antrašte, kai pagrindinis langas pakankamai platus settings_use_native_menu_bar=Naudoti natūralią meniu juostą settings_use_native_menu_bar_description=Naudoti sistemos numatytą meniu juostos stilių settings_use_relative_date_time=Naudoti santykinį datą/laiką settings_use_relative_date_time_description=Naudoti santykinį datą/laiką programoje (pvz., „prieš 2 dienas“ vietoj tikslios datos/laiko) settings_show_icon_labels=Rodyti piktogramų etiketes settings_show_icon_labels_description=Rodyti etiketes po piktogramomis, kai įmanoma (pvz., pagrindinės juostos veiksmai) settings_use_system_tray=Naudoti sistemos dėklą settings_use_system_tray_description=Rodyti programos piktogramą sistemos dėkle, kai programa veikia settings_start_on_boot=Paleisti paleidžiant sistemą settings_start_on_boot_description=Automatiškai paleisti programą prisijungus vartotojui settings_notification_sound=Pranešimo garsas settings_notification_sound_description=Grojamas garsas gavus naują pranešimą settings_browser_integration=Naršyklės integracija settings_browser_integration_description=Priimti atsisiuntimus iš naršyklių settings_browser_integration_server_port=Serverio prievadas settings_browser_integration_server_port_description=Prievadas naršyklės integracijai settings_browser_integration_server_port_describe=Programa klausysis {{port}} prievado settings_dynamic_part_creation=Dinaminis dalių kūrimas settings_dynamic_part_creation_description=Kai dalis baigiama, sukurti kitą dalį padalijant kitas dalis, kad pagerėtų atsisiuntimo greitis settings_show_completion_dialog=Rodyti atsisiuntimo pabaigos langą settings_show_completion_dialog_description=Automatiškai rodyti „Atsisiuntimas baigtas“ langą, kai atsisiuntimas baigtas. settings_show_download_progress_dialog=Rodyti atsisiuntimo eigos langą settings_show_download_progress_dialog_description=Automatiškai rodyti „Atsisiuntimo eiga“ langą, kai prasideda atsisiuntimas. settings_per_host_settings=Per Host Settings settings_per_host_settings_descriptions=These settings will be automatically applied to any new download that matches the specified host. settings_download_max_concurrent_downloads=Maximum Concurrent Downloads settings_download_max_concurrent_downloads_description=The maximum number of files that can be downloaded at the same time (downloads managed by queues are not counted; set to 0 for unlimited) download_item_settings_speed_limit=Greičio riba download_item_settings_speed_limit_description=Greičio riba šiam elementui download_item_settings_show_download_completion_dialog=Rodyti atsisiuntimo užbaigimo dialogą download_item_settings_show_download_completion_dialog_description=Automatiškai rodyti "Atsisiuntimas užbaigtas" dialogą, kai šis atsisiuntimas baigtas. download_item_settings_shutdown_on_completion=Išjungti sistemą užbaigus download_item_settings_shutdown_on_completion_description=Automatiškai išjungti sistemą, kai šis atsisiuntimas bus baigtas. download_item_settings_thread_count=Gijų skaičius download_item_settings_thread_count_description=Kiek gijų naudojama šiam atsisiuntimo elementui (0 – numatytajam) download_item_settings_thread_count_describe={{count}} gijų šiam atsisiuntimui download_item_settings_username_description=Pateikite naudotojo vardą, jei nuoroda yra apsaugotas išteklius download_item_settings_password_description=Pateikite slaptažodį, jei nuoroda yra apsaugotas išteklius download_item_settings_download_page=Atsisiuntimo puslapis download_item_settings_download_page_description=Interneto puslapis, kuriame buvo pradėtas šis atsisiuntimas download_item_settings_file_checksum=Failo kontrolinė suma download_item_settings_file_checksum_description=Maišos eilutė, kuri gali būti naudojama patikrinti, ar failas atsisiųstas teisingai download_item_settings_user_agent=User-Agent download_item_settings_user_agent_description=Custom User-Agent for this item (leave empty to use the default) file_checksum=Failo kontrolinė suma file_checksum_page=Failo kontrolinės sumos tikrintuvas file_checksum_page_file_checksum_default_algorithm=Numatytasis algoritmas file_checksum_page_file_checksum_default_algorithm_help=Numatytasis algoritmas, naudojamas apskaičiuoti failo kontrolines sumas, kai jos nepateikiamos. start=Pradėti calculated_checksum=Apskaičiuota kontrolinė suma saved_checksum=Išsaugota kontrolinė suma checksum_algorithm=Algoritmas file_not_found=Failas nerastas download_not_finished=Atsisiuntimas nebaigtas done=Atlikta waiting=Laukiama matches=Sutampa not_matches=Nesutampa copy_to_clipboard=Kopijuoti į iškarpinę username=Naudotojo vardas password=Slaptažodis average_speed=Vidutinis greitis exact_speed=Tikslus greitis unlimited=Neribotas use_global_settings=Naudoti bendruosius nustatymus cant_run_browser_integration=Nepavyksta paleisti naršyklės integracijos cant_open_file=Nepavyksta atidaryti failo cant_open_folder=Nepavyksta atidaryti katalogo # times for example 2 seconds ago relative_time_long_years={{years}} metų relative_time_long_months={{months}} mėnesių relative_time_long_days={{days}} dienų relative_time_long_hours={{hours}} valandų relative_time_long_minutes={{minutes}} minučių relative_time_long_seconds={{seconds}} sekundžių relative_time_short_years={{years}} m relative_time_short_months={{months}} mėn relative_time_short_days={{days}} d relative_time_short_hours={{hours}} val relative_time_short_minutes={{minutes}} min relative_time_short_seconds={{seconds}} s relative_time_left={{time}} liko relative_time_ago={{time}} atgal auto=Automatinis unspecified=Nenurodyta custom=Pasirinktinis icon=Piktograma author=Autorius link=Nuoroda size=Dydis status=Būsena parts_info_downloaded_size=Atsisiųsta parts_info_total_size=Iš viso speed=Greitis time_left=Liko laiko date_added=Pridėta data info=Informacija download_page_downloaded_size=Atsisiųsta download_page_download_completed=Atsisiuntimas baigtas resume_support=Tęsimo palaikymas yes=Taip no=Ne parts_info=Dalių informacija disconnected=Atsijungta receiving_data=Gaunami duomenys connecting=Jungiamasi warning=Įspėjimas unsupported_resume_warning=Šis atsisiuntimas nepalaiko tęsimo\! Gali tekti jį PERKRAUTI vėliau atsisiuntimų sąraše stop_anyway=Stabdyti vis tiek customize_columns=Pritaikyti stulpelius reset=Atkurti monday=Pirmadienis tuesday=Antradienis wednesday=Trečiadienis thursday=Ketvirtadienis friday=Penktadienis saturday=Šeštadienis sunday=Sekmadienis proxy_open_system_proxy_settings=Atidaryti sistemos tarpinio serverio nustatymus proxy_type=Tarpinio serverio tipas proxy_do_not_use_proxy_for=Nenaudoti tarpinio serverio proxy_do_not_use_proxy_for_description=URL sąrašas, kuriam neturėtų būti naudojamas tarpinis serveris\nGalite naudoti pakaitos simbolius su *\npvz., 192.168.1.* example.com (atskirti tarpais) proxy_change_title=Keisti tarpinį serverį change_proxy=Keisti tarpinį serverį proxy_no=Be tarpinio serverio proxy_system=Sistemos tarpinis serveris proxy_manual=Rankinis tarpinis serveris proxy_pac=Tarpinio serverio automatinė konfigūracija proxy_pac_url=Tarpinio serverio automatinės konfigūracijos URL address=Adresas port=Prievadas address_and_port=Adresas ir prievadas use_authentication=Naudoti autentifikaciją warning_you_may_have_to_restart_the_download_later=Gali tekti perkrauti atsisiuntimą vėliau\! edit_download_title=Redaguoti atsisiuntimą edit_download_update_from_download_page=Atnaujinti iš atsisiuntimo puslapio edit_download_update_from_download_page_description=Kai šis langas atidarytas, galite eiti į atsisiuntimo puslapį ir spustelėti atsisiuntimo mygtuką. Programėlė perims ir atnaujins naujus atsisiuntimo duomenis, kad galėtumėte juos išsaugoti. edit_download_saved_download_item_size_not_match=Išsaugotas atsisiuntimo elementas turi dydį {{currentSize}}, kuris nesutampa su nauju dydžiu {{newSize}}. translators_page_thanks=Su dėkingumu tiems, kurie padėjo išversti šį projektą ❤️ translators=Vertėjai language=Kalba translators_contribute_title=Pagerinti vertimus translators_contribute_description=Norite padėti pagerinti šį projektą? Jei jūsų kalba nėra sąraše arba reikia patobulinimų, galite prisidėti savo vertimais ir padaryti jį geresnį\! contribute=Prisidėti meet_the_translators=Susipažinkite su vertėjais localized_by_translators=Lokalizuota vertėjų confirm_exit=Patvirtinti išėjimą confirm_exit_description=Ar tikrai norite išeiti iš AB Atsisiuntimų Tvarkyklės?\nAktyvūs atsisiuntimai/eilės bus sustabdyti\! update=Atnaujinti update_updater=Atnaujinimo programa update_available=Galimas atnaujinimas update_error=Update Error update_available_suggest_to_to_update=Galite atnaujinti į naujausią versiją, kad mėgautumėtės naujomis funkcijomis, patobulinimais ir našumo gerinimais. update_release_notes=Leidimo pastabos update_check_for_update=Patikrinti atnaujinimus update_checking_for_update=Tikrinami atnaujinimai update_no_update=Naudojate naujausią versiją update_check_error=Klaida, bandant patikrinti atnaujinimus update_app_updated_to_version_n=Programėlė atnaujinta į versiją {{version}} create_desktop_entry=Sukurti darbalaukio elementą shutdown_alert=Išjungimo įspėjimas system_shutdown_soon=Sistema netrukus bus išjungta\! system_shutdown_failed=Nepavyko išjungti sistemos\! system_shutdown_soon_description=Sistema netrukus bus išjungta. Jei vis dar naudojate kompiuterį, išsaugokite savo darbą arba atšaukite išjungimą. system_shutdown_reason_queue_completed=Visi eilėje esantys atsisiuntimai baigti. system_shutdown_reason_queue_end_time_reached=Pasiektas suplanuotas atsisiuntimų eilės pabaigos laikas. system_shutdown_download_finished=Atsisiuntimas baigtas. shutdown_now=Išjungti dabar settings_per_host_settings_new_host= settings_per_host_settings_not_selected=Sukurkite arba pasirinkite naują elementą pirmiausia\! settings_per_host_settings_host=Šeimininkas settings_per_host_settings_host_description=Šie nustatymai bus taikomi atsisiuntimams, atitinkantiems šį šeimininko vardą. Palaikomi pakaitos simboliai (*) (pvz., example.com, *.example.com — naudokite tik vieną). settings_browser_in_launcher=Browser Icon In Launcher settings_browser_in_launcher_description=Show or hide the browser icon in the launcher (app list). sort_by=Sort By welcome=Welcome new_folder=New Folder skip=Skip lets_go=Let's Go next=Next select_all=Select All select_inside=Select Inside select_invert=Select Invert open_settings=Open Settings back=Back service_is_running=Service is running initial_setup_description=Let’s set things up initial_setup_notice=You can change these settings anytime later permission_granted=Permission granted permission_not_granted=Permission not granted permissions=Permissions give_permission=Allow permission give_storage_permission=Allow storage access storage_roots=Storage Roots permissions_initial_title=Permissions setup permissions_initial_description=To work properly, the app needs a few permissions. On the next screen, you’ll see what each permission is used for and you can decide which ones to allow or skip. permissions_done_title=You’re all set permissions_done_description=Everything is ready. All required permissions have been granted and the app is good to go. permissions_manage_storage_title=Manage storage access permissions_manage_storage_reason=This permission lets the app change the download folder, detect duplicate downloads more accurately, and enable some extra features. It’s optional, but recommended for the best experience. permission_read_write_external_storage_title=Read and write storage permission_read_write_external_storage_reason=This permission allows the app to save and manage downloaded files, change the download location, and improve duplicate download detection. permissions_post_notification_title=Post Notification permissions_post_notification_reason=The app needs to run in the background to manage downloads. Notifications are used to keep you informed and allow background operation. permissions_ignore_battery_optimization_title=Ignore Battery Optimization permissions_ignore_battery_optimization_reason=Some devices aggressively limit background activity to save battery, which can pause or stop downloads when the app isn’t open. You can optionally exclude the app from battery optimization to ensure downloads continue uninterrupted open_in_browser=Open In Browser browser=Browser browser_new_tab=New Tab browser_close_tab=Close Tab browser_open_in_new_tab=Open In New Tab browser_open_in_new_background_tab=Open In New Background Tab browser_no_tab_open=No tabs are open browser_tabs=Tabs browser_paste_and_go=Paste And Go browser_bookmarks=Bookmarks browser_add_bookmark=Add Bookmark browser_edit_bookmark=Edit Bookmark browser_add_to_bookmarks=Add To Bookmarks browser_remove_from_bookmarks=Remove From Bookmarks ================================================ FILE: shared/resources/src/commonMain/resources/com/abdownloadmanager/resources/locales/pl_PL.properties ================================================ app_title=AB Download Manager confirm_auto_categorize_downloads_title=Automatycznie kategoryzuj pobrane pliki confirm_auto_categorize_downloads_description=Każdy element bez kategorii zostanie automatycznie dodany do kategorii z nim powiązanej. confirm_reset_to_default_categories_title=Przywróć domyślne kategorie confirm_reset_to_default_categories_description=Spowoduje to USUNIĘCIE wszystkich kategorii i przywrócenie kategorii domyślnych\! confirm_delete_download_items_title=Potwierdź usunięcie confirm_delete_download_items_description=Czy na pewno chcesz usunąć {{count}} plików? confirm_delete_download_unfinished_items_description=Czy na pewno chcesz usunąć {{count}} niedokończonych pobrań? confirm_delete_download_finished_and_unfinished_items_description=Czy na pewno chcesz usunąć {{finishedCount}} dokończonych i {{unfinishedCount}} niedokończonych pobrań? also_delete_file_from_disk=Usuń również plik z dysku confirm_delete_category_item_title=Usuwanie kategorii {{name}} confirm_delete_category_item_description=Czy na pewno chcesz usunąć kategorię"{{value}}"? your_download_will_not_be_deleted=Pobrane pliki nie zostaną usunięte drag_the_file_to_another_app=Przenieś plik do innej aplikacji drop_link_or_file_here=Upuść tutaj link lub plik. nothing_will_be_imported=Nic nie zostanie zaimportowane n_links_will_be_imported={{count}} linków zostanie zaimportowanych n_items_selected={{count}} wybranych plików window_close=Zamknij window_minimize=Zminimalizuj window_maximize=Zmaksymalizuj window_restore=Przywróć delete=Skasuj remove=Usuń cancel=Anuluj close=Zamknij menu=Menu more_options=Więcej opcji ok=Ok add=Dodaj paste=Wklej change=Zmień edit=Edytuj change_anyway=Mimo wszystko zmień download=Pobierz refresh=Odśwież settings=Ustawienia on_completion=Po ukończeniu unknown=Nieznane unknown_error=Nieznany błąd download_item_not_found=Nie znaleziono pliku do pobrania name=Nazwa download_link=Link pobierania not_finished=Nie ukończone all=Wszystkie finished=Ukończone Unfinished=Niedokończone canceled=Anulowane error=Błąd paused=Wstrzymane downloading=Pobieranie added=Dodane idle=BEZCZYNNOŚĆ preparing_file=Przygotowywanie pliku creating_file=Tworzenie pliku resuming=Wznawianie retrying=Ponawianie list_is_empty=Lista jest pusta\! search_in_the_list=Wyszukuj na liście search=Szukaj clear=Wyczyść general=Ogólne enabled=Włączone disabled=Wyłączone default=Domyślny file=Plik tasks=Zadania tools=Narzędzia help=Pomoc system=System all_missing_files=Wszystkie brakujące pliki all_finished=Wszystkie zakończone all_unfinished=Wszystkie niedokończone entire_list=Cała lista download_browser_integration=Pobierz integrację z przeglądarką exit=Wyjdź show_downloads=Pokaż pobrania new_download=Nowe pobieranie stop_all=Zatrzymaj wszystkie import_from_clipboard=Importuj ze schowka batch_download=Pobieranie zbiorcze open=Otwórz share=Udostępnij open_file=Otwórz Plik open_folder=Otwórz folder resume=Wznów pause=Wstrzymaj restart_download=Zrestartuj pobieranie copy=Kopiuj copy_link=Kopiuj link copy_as_curl=Kopiuj jako cURL show_properties=Pokaż właściwości move_to_queue=Przenieś do kolejki move_to_this_queue=Przenieś do tej kolejki move_to_category=Przenieś do kategorii move_to_this_category=Przenieś do tej kategorii categories=Kategorie add_category=Dodaj kategorię edit_category=Edytuj kategorię delete_category=Usuń kategorię category_name=Nazwa kategorii category_download_location=Lokalizacja pobierania kategorii category_download_location_description=Po wybraniu tej kategorii w „Nowe zadanie pobierania” użyj tego katalogu jako „Lokalizacja pobierania” category_file_types=Rodzaje plików kategorii category_file_types_description=Automatycznie umieszczaj te typy plików w tej kategorii. (po dodaniu nowego pobierania)\nOddziel rozszerzenia plików spacją (rozszerzenie1 rozszerzenie2...) category_url_patterns=Wzory adresów URL category_url_patterns_description=Automatycznie umieszczaj pobieranie z tych adresów URL w tej kategorii. (po dodaniu nowego pobierania)\nOddziel adresy URL spacją, możesz również użyć * dla symboli wieloznacznych auto_categorize_downloads=Automatycznie kategoryzuj pobrania restore_defaults=Przywróć domyślne about=O programie version_n=Wersja {{value}} developed_with_love_for_you=Stworzone z ❤️ dla Ciebie donate=Wspomóż visit_the_project_website=Odwiedź stronę projektu this_is_a_free_and_open_source_software=To jest darmowe i otwarte oprogramowanie view_the_source_code=Zobacz kod źródłowy third_party_libraries=Biblioteki zewnętrzne powered_by_open_source_software=Zasilane przez otwarte oprogramowanie view_the_open_source_licenses=Zobacz licencje "Open Source" support_and_community=Wsparcie i społeczność telegram=Telegram channel=Kanał group=Grupa add_download=Nowe zadanie pobierania add_multi_download_page_header=Wybierz pliki, które chcesz pobrać save_to=Zapisz do where_should_each_item_saved=Gdzie powinien być zapisany każdy plik? there_are_multiple_items_please_select_a_way_you_want_to_save_them=Istnieje wiele plików\! Wybierz sposób, w jaki chcesz je zapisać each_item_on_its_own_category=Każdy plik we własnej kategorii each_item_on_its_own_category_description=Każdy plik zostanie umieszczony w kategorii, która posiada ten typ pliku all_items_in_one_category=Każdy plik w jednej kategorii all_items_in_one_category_description=Wszystkie pliki zostaną zapisane w wybranej kategorii all_items_in_one_Location=Każdy plik w jednej lokalizacji all_items_in_one_Location_description=Wszystkie pliki zostaną zapisane w wybranej lokalizacji unselected_all_items_in_specific_location_description=Wszystkie pliki zostaną zapisane w wybranej lokalizacji kategorii no_category_selected=Nie wybrano żadnej kategorii no_categories_found=Nie znaleziono żadnych kategorii download_location=Lokalizacja pobierania location=Lokalizacja select_queue=Wybierz kolejkę without_queue=Bez kolejki use_category=Użyj kategorii cant_write_to_this_folder=Nie można zapisać do tego folderu file_name_already_exists=Taki plik już istnieje download_already_exists=Takie pobieranie już istnieje invalid_file_name=Nieprawidłowa nazwa pliku show_solutions=Pokaż rozwiązania... change_solution=Zmień rozwiązanie select_a_solution=Wybierz rozwiązanie select_download_strategy_description=Podany link znajduje się już na liście pobrań, określ co chcesz w tym wypadku zrobić download_strategy_add_a_numbered_file=Numeruj plik download_strategy_add_a_numbered_file_description=Dodaj indeks na koniec nazwy pobieranego pliku download_strategy_override_existing_file=Nadpisz istniejący plik download_strategy_override_existing_file_description=Usuń istniejące pobieranie i zapisz do tego pliku download_strategy_update_download_link=Zaktualizuj istniejące pobieranie download_strategy_update_download_link_description=Zaktualizuj istniejący link pobierania i jego dane uwierzytelniające download_strategy_show_downloaded_file=Pokaż pobrany plik download_strategy_show_downloaded_file_description=Pokaż już istniejący pobrany plik, abyś mógł nacisnąć przycisk wznowienia lub go otworzyć batch_download_link_help=Wprowadź link, który zawiera symbole wieloznaczne (użyj *) invalid_url=Nieprawidłowy adres URL list_is_too_large_maximum_n_items_allowed=Lista jest zbyt duża\! Maksymalna dozwolona liczba plików to {{count}} enter_range=Wprowadź zakres range_from=Od range_to=Do batch_download_wildcard_length=Długość wieloznacznika first_link=Pierwszy link last_link=Ostatni link open_source_software_used_in_this_app=Otwarte oprogramowanie używane w tej aplikacji links=Linki website=Strona internetowa developers=Programiści source_code=Kod źródłowy license=Licencja no_license_found=Nie znaleziono licencji organization=Organizacja add_new_queue=Dodaj nową kolejkę queue_name=Nazwa kolejki queues=Kolejki stop_queue=Zatrzymaj kolejkę start_queue=Uruchom kolejkę clear_queue_items=Opróżnij kolejkę config=Konfiguracja items=Pliki move_down=Przesuń w dół move_up=Przesuń w górę remove_queue=Usuń kolejkę queue_name_help=Określ nazwę dla tej kolejki queue_name_describe=Nazwa kolejki to {{value}} queue_max_concurrent_download=Maksymalna ilość równoczesnych pobrań queue_max_concurrent_download_description=Maksymalna ilość pobrań dla tej kolejki queue_automatic_stop=Automatyczne zatrzymanie queue_automatic_stop_description=Automatycznie zatrzymaj kolejkę, gdy nie ma w niej plików queue_scheduler=Harmonogram queue_enable_scheduler=Włącz harmonogram queue_active_days=Aktywne dni queue_active_days_description=W jakie dni mają działać harmonogramy? queue_scheduler_enable_auto_start_time=Włącz czas automatycznego startu queue_scheduler_auto_start_time=Czas automatycznego startu queue_scheduler_enable_auto_stop_time=Włącz czas automatycznego startu queue_scheduler_auto_stop_time=Czas automatycznego zatrzymania queue_shutdown_on_completion=Wyłącz system po ukończeniu queue_shutdown_on_completion_description=Automatycznie wyłącz system po zakończeniu kolejki, lub po zaplanowanym czasie. appearance=Wygląd download_engine=Silnik pobierania browser_integration=Integracja z przeglądarką settings_download_max_retries_count=Maksymalna ilość prób pobierania settings_download_max_retries_count_description=Maksymalna liczba ponawianych przez aplikację prób nieudanego pobrania przed poddaniem się settings_download_max_retries_count_describe_no_retries=Nieudane pobrania nie będą ponawiane settings_download_max_retries_count_describe_n_retries=Nieudane pobrania zostaną ponowione {{count}} raz(y) settings_download_thread_count=Liczba wątków settings_download_thread_count_description=Maksymalna liczba wątków pobierania na plik settings_download_thread_count_describe=Pobieranie może mieć do {{count}} wątków settings_download_thread_count_with_large_value_describe=Ostrzeżenie\: Ustawienie dużej liczby wątków może zwiększyć wykorzystanie zasobów systemowych, zmniejszyć wydajność lub spowodować problemy z połączeniem z serwerami. Używaj wyższych wartości tylko wtedy, gdy rozumiesz ich potencjalny wpływ na system i sieć. settings_use_server_last_modified_time=Użyj czasu ostatniej modyfikacji serwera settings_use_server_last_modified_time_description=Podczas pobierania pliku używany jest czas ostatniej modyfikacji pliku lokalnego na serwerze settings_append_extension_to_incomplete_downloads=Dodaj rozszerzenie do niedokończonych pobrań settings_append_extension_to_incomplete_downloads_description=Dodaj rozszerzenie ".part" do niedokończonych pobrań. Pomaga to zidentyfikować niedokończone pobrania i zapobiec przypadkowemu otwarciu/uruchomieniu niekompletnych plików/programów. settings_use_sparse_file_allocation=Niewielka alokacja plików settings_use_sparse_file_allocation_description=Bardziej wydajne tworzenie plików, zwłaszcza na dyskach SSD, poprzez ograniczenie niepotrzebnego zapisu danych. Może to przyspieszyć rozpoczęcie pobierania i zmniejszyć zużycie dysku. Jeśli pobieranie rozpoczyna się powoli lub występuje nietypowa prędkość pobierania, należy rozważyć wyłączenie tej opcji, ponieważ może ona nie być w pełni obsługiwana na niektórych urządzeniach. settings_ignore_ssl_certificates=Ignoruj certyfikaty SSL settings_ignore_ssl_certificates_description=Wyłącza weryfikację certyfikatów SSL. Używaj tylko wtedy, gdy jest to konieczne, ponieważ może to narazić połączenie na zagrożenia bezpieczeństwa. settings_global_speed_limiter=Globalny ogranicznik prędkości settings_global_speed_limiter_description=Globalny limit prędkości pobierania (0 oznacza nieograniczony) settings_show_average_speed=Pokaż średnią prędkość settings_show_average_speed_description=Średnia lub dokładna prędkość pobierania settings_use_category_by_default=Domyślnie używaj kategorii settings_use_category_by_default_description=Domyślnie użyj kategorii podczas dodawania pobierania. settings_default_download_folder=Domyślny folder pobierania settings_default_download_folder_description=Ta lokalizacja będzie używana jako domyślna podczas dodawania nowego pobierania settings_default_download_folder_describe="{{folder}}" zostanie użyty settings_use_proxy=Użyj proxy settings_use_proxy_description=Użyj proxy do pobierania plików settings_use_proxy_describe_no_proxy=Nie używaj proxy settings_use_proxy_describe_system_proxy=Użyj domyślnego proxy systemowego settings_use_proxy_describe_manual_proxy="{{value}}" zostanie użyte settings_use_proxy_describe_pac_proxy=Plik pac "{{value}}" będzie używany settings_track_deleted_files_on_disk=Śledź usunięte pliki na dysku settings_track_deleted_files_on_disk_description=Automatyczne usuwanie plików z listy po ich usunięciu lub przeniesieniu z folderu pobierania. settings_delete_partial_file_on_download_cancellation=Usuń plik częściowy przy anulowaniu pobierania settings_delete_partial_file_on_download_cancellation_description=Gdy pobieranie zostanie anulowane, częściowo pobrany plik zostanie usunięty z dysku, zmniejszając niepotrzebne użycie miejsca na dysku. Pobieranie zostanie jednak zrestartowane do początku przy następnym uruchomieniu. settings_default_user_agent=Domyślny User Agent settings_default_user_agent_description=Określ domyślny ciąg "User Agent", aby zdefiniować sposób, w jaki żądania identyfikują się z serwerami. Może to pomóc w uzyskaniu dostępu do treści zoptymalizowanych dla określonych urządzeń lub w obejściu ograniczeń pobierania nałożonych przez niektóre witryny internetowe. settings_download_size_unit=Jednostka prędkości pobierania settings_download_size_unit_description=Jednostka używana do wyświetlania prędkości pobierania settings_download_speed_unit=Jednostka prędkości pobierania settings_download_speed_unit_description=Jednostka używana do wyświetlania prędkości pobierania settings_theme=Motyw settings_theme_description=Wybierz motyw aplikacji settings_default_dark_theme=Domyślny ciemny motyw settings_default_dark_theme_description=Stosuje się, gdy aplikacja śledzi motyw systemowy, a ciemny motyw jest aktywny settings_default_light_theme=Domyślny jasny motyw settings_default_light_theme_description=Stosuje się, gdy aplikacja śledzi motyw systemowy, a jasny motyw jest aktywny settings_font=Czcionka settings_font_description=Zmień czcionkę używaną w interfejsie aplikacji, niektóre czcionki mogą nie wyświetlać się poprawnie w aplikacji. settings_ui_scale=Skala interfejsu settings_ui_scale_description=Dostosuj rozmiar elementów interfejsu aplikacji settings_language=Język settings_compact_top_bar=Kompaktowy pasek górny settings_compact_top_bar_description=Scal górny pasek z paskiem tytułowym, gdy główne okno ma wystarczającą szerokość settings_use_native_menu_bar=Użyj natywnego paska menu settings_use_native_menu_bar_description=Użyj domyślnego stylu paska menu systemu settings_use_relative_date_time=Użyj względnej daty/czasu settings_use_relative_date_time_description=Użyj względnego formatu daty/czasu dla dat w aplikacji (np. "2 dni temu" zamiast dokładnej daty/czasu) settings_show_icon_labels=Pokaż etykiety ikon settings_show_icon_labels_description=Pokaż etykiety pod ikonami, jeśli to możliwe (np. akcje paska narzędzi głównych) settings_use_system_tray=Użyj zasobnika systemowego settings_use_system_tray_description=Pokaż ikonę w zasobniku systemowym, gdy aplikacja jest uruchomiona settings_start_on_boot=Uruchom przy starcie systemu settings_start_on_boot_description=Automatycznie uruchom aplikację przy logowaniu użytkownika settings_notification_sound=Dźwięk powiadomienia settings_notification_sound_description=Odtwórz dźwięk przy nowym powiadomieniu settings_browser_integration=Integracja z przeglądarką settings_browser_integration_description=Akceptuj pobrania z przeglądarek settings_browser_integration_server_port=Port serwera settings_browser_integration_server_port_description=Port do integracji z przeglądarką settings_browser_integration_server_port_describe=Aplikacja będzie słuchać portu {{port}} settings_dynamic_part_creation=Dynamiczne tworzenie części settings_dynamic_part_creation_description=Gdy część zostanie ukończona, utwórz kolejną część, dzieląc inne części, aby poprawić prędkość pobierania settings_show_completion_dialog=Pokaż okno dialogowe zakończenia pobierania settings_show_completion_dialog_description=Automatycznie pokaż okno dialogowe "Pobieranie zakończone" po zakończeniu pobierania. settings_show_download_progress_dialog=Pokaż okno postępu pobierania settings_show_download_progress_dialog_description=Automatycznie pokaż okno dialogowe "Postęp pobierania" po rozpoczęciu pobierania. settings_per_host_settings=Ustawienia dla serwera settings_per_host_settings_descriptions=Te ustawienia zostaną automatycznie zastosowane do każdego nowego pobierania, które pasuje do określonego serwera. settings_download_max_concurrent_downloads=Maximum Concurrent Downloads settings_download_max_concurrent_downloads_description=The maximum number of files that can be downloaded at the same time (downloads managed by queues are not counted; set to 0 for unlimited) download_item_settings_speed_limit=Limit prędkości download_item_settings_speed_limit_description=Ogranicz prędkość pobierania dla tego pliku download_item_settings_show_download_completion_dialog=Pokaż okno dialogowe zakończenia pobierania download_item_settings_show_download_completion_dialog_description=Automatycznie pokaż okno dialogowe "Pobieranie zakończone" po zakończeniu tego pobierania. download_item_settings_shutdown_on_completion=Wyłącz system po zakończeniu download_item_settings_shutdown_on_completion_description=Automatycznie wyłącz system po zakończeniu pobierania. download_item_settings_thread_count=Liczba wątków download_item_settings_thread_count_description=Ile wątków ma zostać użytych do pobrania tego pliku (domyślnie\: 0) download_item_settings_thread_count_describe={{count}} wątków dla tego pobrania download_item_settings_username_description=Podaj nazwę użytkownika, jeśli link jest chronionym zasobem download_item_settings_password_description=Podaj hasło, jeśli link jest chronionym zasobem download_item_settings_download_page=Strona pobierania download_item_settings_download_page_description=Strona internetowa, na której rozpoczęto pobieranie download_item_settings_file_checksum=Suma kontrolna pliku download_item_settings_file_checksum_description=Ciąg "hash", który może być użyty do sprawdzenia, czy plik został poprawnie pobrany download_item_settings_user_agent=User-Agent download_item_settings_user_agent_description=Niestandardowy User-Agent dla tego elementu (zostaw puste, aby użyć domyślnego) file_checksum=Suma kontrolna pliku file_checksum_page=Sprawdzanie sumy kontrolnej pliku file_checksum_page_file_checksum_default_algorithm=Domyślny algorytm file_checksum_page_file_checksum_default_algorithm_help=Domyślny algorytm używany do obliczania sum kontrolnych plików, gdy nie zostały one podane. start=Rozpocznij calculated_checksum=Obliczona suma kontrolna saved_checksum=Zapisana suma kontrolna checksum_algorithm=Algorytm file_not_found=Nie znaleziono pliku download_not_finished=Pobieranie nieukończone done=Gotowe waiting=Oczekiwanie matches=Dopasowania not_matches=Niepasujące copy_to_clipboard=Kopiuj do schowka username=Nazwa użytkownika password=Hasło average_speed=Średnia prędkość exact_speed=Dokładna prędkość unlimited=Nieograniczona use_global_settings=Użyj ustawień globalnych cant_run_browser_integration=Nie można uruchomić integracji z przeglądarką cant_open_file=Nie można otworzyć pliku cant_open_folder=Nie można otworzyć folderu # times for example 2 seconds ago relative_time_long_years={{years}} lat relative_time_long_months={{months}} miesięcy relative_time_long_days={{days}} dni relative_time_long_hours={{hours}} godzin relative_time_long_minutes={{minutes}} minut relative_time_long_seconds={{seconds}} sekund relative_time_short_years={{years}} lat relative_time_short_months={{months}} mies. relative_time_short_days={{days}} dni relative_time_short_hours={{hours}} godz. relative_time_short_minutes={{minutes}} min. relative_time_short_seconds={{seconds}} sek. relative_time_left={{time}} relative_time_ago={{time}} temu auto=Auto unspecified=Nieokreślona custom=Niestandardowa icon=Ikona author=Autor link=Link size=Rozmiar status=Status parts_info_downloaded_size=Pobrano parts_info_total_size=Ogółem speed=Prędkość time_left=Pozostały czas date_added=Data dodania info=Info download_page_downloaded_size=Pobrano download_page_download_completed=Pobieranie zakończone resume_support=Wsparcie ponawiania yes=Tak no=Nie parts_info=Informacje o częściach disconnected=Rozłączono receiving_data=Pobieranie danych connecting=Łączenie warning=Ostrzeżenie unsupported_resume_warning=To pobieranie nie obsługuje wznawiania\! Być może będziesz musiał ZRESETOWAĆ je później z poziomu listy pobierania stop_anyway=Zatrzymaj mimo to customize_columns=Dostosuj kolumny reset=Zresetuj monday=Poniedziałek tuesday=Wtorek wednesday=Środa thursday=Czwartek friday=Piątek saturday=Sobota sunday=Niedziela proxy_open_system_proxy_settings=Otwórz systemowe ustawienia proxy proxy_type=Typ proxy proxy_do_not_use_proxy_for=Nie używaj proxy dla proxy_do_not_use_proxy_for_description=Lista adresów URL, które nie mogą korzystać z proxy\nMożesz użyć symboli wieloznacznych z *\nna przykład 192.168.1.* example.com (oddzielone spacjami) proxy_change_title=Zmień serwer proxy change_proxy=Zmień serwer proxy proxy_no=Nie używaj proxy proxy_system=Systemowy serwer proxy proxy_manual=Ręczne ustawienia proxy proxy_pac=Automatyczna konfiguracja serwera proxy proxy_pac_url=Automatyczna konfiguracja URL serwera proxy address=Adres port=Port address_and_port=Adres i port use_authentication=Użyj uwierzytelniania warning_you_may_have_to_restart_the_download_later=Konieczne może być późniejsze ponowne uruchomienie pobierania\! edit_download_title=Edytuj zadanie pobierania edit_download_update_from_download_page=Aktualizuj ze strony pobierania edit_download_update_from_download_page_description=Po otwarciu tego okna można przejść do strony pobierania i kliknąć przycisk pobierania. Aplikacja przechwyci i zaktualizuje nowe dane uwierzytelniające pobierania, aby można je było zapisać. edit_download_saved_download_item_size_not_match=Pobrany plik ma rozmiar {{currentSize}}, który nie pasuje do nowego rozmiaru {{newSize}}. translators_page_thanks=Z wdzięcznością dla tych, którzy pomogli przetłumaczyć ten projekt ❤️ translators=Tłumacze language=Język translators_contribute_title=Popraw tłumaczenie translators_contribute_description=Chcesz pomóc ulepszyć ten projekt? Jeśli Twojego języka nie ma na liście lub wymaga on pewnych poprawek, możesz pomóc przy tłumaczeniu\! contribute=Udziel się meet_the_translators=Poznaj tłumaczy localized_by_translators=Zlokalizowane przez tłumaczy confirm_exit=Potwierdź wyjście confirm_exit_description=Czy na pewno chcesz wyjść z AB Download Manager?\nAktywne pobrania/kolejki zostaną zatrzymane\! update=Aktualizacje update_updater=Aktualizator update_available=Dostępna aktualizacja update_error=Błąd aktualizacji update_available_suggest_to_to_update=Możesz zaktualizować do najnowszej wersji, aby cieszyć się nowymi funkcjami, ulepszeniami i poprawkami wydajności. update_release_notes=Informacje o wydaniu update_check_for_update=Sprawdź aktualizacje update_checking_for_update=Sprawdzanie dostępności aktualizacji update_no_update=Używasz najnowszej wersji update_check_error=Błąd podczas sprawdzania aktualizacji update_app_updated_to_version_n=Aplikacja została zaktualizowana do wersji {{version}} create_desktop_entry=Utwórz wpis na pulpicie shutdown_alert=Alarm Wyłączania system_shutdown_soon=System zostanie wkrótce wyłączony\! system_shutdown_failed=Wyłączanie systemu nie powiodło się\! system_shutdown_soon_description=System zostanie wkrótce wyłączony. Jeśli nadal korzystasz z komputera, zapisz swoją pracę lub anuluj wyłączenie. system_shutdown_reason_queue_completed=Wszystkie pobierania w kolejce są zakończone. system_shutdown_reason_queue_end_time_reached=Osiągnięto zaplanowany czas zakończenia kolejki pobierania. system_shutdown_download_finished=Pobieranie zakończone. shutdown_now=Wyłącz teraz settings_per_host_settings_new_host= settings_per_host_settings_not_selected=Najpierw utwórz lub wybierz nowy element\! settings_per_host_settings_host=Serwer settings_per_host_settings_host_description=Te ustawienia zostaną przypisane do pobrań, których nazwa hosta pasuje do podanego wzorca. Obsługiwane są proste symbole (np. *), jak w wyrażeniach regularnych (przykładowo\: example.com, *.example.com — użyj tylko jednego). settings_browser_in_launcher=Browser Icon In Launcher settings_browser_in_launcher_description=Show or hide the browser icon in the launcher (app list). sort_by=Sortuj według welcome=Witaj new_folder=Nowy folder skip=Pomiń lets_go=Zacznijmy next=Dalej select_all=Zaznacz wszystko select_inside=Zaznaczenie wewnątrz select_invert=Zaznaczenie odwrócone open_settings=Otwórz ustawienia back=Wstecz service_is_running=Usługa jest uruchomiona initial_setup_description=Przygotujmy wszystko initial_setup_notice=Możesz zmienić te ustawienia w dowolnym momencie permission_granted=Uprawnienia przyznane permission_not_granted=Nie przyznano uprawnień permissions=Uprawnienia give_permission=Przyznaj uprawnienia give_storage_permission=Zezwól na dostęp do pamięci storage_roots=Storage Roots permissions_initial_title=Konfiguracja uprawnień permissions_initial_description=Aby działać prawidłowo, aplikacja potrzebuje kilku uprawnień. Na następnym ekranie zobaczysz, do czego każde uprawnienie jest używane i możesz zdecydować, na które z nich chcesz zezwolić i które pominąć. permissions_done_title=Wszystko gotowe permissions_done_description=Wszystkie wymagane uprawnienia zostały przyznane i aplikacja jest gotowa do działania. permissions_manage_storage_title=Zarządzaj dostępem do pamięci permissions_manage_storage_reason=To uprawnienie pozwala aplikacji na zmianę folderu pobierania, dokładniejsze wykrywanie duplikatów pobranych plików i włączanie dodatkowych funkcji. Jest ono opcjonalne, ale zalecane. permission_read_write_external_storage_title=Odczyt i zapis pamięci permission_read_write_external_storage_reason=To uprawnienie pozwala aplikacji na zapisywanie i zarządzanie pobranymi plikami, zmianę lokacji pobierania i lepsze wykrywanie duplikatów. permissions_post_notification_title=Wysyłanie powiadomień permissions_post_notification_reason=Aplikacja musi działać w tle, aby zarządzać pobieraniem. Powiadomienia są używane do informowania Cię o stanie pobierania i zezwalania na operację w tle. permissions_ignore_battery_optimization_title=Ignoruj optymalizacje baterii permissions_ignore_battery_optimization_reason=Niektóre urządzenia agresywnie ograniczają aktywność aplikacji w tle, by zredukować zużycie baterii, co może zatrzymać pobieranie, gdy aplikacja nie jest otwarta. Opcjonalnie możesz wykluczyć tę aplikację z optymalizacji baterii, aby upewnić się, że pobieranie będzie kontynuowane po jej zamknięciu open_in_browser=Otwórz w przeglądarce browser=Przeglądarka browser_new_tab=New Tab browser_close_tab=Close Tab browser_open_in_new_tab=Otwórz w nowej karcie browser_open_in_new_background_tab=Otwórz w nowej karcie w tle browser_no_tab_open=Żadne karty nie zostały otworzone browser_tabs=Karty browser_paste_and_go=Wklej i przejdź browser_bookmarks=Bookmarks browser_add_bookmark=Add Bookmark browser_edit_bookmark=Edit Bookmark browser_add_to_bookmarks=Add To Bookmarks browser_remove_from_bookmarks=Remove From Bookmarks ================================================ FILE: shared/resources/src/commonMain/resources/com/abdownloadmanager/resources/locales/pt_BR.properties ================================================ app_title=AB Download Manager confirm_auto_categorize_downloads_title=Categorizar automaticamente os downloads confirm_auto_categorize_downloads_description=Qualquer item não categorizado será automaticamente adicionado à sua categoria relacionada. confirm_reset_to_default_categories_title=Redefinir para as categorias padrão confirm_reset_to_default_categories_description=Isso REMOVERÁ todas as categorias e trará de volta as categorias padrão\! confirm_delete_download_items_title=Confirmar exclusão confirm_delete_download_items_description=Tem certeza de que deseja excluir {{count}} itens? confirm_delete_download_unfinished_items_description=Tem certeza que deseja excluir {{count}} downloads não concluídos? confirm_delete_download_finished_and_unfinished_items_description=Tem certeza que deseja excluir {{finishedCount}} downloads concluídos e {{unfinishedCount}} não concluídos? also_delete_file_from_disk=Excluir também o arquivo do disco confirm_delete_category_item_title=Removendo a categoria {{name}} confirm_delete_category_item_description=Tem certeza que deseja excluir a categoria "{{value}}"? your_download_will_not_be_deleted=Seus downloads não serão deletados drag_the_file_to_another_app=Arraste o arquivo para outro aplicativo drop_link_or_file_here=Solte o link ou arquivo aqui. nothing_will_be_imported=Nada será importado n_links_will_be_imported={count}} links serão importados n_items_selected={{count}} itens selecionados window_close=Fechar window_minimize=Minimizar window_maximize=Maximizar window_restore=Restaurar delete=Excluir remove=Remover cancel=Cancelar close=Fechar menu=Menu more_options=Mais opções ok=Ok add=Adicionar paste=Colar change=Mudança edit=Editar change_anyway=Mudar mesmo assim download=Download refresh=Atualizar settings=Configurações on_completion=Ao concluir unknown=Desconhecido unknown_error=Erro desconhecido download_item_not_found=Download do item não encontrado name=Nome download_link=Link de download not_finished=Não finalizado all=Categorias finished=Concluído Unfinished=Inacabado canceled=Cancelado error=Erro paused=Pausado downloading=Baixando added=Adicionado idle=INATIVO preparing_file=Preparando Arquivo creating_file=Criando Arquivo resuming=Continuar retrying=Repetindo list_is_empty=A lista está vazia\! search_in_the_list=Pesquisar na Lista search=Pesquisar clear=Limpar general=Geral enabled=Ativado disabled=Desativado default=Padrão file=Arquivo tasks=Tarefas tools=Ferramentas help=Ajuda system=Sistema all_missing_files=Todos os arquivos ausentes all_finished=Todos concluídos all_unfinished=Todos não concluídos entire_list=Lista Completa download_browser_integration=Baixar integração do navegador exit=Sair show_downloads=Mostrar downloads new_download=Novo download stop_all=Parar Tudo import_from_clipboard=Importar da área de transferência batch_download=Download em lote open=Abrir share=Compartilhar open_file=Abrir Arquivo open_folder=Abrir Pasta resume=Retomar pause=Pausar restart_download=Reiniciar download copy=Copiar copy_link=Copiar link copy_as_curl=Copiar como cURL show_properties=Mostrar Propriedades move_to_queue=Mover para a fila move_to_this_queue=Mover para esta fila move_to_category=Mover Para a Categoria move_to_this_category=Mover para esta categoria categories=Categorias add_category=Adicionar Categoria edit_category=Editar Categoria delete_category=Excluir Categoria category_name=Nome da Categoria category_download_location=Local de download da categoria category_download_location_description=Quando esta categoria for escolhida em "Adicionar download", use este diretório como "Local de download" category_file_types=Tipos de arquivo de categoria category_file_types_description=Coloque automaticamente esses tipos de arquivo nesta categoria. (quando você adicionar um novo download)\nSepare as extensões de arquivo com espaço (ext1 ext2 ...) category_url_patterns=Padrões de URL category_url_patterns_description=Coloque automaticamente o download dessas URLs nesta categoria. (quando você adicionar um novo download)\nSepare as URLs com espaço; você também pode usar * como caractere curinga. auto_categorize_downloads=Categorizar automaticamente os downloads restore_defaults=Restaurar Padrões about=Sobre version_n=Versão {{value}} developed_with_love_for_you=Desenvolvido com ❤️ para você donate=Doar visit_the_project_website=Visite o site do projeto this_is_a_free_and_open_source_software=Este é um software livre e de código aberto view_the_source_code=Veja o código-fonte third_party_libraries=Bibliotecas de Terceiros powered_by_open_source_software=Desenvolvido por Open Source Software view_the_open_source_licenses=Ver as licenças da Open-Source support_and_community=Suporte e comunidade telegram=Telegram channel=Canal group=Grupo add_download=Adicionar download add_multi_download_page_header=Selecione os itens que você deseja selecionar para baixar save_to=Salvar para where_should_each_item_saved=Onde cada item deve ser salvo? there_are_multiple_items_please_select_a_way_you_want_to_save_them=Existem vários itens\! Por favor, selecione uma maneira que queira salvá-los each_item_on_its_own_category=Cada item em sua própria categoria each_item_on_its_own_category_description=Cada item será colocado em uma categoria que tem esse tipo de arquivo all_items_in_one_category=Todos os itens em uma Categoria all_items_in_one_category_description=Todos os arquivos serão salvos no local da categoria selecionada all_items_in_one_Location=Todos os itens em um só Local all_items_in_one_Location_description=Todos os itens serão salvos no diretório selecionado unselected_all_items_in_specific_location_description=Todos os arquivos serão salvos no local da categoria selecionada no_category_selected=Nenhuma categoria selecionada no_categories_found=Nenhuma categoria encontrada download_location=Local de download location=Localização select_queue=Selecionar fila without_queue=Sem fila use_category=Usar Categoria cant_write_to_this_folder=Não foi possível gravar nesta pasta file_name_already_exists=Nome de arquivo já existe download_already_exists=O download já existe invalid_file_name=Nome de arquivo inválido show_solutions=Mostrar soluções... change_solution=Alterar solução select_a_solution=Selecione uma solução select_download_strategy_description=O link que você forneceu já está na lista de downloads, por favor, especifique o que você quer fazer download_strategy_add_a_numbered_file=Adicionar um arquivo numerado download_strategy_add_a_numbered_file_description=Adicionar um índice após o final do nome do arquivo de download download_strategy_override_existing_file=Sobrescrever o arquivo existente download_strategy_override_existing_file_description=Remover o download existente e escrever nesse arquivo download_strategy_update_download_link=Atualizar download existente download_strategy_update_download_link_description=Atualizar o link de download existente e suas credenciais download_strategy_show_downloaded_file=Mostrar arquivo baixado download_strategy_show_downloaded_file_description=Mostrar o item de download já existente, assim você pode pressionar em continuar ou abri-lo batch_download_link_help=Digite um link que contém caracteres curinga (use *) invalid_url=URL Inválida list_is_too_large_maximum_n_items_allowed=A lista é muito grande\! Máximo de {{count}} itens permitidos enter_range=Insira o intervalo range_from=De range_to=Para batch_download_wildcard_length=Comprimento do curinga first_link=Primeiro Link last_link=Último link open_source_software_used_in_this_app=Software de código aberto usado neste aplicativo links=Links website=Site developers=Desenvolvedores source_code=Código Fonte license=Licença no_license_found=Nenhuma licença encontrada organization=Organização add_new_queue=Adicionar nova fila queue_name=Nome da fila queues=Filas stop_queue=Parar fila start_queue=Iniciar fila clear_queue_items=Fila vazia config=Configuração items=Itens move_down=Mover para baixo move_up=Mover para cima remove_queue=Remover fila queue_name_help=Especifique um nome para esta fila queue_name_describe=Nome da fila é {{value}} queue_max_concurrent_download=Download máximo simultâneo queue_max_concurrent_download_description=Download máximo para esta fila queue_automatic_stop=Parada automática queue_automatic_stop_description=Parar automaticamente a fila quando não houver nenhum item nela queue_scheduler=Agendador queue_enable_scheduler=Ativar o agendador queue_active_days=Dias ativos queue_active_days_description=Em quais dias a funcionalidade dos agendadores funcionará? queue_scheduler_enable_auto_start_time=Habilitar hora de início automático queue_scheduler_auto_start_time=Hora de início automático queue_scheduler_enable_auto_stop_time=Ativar tempo de parada automática queue_scheduler_auto_stop_time=Tempo para parada automática queue_shutdown_on_completion=Desligar o sistema ao concluir queue_shutdown_on_completion_description=Automaticamente desliga o sistema quando esta fila é concluída. ou quando o horário de término programado é atingido. appearance=Aparência download_engine=Mecanismo de download browser_integration=Integração com Navegadores settings_download_max_retries_count=Máximo de tentativas de download settings_download_max_retries_count_description=Número máximo de vezes que o aplicativo tentará baixar novamente antes de desistir settings_download_max_retries_count_describe_no_retries=Downloads com falha não serão repetidos settings_download_max_retries_count_describe_n_retries=Os downloads com falha serão repetidos {{count}} vez(es) settings_download_thread_count=Contagem de threads settings_download_thread_count_description=Máximo de threads de download por item de download settings_download_thread_count_describe=Um download pode ter até {{count}} threads settings_download_thread_count_with_large_value_describe=Aviso\: Definir uma contagem de threads alta pode aumentar o uso de recursos do sistema, reduzir o desempenho ou causar problemas de conexão com servidores. Use valores mais altos somente se você entender o impacto potencial em seu sistema e rede. settings_use_server_last_modified_time=Usar o horário de última modificação do servidor settings_use_server_last_modified_time_description=Ao baixar um arquivo, use o horário de última modificação do servidor para o arquivo local settings_append_extension_to_incomplete_downloads=Acrescentar extensão aos downloads incompletos settings_append_extension_to_incomplete_downloads_description=Acrescentar a extensão ".part" para downloads incompletos. Isso ajuda a identificar downloads incompletos e impede a abertura acidental deles. settings_use_sparse_file_allocation=Alocação de arquivo esparso settings_use_sparse_file_allocation_description=Crie arquivos de forma mais eficiente, especialmente em SSDs, reduzindo gravações de dados desnecessárias. Isso pode acelerar o início dos downloads e reduzir o uso do disco. Se os downloads começarem lentamente ou você experimentar velocidades de download incomuns, considere desativar esta opção, pois pode não ser totalmente suportada em alguns dispositivos. settings_ignore_ssl_certificates=Ignorar certificados SSL settings_ignore_ssl_certificates_description=Desabilita a verificação de certificado SSL. Use somente se necessário, pois pode expor a sua conexão a riscos de segurança. settings_global_speed_limiter=Limitador de Velocidade Global settings_global_speed_limiter_description=Limite da velocidade de download global (0 significa ilimitado) settings_show_average_speed=Mostrar Velocidade Média settings_show_average_speed_description=Velocidade de download em média ou precisão settings_use_category_by_default=Usar categoria como padrão settings_use_category_by_default_description=Usar categoria por padrão ao adicionar um download. settings_default_download_folder=Pasta de Download Padrão settings_default_download_folder_description=Quando você adicionar um novo download, este local é usado por padrão settings_default_download_folder_describe="{{folder}}" será usada settings_use_proxy=Usar Proxy settings_use_proxy_description=Usar proxy para baixar arquivos settings_use_proxy_describe_no_proxy=Nenhum Proxy será utilizado settings_use_proxy_describe_system_proxy=Sistema de Proxy será usado settings_use_proxy_describe_manual_proxy="{{value}}" será utilizado settings_use_proxy_describe_pac_proxy=arquivo pac "{{value}}" será usado settings_track_deleted_files_on_disk=Rastrear arquivos excluídos no disco settings_track_deleted_files_on_disk_description=Remover automaticamente os arquivos da lista quando eles são excluídos ou movidos da pasta de download. settings_delete_partial_file_on_download_cancellation=Excluir o arquivo parcial ao cancelar download settings_delete_partial_file_on_download_cancellation_description=Quando um download for cancelado, o arquivo parcialmente baixado será excluído do disco. Isso ajuda a manter sua pasta de download limpa e reduz o uso de espaço em disco desnecessário. No entanto, o download reiniciará do início da próxima vez que você iniciá-lo. settings_default_user_agent=User Agent Padrão settings_default_user_agent_description=Especifique a string padrão do User Agent para definir como as solicitações se identificam aos servidores. Isso pode ajudar a acessar conteúdo otimizado para determinados dispositivos ou a contornar limitações de download impostas por certos sites. settings_download_size_unit=Unidade de tamanho de download settings_download_size_unit_description=Unidade usada para exibir o tamanho do download settings_download_speed_unit=Unidade de Velocidade de Download settings_download_speed_unit_description=Unidade usada para exibir a velocidade de download settings_theme=Tema settings_theme_description=Selecione um tema para o App settings_default_dark_theme=Tema escuro padrão settings_default_dark_theme_description=Aplica-se quando o aplicativo segue o tema do sistema e o modo escuro está ativo settings_default_light_theme=Tema claro padrão settings_default_light_theme_description=Aplica-se quando o aplicativo segue o tema do sistema e o modo claro está ativo settings_font=Fonte settings_font_description=Alterar a fonte utilizada na interface do aplicativo. Algumas fontes podem não ser exibidas corretamente no aplicativo. settings_ui_scale=Escala da Interface settings_ui_scale_description=Ajustar o tamanho dos elementos da interface do aplicativo settings_language=Idioma settings_compact_top_bar=Barra Compacta Superior settings_compact_top_bar_description=Mesclar a barra superior com a barra de título quando a janela principal tiver largura suficiente settings_use_native_menu_bar=Usar barra de menu nativa settings_use_native_menu_bar_description=Usar estilo da barra de menu padrão do sistema settings_use_relative_date_time=Usar data/hora relativa settings_use_relative_date_time_description=Usar formato de data/hora relativa para datas no aplicativo (por exemplo, "2 dias atrás" em vez da data/hora exata) settings_show_icon_labels=Mostrar Marcadores de Ícones settings_show_icon_labels_description=Mostrar rótulos abaixo dos ícones quando possível (como ações na barra de ferramentas iniciais) settings_use_system_tray=Usar bandeja do sistema settings_use_system_tray_description=Mostrar ícone da bandeja do sistema quando o aplicativo estiver em execução settings_start_on_boot=Executar ao iniciar settings_start_on_boot_description=Iniciar automaticamente aplicativo em logins de usuário settings_notification_sound=Som da Notificação settings_notification_sound_description=Tocar som em uma nova notificação settings_browser_integration=Integração com Navegadores settings_browser_integration_description=Aceitar downloads dos navegadores settings_browser_integration_server_port=Porta do Servidor settings_browser_integration_server_port_description=Porta para integração do navegador settings_browser_integration_server_port_describe=O app ouvirá a porta {{port}} settings_dynamic_part_creation=Criação de parte dinâmica settings_dynamic_part_creation_description=Quando uma parte terminar crie outra parte dividindo outras partes para melhorar a velocidade de download settings_show_completion_dialog=Mostrar janela de conclusão do Download settings_show_completion_dialog_description=Mostrar automaticamente o diálogo "Download Completo" quando um download terminar. settings_show_download_progress_dialog=Mostrar janela de conclusão do Download settings_show_download_progress_dialog_description=Mostrar automaticamente o diálogo "Download Completo" quando um download terminar. settings_per_host_settings=Configurações Por Host settings_per_host_settings_descriptions=Essas configurações serão aplicadas automaticamente a qualquer novo download que corresponda ao host especificado. settings_download_max_concurrent_downloads=Máximo de downloads simultâneos settings_download_max_concurrent_downloads_description=O número máximo de arquivos que podem ser baixados ao mesmo tempo (downloads gerenciados por filas não são contados; defina como 0 para ilimitado) download_item_settings_speed_limit=Limite de Velocidade download_item_settings_speed_limit_description=Limitar velocidade de download deste item download_item_settings_show_download_completion_dialog=Mostrar janela de conclusão do Download download_item_settings_show_download_completion_dialog_description=Mostrar automaticamente o diálogo "Download Completo" quando um download terminar. download_item_settings_shutdown_on_completion=Desligar o sistema ao concluir download_item_settings_shutdown_on_completion_description=Automaticamente desliga o sistema quando este download terminar. download_item_settings_thread_count=Contagem de Thread download_item_settings_thread_count_description=Quantas threads foram usadas para baixar este item de download (0 para padrão)? download_item_settings_thread_count_describe={{count}} threads para este download download_item_settings_username_description=Forneça um nome de usuário se o link for um recurso protegido download_item_settings_password_description=Forneça uma senha se o link for um recurso protegido download_item_settings_download_page=Página de Download download_item_settings_download_page_description=A página da web onde este download foi iniciado download_item_settings_file_checksum=Checksum do arquivo download_item_settings_file_checksum_description=Uma string hash que pode ser usada para verificar se o arquivo foi baixado corretamente download_item_settings_user_agent=Agente do usuário download_item_settings_user_agent_description=Agente do usuário personalizado para este item (deixe vazio para usar o padrão) file_checksum=Checksum do arquivo file_checksum_page=Verificador de checksum do arquivo file_checksum_page_file_checksum_default_algorithm=Algoritmo Padrão file_checksum_page_file_checksum_default_algorithm_help=O algoritmo padrão usado para calcular as somas de verificação de arquivo quando elas não são fornecidas. start=Iniciar calculated_checksum=Checksum Calculado saved_checksum=Checksum salvo checksum_algorithm=Algoritmo file_not_found=Arquivo não encontrado download_not_finished=Download não concluído done=Concluído waiting=Aguardando matches=Corresponde not_matches=Não corresponde copy_to_clipboard=Copiar Para Área de Transferência username=Nome de Usuário password=Senha average_speed=Velocidade Média exact_speed=Velocidade Exata unlimited=Ilimitado use_global_settings=Utilizar Definições Globais cant_run_browser_integration=Não é possível executar a integração do navegador cant_open_file=Impossível Abrir o Arquivo cant_open_folder=Impossível Abrir Pasta # times for example 2 seconds ago relative_time_long_years={{years}} anos relative_time_long_months={{months}} meses relative_time_long_days={{days}} dias relative_time_long_hours={{hours}} horas relative_time_long_minutes={{minutes}} minutos relative_time_long_seconds={{seconds}} segundos relative_time_short_years={{years}} a relative_time_short_months={{months}} M relative_time_short_days={{days}} d relative_time_short_hours={{hours}} hr relative_time_short_minutes={{minutes}} min relative_time_short_seconds={{seconds}} seg relative_time_left={{time}} restantes relative_time_ago={{time}} atrás auto=Auto unspecified=Não especificado custom=Personalizado icon=Ícone author=Autor link=Link size=Tamanho status=Status parts_info_downloaded_size=Baixado parts_info_total_size=Total speed=Velocidade time_left=Tempo Restante date_added=Adicionado na Data info=Info download_page_downloaded_size=Baixado download_page_download_completed=Download concluído resume_support=Suporte para continuação yes=Sim no=Não parts_info=Informações das partes disconnected=Desconectado receiving_data=Recebendo Dados connecting=Enviar GET warning=Aviso unsupported_resume_warning=Este download não oferece suporte a continuação\! Você pode ter que reiniciá-lo mais tarde na lista de downloads stop_anyway=Parar Assim Mesmo customize_columns=Personalizar Colunas reset=Redefinir monday=Segunda-feira tuesday=Terça-feira wednesday=Quarta-feira thursday=Quinta-feira friday=Sexta-feira saturday=Sábado sunday=Domingo proxy_open_system_proxy_settings=Abrir Configurações do Proxy do Sistema proxy_type=Tipo de proxy proxy_do_not_use_proxy_for=Não usar proxy para proxy_do_not_use_proxy_for_description=Uma lista de urls que não podem ser proxy\nVocê pode usar curinga com *\npor exemplo 192.168.1.* exemplo.com (separado por espaço) proxy_change_title=Mudar Proxy change_proxy=Mudar Proxy proxy_no=Sem Proxy proxy_system=Proxy do sistema proxy_manual=Proxy Manual proxy_pac=Configuração automática de proxy proxy_pac_url=URL de configuração automática de proxy address=Endereço de IP port=Porta address_and_port=Endereço de IP e Porta use_authentication=Usar Autenticação warning_you_may_have_to_restart_the_download_later=Você talvez precise reiniciar o download mais tarde\! edit_download_title=Editar Download edit_download_update_from_download_page=Atualizar a partir da página de download edit_download_update_from_download_page_description=Quando esta janela estiver aberta, você pode acessar a página de download e clicar no botão de download. O aplicativo capturará e atualizará as novas credenciais de download para que você possa salvá-las. edit_download_saved_download_item_size_not_match=O item de download salvo possui um tamanho de {{currentSize}}, que não corresponde ao novo tamanho de {{newSize}}. translators_page_thanks=Com gratidão a todos que ajudaram a traduzir este projeto ❤️ translators=Tradutores language=Idioma translators_contribute_title=Melhorar Traduções translators_contribute_description=Quer ajudar a melhorar este projeto? Se o seu idioma não estiver listado ou precisar de ajustes, você pode contribuir com suas traduções e torná-lo ainda melhor\! contribute=Contribuir meet_the_translators=Conheça os Tradutores localized_by_translators=Localizado por Tradutores confirm_exit=Deseja Sair? confirm_exit_description=Tem certeza que deseja sair do AB Download Manager?\nDownloads ativos/filas serão interrompidos\! update=Atualizar update_updater=Atualizador update_available=Atualização disponível update_error=Erro na atualização update_available_suggest_to_to_update=Você pode atualizar para a versão mais recente para desfrutar de novos recursos, aprimoramentos e melhorias de desempenho. update_release_notes=Notas de lançamento update_check_for_update=Verificar Atualizações update_checking_for_update=Verificando por atualizações update_no_update=Você está na última versão update_check_error=Erro durante a verificação de atualizações update_app_updated_to_version_n=App atualizado para a versão {{version}} create_desktop_entry=Criar entrada na Área de Trabalho shutdown_alert=Alerta de desligamento system_shutdown_soon=O sistema irá desligar em breve\! system_shutdown_failed=Erro ao desligar o sistema\! system_shutdown_soon_description=O sistema será desligado em breve. Se você ainda estiver usando o computador, salve o trabalho ou cancele o desligamento. system_shutdown_reason_queue_completed=Todos os downloads na fila estão completos. system_shutdown_reason_queue_end_time_reached=Hora de término programada para a fila de download atingida. system_shutdown_download_finished=Download concluído. shutdown_now=Desligar agora settings_per_host_settings_new_host= settings_per_host_settings_not_selected=Crie ou selecione um novo item primeiro\! settings_per_host_settings_host=Hospedar settings_per_host_settings_host_description=Essas configurações serão aplicadas a downloads correspondentes a este nome do host. Wildcards ( *) são suportados (por exemplo, exemplo.com, *.example.com - use apenas um). settings_browser_in_launcher=Ícone do navegador na Launcher settings_browser_in_launcher_description=Exibir ou ocultar o ícone do navegador no launcher (lista de aplicativos). sort_by=Ordenar por welcome=Bem-vindo new_folder=Nova pasta skip=Pular lets_go=Vamos lá next=Próximo select_all=Selecionar todos select_inside=Selecionar dentro select_invert=Inverter seleção open_settings=Abrir Configurações back=Voltar service_is_running=O serviço está em execução initial_setup_description=Vamos configurar as coisas initial_setup_notice=Você pode alterar essas configurações a qualquer momento depois permission_granted=Permissão concedida permission_not_granted=Permissão não concedida permissions=Permissões give_permission=Permitir permissão give_storage_permission=Permitir acesso ao armazenamento storage_roots=Raízes de armazenamento permissions_initial_title=Vamos configurar as coisas permissions_initial_description=Para funcionar corretamente, o aplicativo precisa de algumas permissões. Na próxima tela, você verá para que cada permissão é usada e você poderá decidir quais permitir ou ignorar. permissions_done_title=Tudo pronto permissions_done_description=Está tudo pronto. Todas as permissões necessárias foram concedidas e o aplicativo está pronto para ser usado. permissions_manage_storage_title=Gerenciar acesso ao armazenamento permissions_manage_storage_reason=Esta permissão permite ao aplicativo alterar a pasta de download, detectar downloads duplicados com mais precisão e habilitar alguns recursos extras. Ele é opcional, mas recomendado para a melhor experiência. permission_read_write_external_storage_title=Leitura e escrita no armazenamento permission_read_write_external_storage_reason=Esta permissão permite que o aplicativo salve e gerencie arquivos baixados, altere o local de download e melhore a detecção de download duplicados. permissions_post_notification_title=Acesso a notificações permissions_post_notification_reason=O aplicativo precisa ser executado em segundo plano para gerenciar downloads. As notificações são usadas para mantê-lo informado e permitir operações em segundo plano. permissions_ignore_battery_optimization_title=Ignorar otimizações da bateria permissions_ignore_battery_optimization_reason=Alguns dispositivos limitam agressivamente a atividade em segundo plano para economizar bateria, que pode pausar ou interromper os downloads quando o aplicativo não estiver aberto. Uma opção é excluir o aplicativo da otimização de bateria para garantir que os downloads não sejam interrompidos open_in_browser=Abrir no navegador browser=Navegador browser_new_tab=Nova aba browser_close_tab=Fechar aba browser_open_in_new_tab=Abrir em nova aba browser_open_in_new_background_tab=Abrir em nova aba em segundo plano browser_no_tab_open=Nenhuma aba está aberta browser_tabs=Abas browser_paste_and_go=Colar e ir browser_bookmarks=Marcadores browser_add_bookmark=Adicionar marcador browser_edit_bookmark=Editar marcador browser_add_to_bookmarks=Adicionar aos marcadores browser_remove_from_bookmarks=Remover dos marcadores ================================================ FILE: shared/resources/src/commonMain/resources/com/abdownloadmanager/resources/locales/ru_RU.properties ================================================ app_title=AB Download Manager confirm_auto_categorize_downloads_title=Автоматическая сортировка загрузок по категориям confirm_auto_categorize_downloads_description=Любой неотсортированный элемент будет автоматически добавлен в соответствующую категорию. confirm_reset_to_default_categories_title=Сброс к Категориям По умолчанию confirm_reset_to_default_categories_description=Это приведет к удалению всех категорий и вернёт категории по умолчанию\! confirm_delete_download_items_title=Подтвердить удаление confirm_delete_download_items_description=Вы уверены, что хотите удалить {{count}} элементов? confirm_delete_download_unfinished_items_description=Вы уверены, что хотите удалить {{count}} незаконченных загрузок? confirm_delete_download_finished_and_unfinished_items_description=Вы уверены, что хотите удалить {{finishedCount}} завершённых и {{unfinishedCount}} незаконченных загрузок? also_delete_file_from_disk=Также удалить файл с диска confirm_delete_category_item_title=Удаление {{name}} категории confirm_delete_category_item_description=Вы уверены, что хотите удалить "{{value}}" Категорию? your_download_will_not_be_deleted=Ваши загрузки не будут удалены drag_the_file_to_another_app=Перетащить файл в другое приложение drop_link_or_file_here=Перетащите ссылку или файл сюда. nothing_will_be_imported=Ничего не будет импортировано n_links_will_be_imported={{count}} ссылок будет импортировано n_items_selected={{count}} элементов выбрано window_close=Закрыть window_minimize=Свернуть window_maximize=Развернуть window_restore=Восстановить delete=Удалить remove=Удалить cancel=Отмена close=Закрыть menu=Меню more_options=Больше Опций ok=ОK add=Добавить paste=Вставить change=Изменить edit=Редактировать change_anyway=Всё равно Изменить download=Загрузить refresh=Обновить settings=Настройки on_completion=По Завершении unknown=Неизвестно unknown_error=Неизвестная Ошибка download_item_not_found=Элемент для загрузки не найден name=Имя download_link=Ссылка для загрузки not_finished=Не завершено all=Все finished=Завершено Unfinished=Незаконченно canceled=Отменено error=Ошибка paused=Приостановлено downloading=Загружается added=Добавлено idle=НЕАКТИВНО preparing_file=Подготовка файла creating_file=Создание файла resuming=Возобновление retrying=Повтор list_is_empty=Список пуст\! search_in_the_list=Поиск по списку search=Поиск clear=Очистить general=Основные enabled=Включено disabled=Отключено default=По умолчанию file=Файл tasks=Задания tools=Инструменты help=Справка system=Системный all_missing_files=Все Отсутствующие all_finished=Все Завершённые all_unfinished=Все Незаконченные entire_list=Весь Список download_browser_integration=Установить Расширение для Браузера exit=Выход show_downloads=Показать Загрузки new_download=Новая Загрузка stop_all=Остановить Все import_from_clipboard=Вставить из Буфера обмена batch_download=Пакетная Загрузка open=Открыть share=Поделиться open_file=Открыть Файл open_folder=Открыть Папку resume=Продолжить pause=Приостановить restart_download=Перезапустить Загрузку copy=Скопировать copy_link=Копировать ссылку copy_as_curl=Скопировать для cURL show_properties=Показать Свойства move_to_queue=Переместить в Очередь move_to_this_queue=Переместить в эту Очередь move_to_category=Переместить в Категорию move_to_this_category=Переместить в эту категорию categories=Категории add_category=Добавить Категорию edit_category=Редактировать Категорию delete_category=Удалить Категорию category_name=Имя Категории category_download_location=Расположение Категории Загрузки category_download_location_description=Если эта категория выбрана в разделе "Добавить Загрузку", используйте этот каталог как "Папка для Загрузки" category_file_types=Типы файлов категории category_file_types_description=Автоматически помещайте эти типы файлов в эту категорию. (при добавлении новой загрузки)\nРазделяйте расширения файлов пробелом (ext1 ext2 ...) category_url_patterns=Шаблоны URL category_url_patterns_description=Автоматически помещайте загрузки из этих URLs в эту категорию. (при добавлении новой загрузки)\nРазделяйте URLs пробелом, вы также можете использовать * в качестве подстановочного знака auto_categorize_downloads=Автоматическая сортировка Загрузок по Категориям restore_defaults=Восстановить Настройки По умолчанию about=О программе version_n=Версия {{value}} developed_with_love_for_you=Разработано с ❤️ для вас donate=Пожертвовать visit_the_project_website=Посетить веб-сайт проекта this_is_a_free_and_open_source_software=Это бесплатное программное обеспечение с Открытым Исходным кодом view_the_source_code=Посмотреть Исходный Код third_party_libraries=Сторонние Библиотеки powered_by_open_source_software=На основе Open Source Software view_the_open_source_licenses=Просмотр лицензий с Открытым Исходным кодом support_and_community=Поддержка и Сообщество telegram=Telegram channel=Канал group=Группа add_download=Добавить Загрузку add_multi_download_page_header=Выберите элементы, которые вы хотите загрузить save_to=Сохранить в where_should_each_item_saved=Где следует сохранять каждый элемент? there_are_multiple_items_please_select_a_way_you_want_to_save_them=Здесь несколько элементов\! пожалуйста, выберите способ их сохранения each_item_on_its_own_category=Каждый элемент в своей категории each_item_on_its_own_category_description=Каждый элемент будет помещен в категорию, которая имеет этот тип файла all_items_in_one_category=Все элементы в одной Категории all_items_in_one_category_description=Все файлы будут сохранены в выбранной категории all_items_in_one_Location=Все элементы в одной Папке all_items_in_one_Location_description=Все элементы будут сохранены в выбранном каталоге unselected_all_items_in_specific_location_description=Все файлы будут сохранены в папке выбранной категории no_category_selected=Категория не Выбрана no_categories_found=Категории Отсутствуют download_location=Папка для Загрузки location=Расположение select_queue=Выбрать Очередь without_queue=Без Очереди use_category=Использовать Категорию cant_write_to_this_folder=Невозможно записать в эту папку file_name_already_exists=Имя файла уже существует download_already_exists=Загрузка уже существует invalid_file_name=Неверное имя файла show_solutions=Показать решения... change_solution=Изменить select_a_solution=Выбрать решение select_download_strategy_description=Указанная вами ссылка уже есть в списках загрузок, пожалуйста, уточните, что вы хотите сделать download_strategy_add_a_numbered_file=Добавить пронумерованный файл download_strategy_add_a_numbered_file_description=Добавить индекс после окончания загрузки имени файла download_strategy_override_existing_file=Перезаписать существующий файл download_strategy_override_existing_file_description=Удалить существующую загрузку и запись об этом файле download_strategy_update_download_link=Обновить существующую загрузку download_strategy_update_download_link_description=Обновить существующую ссылку для загрузки и её учётные данные download_strategy_show_downloaded_file=Показать загруженный файл download_strategy_show_downloaded_file_description=Показать уже существующий элемент загрузки, чтобы вы могли нажать продолжить или открыть его batch_download_link_help=Введите ссылку, содержащую подстановочные знаки (используйте *) invalid_url=Неверный URL list_is_too_large_maximum_n_items_allowed=Список слишком большой\! Максимум {{count}} элементов разрешено enter_range=Введите диапазон range_from=От range_to=К batch_download_wildcard_length=Длина подстановочного знака first_link=Первая Ссылка last_link=Последняя Ссылка open_source_software_used_in_this_app=В этом Приложении используется программное обеспечение с Открытым Исходным кодом links=Ссылки website=Веб-сайт developers=Разработчики source_code=Исходный Код license=Лицензия no_license_found=Лицензия не найдена organization=Организация add_new_queue=Добавить Новую Очередь queue_name=Название Очереди queues=Очереди stop_queue=Остановить Очередь start_queue=Запустить Очередь clear_queue_items=Пустая Очередь config=Конфигурация items=Элементы move_down=Переместить вниз move_up=Переместить вверх remove_queue=Удалить Очередь queue_name_help=Укажите название для этой очереди queue_name_describe=Название очереди {{value}} queue_max_concurrent_download=Максимальная одновременная загрузка queue_max_concurrent_download_description=Максимальная загрузка для этой очереди queue_automatic_stop=Автоматическая остановка queue_automatic_stop_description=Автоматическая остановка очереди, когда в ней нет элементов queue_scheduler=Планировщик queue_enable_scheduler=Включить Планировщик queue_active_days=Активные Дни queue_active_days_description=В какие дни работают планировщики? queue_scheduler_enable_auto_start_time=Включить Таймер Авто Запуска queue_scheduler_auto_start_time=Время Авто Запуска queue_scheduler_enable_auto_stop_time=Включить Таймер Автоматической Остановки queue_scheduler_auto_stop_time=Время Автоматической Остановки queue_shutdown_on_completion=Выключить Систему По Завершении queue_shutdown_on_completion_description=Автоматически выключить систему по завершении этой очереди или по достижении запланированного времени окончания. appearance=Вид download_engine=Модуль Загрузки browser_integration=Интеграция с Браузером settings_download_max_retries_count=Максимальная Повторная Загрузка settings_download_max_retries_count_description=Максимальное количество повторных попыток приложения завершить неудачную загрузку settings_download_max_retries_count_describe_no_retries=Неудачные загрузки не будут повторены settings_download_max_retries_count_describe_n_retries=Неудачные загрузки будут повторены {{count}} раз(а) settings_download_thread_count=Количество Потоков settings_download_thread_count_description=Максимальное количество потоков загрузки для каждого элемента загрузки settings_download_thread_count_describe=Загрузка может содержать до {{count}} потоков settings_download_thread_count_with_large_value_describe=Предупреждение\: Установка большого количества потоков может увеличить использование системных ресурсов, снизить производительность или вызвать проблемы с подключением к серверам. Используйте более высокие значения только в том случае, если вы понимаете потенциальное влияние на вашу систему и сеть. settings_use_server_last_modified_time=Использовать Время Последнего Изменения на Сервере settings_use_server_last_modified_time_description=При загрузке файла используйте время последнего изменения локального файла на сервере settings_append_extension_to_incomplete_downloads=Добавлять расширение к Незавершённым Загрузкам settings_append_extension_to_incomplete_downloads_description=Добавлять расширение ".part" к незавершённым загрузкам. Это помогает идентифицировать незаконченные загрузки и предотвращает случайное открытие неполных файлов. settings_use_sparse_file_allocation=Разреженное Распределение Файлов settings_use_sparse_file_allocation_description=Создавайте файлы более эффективно, особенно на SSDs, сокращая объем ненужной записи данных. Это может ускорить начало загрузки и снизить нагрузку на диск. Если загрузка начинается медленно или у вас наблюдается необычная скорость загрузки, подумайте о том, чтобы отключить эту опцию, поскольку на некоторых устройствах она может поддерживаться не полностью. settings_ignore_ssl_certificates=Игнорировать SSL-сертификаты settings_ignore_ssl_certificates_description=Отключает проверку SSL-сертификата. Используйте только при необходимости, так как это может привести к угрозе безопасности вашего соединения. settings_global_speed_limiter=Глобальный Ограничитель Скорости settings_global_speed_limiter_description=Глобальное ограничение скорости загрузки (0 означает неограниченную) settings_show_average_speed=Показывать Среднюю Скорость settings_show_average_speed_description=Скорость загрузки в среднем или точном значении settings_use_category_by_default=Использовать категорию по умолчанию settings_use_category_by_default_description=Использовать категорию по умолчанию при добавлении загрузки. settings_default_download_folder=Папка Загрузки По умолчанию settings_default_download_folder_description=При добавлении новой загрузки это расположение используется по умолчанию settings_default_download_folder_describe="{{folder}}" будет использоваться settings_use_proxy=Использовать Прокси settings_use_proxy_description=Использовать прокси для загрузки файлов settings_use_proxy_describe_no_proxy=Прокси не будет использоваться settings_use_proxy_describe_system_proxy=Будет использоваться системный Прокси settings_use_proxy_describe_manual_proxy="{{value}}" будет использоваться settings_use_proxy_describe_pac_proxy=PAC-файл "{{value}}" будет использоваться settings_track_deleted_files_on_disk=Отслеживать Удалённые Файлы На Диске settings_track_deleted_files_on_disk_description=Автоматически очищать список файлов при их удалении или перемещении из каталога загрузки. settings_delete_partial_file_on_download_cancellation=Удалить Частичный Файл при отмене Загрузки settings_delete_partial_file_on_download_cancellation_description=Когда загрузка отменена, частично загруженный файл будет удален с диска. Это поможет очистить вашу папку для загрузки и сократить ненужное использование дискового пространства. Однако, при следующем запуске загрузка начнется с самого начала. settings_default_user_agent=User-Agent по умолчанию settings_default_user_agent_description=Укажите строку User-Agent по умолчанию, чтобы определить способ идентификации запросов к серверам. Это может помочь в получении доступа к контенту, оптимизированному для конкретных устройств, или в обходе ограничений на загрузку, установленных определёнными веб-сайтами. settings_download_size_unit=Единица измерения Размера settings_download_size_unit_description=Единица измерения, используемая для отображения размера загрузки settings_download_speed_unit=Единица измерения Скорости settings_download_speed_unit_description=Единица измерения, используемая для отображения скорости загрузки settings_theme=Тема settings_theme_description=Выберите тему для Приложения settings_default_dark_theme=Тёмная Тема По умолчанию settings_default_dark_theme_description=Применяется, когда приложение соответствует системной теме и активен тёмный режим settings_default_light_theme=Светлая Тема По умолчанию settings_default_light_theme_description=Применяется, когда приложение соответствует системной теме и активен светлый режим settings_font=Шрифт settings_font_description=Изменить шрифт, используемый в интерфейсе приложения. Некоторые шрифты могут отображаться некорректно. settings_ui_scale=Масштаб Интерфейса settings_ui_scale_description=Отрегулируйте размер элементов интерфейса приложения settings_language=Язык settings_compact_top_bar=Компактная Верхняя Панель settings_compact_top_bar_description=Объединить верхнюю панель со строкой заголовка, когда главное окно имеет достаточную ширину settings_use_native_menu_bar=Использовать Стандартную Панель Меню settings_use_native_menu_bar_description=Использовать системный стиль строки меню по умолчанию settings_use_relative_date_time=Использовать относительную дату/время settings_use_relative_date_time_description=Использовать относительный формат даты/времени (например, "2 дня назад" вместо точной даты/времени) settings_show_icon_labels=Отображать Надписи Значков settings_show_icon_labels_description=По возможности отображать надписи под значками ( например, действия на главной панели инструментов ) settings_use_system_tray=Использовать System Tray settings_use_system_tray_description=Отображать значок системного лотка при запуске приложения settings_start_on_boot=Запуск при Загрузке settings_start_on_boot_description=Автоматический запуск приложения при входе пользователя в систему settings_notification_sound=Звук Уведомления settings_notification_sound_description=Воспроизводить звук при новом уведомлении settings_browser_integration=Интеграция с Браузером settings_browser_integration_description=Принимать загрузки из браузеров settings_browser_integration_server_port=Порт Сервера settings_browser_integration_server_port_description=Порт для интеграции с браузером settings_browser_integration_server_port_describe=Приложение будет прослушивать порт {{port}} settings_dynamic_part_creation=Динамическая сегментация settings_dynamic_part_creation_description=Когда одна часть завершена, формируется другая, разделяющая оставшиеся сегменты для увеличения скорости загрузки settings_show_completion_dialog=Показать диалоговое окно Завершения Загрузки settings_show_completion_dialog_description=Автоматически показывать диалоговое окно «Завершение Загрузки» при завершении загрузки. settings_show_download_progress_dialog=Показывать диалоговое окно Прогресса Загрузки settings_show_download_progress_dialog_description=Автоматически показывать диалоговое окно «Прогресс Загрузки» при запуске загрузки. settings_per_host_settings=Настройки хоста settings_per_host_settings_descriptions=Эти настройки будут автоматически применены к любой новой загрузке, соответствующей указанному хосту. settings_download_max_concurrent_downloads=Maximum Concurrent Downloads settings_download_max_concurrent_downloads_description=The maximum number of files that can be downloaded at the same time (downloads managed by queues are not counted; set to 0 for unlimited) download_item_settings_speed_limit=Ограничение Скорости download_item_settings_speed_limit_description=Ограничить скорость загрузки для этого элемента download_item_settings_show_download_completion_dialog=Показать диалоговое окно Завершения download_item_settings_show_download_completion_dialog_description=Автоматически показывать диалоговое окно «Завершение Загрузки» при завершении этой загрузки. download_item_settings_shutdown_on_completion=Выключить Систему По Завершении download_item_settings_shutdown_on_completion_description=Автоматически выключить систему по завершении загрузки. download_item_settings_thread_count=Количество потоков download_item_settings_thread_count_description=Сколько потоков использовать для загрузки этого элемента (0 по умолчанию) download_item_settings_thread_count_describe={{count}} потоков для этой загрузки download_item_settings_username_description=Введите имя пользователя, если ссылка защищена download_item_settings_password_description=Введите пароль, если ссылка защищена download_item_settings_download_page=Страница Загрузки download_item_settings_download_page_description=Веб-страница, на которой была запущена загрузка download_item_settings_file_checksum=Контрольная сумма download_item_settings_file_checksum_description=Хеш-строка, которая может использоваться для проверки правильности загрузки файла download_item_settings_user_agent=User-Agent download_item_settings_user_agent_description=Пользовательский User-Agent для этого элемента (оставьте пустым, чтобы использовать значение по умолчанию) file_checksum=Контрольная сумма Файла file_checksum_page=Проверка Контрольной суммы Файла file_checksum_page_file_checksum_default_algorithm=Алгоритм по умолчанию file_checksum_page_file_checksum_default_algorithm_help=Алгоритм по умолчанию, используемый для расчёта контрольных сумм файлов, когда они не указаны. start=Начать calculated_checksum=Рассчитанная Контрольная сумма saved_checksum=Сохранённая Контрольная сумма checksum_algorithm=Алгоритм file_not_found=Файл не найден download_not_finished=Загрузка не завершена done=Готово waiting=Ожидание matches=Совпадает not_matches=Не Совпадает copy_to_clipboard=Копировать в Буфер обмена username=Имя пользователя password=Пароль average_speed=Средняя Скорость exact_speed=Точная скорость unlimited=Неограниченно use_global_settings=Использовать Глобальные Настройки cant_run_browser_integration=Невозможно запустить интеграцию с браузером cant_open_file=Невозможно Открыть Файл cant_open_folder=Невозможно Открыть Папку # times for example 2 seconds ago relative_time_long_years={{years}} годы relative_time_long_months={{months}} месяцев relative_time_long_days={{days}} дней relative_time_long_hours={{hours}} часов relative_time_long_minutes={{minutes}} минут relative_time_long_seconds={{seconds}} секунд relative_time_short_years={{years}} г relative_time_short_months={{months}} M relative_time_short_days={{days}} д relative_time_short_hours={{hours}} ч relative_time_short_minutes={{minutes}} мин relative_time_short_seconds={{seconds}} сек relative_time_left={{time}} осталось relative_time_ago={{time}} назад auto=Авто unspecified=Не указано custom=Пользовательский icon=Иконка author=Автор link=Ссылка size=Размер status=Статус parts_info_downloaded_size=Загружено parts_info_total_size=Всего speed=Скорость time_left=Оставшееся время date_added=Дата добавления info=Информация download_page_downloaded_size=Загружено download_page_download_completed=Загрузка Завершена resume_support=Возобновить Поддержку yes=Да no=Нет parts_info=Информация о Частях disconnected=Нет подключения receiving_data=Получение Данных connecting=Подключение warning=Предупреждение unsupported_resume_warning=Эта загрузка не поддерживает возобновление\! Возможно, вам придется ПЕРЕЗАПУСТИТЬ ее позже в Списке Загрузок stop_anyway=Остановить в Любом случае customize_columns=Настроить столбцы reset=Сбросить monday=Понедельник tuesday=Вторник wednesday=Среда thursday=Четверг friday=Пятница saturday=Суббота sunday=Воскресенье proxy_open_system_proxy_settings=Открыть Системные Настройки Прокси proxy_type=Тип Прокси proxy_do_not_use_proxy_for=Не Использовать прокси для proxy_do_not_use_proxy_for_description=Список URLs, для которых нельзя использовать прокси\nВы можете использовать подстановочный знак с *\nнапример 192.168.1.* example.com (через пробел) proxy_change_title=Изменить Прокси change_proxy=Изменить proxy_no=Без Прокси proxy_system=Системный Прокси proxy_manual=Ручная настройка proxy_pac=Автоматическая Настройка Прокси proxy_pac_url=URL-адрес Автоматической Настройки Прокси address=Адрес port=Порт address_and_port=Адрес & Порт use_authentication=Использовать Аутентификацию warning_you_may_have_to_restart_the_download_later=Возможно, вам придется перезапустить загрузку позже\! edit_download_title=Редактировать Загрузку edit_download_update_from_download_page=Обновить со Страницы Загрузки edit_download_update_from_download_page_description=Когда это окно откроется, вы можете перейти на страницу загрузки и нажать кнопку загрузить. Приложение зафиксирует и обновит новые учетные данные для загрузки, чтобы вы могли их сохранить. edit_download_saved_download_item_size_not_match=Сохраненный элемент загрузки имеет размер {{currentSize}}, который не соответствует новому размеру {{newSize}}. translators_page_thanks=С Благодарностью Тем, Кто Помог Перевести Этот Проект ❤️ translators=Переводчики language=Язык translators_contribute_title=Улучшить Перевод translators_contribute_description=Хотите помочь улучшить этот проект? Если вашего языка нет в списке или он нуждается в доработке, вы можете добавить свои переводы и улучшить его\! contribute=Внести вклад meet_the_translators=Знакомство с Переводчиками localized_by_translators=Локализовано Переводчиками confirm_exit=Подтвердить Выход confirm_exit_description=Вы уверены, что хотите выйти из AB Download Manager?\nАктивные загрузки/очереди будут остановлены\! update=Обновить update_updater=Модуль обновления update_available=Доступно Обновление update_error=Ошибка обновления update_available_suggest_to_to_update=Вы можете обновиться до последней версии, чтобы воспользоваться новыми функциями, улучшениями и повышением производительности. update_release_notes=Список Изменений update_check_for_update=Проверить Обновления update_checking_for_update=Проверка Обновлений update_no_update=Вы используете последнюю версию update_check_error=Ошибка при проверке обновлений update_app_updated_to_version_n=Приложение обновлено до версии {{version}} create_desktop_entry=Создать запись на рабочем столе shutdown_alert=Оповещение о Выключении system_shutdown_soon=Система Будет Выключена\! system_shutdown_failed=Ошибка Выключения Системы\! system_shutdown_soon_description=Система скоро завершит работу. Если вы всё ещё используете компьютер, пожалуйста, сохраните данные или отмените выключение. system_shutdown_reason_queue_completed=Все загрузки в очереди завершены. system_shutdown_reason_queue_end_time_reached=Достигнуто запланированное время окончания очереди загрузки. system_shutdown_download_finished=Загрузка завершена. shutdown_now=Выключить Сейчас settings_per_host_settings_new_host=Новый хост settings_per_host_settings_not_selected=Сначала создайте или выберите новый элемент\! settings_per_host_settings_host=Сервер settings_per_host_settings_host_description=Эти настройки будут применены к загрузкам, соответствующим этому имени хоста. Поддерживаются подстановочные знаки (*) (например, example.com, *.example.com — используйте только один). settings_browser_in_launcher=Browser Icon In Launcher settings_browser_in_launcher_description=Show or hide the browser icon in the launcher (app list). sort_by=Сортировать по welcome=Добро пожаловать new_folder=Новая Папка skip=Пропустить lets_go=Начать next=Продолжить select_all=Выбрать Все select_inside=Выделить select_invert=Инвертировать выделение open_settings=Открыть Настройки back=Назад service_is_running=Служба запущена initial_setup_description=Приступить к настройке initial_setup_notice=Вы можете изменить эти настройки в любое время permission_granted=Разрешение предоставлено permission_not_granted=Разрешение не предоставлено permissions=Разрешения give_permission=Выдать разрешение give_storage_permission=Разрешить доступ к хранилищу storage_roots=Storage Roots permissions_initial_title=Настройка разрешений permissions_initial_description=Для корректной работы приложению требуется несколько разрешений. На следующем экране вы увидите, для чего используется каждое разрешение, и сможете решить, какие из них разрешить, а какие пропустить. permissions_done_title=Готово permissions_done_description=Всё готово. Все необходимые разрешения предоставлены, и приложение готово к запуску. permissions_manage_storage_title=Управление доступом к хранилищу permissions_manage_storage_reason=Это разрешение позволяет приложению изменять папку загрузок, более точно обнаруживать дубликаты загрузок и включать некоторые дополнительные функции. Оно необязательно, но рекомендуется для наилучшего результата. permission_read_write_external_storage_title=Доступ к хранилищу для чтения и записи permission_read_write_external_storage_reason=Это разрешение позволяет приложению сохранять и управлять загруженными файлами, изменять местоположение загрузки и улучшать обнаружение дубликатов загрузок. permissions_post_notification_title=Post-уведомление permissions_post_notification_reason=Приложение должно работать в фоновом режиме для управления загрузками. Уведомления используются для информирования пользователя и обеспечения работы в фоновом режиме. permissions_ignore_battery_optimization_title=Отключить Экономию Батареи permissions_ignore_battery_optimization_reason=Некоторые устройства жёстко ограничивают фоновую активность для экономии заряда батареи, что может привести к приостановке или остановке загрузки, когда приложение закрыто. При желании вы можете исключить приложение из программы оптимизации заряда батареи, чтобы обеспечить непрерывную загрузку open_in_browser=Открыть в браузере browser=Браузер browser_new_tab=New Tab browser_close_tab=Close Tab browser_open_in_new_tab=Открыть в новой вкладке browser_open_in_new_background_tab=Открыть в новой фоновой вкладке browser_no_tab_open=Нет открытых вкладок browser_tabs=Вкладки browser_paste_and_go=Вставить и перейти browser_bookmarks=Bookmarks browser_add_bookmark=Add Bookmark browser_edit_bookmark=Edit Bookmark browser_add_to_bookmarks=Add To Bookmarks browser_remove_from_bookmarks=Remove From Bookmarks ================================================ FILE: shared/resources/src/commonMain/resources/com/abdownloadmanager/resources/locales/sq_AL.properties ================================================ app_title=Menaxheri i Shkarkimeve AB confirm_auto_categorize_downloads_title=Auto-kategorizo shkarkimet confirm_auto_categorize_downloads_description=Çdo artikull pa kategori do të shtohet automatikisht në kategorinë përkatëse. confirm_reset_to_default_categories_title=Rikthe Kategoritë në Gjendjen Fillestare confirm_reset_to_default_categories_description=kjo do te HEQË të gjitha kategoritë dhe do të rikthejë kateroritë fillestare\! confirm_delete_download_items_title=Konfirmo Fshirjen confirm_delete_download_items_description=A je i sigurt që dëshiron të fshish {{count}} artikuj? confirm_delete_download_unfinished_items_description=Are you sure you want to delete {{count}} unfinished downloads? confirm_delete_download_finished_and_unfinished_items_description=Are you sure you want to delete {{finishedCount}} finished and {{unfinishedCount}} unfinished downloads? also_delete_file_from_disk=Fshi gjithashtu skedarin nga disku confirm_delete_category_item_title=Po fshihet kategoria {{name}} confirm_delete_category_item_description=A je i sigurt që dëshiron të fshish kategorinë “{{value}}”? your_download_will_not_be_deleted=Shkarkimet tuaja nuk do të fshihen drag_the_file_to_another_app=Drag the file to another app drop_link_or_file_here=Hidh lidhjen ose skedarin këtu. nothing_will_be_imported=Asgjë nuk do të importohet n_links_will_be_imported={{count}} lidhje do të importohen n_items_selected={{count}} artikuj të përzgjedhur window_close=Close window_minimize=Minimize window_maximize=Maximize window_restore=Restore delete=Fshi remove=Hiq cancel=Anulo close=Mbyll menu=Menu more_options=More Options ok=Në rregull add=Shto paste=Paste change=Ndrysho edit=Edit change_anyway=Change Anyway download=Shkarko refresh=Rifresko settings=Konfigurimet on_completion=On Completion unknown=E panjohur unknown_error=Gabim i panjohur download_item_not_found=Artikulli i shkarkimit nuk u gjet name=Emri download_link=Lidhja e shkarkimit not_finished=Nuk përfundoi all=Të gjitha finished=Përfunduar Unfinished=E papërfunduar canceled=Anuluar error=Gabim paused=Pauzuar. downloading=Duke shkarkuar added=Shtuar idle=Në pritje preparing_file=Duke pergatitur skedarin. creating_file=Duke krijuar skedarin resuming=Duke rifilluar retrying=Retrying list_is_empty=Lista është bosh\! search_in_the_list=Kërko në listë search=Kërko clear=Pastro general=Të përgjithshme enabled=Aktivizuar disabled=Çaktivizuar default=Default file=Skedar tasks=Detyra tools=Vegla help=Ndihmë system=System all_missing_files=All Missing Files all_finished=Të gjitha të përfunduara all_unfinished=Të gjitha të papërfunduara entire_list=Lista e plotë download_browser_integration=Shkarko integrimin për Browser exit=Dalje show_downloads=Shfaq Shkarkimet new_download=Shkarkim i Ri stop_all=Ndal të gjitha import_from_clipboard=Importo nga Clipboard batch_download=Shkarkim në Grup open=Hap share=Share open_file=Hap Skedarin open_folder=Hap Dosjen resume=Rivazhdo pause=Pauzo restart_download=Rifillo Shkarkimin copy=Copy copy_link=Kopjo lidhjen copy_as_curl=Copy as cURL show_properties=Shfaq Vetitë move_to_queue=Zhvendos në Radhë move_to_this_queue=Move to this Queue move_to_category=Zhvendos në Kategori move_to_this_category=Move to this category categories=Categories add_category=Shto Kategori edit_category=Redakto Kategorinë delete_category=Fshi Kategorinë category_name=Emri i Kategorisë category_download_location=Vendndodhja e Shkarkimit të Kategorisë category_download_location_description=Kur zgjidhet kjo kategori në “Shto Shkarkim”, përdorer kjo dosje si “Vendndodhje e Shkarkimit” category_file_types=Llojet e skedarëve të kategorisë category_file_types_description=Vendos automatikisht këto lloje skedarësh në këtë kategori. (kur shtoni shkarkim të ri) Ndani shtrirjet e skedarëve me hapësirë (ext1 ext2 ...) category_url_patterns=Modelet e URL-ve category_url_patterns_description=Vendos automatikisht shkarkimet nga këto URL në këtë kategori. (kur shtoni shkarkim të ri) Ndani URL-të me hapësirë, mund të përdorni edhe * si shenjë për të gjitha auto_categorize_downloads=Auto Kategorizo Shkarkimet restore_defaults=Rikthe në Gjendjen Fillestare about=Rreth version_n=Versioni {{value}} developed_with_love_for_you=Zhvilluar me ❤️ për ty donate=Donate visit_the_project_website=Vizito faqen e internetit të projektit this_is_a_free_and_open_source_software=Ky është një program falas dhe me Burim të Hapur view_the_source_code=Shiko Kodin Burimor third_party_libraries=Third Party Libraries powered_by_open_source_software=Funksionon me Softuer të Burimit të Hapur view_the_open_source_licenses=Shiko licencat e Burimit të Hapur support_and_community=Mbështetje & Komuniteti telegram=Telegram channel=Kanal group=Grup add_download=Shto Shkarkim add_multi_download_page_header=Zgjidh artikujt që dëshiron të marrësh për shkarkim save_to=Ruaj Në where_should_each_item_saved=Ku duhet të ruhet secili artikull? there_are_multiple_items_please_select_a_way_you_want_to_save_them=Ka disa artikuj\! Ju lutem zgjidhni një mënyrë se si dëshironi t'i ruani ato each_item_on_its_own_category=Çdo artikull në kategorinë e vet each_item_on_its_own_category_description=Çdo artikull do të vendoset në një kategori që ka atë lloj skedari all_items_in_one_category=Të gjithë artikujt në një kategori all_items_in_one_category_description=Të gjithë skedarët do të ruhen në vendndodhjen e kategorisë së zgjedhur all_items_in_one_Location=Të gjithë artikujt në një vendndodhje all_items_in_one_Location_description=Të gjithë artikujt do të ruhen në dosjen e zgjedhur unselected_all_items_in_specific_location_description=All files will be saved in the selected category location no_category_selected=Asnjë kategori e zgjedhur no_categories_found=No Categories Found download_location=Vendndodhja e Shkarkimit location=Vendndodhja select_queue=Zgjidh Radhën without_queue=Pa Radhë use_category=Përdor Kategorinë cant_write_to_this_folder=Nuk mund të shkruaj në këtë dosje file_name_already_exists=Emri i skedarit ekziston tashmë download_already_exists=Download already exists invalid_file_name=Emër i pavlefshëm i skedarit show_solutions=Shfaq zgjidhjet change_solution=Ndrysho zgjidhjen select_a_solution=Zgjidh një zgjidhje select_download_strategy_description=Lidhja që ofruat është tashmë në listën e shkarkimeve, ju lutem përcaktoni çfarë dëshironi të bëni download_strategy_add_a_numbered_file=Shto një skedar të numëruar download_strategy_add_a_numbered_file_description=Shto një indeks në fund të emrit të skedarit të shkarkuar download_strategy_override_existing_file=Mbivendos skedarin ekzistues download_strategy_override_existing_file_description=Hiq skedarin ekzistues dhe shkruaj në atë skedar download_strategy_update_download_link=Update existing download download_strategy_update_download_link_description=Update the existing download link and its credentials download_strategy_show_downloaded_file=Shfaq skedarin e shkarkuar download_strategy_show_downloaded_file_description=Shfaq artikullin ekzistues të shkarkuar, që të mund të vazhdoni ose ta hapni batch_download_link_help=Vendos një lidhje që përmban shenja për të gjitha (përdor *) invalid_url=URL e pavlefshme list_is_too_large_maximum_n_items_allowed=Lista është shumë e madhe\! maksimumi {{count}} artikuj lejohen enter_range=Vendos shtrirjen range_from=Nga range_to=Deri në batch_download_wildcard_length=Gjatësia e wildcard first_link=Lidhja e Parë last_link=Lidhja e fundit open_source_software_used_in_this_app=Softuer me Burim të Hapur i përdorur në këtë Aplikacion links=Lidhjet website=Faqja e Internetit developers=Zhvilluesit source_code=Kodi Burimor license=Licenca no_license_found=Licenca nuk u gjet organization=Organizata add_new_queue=Shto radhë të re queue_name=Emri i Radhës queues=Radhët stop_queue=Ndal Radhën start_queue=Fillo Radhën clear_queue_items=Empty Queue config=Konfigurimi items=Artikujt move_down=Zhvendos poshtë move_up=Zhvendos lart remove_queue=Hiq Radhën queue_name_help=Specifiko një emër për këtë radhë queue_name_describe=Emri i radhës është {{value}} queue_max_concurrent_download=Shkarkim maksimal i njëkohshëm queue_max_concurrent_download_description=Shkarkim maksimal për këtë radhë queue_automatic_stop=Ndalim automatik queue_automatic_stop_description=Ndalim automatik i radhës kur nuk ka artikuj në të queue_scheduler=Planifikuesi queue_enable_scheduler=Aktivizo Planifikuesin queue_active_days=Ditët aktive queue_active_days_description=Në cilat ditë funksionon planifikuesi? queue_scheduler_enable_auto_start_time=Enable Auto Start Time queue_scheduler_auto_start_time=Koha e nisjes automatike queue_scheduler_enable_auto_stop_time=Aktivizo kohën automatike të ndalimit queue_scheduler_auto_stop_time=Koha e ndalimit automatik queue_shutdown_on_completion=Shutdown System On Completion queue_shutdown_on_completion_description=Automatically shutdown the system when this queue is completed. or when the scheduled end time is reached. appearance=Pamja download_engine=Motori i shkarkimit browser_integration=Integrimi në Browser settings_download_max_retries_count=Maximum Download Retries settings_download_max_retries_count_description=The maximum number of times the app will retry a failed download before giving up settings_download_max_retries_count_describe_no_retries=Failed downloads won't be retried settings_download_max_retries_count_describe_n_retries=Failed downloads will be retried {{count}} time(s) settings_download_thread_count=Numri i fijeve settings_download_thread_count_description=Numri maksimal i fijeve për shkarkim për çdo artikull settings_download_thread_count_describe=Një shkarkim mund të ketë deri në {{count}} fije settings_download_thread_count_with_large_value_describe=Warning\: Setting a high thread count may increase system resource usage, reduce performance, or cause connection issues with servers. Use higher values only if you understand the potential impact on your system and network. settings_use_server_last_modified_time=Përdor kohën e fundit të modifikimit të serverit settings_use_server_last_modified_time_description=Kur shkarkon një skedar, përdor kohën e fundit të modifikimit të serverit për skedarin lokal settings_append_extension_to_incomplete_downloads=Append Extension To Incomplete Downloads settings_append_extension_to_incomplete_downloads_description=Append ".part" extension to incomplete downloads. This helps to identify unfinished downloads and prevents accidental opening of incomplete files. settings_use_sparse_file_allocation=Shpërndarje e Skedarëve të Rrallë settings_use_sparse_file_allocation_description=Krijoni skedarë më me efektivitet, veçanërisht në SSD, duke ulur shkrimet e panevojshme të të dhënave. Kjo mund të përshpejtojë fillimin e shkarkimeve dhe të ulë përdorimin e diskut. Nëse shkarkimet fillojnë ngadalë ose hasni shpejtësi të pazakonta shkarkimi, konsideroni çaktivizimin e kësaj mundësie, pasi mund të mos jetë plotësisht e përkrahur në disa pajisje. settings_ignore_ssl_certificates=Ignore SSL Certificates settings_ignore_ssl_certificates_description=Disables SSL certificate verification. Use only if necessary, as it may expose your connection to security risks. settings_global_speed_limiter=Kufizues Global i Shpejtësisë settings_global_speed_limiter_description=Kufiri global i shpejtësisë së shkarkimit (0 do të thotë pa kufi) settings_show_average_speed=Shfaq Shpejtësinë Mesatare settings_show_average_speed_description=Shkarko shpejtësinë në mesatare ose me saktësi settings_use_category_by_default=Use Category By Default settings_use_category_by_default_description=Use category by default when adding a download. settings_default_download_folder=Dosja e Paracaktuar e Shkarkimeve settings_default_download_folder_description=Kur shton një shkarkim të ri, kjo vendndodhje përdoret si parazgjedhje settings_default_download_folder_describe=Dosja “{{folder}}” do të përdoret settings_use_proxy=Përdor Proxy settings_use_proxy_description=Përdor Proxy për shkarkimin e skedarëve settings_use_proxy_describe_no_proxy=Asnjë Proxy nuk do të përdoret settings_use_proxy_describe_system_proxy=Proxy i Sistemit do të përdoret settings_use_proxy_describe_manual_proxy={{value}} do të përdoret settings_use_proxy_describe_pac_proxy=PAC file "{{value}}" will be used settings_track_deleted_files_on_disk=Track Deleted Files On Disk settings_track_deleted_files_on_disk_description=Automatically remove files from the list when they are deleted or moved from the download directory. settings_delete_partial_file_on_download_cancellation=Delete Partial File On Download Cancellation settings_delete_partial_file_on_download_cancellation_description=When a download is canceled, the partially downloaded file will be deleted from the disk. This helps keep your download folder clean and reduces unnecessary disk space usage. However, the download will restart from the beginning the next time you start it. settings_default_user_agent=Default User-Agent settings_default_user_agent_description=Specify the Default-User Agent string to define how requests identify to servers. This can help in accessing content optimized for particular devices or in circumventing download limitations imposed by certain websites. settings_download_size_unit=Download Size Unit settings_download_size_unit_description=Unit used to display the download size settings_download_speed_unit=Download Speed Unit settings_download_speed_unit_description=Unit used to display the download speed settings_theme=Tema settings_theme_description=Zgjidh një temë për Aplikacionin settings_default_dark_theme=Default Dark Theme settings_default_dark_theme_description=Applies when the app follows the system theme and dark mode is active settings_default_light_theme=Default Light Theme settings_default_light_theme_description=Applies when the app follows the system theme and light mode is active settings_font=Font settings_font_description=Change the font used in the app interface, Some fonts might not display correctly in the app. settings_ui_scale=UI Scale settings_ui_scale_description=Adjust the size of the app's interface elements settings_language=Gjuha settings_compact_top_bar=Shiriti i Sipërm i Ngushtë settings_compact_top_bar_description=Bashko shiritin e sipërm me shiritin e titullit kur dritarja kryesore ka gjerësi të mjaftueshme settings_use_native_menu_bar=Use Native Menu Bar settings_use_native_menu_bar_description=Use the system's default menu bar style settings_use_relative_date_time=Use relative date/time settings_use_relative_date_time_description=Use relative date/time format for dates in the app (e.g., "2 days ago" instead of the exact date/time) settings_show_icon_labels=Show Icon Labels settings_show_icon_labels_description=Show labels under icons when possible ( like home toolbar actions ) settings_use_system_tray=Use System Tray settings_use_system_tray_description=Show system tray icon when the app is running settings_start_on_boot=Nisje gjatë Startimit settings_start_on_boot_description=Nis automatikisht aplikacionin kur përdoruesi futet në sistem settings_notification_sound=Nisje gjatë Startimit settings_notification_sound_description=Tingull kur ka një njoftim të ri settings_browser_integration=Integrimi në Browser settings_browser_integration_description=Prano shkarkime nga Browseri settings_browser_integration_server_port=Porti i Serverit settings_browser_integration_server_port_description=Porti për integrimin me shfletuesin settings_browser_integration_server_port_describe=Aplikacioni do të dëgjojë në portin {{port}} settings_dynamic_part_creation=Krijimi Dinamik i Pjesëve settings_dynamic_part_creation_description=Kur një pjesë përfundon, krijo një pjesë tjetër duke ndarë pjesët e tjera për të përmirësuar shpejtësinë e shkarkimit settings_show_completion_dialog=Show Download Completion Dialog settings_show_completion_dialog_description=Automatically show "Download Complete" dialog when a download finished. settings_show_download_progress_dialog=Show Download Progress Dialog settings_show_download_progress_dialog_description=Automatically show "Download Progress" dialog when a download started. settings_per_host_settings=Per Host Settings settings_per_host_settings_descriptions=These settings will be automatically applied to any new download that matches the specified host. settings_download_max_concurrent_downloads=Maximum Concurrent Downloads settings_download_max_concurrent_downloads_description=The maximum number of files that can be downloaded at the same time (downloads managed by queues are not counted; set to 0 for unlimited) download_item_settings_speed_limit=Kufiri i Shpejtësisë download_item_settings_speed_limit_description=Kufizo shpejtësinë e shkarkimit për këtë artikull download_item_settings_show_download_completion_dialog=Show Download Completion Dialog download_item_settings_show_download_completion_dialog_description=Automatically Show the "Download Complete" dialog when this download is finished. download_item_settings_shutdown_on_completion=Shutdown System On Completion download_item_settings_shutdown_on_completion_description=Automatically shutdown the system when this download is finished. download_item_settings_thread_count=Numri i Fijeve download_item_settings_thread_count_description=Sa fije përdoren për të shkarkuar këtë artikull (0 për normalen) download_item_settings_thread_count_describe={{count}} fije për këtë shkarkim download_item_settings_username_description=Vendos një emër përdoruesi nëse lidhja është një burim i mbrojtur download_item_settings_password_description=Vendos një fjalëkalim nëse lidhja është një burim i mbrojtur download_item_settings_download_page=Download Page download_item_settings_download_page_description=The webpage where this download was initiated download_item_settings_file_checksum=File Checksum download_item_settings_file_checksum_description=A hash string which can be used to check if file is downloaded correctly download_item_settings_user_agent=User-Agent download_item_settings_user_agent_description=Custom User-Agent for this item (leave empty to use the default) file_checksum=File Checksum file_checksum_page=File Checksum Checker file_checksum_page_file_checksum_default_algorithm=Default Algorithm file_checksum_page_file_checksum_default_algorithm_help=The default algorithm used to calculate file checksums when they are not provided. start=Start calculated_checksum=Calculated Checksum saved_checksum=Saved Checksum checksum_algorithm=Algorithm file_not_found=File not found download_not_finished=Download not finished done=Done waiting=Waiting matches=Matches not_matches=Not Matches copy_to_clipboard=Copy To Clipboard username=Emri i Përdoruesit password=Fjalëkalimi average_speed=Shpejtësia Mesatare exact_speed=Shpejtësia e Saktë unlimited=Pa Kufizim use_global_settings=Përdor Cilësimet Globale cant_run_browser_integration=Nuk mund të kryhet integrimi me shfletuesin cant_open_file=Nuk mund të hapet Skedari cant_open_folder=Nuk mund të hapet Dosja # times for example 2 seconds ago relative_time_long_years={{years}} vite relative_time_long_months={{months}} muaj relative_time_long_days={{days}} ditë relative_time_long_hours={{hours}} orë relative_time_long_minutes={{minutes}} minuta relative_time_long_seconds={{seconds}} sekonda relative_time_short_years={{years}} v relative_time_short_months={{months}} mu relative_time_short_days={{days}} d relative_time_short_hours={{hours}} orë relative_time_short_minutes={{minutes}} min relative_time_short_seconds={{seconds}} sek relative_time_left={{time}} mbetur relative_time_ago={{time}} më parë auto=Automatik unspecified=E Paspecifikuar custom=E Përshtatur icon=Ikona author=Autori link=Lidhja size=Madhësia status=Statusi parts_info_downloaded_size=Shkarkuar parts_info_total_size=Totali speed=Shpejtësia time_left=Koha e Mbetur date_added=Data e Shtuar info=Informacioni download_page_downloaded_size=Shkarkuar download_page_download_completed=Download Completed resume_support=Mbështetje për Vazhdim yes=Po no=Jo parts_info=Informacion për Pjesët disconnected=I Shkëputur receiving_data=Duke Marrë të Dhëna connecting=Dërgo Kërkesën warning=Paralajmërim unsupported_resume_warning=Ky shkarkim nuk mbështet vazhdimin\! Mund të jetë e nevojshme ta RINISNI më vonë në Listën e Shkarkimeve stop_anyway=Ndal Prapëseprapë customize_columns=Personalizo Kolonat reset=Rivendos monday=E Hënë tuesday=E Martë wednesday=E Mërkurë thursday=E Enjte friday=E Premte saturday=E Shtunë sunday=E Diel proxy_open_system_proxy_settings=Open System Proxy Settings proxy_type=Proxy type proxy_do_not_use_proxy_for=Don't Use proxy for proxy_do_not_use_proxy_for_description=A list of urls that may not be proxied\nYou can use wildcard with *\nfor example 192.168.1.* example.com (space separated) proxy_change_title=Change Proxy change_proxy=Change Proxy proxy_no=No Proxy proxy_system=System Proxy proxy_manual=Manual Proxy proxy_pac=Proxy Auto Configuration proxy_pac_url=Proxy Auto Configuration URL address=Address port=Port address_and_port=Address & Port use_authentication=Use Authentication warning_you_may_have_to_restart_the_download_later=You may have to restart the download later\! edit_download_title=Edit Download edit_download_update_from_download_page=Update from Download Page edit_download_update_from_download_page_description=When this window is open, you can go to the Download Page and click the download button. The app will capture and update the new download credentials so you can save them. edit_download_saved_download_item_size_not_match=The saved download item has a size of {{currentSize}}, which does not match the new size of {{newSize}}. translators_page_thanks=With Gratitude to Those Who Helped Translate This Project ❤️ translators=Translators language=Language translators_contribute_title=Improve Translations translators_contribute_description=Want to help improve this project? If your language isn't listed or needs some tweaks, you can contribute your translations and make it better\! contribute=Contribute meet_the_translators=Meet the Translators localized_by_translators=Localized by Translators confirm_exit=Confirm Exit confirm_exit_description=Are you sure you want to exit AB Download Manager?\nActive downloads/queues will be stopped\! update=Update update_updater=Updater update_available=Update Available update_error=Update Error update_available_suggest_to_to_update=You can update to the latest version to enjoy new features, enhancements, and performance improvements. update_release_notes=Release Notes update_check_for_update=Check for Update update_checking_for_update=Checking for Update update_no_update=You are using the latest version update_check_error=Error while checking for update update_app_updated_to_version_n=App updated to version {{version}} create_desktop_entry=Create Desktop Entry shutdown_alert=Shut Down Alert system_shutdown_soon=System Will Shut Down Soon\! system_shutdown_failed=System Shut Down Failed\! system_shutdown_soon_description=The system will shut down soon. If you're still using the computer, please save your work or cancel the shutdown. system_shutdown_reason_queue_completed=All downloads in the queue are complete. system_shutdown_reason_queue_end_time_reached=Scheduled end time for the download queue reached. system_shutdown_download_finished=Download completed. shutdown_now=Shut Down Now settings_per_host_settings_new_host= settings_per_host_settings_not_selected=Create or select a new item first\! settings_per_host_settings_host=Host settings_per_host_settings_host_description=These settings will be applied to downloads matching this hostname. Wildcards (*) are supported (e.g., example.com, *.example.com — use only one). settings_browser_in_launcher=Browser Icon In Launcher settings_browser_in_launcher_description=Show or hide the browser icon in the launcher (app list). sort_by=Sort By welcome=Welcome new_folder=New Folder skip=Skip lets_go=Let's Go next=Next select_all=Select All select_inside=Select Inside select_invert=Select Invert open_settings=Open Settings back=Back service_is_running=Service is running initial_setup_description=Let’s set things up initial_setup_notice=You can change these settings anytime later permission_granted=Permission granted permission_not_granted=Permission not granted permissions=Permissions give_permission=Allow permission give_storage_permission=Allow storage access storage_roots=Storage Roots permissions_initial_title=Permissions setup permissions_initial_description=To work properly, the app needs a few permissions. On the next screen, you’ll see what each permission is used for and you can decide which ones to allow or skip. permissions_done_title=You’re all set permissions_done_description=Everything is ready. All required permissions have been granted and the app is good to go. permissions_manage_storage_title=Manage storage access permissions_manage_storage_reason=This permission lets the app change the download folder, detect duplicate downloads more accurately, and enable some extra features. It’s optional, but recommended for the best experience. permission_read_write_external_storage_title=Read and write storage permission_read_write_external_storage_reason=This permission allows the app to save and manage downloaded files, change the download location, and improve duplicate download detection. permissions_post_notification_title=Post Notification permissions_post_notification_reason=The app needs to run in the background to manage downloads. Notifications are used to keep you informed and allow background operation. permissions_ignore_battery_optimization_title=Ignore Battery Optimization permissions_ignore_battery_optimization_reason=Some devices aggressively limit background activity to save battery, which can pause or stop downloads when the app isn’t open. You can optionally exclude the app from battery optimization to ensure downloads continue uninterrupted open_in_browser=Open In Browser browser=Browser browser_new_tab=New Tab browser_close_tab=Close Tab browser_open_in_new_tab=Open In New Tab browser_open_in_new_background_tab=Open In New Background Tab browser_no_tab_open=No tabs are open browser_tabs=Tabs browser_paste_and_go=Paste And Go browser_bookmarks=Bookmarks browser_add_bookmark=Add Bookmark browser_edit_bookmark=Edit Bookmark browser_add_to_bookmarks=Add To Bookmarks browser_remove_from_bookmarks=Remove From Bookmarks ================================================ FILE: shared/resources/src/commonMain/resources/com/abdownloadmanager/resources/locales/th_TH.properties ================================================ app_title=AB DownloadManager confirm_auto_categorize_downloads_title=จัดหมวดหมู่ดาวน์โหลดอัตโนมัติ confirm_auto_categorize_downloads_description=รายการใด ๆ ที่ไม่ได้จัดหมวดหมู่จะถูกเพิ่มลงในหมวดหมู่ที่เกี่ยวข้องโดยอัตโนมัติ confirm_reset_to_default_categories_title=รีเซ็ตเป็นหมวดหมู่เริ่มต้น confirm_reset_to_default_categories_description=การดำเนินการนี้จะลบหมวดหมู่ทั้งหมดและคืนค่าหมวดหมู่เริ่มต้นกลับมา\! confirm_delete_download_items_title=ยืนยันลบ confirm_delete_download_items_description=คุณแน่ใจว่าต้องการลบ {{count}} รายการ ? confirm_delete_download_unfinished_items_description=คุณแน่ใจหรือไม่ว่าต้องการลบ {{count}} รายการที่ดาวน์โหลดยังไม่เสร็จ? confirm_delete_download_finished_and_unfinished_items_description=คุณแน่ใจหรือไม่ว่าต้องการลบการดาวน์โหลดที่เสร็จสิ้นแล้ว {{finishedCount}} รายการและการดาวน์โหลดที่ยังไม่เสร็จสิ้น {{unfinishedCount}} รายการ? also_delete_file_from_disk=ลบไฟล์ออกจากดิสก์ confirm_delete_category_item_title=กำลังลบหมวดหมู่ {{name}} confirm_delete_category_item_description=คุณแน่ใจหรือไม่ว่าต้องการลบหมวดหมู่ "{{value}}" your_download_will_not_be_deleted=ดาวน์โหลดของคุณจะไม่ถูกลบ drag_the_file_to_another_app=ลากไฟล์ไปยังแอปอื่นๆ drop_link_or_file_here=วางลิงค์หรือไฟล์ที่นี่ nothing_will_be_imported=ไม่มีอะไรจะนำเข้า n_links_will_be_imported={{count}} ลิงก์จะถูกนำเข้า n_items_selected={{count}} รายการที่เลือก window_close=ปิด window_minimize=ย่อขนาด window_maximize=ใหญ่สุด window_restore=คืนค่าหน้าต่าง delete=ลบออก remove=ลบถาวร cancel=ยกเลิก close=ปิด menu= more_options=More Options ok=ตกลง add=เพิ่ม paste=Paste change=เปลี่ยน edit=แก้ไข change_anyway=เปลี่ยนแปลงต่อไป download=ดาวน์โหลด refresh=รีเฟรช settings=ตั้งค่า on_completion=เมื่อเสร็จสิ้น unknown=ไม่ทราบ unknown_error=ข้อผิดพลาดที่ไม่รู้จัก download_item_not_found=ไม่พบรายการดาวน์โหลด name=ชื่อ download_link=ลิงค์ดาวน์โหลด not_finished=ยังไม่เสร็จสิ้น all=ทั้งหมด finished=เสร็จสิ้น Unfinished=ยังไม่เสร็จสิ้น canceled=ยกเลิก error=ผิดพลาด paused=หยุดชั่วคราว downloading=กำลังดาวน์โหลด added=เพิ่ม idle=ไม่ใช้งาน preparing_file=เตรียมไฟล์ creating_file=สร้างไฟล์ resuming=ดำเนินการต่อ retrying=กำลังลองใหม่ list_is_empty=รายการว่างเปล่า\! search_in_the_list=ค้นหาในรายการ search=ค้นหา clear=เคลียร์ general=ทั่วไป enabled=เปิดใช้งาน disabled=ปิดใช้งาน default=ค่าเริ่มต้น file=ไฟล์ tasks=งาน tools=เครื่องมือ help=ช่วยเหลือ system=ระบบ all_missing_files=ไฟล์ที่หายไปทั้งหมด all_finished=เสร็จสิ้นทั้งหมด all_unfinished=ไม่เสร็จสิ้นทั้งหมด entire_list=รายการทั้งหมด download_browser_integration=รวมเบราว์เซอร์การดาวน์โหลด exit=ออก show_downloads=แสดงการดาวน์โหลด new_download=เพิ่มดาวน์โหลด stop_all=หยุดทั้งหมด import_from_clipboard=นำเข้าจากคลิปบอร์ด batch_download=ดาวน์โหลดหลายรายการ open=เปิด share=Share open_file=เปิดไฟล์ open_folder=เปิดโฟลเดอร์ resume=ดำเนินการต่อ pause=หยุดชั่วคราว restart_download=เริ่มการดาวน์โหลดใหม่ copy=คัดลอก copy_link=คัดลอกลิงค์ copy_as_curl=คัดลอกเป็น cURL show_properties=แสดงคุณสมบัติ move_to_queue=ย้ายไปคิว move_to_this_queue=ย้ายไปคิวนี้ move_to_category=ย้ายไปยังหมวดหมู่ move_to_this_category=ย้ายไปหมวดหมู่นี้ categories=ประเภท add_category=เพิ่มหมวดหมู่ edit_category=แก้ไขหมวดหมู่ delete_category=ลบหมวดหมู่ category_name=ชื่อหมวดหมู่ category_download_location=หมวดหมู่การดาวน์โหลด category_download_location_description=เมื่อเลือกหมวดหมู่นี้ใน "เพิ่มดาวน์โหลด" ให้ใช้ไดเรกทอรีนี้เป็น "ตำแหน่งดาวน์โหลด" category_file_types=ประเภทหมวดหมู่ไฟล์ category_file_types_description=ใส่ประเภทไฟล์เหล่านี้ลงในหมวดหมู่นี้โดยอัตโนมัติ (เมื่อคุณเพิ่มไฟล์ดาวน์โหลดใหม่)\nแยกนามสกุลไฟล์ด้วยช่องว่าง (ext1 ext2 ...) category_url_patterns=รูปแบบ URL category_url_patterns_description=ใส่ลิงค์ดาวน์โหลดจาก URL อัตโนมัติเหล่านี้ลงในหมวดหมู่นี้โดยอัตโนมัติ (เมื่อคุณเพิ่มการดาวน์โหลดใหม่)\nคั่น URL ด้วยช่องว่าง คุณยังสามารถใช้ * เป็นไวล์ดการ์ดได้อีกด้วย auto_categorize_downloads=จัดหมวดหมู่ดาวน์โหลดอัตโนมัติ restore_defaults=คืนค่าเริ่มต้น about=เกี่ยวกับ version_n=เวอร์ชัน {{value}} developed_with_love_for_you=พัฒนาด้วย ❤️ เพื่อคุณ donate=บริจาค visit_the_project_website=เยี่ยมชมเว็บไซต์โครงการ this_is_a_free_and_open_source_software=นี่เป็นซอฟต์แวร์โอเพ่นซอร์สและฟรี view_the_source_code=ดูซอสโค้ด third_party_libraries=Third Party Libraries powered_by_open_source_software=ขับเคลื่อนด้วยซอฟต์แวร์โอเพ่นซอร์ส view_the_open_source_licenses=ดูใบอนุญาตโอเพนซอร์ส support_and_community=การช่วยเหลือและชุมชน telegram=Telegram channel=ช่อง group=กลุ่ม add_download=เพิ่มดาวน์โหลด add_multi_download_page_header=เลือกรายการที่คุณต้องการดาวน์โหลด save_to=บันทึกไปยัง where_should_each_item_saved=บันทึกแต่ละรายการไว้ที่ใด? there_are_multiple_items_please_select_a_way_you_want_to_save_them=มีหลายรายการ\! กรุณาเลือกวิธีที่คุณต้องการบันทึก each_item_on_its_own_category=แต่ละรายการมีหมวดหมู่ของตัวเอง each_item_on_its_own_category_description=แต่ละรายการจะถูกจัดอยู่ในหมวดหมู่ประเภทไฟล์นั้นๆ all_items_in_one_category=รายการทั้งหมดอยู่ในหนึ่งหมวดหมู่ all_items_in_one_category_description=ไฟล์ทั้งหมดจะถูกบันทึกไว้ในหมวดหมู่ที่เลือก all_items_in_one_Location=รายการทั้งหมดอยู่ในที่เดียว all_items_in_one_Location_description=รายการทั้งหมดจะถูกบันทึกไว้ในไดเร็กทอรีที่เลือก unselected_all_items_in_specific_location_description=ไฟล์ทั้งหมดจะถูกบันทึกไว้ในหมวดหมู่ที่เลือก no_category_selected=ไม่มีหมวดหมู่ที่เลือก no_categories_found=ไม่พบหมวดหมู่ download_location=ตำแหน่งการดาวน์โหลด location=ที่ตั้ง select_queue=เลือกคิว without_queue=ไม่มีคิว use_category=ใช้หมวดหมู่ cant_write_to_this_folder=ไม่สามารถเขียนลงโฟลเดอร์นี้ได้ file_name_already_exists=มีชื่อไฟล์นี้อยู่แล้ว download_already_exists=ดาวน์โหลดนี้มีอยู่แล้ว invalid_file_name=ชื่อไฟล์ไม่ถูกต้อง show_solutions=แสดงแนวทาง change_solution=เปลี่ยนแปลงโซลูชั่น select_a_solution=เลือกโซลูชัน select_download_strategy_description=ลิงก์ที่คุณให้มามีอยู่ในรายการดาวน์โหลดแล้ว กรุณาระบุสิ่งที่คุณต้องการทำ download_strategy_add_a_numbered_file=เพิ่มไฟล์ที่มีหมายเลข download_strategy_add_a_numbered_file_description=เพิ่มดัชนีหลังชื่อไฟล์ดาวน์โหลด download_strategy_override_existing_file=แทนที่ไฟล์ที่มีอยู่แล้ว download_strategy_override_existing_file_description=ลบดาวน์โหลดที่มีอยู่และเขียนทับลงในไฟล์นั้นๆ download_strategy_update_download_link=อัพเดทดาวน์โหลดที่มีอยู่ download_strategy_update_download_link_description=อัปเดตลิงก์ดาวน์โหลดและข้อมูลที่มีอยู่ download_strategy_show_downloaded_file=แสดงไฟล์ที่ดาวน์โหลด download_strategy_show_downloaded_file_description=แสดงรายการดาวน์โหลดที่มีอยู่แล้ว คุณสามารถกดดำเนินการต่อหรือเปิดได้ batch_download_link_help=ระบุลิงก์ที่มีไวด์การ์ด (ใช้ *) invalid_url=URL ไม่ถูกต้อง list_is_too_large_maximum_n_items_allowed=รายการมีขนาดใหญ่เกินไป\! อนุญาตให้มีรายการสูงสุด {{count}} รายการ enter_range=ระบุช่วง range_from=จาก range_to=ถึง batch_download_wildcard_length=ความยาวของไวด์การ์ด first_link=จากลิงค์ last_link=ถึงลิงค์ open_source_software_used_in_this_app=ซอฟต์แวร์โอเพ่นซอร์สที่ใช้ในแอพนี้ links=ลิงค์ website=เว็บไซต์ developers=ผู้พัฒนา source_code=ซอร์สโค้ด license=ใบอนุญาต no_license_found=ไม่พบใบอนุญาต organization=องค์กร add_new_queue=เพิ่มรายการใหม่ queue_name=ชื่อคิว queues=คิว stop_queue=หยนุดคิว start_queue=เริ่มคิว clear_queue_items=ไม่มีคิว config=ตั้งค่า items=รายการ move_down=เลื่อนลง move_up=เลื่อนขึ้น remove_queue=ลบคิว queue_name_help=ระบุชื่อสำหรับคิวนี้ queue_name_describe=ชื่อคิวคือ {{value}} queue_max_concurrent_download=ดาวน์โหลดพร้อมกันสูงสุด queue_max_concurrent_download_description=ดาวน์โหลดสูงสุดสำหรับคิวนี้ queue_automatic_stop=หยุดอัตโนมัติ queue_automatic_stop_description=หยุดคิวอัตโนมัติเมื่อไม่มีรายการ queue_scheduler=ตารางเวลา queue_enable_scheduler=เปิดใช้งานตัวกำหนดเวลา queue_active_days=วันแอ็คทีฟ queue_active_days_description=ตัวกำหนดตารางงานมีวันไหนบ้าง? queue_scheduler_enable_auto_start_time=เปิดใช้งานเริ่มต้นอัตโนมัติ queue_scheduler_auto_start_time=เวลาเริ่มต้นอัตโนมัติ queue_scheduler_enable_auto_stop_time=เปิดใช้งานการหยุดเวลาอัตโนมัติ queue_scheduler_auto_stop_time=เวลาหยุดอัตโนมัติ queue_shutdown_on_completion=ปิดเครื่องเมื่อเสร็จสิ้น queue_shutdown_on_completion_description=ปิดระบบโดยอัตโนมัติเมื่อคิวนี้เสร็จสิ้น หรือเมื่อถึงเวลาสิ้นสุดที่กำหนดไว้ appearance=รูปแบบ download_engine=ดาวน์โหลดเครื่องมือ browser_integration=ใช้งานบนเบราว์เซอร์ settings_download_max_retries_count=จำนวนครั้งสูงสุดในการดาวน์โหลดซ้ำ settings_download_max_retries_count_description=จำนวนครั้งสูงสุดที่โปรแกรมจะลองดาวน์โหลดใหม่อีกครั้งก่อนที่จะยกเลิก settings_download_max_retries_count_describe_no_retries=ดาวน์โหลดล้มเหลวจะไม่ถูกลองอีกครั้ง settings_download_max_retries_count_describe_n_retries=ดาวน์โหลดล้มเหลวจะลองใหม่อีก {{count}} ครั้ง settings_download_thread_count=จำนวนเธรด settings_download_thread_count_description=เธรดดาวน์โหลดสูงสุดต่อรายการดาวน์โหลด settings_download_thread_count_describe=การดาวน์โหลดสามารถมีเธรดได้สูงสุด {{count}} เธรด settings_download_thread_count_with_large_value_describe=คำเตือน\: การตั้งค่าจำนวนเธรดที่สูงอาจเพิ่มการใช้ทรัพยากรระบบ ลดประสิทธิภาพ หรือทำให้เกิดปัญหาการเชื่อมต่อกับเซิร์ฟเวอร์ ใช้ค่าที่สูงขึ้นเฉพาะเมื่อคุณเข้าใจถึงผลกระทบที่อาจเกิดขึ้นกับระบบและเครือข่ายของคุณเท่านั้น settings_use_server_last_modified_time=ใช้เวลาที่แก้ไขล่าสุดของเซิร์ฟเวอร์ settings_use_server_last_modified_time_description=เมื่อดาวน์โหลดไฟล์ ให้ใช้เวลของเซิร์ฟเวอร์ล่าสุดสำหรับไฟล์ settings_append_extension_to_incomplete_downloads=Append Extension To Incomplete Downloads settings_append_extension_to_incomplete_downloads_description= settings_use_sparse_file_allocation=จัดสรรไฟล์ขนาดเล็ก settings_use_sparse_file_allocation_description=สร้างไฟล์อย่างมีประสิทธิภาพมากขึ้น โดยเฉพาะบน SSD โดยลดการเขียนข้อมูลที่ไม่จำเป็น วิธีนี้จะช่วยให้การดาวน์โหลดเริ่มต้นเร็วขึ้นและลดการใช้ดิสก์ หากการดาวน์โหลดเริ่มต้นช้าลงหรือคุณพบว่าความเร็วในการดาวน์โหลดผิดปกติ โปรดพิจารณาปิดใช้งานตัวเลือกนี้ เนื่องจากอุปกรณ์บางเครื่องอาจไม่รองรับตัวเลือกนี้ settings_ignore_ssl_certificates=ไม่สนใจใบรับรอง SSL settings_ignore_ssl_certificates_description=ปิดใช้งานการตรวจสอบใบรับรอง SSL ใช้เฉพาะเมื่อจำเป็นเท่านั้น เนื่องจากอาจทำให้การเชื่อมต่อของคุณเสี่ยงต่อความปลอดภัย settings_global_speed_limiter=จำกัดความเร็วทั่วโลก settings_global_speed_limiter_description=ขีดจำกัดความเร็วการดาวน์โหลดทั่วโลก (0 หมายถึงไม่จำกัด) settings_show_average_speed=แสดงความเร็วเฉลี่ย settings_show_average_speed_description=ความเร็วในการดาวน์โหลดโดยเฉลี่ยหรือความแม่นยำ settings_use_category_by_default=ใช้หมวดหมู่เริ่มต้น settings_use_category_by_default_description=ใช้หมวดหมู่เริ่มต้นเมื่อเพิ่มการดาวน์โหลด settings_default_download_folder=โฟลเดอร์ดาวน์โหลดเริ่มต้น settings_default_download_folder_description=เมื่อคุณเพิ่มดาวน์โหลดใหม่ ตำแหน่งนี้จะถูกใช้ตามค่าเริ่มต้น settings_default_download_folder_describe=ใช้โฟลเดอร์ "{{folder}} settings_use_proxy=ใช้พร็อกซี settings_use_proxy_description="ตั้งค่าใช้พร็อกซี" settings_use_proxy_describe_no_proxy=ไม่ใช้พร็อกซี settings_use_proxy_describe_system_proxy=ใช้พร็อกซีของระบบ settings_use_proxy_describe_manual_proxy="{{value}}" จะถูกใช้ settings_use_proxy_describe_pac_proxy=ไฟล์ pac "{{value}}" จะถูกใช้ settings_track_deleted_files_on_disk=ติดตามไฟล์ที่ถูกลบบนดิสก์ settings_track_deleted_files_on_disk_description=ลบไฟล์โดยอัตโนมัติจากรายการเมื่อไฟล์ถูกลบหรือย้ายจากไดเร็กทอรีดาวน์โหลด settings_delete_partial_file_on_download_cancellation=Delete Partial File On Download Cancellation settings_delete_partial_file_on_download_cancellation_description=When a download is canceled, the partially downloaded file will be deleted from the disk. This helps keep your download folder clean and reduces unnecessary disk space usage. However, the download will restart from the beginning the next time you start it. settings_default_user_agent=ผู้ใช้เริ่มต้น settings_default_user_agent_description=ระบุสตริงตัวแทนผู้ใช้เริ่มต้นเพื่อกำหนดคำขอระบุตัวตนไปยังเซิร์ฟเวอร์ ซึ่งช่วยในการเข้าถึงเนื้อหาที่ปรับให้เหมาะสมสำหรับอุปกรณ์หรือในการหลีกเลี่ยงข้อจำกัดการดาวน์โหลดที่กำหนดโดยเว็บไซต์ที่ settings_download_size_unit=หน่วยของขนาดไฟล์ที่ดาวน์โหลด settings_download_size_unit_description=หน่วยที่ใช้แสดงขนาดการดาวน์โหลด settings_download_speed_unit=ความเร็วในการดาวน์โหลด settings_download_speed_unit_description=หน่วยความเร็วดาวน์โหลด settings_theme=รูปแบบ settings_theme_description=เลือกรูปแบบสำหรับแอป settings_default_dark_theme=ธีมมืด ค่าเริ่มต้น settings_default_dark_theme_description=Applies when the app follows the system theme and dark mode is active settings_default_light_theme=ธีมสว่าง ค่าเริ่มต้น settings_default_light_theme_description=Applies when the app follows the system theme and light mode is active settings_font=แบบอักษร settings_font_description=เปลี่ยนแบบอักษรที่ใช้ในหน้าตาแอป บางแบบอักษรอาจแสดงผลไม่ถูกต้องในแอป settings_ui_scale=สเกล UI settings_ui_scale_description=ปรับขนาดองค์ประกอบอินเทอร์เฟซของแอป settings_language=ภาษา settings_compact_top_bar=ท็อปบาร์ขนาดกะทัดรัด settings_compact_top_bar_description=รวมแถบด้านบนกับแถบชื่อเรื่องเมื่อหน้าต่างหลักมีความกว้างเพียงพอ settings_use_native_menu_bar=ใช้แถบเมนูของระบบ settings_use_native_menu_bar_description=ใช้รูปแบบแถบเมนูเริ่มต้นของระบบ settings_use_relative_date_time=ใช้รูปแบบวันที่/เวลาแบบสัมพัทธ์ settings_use_relative_date_time_description=ใช้รูปแบบวันที่/เวลาแบบสัมพันธ์ในแอป (เช่น '2 วันที่แล้ว' แทนวันที่/เวลาที่แน่นอน) settings_show_icon_labels=แสดงป้ายไอคอน settings_show_icon_labels_description=แสดงป้ายกำกับใต้ไอคอนเมื่อทำได้ (เช่น การดำเนินการบนแถบเครื่องมือหน้าแรก) settings_use_system_tray=ใช้ System Tray settings_use_system_tray_description=แสดงไอคอนระบบเมื่อแอปกำลังทำงาน settings_start_on_boot=เริ่มต้นเมื่อบูต settings_start_on_boot_description=เริ่มแอปพลิเคชันอัตโนมัติเมื่อเข้าสู่ระบบ settings_notification_sound=เสียงแจ้งเตือน settings_notification_sound_description=เล่นเสียงเมื่อมีการแจ้งเตือนใหม่ settings_browser_integration=รวบรวมบนเบราว์เซอร์ settings_browser_integration_description=ยอมรับการดาวน์โหลดจากเบราว์เซอร์ settings_browser_integration_server_port=พอร์ตเซิร์ฟเวอร์ settings_browser_integration_server_port_description=พอร์ตสำหรับการรวบเบราว์เซอร์ settings_browser_integration_server_port_describe=แอปจะเฝ้าดูพอร์ต {{port}} settings_dynamic_part_creation=สร้างชิ้นส่วนไดนามิก settings_dynamic_part_creation_description=เมื่อส่วนหนึ่งเสร็จสิ้น ให้สร้างส่วนอื่นโดยแบ่งส่วนอื่นๆ ออกเพื่อเพิ่มความเร็วในการดาวน์โหลด settings_show_completion_dialog=แสดงกล่องโต้ตอบดาวน์โหลดเมื่อเสร็จสมบูรณ์ settings_show_completion_dialog_description=แสดงกล่องโต้ตอบ "ดาวน์โหลดเสร็จสิ้น" โดยอัตโนมัติเมื่อการดาวน์โหลดเสร็จสิ้น settings_show_download_progress_dialog=แสดงกล่องโต้ตอบความคืบหน้าการดาวน์โหลด settings_show_download_progress_dialog_description=แสดงกล่องโต้ตอบ "ความคืบหน้าการดาวน์โหลด" โดยอัตโนมัติเมื่อการดาวน์โหลดเริ่มต้น settings_per_host_settings=Per Host Settings settings_per_host_settings_descriptions=การตั้งค่าเหล่านี้จะถูกนำไปใช้กับการดาวน์โหลดใหม่ที่ตรงกับโฮสต์ที่ระบุโดยอัตโนมัติ settings_download_max_concurrent_downloads=Maximum Concurrent Downloads settings_download_max_concurrent_downloads_description=The maximum number of files that can be downloaded at the same time (downloads managed by queues are not counted; set to 0 for unlimited) download_item_settings_speed_limit=จำกัดความเร็ว download_item_settings_speed_limit_description=จำกัดความเร็วดาวน์โหลดสำหรับรายการนี้ download_item_settings_show_download_completion_dialog=แสดงกล่องโต้ตอบเมื่อดาวน์โหลดเสร็จสมบูรณ์ download_item_settings_show_download_completion_dialog_description=แสดงกล่องโต้ตอบ "ดาวน์โหลดเสร็จสิ้น" โดยอัตโนมัติเมื่อดาวน์โหลดนี้เสร็จสิ้น download_item_settings_shutdown_on_completion=ปิดระบบเมื่อเสร็จสิ้น download_item_settings_shutdown_on_completion_description=ปิดระบบโดยอัตโนมัติเมื่อดาวน์โหลดนี้เสร็จสิ้น download_item_settings_thread_count=จำนวนเธรด download_item_settings_thread_count_description=ใช้เธรดจำนวนเท่าใดในการดาวน์โหลดดาวน์โหลดนี้ (0 เป็นค่าเริ่มต้น) download_item_settings_thread_count_describe={{count}} สำหรับการดาวน์โหลดนี้ download_item_settings_username_description=ระบุชื่อผู้ใช้หากลิงก์ได้รับการป้องกัน download_item_settings_password_description=ระบุรหัสผ่านหากลิงก์ได้รับการป้องกัน download_item_settings_download_page=หน้าดาวน์โหลด download_item_settings_download_page_description=เว็บเพจที่เริ่มการดาวน์โหลดนี้ download_item_settings_file_checksum=ตั้งค่าตรวจสอบไฟล์ที่ดาวน์โหลด (Checksum) download_item_settings_file_checksum_description=สตริงแฮชที่สามารถใช้ตรวจสอบว่าไฟล์ได้รับการดาวน์โหลดอย่างถูกต้องหรือไม่ download_item_settings_user_agent=User-Agent download_item_settings_user_agent_description=Custom User-Agent for this item (leave empty to use the default) file_checksum=ตรวจสอบไฟล์ (Checksum) file_checksum_page=ตรวจสอบไฟล์ (Checksum) file_checksum_page_file_checksum_default_algorithm=อัลกอริทึมเริ่มต้น file_checksum_page_file_checksum_default_algorithm_help=อัลกอริทึมเริ่มต้นที่ใช้เพื่อคำนวณค่าตรวจสอบความถูกต้องของไฟล์เมื่อไม่มีการระบุไว้ start=เริ่ม calculated_checksum=คำนวน Checksum saved_checksum=บันทึก Checksum checksum_algorithm=อัลกอริ file_not_found=ไม่พบไฟล์ download_not_finished=ดาวน์โหลดไม่เสร็จสิ้น done=เรียบร้อย waiting=กำลังรอ matches=จับคู่ not_matches=ไม่จับคู่ copy_to_clipboard=คัดลอกไปยังคลิปบอร์ด username=ชื่อผู้ใช้ password=รหัสผ่าน average_speed=ความเร็วเฉลี่ย exact_speed=ความเร็วที่แน่นอน unlimited=ไม่จำกัด use_global_settings=ใช้ตั้งค่าทั่วไป cant_run_browser_integration=ไม่สามารถทำงานบนเบราว์เซอร์ได้ cant_open_file=ไม่สามารถเปิดไฟล์ cant_open_folder=ไม่สามารถเปิดโฟลเดอร์ # times for example 2 seconds ago relative_time_long_years={{years}} ปี relative_time_long_months={{months}} เดือน relative_time_long_days={{days}} วัน relative_time_long_hours={{hours}} ชั่วโมง relative_time_long_minutes={{minutes}} นาที relative_time_long_seconds={{seconds}} วินาที relative_time_short_years={{years}} ปี relative_time_short_months={{months}} เดือน relative_time_short_days={{days}} วัน relative_time_short_hours={{hours}} ชั่วโมง relative_time_short_minutes={{minutes}} นาที relative_time_short_seconds={{seconds}} วินาที relative_time_left=เหลือเวลา {{time}} relative_time_ago={{time}} ที่ผ่านมา auto=อัตโนมัติ unspecified=ไม่ระบุ custom=กำหนดเอง icon=ไอคอน author=ผู้เขียน link=ลิงค์ size=ขนาด status=สถานะ parts_info_downloaded_size=ดาวน์โหลด parts_info_total_size=ทั้งหมด speed=ความเร็ว time_left=เวลาที่เหลือ date_added=วันที่เพิ่ม info=ข้อมูล download_page_downloaded_size=ดาวน์โหลดแล้ว download_page_download_completed=ดาวน์โหลดเสร็จสิ้น resume_support=ดำเนินการสนับสนุนการต่อ yes=ใช่ no=ไม่ parts_info=ข้อมูลชิ้นส่วน disconnected=ถูกตัดการเชื่อมต่อ receiving_data=รับข้อมูล connecting=กำลังเชื่อมต่อ warning=คำเตือน unsupported_resume_warning=ดาวน์โหลดนี้ไม่รองรับการเริ่มใหม่อีกครั้ง\! คุณอาจต้องเริ่มใหม่อีกครั้งในภายหลังในรายการดาวน์โหลด stop_anyway=หยุดต่อไป customize_columns=ปรับแต่งคอลัมน์ reset=รีเซ็ต monday=จันทร์ tuesday=อังคาร wednesday=พุธ thursday=พฤหัสบดี friday=ศุกร์ saturday=เสาร์ sunday=อาทิตย์ proxy_open_system_proxy_settings=เปิดการตั้งค่าพร็อกซีระบบ proxy_type=ประเภทพร็อกซี proxy_do_not_use_proxy_for=อย่าใช้พร็อกซีสำหรับ proxy_do_not_use_proxy_for_description=รายการพร็อกซี คุณสามารถใช้ไวล์ดการ์ด * ได้\nเช่น 192.168.1.* example.com (คั่นด้วยช่องว่าง) proxy_change_title=เปลี่ยนพร็อกซี change_proxy=เปลี่ยนพร็อกซี proxy_no=ไม่มีพร็อกซี proxy_system=พร็อกซีของระบบ proxy_manual=ระบุพร็อกซีเอง proxy_pac=ตั้งค่าพร็อกซีอัตโนมัติ proxy_pac_url=URL กำหนดค่าพร็อกซีอัตโนมัติ address=ที่อยู่ port=พอร์ต address_and_port=ที่อยู่และพอร์ต use_authentication=ใช้การตรวจสอบสิทธิ์ warning_you_may_have_to_restart_the_download_later=คุณอาจต้องเริ่มการดาวน์โหลดใหม่อีกครั้งในภายหลัง\! edit_download_title=แก้ไขดาวน์โหลด edit_download_update_from_download_page=อัปเดตจากหน้าดาวน์โหลด edit_download_update_from_download_page_description=เมื่อหน้าต่างนี้เปิดขึ้น คุณสามารถไปที่หน้าดาวน์โหลดและคลิกปุ่มดาวน์โหลด โปรแกรมจะบันทึกและอัปเดตข้อมูลการดาวน์โหลดใหม่ เพื่อให้คุณสามารถบันทึกข้อมูลเหล่านี้ได้ edit_download_saved_download_item_size_not_match=รายการดาวน์โหลดมีขนาด {{currentSize}} ไม่ตรงกับขนาดใหม่ของ {{newSize}} translators_page_thanks=ขอบคุณที่ช่วยแปลโครงการนี้ ❤️ translators=แปลภาษา language=ภาษา translators_contribute_title=ปรับปรุงการแปล translators_contribute_description=ต้องการช่วยปรับปรุงโครงการนี้หรือไม่ หากภาษาของคุณไม่อยู่ในรายการหรือต้องการแก้ไข คุณสามารถช่วยแปลและปรับปรุงให้ดีขึ้นได้\! contribute=สนับสนุน meet_the_translators=พบกับนักแปลภาษา localized_by_translators=แปลโดยเจ้าของภาษา confirm_exit=ยืนยันการออก confirm_exit_description=คุณต้องการออกจากโปรแกรม ?\nดาวน์โหลด/คิวที่ใช้งานอยู่จะหยุดลง\! update=อัปเดต update_updater=อัปเดต update_available=อัปเดตพร้อมใช้งาน update_error=Update Error update_available_suggest_to_to_update=คุณสามารถอัปเดตเป็นเวอร์ชั่นล่าสุดเพื่อพบกับคุณสมบัติใหม่ การปรับปรุง และการปรับปรุงประสิทธิภาพ update_release_notes=หมายเหตุ update_check_for_update=ตรวจสอบอัปเดต update_checking_for_update=กำลังตรวจสอบอัปเดต update_no_update=คุณกำลังใช้เวอร์ชั่นล่าสุด update_check_error=เกิดข้อผิดพลาดขณะตรวจสอบการอัปเดต update_app_updated_to_version_n=ได้อัพเดตเวอร์ชัน{{version}} create_desktop_entry=Create Desktop Entry shutdown_alert=แจ้งเตือนการปิดเครื่อง system_shutdown_soon=ระบบจะปิดเครื่องในเร็ว ๆ นี้\! system_shutdown_failed=การปิดระบบล้มเหลว\! system_shutdown_soon_description=ระบบจะปิดเครื่องในเร็ว ๆ นี้ หากคุณยังใช้งานอยู่ กรุณาบันทึกงานของคุณหรือยกเลิกการปิดเครื่อง system_shutdown_reason_queue_completed=การดาวน์โหลดทั้งหมดในคิวเสร็จสมบูรณ์แล้ว system_shutdown_reason_queue_end_time_reached=ถึงเวลาสิ้นสุดที่กำหนดไว้สำหรับคิวดาวน์โหลดแล้ว system_shutdown_download_finished=ดาวน์โหลดเสร็จสมบูรณ์ shutdown_now=ปิดเครื่องทันที settings_per_host_settings_new_host= settings_per_host_settings_not_selected=Create or select a new item first\! settings_per_host_settings_host=โฮสต์ settings_per_host_settings_host_description=These settings will be applied to downloads matching this hostname. Wildcards (*) are supported (e.g., example.com, *.example.com — use only one). settings_browser_in_launcher=Browser Icon In Launcher settings_browser_in_launcher_description=Show or hide the browser icon in the launcher (app list). sort_by=Sort By welcome=Welcome new_folder=New Folder skip=Skip lets_go=Let's Go next=Next select_all=Select All select_inside=Select Inside select_invert=Select Invert open_settings=Open Settings back=Back service_is_running=กำลังดำเนินการ initial_setup_description=Let’s set things up initial_setup_notice=You can change these settings anytime later permission_granted=Permission granted permission_not_granted=Permission not granted permissions=Permissions give_permission=Allow permission give_storage_permission=Allow storage access storage_roots=Storage Roots permissions_initial_title=Permissions setup permissions_initial_description=To work properly, the app needs a few permissions. On the next screen, you’ll see what each permission is used for and you can decide which ones to allow or skip. permissions_done_title=You’re all set permissions_done_description=Everything is ready. All required permissions have been granted and the app is good to go. permissions_manage_storage_title=Manage storage access permissions_manage_storage_reason=This permission lets the app change the download folder, detect duplicate downloads more accurately, and enable some extra features. It’s optional, but recommended for the best experience. permission_read_write_external_storage_title=Read and write storage permission_read_write_external_storage_reason=This permission allows the app to save and manage downloaded files, change the download location, and improve duplicate download detection. permissions_post_notification_title=Post Notification permissions_post_notification_reason=The app needs to run in the background to manage downloads. Notifications are used to keep you informed and allow background operation. permissions_ignore_battery_optimization_title=Ignore Battery Optimization permissions_ignore_battery_optimization_reason=Some devices aggressively limit background activity to save battery, which can pause or stop downloads when the app isn’t open. You can optionally exclude the app from battery optimization to ensure downloads continue uninterrupted open_in_browser=Open In Browser browser=Browser browser_new_tab=New Tab browser_close_tab=Close Tab browser_open_in_new_tab=Open In New Tab browser_open_in_new_background_tab=Open In New Background Tab browser_no_tab_open=No tabs are open browser_tabs=Tabs browser_paste_and_go=Paste And Go browser_bookmarks=Bookmarks browser_add_bookmark=Add Bookmark browser_edit_bookmark=Edit Bookmark browser_add_to_bookmarks=Add To Bookmarks browser_remove_from_bookmarks=Remove From Bookmarks ================================================ FILE: shared/resources/src/commonMain/resources/com/abdownloadmanager/resources/locales/tr_TR.properties ================================================ app_title=AB İndirme Yöneticisi confirm_auto_categorize_downloads_title=İndirmeleri otomatik kategorize et confirm_auto_categorize_downloads_description=Kategorize edilmemiş herhangi bir öğe, otomatik olarak ilgili kategorisine eklenecektir. confirm_reset_to_default_categories_title=Varsayılan Kategorilere Sıfırla confirm_reset_to_default_categories_description=Bu işlem, tüm kategorileri SİLECEK ve varsayılan kategorileri geri getirecektir\! confirm_delete_download_items_title=Silmeyi Onayla confirm_delete_download_items_description={{count}} öğeyi silmek istediğinizden emin misiniz? confirm_delete_download_unfinished_items_description={{count}} tamamlanmamış indirmeyi silmek istediğinizden emin misiniz? confirm_delete_download_finished_and_unfinished_items_description={{finishedCount}} tamamlanmış ve {{unfinishedCount}} tamamlanmamış indirmeyi silmek istediğinizden emin misiniz? also_delete_file_from_disk=Dosyayı diskten de sil confirm_delete_category_item_title={{name}} kategorisi kaldırılıyor confirm_delete_category_item_description="{{value}}" Kategorisini silmek istediğinizden emin misiniz? your_download_will_not_be_deleted=İndirmeleriniz silinmeyecektir drag_the_file_to_another_app=Dosyayı başka bir uygulamaya sürükleyin drop_link_or_file_here=Bağlantıyı veya dosyayı buraya bırakın. nothing_will_be_imported=Hiçbir şey içe aktarılmayacak n_links_will_be_imported={{count}} bağlantı içe aktarılacak n_items_selected={{count}} öğe seçildi window_close=Kapat window_minimize=Simge Durumuna Küçült window_maximize=Ekranı Kapla window_restore=Geri Yükle delete=Sil remove=Kaldır cancel=İptal close=Kapat menu=Menü more_options=Diğer Seçenekler ok=Tamam add=Ekle paste=Yapıştır change=Değiştir edit=Düzenle change_anyway=Yine de Değiştir download=İndir refresh=Yenile settings=Ayarlar on_completion=Tamamlandığında unknown=Bilinmiyor unknown_error=Bilinmeyen Hata download_item_not_found=İndirme öğesi bulunamadı name=Ad download_link=İndirme bağlantısı not_finished=Tamamlanmadı all=Tümü finished=Tamamlananlar Unfinished=Tamamlanmayanlar canceled=İptal Edildi error=Hata paused=Duraklatıldı downloading=İndiriliyor added=Eklendi idle=BOŞTA preparing_file=Dosya Hazırlanıyor creating_file=Dosya Oluşturuluyor resuming=Devam Ediliyor retrying=Yeniden Deneniyor list_is_empty=Liste boş\! search_in_the_list=Listede Ara search=Ara clear=Temizle general=Genel enabled=Etkin disabled=Devre Dışı default=Varsayılan file=Dosya tasks=Görevler tools=Araçlar help=Yardım system=Sistem all_missing_files=Tüm Eksik Dosyalar all_finished=Tüm Tamamlananlar all_unfinished=Tüm Tamamlanmayanlar entire_list=Tüm Liste download_browser_integration=Tarayıcı Entegrasyonunu İndir exit=Çıkış show_downloads=İndirmeleri Göster new_download=Yeni İndirme stop_all=Tümünü Durdur import_from_clipboard=Panodan İçe Aktar batch_download=Toplu İndirme open=Aç share=Paylaş open_file=Dosyayı Aç open_folder=Klasörü Aç resume=Devam Et pause=Duraklat restart_download=İndirmeyi Yeniden Başlat copy=Kopyala copy_link=Bağlantıyı Kopyala copy_as_curl=cURL olarak Kopyala show_properties=Özellikleri Göster move_to_queue=Kuyruğa Taşı move_to_this_queue=Bu Kuyruğa Taşı move_to_category=Kategoriye Taşı move_to_this_category=Bu kategoriye taşı categories=Kategoriler add_category=Kategori Ekle edit_category=Kategoriyi Düzenle delete_category=Kategoriyi Sil category_name=Kategori Adı category_download_location=Kategori İndirme Konumu category_download_location_description="İndirme Ekle" penceresinde bu kategori seçildiğinde, "İndirme Konumu" olarak bu dizini kullan category_file_types=Kategori dosya türleri category_file_types_description=Bu dosya türlerini otomatik olarak bu kategoriye yerleştir (yeni indirme eklediğinizde).\nDosya uzantılarını boşlukla ayırın (uznt1 uznt2 ...) category_url_patterns=URL Desenleri category_url_patterns_description=Bu URL'lerden yapılan indirmeleri otomatik olarak bu kategoriye yerleştir (yeni indirme eklediğinizde).\nURL'leri boşlukla ayırın, joker karakter için * kullanabilirsiniz auto_categorize_downloads=İndirmeleri Otomatik Kategorize Et restore_defaults=Varsayılanları Geri Yükle about=Hakkında version_n=Sürüm {{value}} developed_with_love_for_you=Sizin için ❤️ ile geliştirildi donate=Bağış Yap visit_the_project_website=Proje web sitesini ziyaret et this_is_a_free_and_open_source_software=Bu, ücretsiz ve açık kaynaklı bir yazılımdır view_the_source_code=Kaynak Kodunu Gör third_party_libraries=Üçüncü Parti Kütüphaneler powered_by_open_source_software=Açık Kaynaklı Yazılımlar Tarafından Desteklenmektedir view_the_open_source_licenses=Açık Kaynak Lisanslarını Görüntüle support_and_community=Destek & Topluluk telegram=Telegram channel=Kanal group=Grup add_download=İndirme Ekle add_multi_download_page_header=İndirmek için almak istediğiniz öğeleri seçin save_to=Kaydetme Yeri where_should_each_item_saved=Her bir öğe nereye kaydedilmeli? there_are_multiple_items_please_select_a_way_you_want_to_save_them=Birden fazla öğe var\! Lütfen onları kaydetmek için bir yol seçin each_item_on_its_own_category=Her öğe kendi kategorisine each_item_on_its_own_category_description=Her öğe, o dosya türüne sahip bir kategoriye yerleştirilecektir all_items_in_one_category=Tüm öğeler tek bir kategoride all_items_in_one_category_description=Tüm dosyalar seçilen kategoriye kaydedilecektir all_items_in_one_Location=Tüm öğeler tek bir konumda all_items_in_one_Location_description=Tüm öğeler seçilen dizine kaydedilecektir unselected_all_items_in_specific_location_description=Tüm dosyalar, seçilen kategori konumuna kaydedilecektir no_category_selected=Kategori Seçilmedi no_categories_found=Kategori Bulunamadı download_location=İndirme Konumu location=Konum select_queue=Kuyruk Seç without_queue=Kuyruksuz use_category=Kategori Kullan cant_write_to_this_folder=Bu klasöre yazılamıyor file_name_already_exists=Dosya adı zaten mevcut download_already_exists=İndirme zaten mevcut invalid_file_name=Geçersiz dosya adı show_solutions=Çözümleri göster... change_solution=Çözümü değiştir select_a_solution=Bir çözüm seçin select_download_strategy_description=Sağladığınız bağlantı zaten indirme listelerinde, lütfen ne yapmak istediğinizi belirtin download_strategy_add_a_numbered_file=Numaralandırılmış bir dosya ekle download_strategy_add_a_numbered_file_description=İndirme dosya adının sonuna bir dizin ekle download_strategy_override_existing_file=Mevcut dosyanın üzerine yaz download_strategy_override_existing_file_description=Mevcut indirmeyi kaldır ve o dosyaya yaz download_strategy_update_download_link=Mevcut indirmeyi güncelle download_strategy_update_download_link_description=Mevcut indirme bağlantısını ve kimlik bilgilerini güncelle download_strategy_show_downloaded_file=İndirilen dosyayı göster download_strategy_show_downloaded_file_description=Zaten mevcut olan indirme öğesini göster, böylece devam et'e basabilir veya açabilirsiniz batch_download_link_help=Joker karakterler içeren bir bağlantı girin (* kullanın) invalid_url=Geçersiz URL list_is_too_large_maximum_n_items_allowed=Liste çok büyük\! En fazla {{count}} öğeye izin veriliyor enter_range=Aralık girin range_from=Başlangıç range_to=Bitiş batch_download_wildcard_length=Joker karakter uzunluğu first_link=İlk Bağlantı last_link=Son Bağlantı open_source_software_used_in_this_app=Bu Uygulamada Kullanılan Açık Kaynaklı Yazılımlar links=Bağlantılar website=Web Sitesi developers=Geliştiriciler source_code=Kaynak Kodu license=Lisans no_license_found=Lisans bulunamadı organization=Kuruluş add_new_queue=Yeni Kuyruk Ekle queue_name=Kuyruk Adı queues=Kuyruklar stop_queue=Kuyruğu Durdur start_queue=Kuyruğu Başlat clear_queue_items=Kuyruğu Boşalt config=Yapılandırma items=Öğeler move_down=Aşağı Taşı move_up=Yukarı Taşı remove_queue=Kuyruğu Kaldır queue_name_help=Bu kuyruk için bir ad belirtin queue_name_describe=Kuyruk adı {{value}} queue_max_concurrent_download=Maksimum eş zamanlı indirme queue_max_concurrent_download_description=Bu kuyruk için maksimum indirme sayısı queue_automatic_stop=Otomatik durdurma queue_automatic_stop_description=İçinde öğe kalmadığında kuyruğu otomatik olarak durdur queue_scheduler=Zamanlayıcı queue_enable_scheduler=Zamanlayıcıyı Etkinleştir queue_active_days=Aktif Günler queue_active_days_description=Zamanlayıcı hangi günler çalışsın? queue_scheduler_enable_auto_start_time=Otomatik Başlatma Zamanını Etkinleştir queue_scheduler_auto_start_time=Otomatik Başlatma Zamanı queue_scheduler_enable_auto_stop_time=Otomatik Durdurma Zamanını Etkinleştir queue_scheduler_auto_stop_time=Otomatik Durdurma Zamanı queue_shutdown_on_completion=Tamamlandığında Sistemi Kapat queue_shutdown_on_completion_description=Bu kuyruk tamamlandığında veya zamanlanmış bitiş zamanına ulaşıldığında sistemi otomatik olarak kapat. appearance=Görünüm download_engine=İndirme Motoru browser_integration=Tarayıcı Entegrasyonu settings_download_max_retries_count=Maksimum İndirme Deneme Sayısı settings_download_max_retries_count_description=Uygulamanın, başarısız bir indirmeyi pes etmeden önce en fazla kaç kez yeniden deneyeceği settings_download_max_retries_count_describe_no_retries=Başarısız indirmeler yeniden denenmeyecek settings_download_max_retries_count_describe_n_retries=Başarısız indirmeler {{count}} kez yeniden denenecek settings_download_thread_count=İş Parçacığı Sayısı settings_download_thread_count_description=İndirme öğesi başına maksimum indirme iş parçacığı settings_download_thread_count_describe=Bir indirme en fazla {{count}} iş parçacığına sahip olabilir settings_download_thread_count_with_large_value_describe=Uyarı\: Yüksek bir iş parçacığı sayısı ayarlamak, sistem kaynak kullanımını artırabilir, performansı düşürebilir veya sunucularla bağlantı sorunlarına neden olabilir. Yüksek değerleri yalnızca sisteminiz ve ağınız üzerindeki potansiyel etkisini anlıyorsanız kullanın. settings_use_server_last_modified_time=Sunucunun Son Değiştirilme Zamanını Kullan settings_use_server_last_modified_time_description=Bir dosyayı indirirken, yerel dosya için sunucunun son değiştirilme zamanını kullan settings_append_extension_to_incomplete_downloads=Tamamlanmamış İndirmelere Uzantı Ekle settings_append_extension_to_incomplete_downloads_description=Tamamlanmamış indirmelere ".part" uzantısını ekle. Bu, bitmemiş indirmeleri tanımlamaya yardımcı olur ve eksik dosyaların yanlışlıkla açılmasını önler. settings_use_sparse_file_allocation=Seyrek Dosya Ayırma Kullanımı settings_use_sparse_file_allocation_description=Gereksiz veri yazımını azaltarak, özellikle SSD'lerde dosyaları daha verimli oluşturun. Bu, indirme başlangıcını hızlandırabilir ve disk kullanımını azaltabilir. İndirmeler yavaş başlıyorsa veya olağandışı indirme hızları yaşıyorsanız, bazı cihazlarda tam olarak desteklenmeyebileceği için bu seçeneği devre dışı bırakmayı düşünün. settings_ignore_ssl_certificates=SSL Sertifikalarını Yoksay settings_ignore_ssl_certificates_description=SSL sertifika doğrulamasını devre dışı bırakır. Bağlantınızı güvenlik risklerine maruz bırakabileceğinden, yalnızca gerekliyse kullanın. settings_global_speed_limiter=Genel Hız Sınırlayıcı settings_global_speed_limiter_description=Genel indirme hızı sınırı (0, sınırsız anlamına gelir) settings_show_average_speed=Ortalama Hızı Göster settings_show_average_speed_description=İndirme hızını ortalama veya anlık olarak göster settings_use_category_by_default=Varsayılan Olarak Kategoriyi Kullan settings_use_category_by_default_description=Bir indirme eklerken varsayılan olarak kategori kullan. settings_default_download_folder=Varsayılan İndirme Klasörü settings_default_download_folder_description=Yeni bir indirme eklediğinizde bu konum varsayılan olarak kullanılır settings_default_download_folder_describe="{{folder}}" kullanılacak settings_use_proxy=Proxy Kullan settings_use_proxy_description=Dosyaları indirmek için proxy kullan settings_use_proxy_describe_no_proxy=Proxy kullanılmayacak settings_use_proxy_describe_system_proxy=Sistem Proxy'si kullanılacak settings_use_proxy_describe_manual_proxy="{{value}}" kullanılacak settings_use_proxy_describe_pac_proxy=PAC dosyası "{{value}}" kullanılacak settings_track_deleted_files_on_disk=Diskteki Silinmiş Dosyaları İzle settings_track_deleted_files_on_disk_description=Dosyalar indirme dizininden silindiğinde veya taşındığında listeden otomatik olarak kaldır. settings_delete_partial_file_on_download_cancellation=İndirme İptalinde Kısmi Dosyayı Sil settings_delete_partial_file_on_download_cancellation_description=Bir indirme iptal edildiğinde, kısmen indirilmiş dosya diskten silinir. Bu, indirme klasörünüzü temiz tutmaya yardımcı olur ve gereksiz disk alanı kullanımını azaltır. Ancak, bir sonraki başlatışınızda indirme baştan başlayacaktır. settings_default_user_agent=Varsayılan User Agent settings_default_user_agent_description=İsteklerin sunuculara nasıl tanımlandığını belirtmek için Varsayılan User Agent dizesini belirleyin. Bu, belirli cihazlar için optimize edilmiş içeriğe erişmeye veya bazı web sitelerinin uyguladığı indirme sınırlamalarını aşmaya yardımcı olabilir. settings_download_size_unit=İndirme Boyutu Birimi settings_download_size_unit_description=İndirme boyutunu göstermek için kullanılan birim settings_download_speed_unit=İndirme Hızı Birimi settings_download_speed_unit_description=İndirme hızını göstermek için kullanılan birim settings_theme=Tema settings_theme_description=Uygulama için bir tema seçin settings_default_dark_theme=Varsayılan Koyu Tema settings_default_dark_theme_description=Uygulama sistem temasını takip ettiğinde ve karanlık mod aktif olduğunda uygulanır settings_default_light_theme=Varsayılan Açık Tema settings_default_light_theme_description=Uygulama sistem temasını takip ettiğinde ve aydınlık mod aktif olduğunda uygulanır settings_font=Yazı Tipi settings_font_description=Uygulama arayüzünde kullanılan yazı tipini değiştirin, bazı yazı tipleri uygulamada doğru görüntülenmeyebilir. settings_ui_scale=Arayüz Ölçeği settings_ui_scale_description=Uygulamanın arayüz elemanlarının boyutunu ayarlayın settings_language=Dil settings_compact_top_bar=Kompakt Üst Çubuk settings_compact_top_bar_description=Ana pencere yeterli genişliğe sahip olduğunda üst çubuğu başlık çubuğuyla birleştir settings_use_native_menu_bar=Yerel Menü Çubuğunu Kullan settings_use_native_menu_bar_description=Sistemin varsayılan menü çubuğu stilini kullan settings_use_relative_date_time=Göreceli tarih/saat kullan settings_use_relative_date_time_description=Uygulamadaki tarihler için göreceli tarih/saat formatını kullanın (örneğin, tam tarih/saat yerine "2 gün önce") settings_show_icon_labels=Simge Etiketlerini Göster settings_show_icon_labels_description=Mümkün olduğunda simgelerin altında etiketleri göster (örneğin ana araç çubuğu eylemleri) settings_use_system_tray=Sistem Tepsisini Kullan settings_use_system_tray_description=Uygulama çalışırken sistem tepsisi simgesini göster settings_start_on_boot=Açılışta Başlat settings_start_on_boot_description=Kullanıcı oturum açtığında uygulamayı otomatik başlat settings_notification_sound=Bildirim Sesi settings_notification_sound_description=Yeni bildirim geldiğinde ses çal settings_browser_integration=Tarayıcı Entegrasyonu settings_browser_integration_description=Tarayıcılardan indirmeleri kabul et settings_browser_integration_server_port=Sunucu Portu settings_browser_integration_server_port_description=Tarayıcı entegrasyonu için port settings_browser_integration_server_port_describe=Uygulama {{port}} portunu dinleyecek settings_dynamic_part_creation=Dinamik parça oluşturma settings_dynamic_part_creation_description=Bir parça bittiğinde, indirme hızını artırmak için diğer parçaları bölerek başka bir parça oluştur. settings_show_completion_dialog=İndirme Tamamlanma Kutusunu Göster settings_show_completion_dialog_description=Bir indirme tamamlandığında "İndirme Tamamlandı" iletişim kutusunu otomatik olarak göster. settings_show_download_progress_dialog=İndirme İlerlemesi iletişim kutusunu göster settings_show_download_progress_dialog_description=Bir indirme başladığında "İndirme İlerlemesi" iletişim kutusunu otomatik olarak göster. settings_per_host_settings=Sunucu Başına Ayarlar settings_per_host_settings_descriptions=Bu ayarlar, belirtilen sunucu ile eşleşen her yeni indirmeye otomatik olarak uygulanacaktır. settings_download_max_concurrent_downloads=Maximum Concurrent Downloads settings_download_max_concurrent_downloads_description=The maximum number of files that can be downloaded at the same time (downloads managed by queues are not counted; set to 0 for unlimited) download_item_settings_speed_limit=Hız Sınırı download_item_settings_speed_limit_description=Bu öğe için indirme hızını sınırla download_item_settings_show_download_completion_dialog=İndirme Tamamlandı iletişim kutusunu göster download_item_settings_show_download_completion_dialog_description=Bu indirme tamamlandığında "İndirme Tamamlandı" iletişim kutusunu otomatik olarak göster. download_item_settings_shutdown_on_completion=Tamamlandığında Sistemi Kapat download_item_settings_shutdown_on_completion_description=Bu indirme tamamlandığında sistemi otomatik olarak kapat. download_item_settings_thread_count=İş Parçacığı sayısı download_item_settings_thread_count_description=Bu indirme öğesini indirmek için kaç iş parçacığı kullanılacak (0 varsayılan için) download_item_settings_thread_count_describe=Bu indirme için {{count}} iş parçacığı download_item_settings_username_description=Bağlantı korumalı bir kaynak ise bir kullanıcı adı sağlayın download_item_settings_password_description=Bağlantı korumalı bir kaynak ise bir şifre sağlayın download_item_settings_download_page=İndirme Sayfası download_item_settings_download_page_description=Bu indirmenin başlatıldığı web sayfası download_item_settings_file_checksum=Dosya Sağlaması download_item_settings_file_checksum_description=Dosyanın doğru indirilip indirilmediğini kontrol etmek için kullanılabilecek bir hash dizesi download_item_settings_user_agent=User-Agent download_item_settings_user_agent_description=Bu öğe için özel Kullanıcı Aracısı (User-Agent) (varsayılanı kullanmak için boş bırakın) file_checksum=Dosya Sağlaması file_checksum_page=Dosya Sağlama Denetleyicisi file_checksum_page_file_checksum_default_algorithm=Varsayılan Algoritma file_checksum_page_file_checksum_default_algorithm_help=Dosya sağlamaları belirtilmediğinde hesaplamak için kullanılan varsayılan algoritma. start=Başlat calculated_checksum=Hesaplanan Sağlama saved_checksum=Kaydedilen Sağlama checksum_algorithm=Algoritma file_not_found=Dosya bulunamadı download_not_finished=İndirme tamamlanmadı done=Bitti waiting=Bekliyor matches=Eşleşiyor not_matches=Eşleşmiyor copy_to_clipboard=Panoya Kopyala username=Kullanıcı Adı password=Şifre average_speed=Ortalama Hız exact_speed=Anlık Hız unlimited=Sınırsız use_global_settings=Genel Ayarları Kullan cant_run_browser_integration=Tarayıcı entegrasyonu çalıştırılamıyor cant_open_file=Dosya Açılamıyor cant_open_folder=Klasör Açılamıyor # times for example 2 seconds ago relative_time_long_years={{years}} yıl relative_time_long_months={{months}} ay relative_time_long_days={{days}} gün relative_time_long_hours={{hours}} saat relative_time_long_minutes={{minutes}} dakika relative_time_long_seconds={{seconds}} saniye relative_time_short_years={{years}} y relative_time_short_months={{months}} ay relative_time_short_days={{days}} g relative_time_short_hours={{hours}} sa relative_time_short_minutes={{minutes}} dk relative_time_short_seconds={{seconds}} sn relative_time_left={{time}} kaldı relative_time_ago={{time}} önce auto=Otomatik unspecified=Belirtilmemiş custom=Özel icon=Simge author=Yazar link=Bağlantı size=Boyut status=Durum parts_info_downloaded_size=İndirilen parts_info_total_size=Toplam speed=Hız time_left=Kalan Süre date_added=Eklenme Tarihi info=Bilgi download_page_downloaded_size=İndirilen download_page_download_completed=İndirme Tamamlandı resume_support=Devam Desteği yes=Evet no=Hayır parts_info=Parça Bilgisi disconnected=Bağlantı Kesildi receiving_data=Veri Alınıyor connecting=Bağlanılıyor warning=Uyarı unsupported_resume_warning=Bu indirme devam etmeyi desteklemiyor\! İndirme Listesinde daha sonra YENİDEN BAŞLATMANIZ gerekebilir stop_anyway=Yine de Durdur customize_columns=Sütunları Özelleştir reset=Sıfırla monday=Pazartesi tuesday=Salı wednesday=Çarşamba thursday=Perşembe friday=Cuma saturday=Cumartesi sunday=Pazar proxy_open_system_proxy_settings=Sistem Proxy Ayarlarını Aç proxy_type=Proxy türü proxy_do_not_use_proxy_for=Proxy kullanılmayacak adresler proxy_do_not_use_proxy_for_description=Proxy kullanılmayacak URL'lerin listesi\n* ile joker karakter kullanabilirsiniz\nörneğin 192.168.1.* example.com (boşlukla ayrılmış) proxy_change_title=Proxy Değiştir change_proxy=Proxy Değiştir proxy_no=Proxy Yok proxy_system=Sistem Proxy'si proxy_manual=Manuel Proxy proxy_pac=Proxy Otomatik Yapılandırma proxy_pac_url=Proxy Otomatik Yapılandırma URL'si address=Adres port=Port address_and_port=Adres & Port use_authentication=Kimlik Doğrulama Kullan warning_you_may_have_to_restart_the_download_later=İndirmeyi daha sonra yeniden başlatmanız gerekebilir\! edit_download_title=İndirmeyi Düzenle edit_download_update_from_download_page=İndirme Sayfasından Güncelle edit_download_update_from_download_page_description=Bu pencere açıkken, İndirme Sayfasına gidip indirme düğmesine tıklayabilirsiniz. Uygulama, yeni indirme kimlik bilgilerini yakalayacak ve güncelleyecektir, böylece onları kaydedebilirsiniz. edit_download_saved_download_item_size_not_match=Kaydedilen indirme öğesinin boyutu {{currentSize}}, yeni boyut olan {{newSize}} ile eşleşmiyor. translators_page_thanks=Bu Projenin Çevrilmesine Yardımcı Olanlara Minnetle ❤️ translators=Çevirmenler language=Dil translators_contribute_title=Çevirileri İyileştir translators_contribute_description=Bu projeyi geliştirmeye yardımcı olmak ister misiniz? Diliniz listede yoksa veya bazı düzenlemelere ihtiyacı varsa, çevirilerinize katkıda bulunabilir ve daha iyi hale getirebilirsiniz\! contribute=Katkıda Bulun meet_the_translators=Çevirmenlerle Tanışın localized_by_translators=Çevirmenler Tarafından Yerelleştirildi confirm_exit=Çıkışı Onayla confirm_exit_description=AB İndirme Yöneticisi'nden çıkmak istediğinizden emin misiniz?\nAktif indirmeler/kuyruklar durdurulacaktır\! update=Güncelle update_updater=Güncelleyici update_available=Güncelleme Mevcut update_error=Güncelleme Hatası update_available_suggest_to_to_update=Yeni özelliklerin, geliştirmelerin ve performans iyileştirmelerinin tadını çıkarmak için en son sürüme güncelleyebilirsiniz. update_release_notes=Sürüm Notları update_check_for_update=Güncellemeleri Kontrol Et update_checking_for_update=Güncellemeler kontrol ediliyor update_no_update=En son sürümü kullanıyorsunuz update_check_error=Güncelleme kontrolü sırasında hata oluştu update_app_updated_to_version_n=Uygulama {{version}} sürümüne güncellendi create_desktop_entry=Masaüstü Kısayolu Oluştur shutdown_alert=Kapatma Uyarısı system_shutdown_soon=Sistem Yakında Kapanacak\! system_shutdown_failed=Sistem Kapatma Başarısız Oldu\! system_shutdown_soon_description=Sistem yakında kapanacak. Bilgisayarı hala kullanıyorsanız, lütfen çalışmanızı kaydedin veya kapatma işlemini iptal edin. system_shutdown_reason_queue_completed=Kuyruktaki tüm indirmeler tamamlandı. system_shutdown_reason_queue_end_time_reached=İndirme kuyruğu için zamanlanmış bitiş zamanına ulaşıldı. system_shutdown_download_finished=İndirme tamamlandı. shutdown_now=Hemen Kapat settings_per_host_settings_new_host= settings_per_host_settings_not_selected=Önce yeni bir öğe oluşturun veya seçin\! settings_per_host_settings_host=Sunucu settings_per_host_settings_host_description=Bu ayarlar, bu hostname (sunucu adı) ile eşleşen indirmelere uygulanacaktır. Joker (*) karakteri desteklenir (örneğin\: example.com, *.example.com — yalnızca birini kullanın). settings_browser_in_launcher=Başlatıcıda Tarayıcı Simgesi settings_browser_in_launcher_description=Tarayıcı simgesini menüde göster veya gizle. sort_by=Sıralama Ölçütü welcome=Hoşgeldiniz new_folder=Yeni Klasör skip=Geç lets_go=Hadi next=Sonraki select_all=Tümünü Seç select_inside=İçeriği Seç select_invert=Seçimi Tersine Çevir open_settings=Ayarlar back=Geri service_is_running=Hizmet Çalışıyor initial_setup_description=Hadi Ayarlayalım initial_setup_notice=Bu ayarları istediğiniz zaman değiştirebilirsiniz permission_granted=Erişim Onaylandı permission_not_granted=Erişim Reddedildi permissions=Erişim give_permission=Erişime izin ver give_storage_permission=Depolama erişimine izin ver storage_roots=Storage Roots permissions_initial_title=İzinleri yapılandır permissions_initial_description=Uygulamanın doğru çalışması için bazı izinler gereklidir. Bir sonraki ekranda izinlerin amacını görebilir; dilediğinizi onaylayabilir veya atlayabilirsiniz. permissions_done_title=Her şey hazır permissions_done_description=Her şey hazır. Gerekli tüm izinler verildi ve uygulama kullanıma hazır. permissions_manage_storage_title=Depolama izinlerini yönet permissions_manage_storage_reason=Bu izin indirme klasörünü değiştirme, kopya dosyaları algılama ve ek özellikleri kullanma imkanı sunar. İsteğe bağlıdır ancak tam performans için önerilir. permission_read_write_external_storage_title=Okuma ve yazma erişimi permission_read_write_external_storage_reason=Bu izinle uygulama indirilenleri kaydedip yönetebilir, indirme konumunu değiştirebilir ve kopya dosyaları daha iyi tespit edebilir. permissions_post_notification_title=Bildirimlere izin ver permissions_post_notification_reason=İndirmeleri yönetmek için uygulamanın arka planda çalışması gerekir. Bildirimler, hem sizi bilgilendirmek hem de arka planda çalışmayı sürdürmek için kullanılır. permissions_ignore_battery_optimization_title=Pil Optimizasyonunu Yoksay permissions_ignore_battery_optimization_reason=Bazı cihazlar pil tasarrufu için arka plan işlemlerini kısıtlayarak indirmeleri durdurabilir. İndirmelerin kesintisiz sürmesi için uygulamayı isteğe bağlı olarak pil optimizasyonu dışında bırakabilirsiniz open_in_browser=Tarayıcıda Aç browser=Tarayıcı browser_new_tab=Yeni Sekme browser_close_tab=Sekmeyi Kapat browser_open_in_new_tab=Yeni Sekmede Aç browser_open_in_new_background_tab=Arka Planda Aç browser_no_tab_open=Hiçbir Sekme Açık Değil browser_tabs=Sekmeler browser_paste_and_go=Yapıştır ve devam et browser_bookmarks=Yer İşaretleri browser_add_bookmark=Yer İşareti Ekle browser_edit_bookmark=Yer İşaretini Düzenle browser_add_to_bookmarks=Yer İşaretlerine Ekle browser_remove_from_bookmarks=Yer İşaretlerinden Kaldır ================================================ FILE: shared/resources/src/commonMain/resources/com/abdownloadmanager/resources/locales/uk_UA.properties ================================================ app_title=AB Download Manager confirm_auto_categorize_downloads_title=Автоматична категоризація завантажень confirm_auto_categorize_downloads_description=Елементи без категорії будуть автоматично додаватись до відповідних категорій. confirm_reset_to_default_categories_title=Відновлення типових категорій confirm_reset_to_default_categories_description=Всі категорії будуть видалені та відновлені за замовчуванням\! confirm_delete_download_items_title=Підтвердження видалення confirm_delete_download_items_description=Видалити {{count}} елементів ? confirm_delete_download_unfinished_items_description=Ви дійсно бажаєте видалити {{count}} незавершених завантажень ? confirm_delete_download_finished_and_unfinished_items_description=Ви дійсно бажаєте видалити {{finishedCount}} завершених та {{unfinishedCount}} незавершених завантажень ? also_delete_file_from_disk=Видалити файл з диска confirm_delete_category_item_title=Видалення {{name}} категорії confirm_delete_category_item_description=Бажаєте видалити категорію {{value}} ? your_download_will_not_be_deleted=Завантаження не будуть видалені drag_the_file_to_another_app=Перетягнути файл в інший застосунок drop_link_or_file_here=Перетягніть посилання або файл сюди. nothing_will_be_imported=Імпортувати нічого n_links_will_be_imported=Посилань буде імпортовано\: {{count}} n_items_selected=Елементів обрано\: {{count}} window_close=Закрити window_minimize=Згорнути window_maximize=Розгорнути window_restore=Відновити delete=Видалити remove=Видалити cancel=Скасувати close=Закрити menu=Меню more_options=Інші параметри ok=Ок add=Додати paste=Вставити change=Змінити edit=Редагувати change_anyway=Застосувати зміни download=Завантажити refresh=Оновити settings=Налаштування on_completion=Після завершення unknown=Невідомо unknown_error=Невідома помилка download_item_not_found=Завантаження не знайдено name=Ім'я download_link=Посилання not_finished=Не завершено all=Всі finished=Завантажено Unfinished=Не завантажено canceled=Скасовані error=З помилкою paused=Призупинено downloading=Завантаження added=Додано idle=Призупинено preparing_file=Підготовка файлу creating_file=Створення файлу resuming=Відновлення retrying=Повторна спроба list_is_empty=Список пустий\! search_in_the_list=Пошук в списку search=Пошук clear=Очистити general=Основне enabled=Увімкнено disabled=Вимкнено default=За замовчуванням file=Файл tasks=Задачі tools=Інструменти help=Допомога system=Система all_missing_files=Усі відсутні файли all_finished=Всі завершені all_unfinished=Всі незавершені entire_list=Весь список download_browser_integration=Завантажити інтеграцію з браузером exit=Вийти show_downloads=Показати завантаження new_download=Нове завантаження stop_all=Зупинити всі import_from_clipboard=Імпортувати з буферу обміну batch_download=Пакетне завантаження open=Відкрити share=Поділитися open_file=Відкрити файл open_folder=Відкрити каталог resume=Продовжити pause=Призупинити restart_download=Перезавантажити copy=Копіювати copy_link=Копіювати посилання copy_as_curl=Копіювати як cURL show_properties=Показати властивості move_to_queue=Перемістити до черги move_to_this_queue=Перемістити до цієї черги move_to_category=Перемістити до категорії move_to_this_category=Перемістити до цієї категорії categories=Категорії add_category=Додати категорію edit_category=Редагувати категорію delete_category=Видалити категорію category_name=Назва категорії category_download_location=Розташування завантажень в категорії category_download_location_description=Використовувати цей каталог в якості розташування завантаження, якщо ця категорія обрана у вікні "Додавання завантаження" category_file_types=Типи файлів в категорії category_file_types_description=Автоматично додавати ці типи файлів до цієї категорії при додаванні завантаження.\nРозділіть розширення файлів за допомогою пробілу (ext1 ext2 ...) category_url_patterns=Шаблони URL category_url_patterns_description=Автоматично додати завантаження із зазначених посилань до цієї категорії (при доданні нового завантаження).\nРозділіть посилання пробілом. Можна використовувати * підставляння auto_categorize_downloads=Автоматично категоризувати завантаження restore_defaults=Відновити налаштування за замовчуванням about=Про застосунок version_n=Версія {{value}} developed_with_love_for_you=Створено з ❤️ до вас donate=Пожертвувати visit_the_project_website=Перейти на веб-сайт проекту this_is_a_free_and_open_source_software=Це безкоштовне та вільне програмне забезпечення view_the_source_code=Переглянути початковий код third_party_libraries=Сторонні бібліотеки powered_by_open_source_software=Powered by Open Source Software view_the_open_source_licenses=Переглянути ліцензію з відкритим кодом support_and_community=Підтримка та спільнота telegram=Telegram channel=Канал group=Група add_download=Додавання завантаження add_multi_download_page_header=Оберіть елементи, котрі бажаєте завантажити\n save_to=Зберегти до where_should_each_item_saved=Куди потрібно зберегти кожен елемент? there_are_multiple_items_please_select_a_way_you_want_to_save_them=Є кілька предметів\! Будь ласка, оберіть спосіб їх збереження each_item_on_its_own_category=Кожен елемент в окремій категорії each_item_on_its_own_category_description=Кожен елемент буде розміщуватися у категорії, яка має вказаний тип файлу all_items_in_one_category=Всі елементи в одній категорії all_items_in_one_category_description=Все елементи будуть збережені до каталогу обраної категорії all_items_in_one_Location=Всі елементи в одному місці all_items_in_one_Location_description=Все елементи будуть збережені до обраного каталогу unselected_all_items_in_specific_location_description=Всі файли будуть збережені до розташування обраної категорії no_category_selected=Категорія не обрана no_categories_found=Категорії не знайдено download_location=Каталог завантаження location=Розташування select_queue=Обрати чергу without_queue=Без черги use_category=Використати категорію cant_write_to_this_folder=Не вдалося записати до цього каталогу file_name_already_exists=Файл з таким ім'ям вже існує download_already_exists=Завантаження вже існує invalid_file_name=Некоректне ім'я файлу show_solutions=Показати рішення... change_solution=Змінити рішення select_a_solution=Оберіть рішення select_download_strategy_description=Завантаження з таким посиланням вже є у списку завантажень. Що бажаєте зробити ? download_strategy_add_a_numbered_file=Додати номер до назви файлу download_strategy_add_a_numbered_file_description=В кінець назви нового завантаження буде додано числовий індекс. download_strategy_override_existing_file=Перезаписати наявний файл download_strategy_override_existing_file_description=Наявне завантаження буде видалено зі списку, а файл — перезаписано. download_strategy_update_download_link=Оновити чинне завантаження download_strategy_update_download_link_description=Оновити наявне посилання завантаження та його параметри download_strategy_show_downloaded_file=Показати відомості про завантажений файл download_strategy_show_downloaded_file_description=Буде показано вікно з властивостями вже наявного завантаження, щоб ви могли продовжити завантаження та подивитись відомості про нього. batch_download_link_help=Введіть посилання, що містить символи підстановки (використовуйте *) invalid_url=Некоректна адреса URL list_is_too_large_maximum_n_items_allowed=Список занадто великий. Дозволена кількість\: {{count}} enter_range=Введіть діапазон range_from=З range_to=До batch_download_wildcard_length=Довжина підстановки first_link=Перше посилання last_link=Останнє посилання open_source_software_used_in_this_app=Програмне забезпечення з відкритим кодом використовується в цьому застосунку links=Посилання website=Веб-сайт developers=Розробники source_code=Початковий код license=Ліцензія no_license_found=Ліцензію не знайдено organization=Організація add_new_queue=Додати нову чергу queue_name=Назва черги\: queues=Черги stop_queue=Зупинити чергу start_queue=Запустити чергу clear_queue_items=Очистити чергу config=Конфігурація items=Елементи move_down=Перемістити вниз move_up=Перемістити вгору remove_queue=Видалити чергу queue_name_help=Вкажіть назву цієї черги queue_name_describe=Назва черги {{value}} queue_max_concurrent_download=Кількість одночасних завантажень queue_max_concurrent_download_description=Максимальна кількість одночасних завантажень для цієї черги queue_automatic_stop=Автоматична зупинка queue_automatic_stop_description=Автоматично зупинити чергу при відсутності елементів в ній. queue_scheduler=Планувальник queue_enable_scheduler=Увімкнути queue_active_days=Дні queue_active_days_description=В які дні буде працювати планувальник ? queue_scheduler_enable_auto_start_time=Почати завантаження у вказаний час queue_scheduler_auto_start_time=Час автоматичного запуску queue_scheduler_enable_auto_stop_time=Зупинити завантаження у вказаний час queue_scheduler_auto_stop_time=Час автоматичної зупинки queue_shutdown_on_completion=Вимкнути комп'ютер queue_shutdown_on_completion_description=Автоматично вимкнути комп'ютер коли черга буде завершена, або настане час зупинки черги. appearance=Зовнішній вигляд download_engine=Завантажувач browser_integration=Інтеграція з браузером settings_download_max_retries_count=Кількість спроб відновлення завантаження settings_download_max_retries_count_description=Застосунок буде намагатись автоматично відновити невдале завантаження вказану кількість разів. Якщо кількість спроб буде вичерпана - завантаження буде зупинено. settings_download_max_retries_count_describe_no_retries=Завантаження з помилкою не будуть відновлені settings_download_max_retries_count_describe_n_retries=Завантаження з помилкою будуть відновлені {{count}} разів settings_download_thread_count=Кількість потоків settings_download_thread_count_description=Максимальна кількість з'єднань (потоків) на завантаження. settings_download_thread_count_describe=Для завантаження файлу буде використано до {{count}} потоків settings_download_thread_count_with_large_value_describe=Попередження\: висока кількість потоків завантаження може збільшити споживання ресурсів системи, зменшити продуктивність або призвести до проблем із підключенням до серверів, що може унеможливити подальше завантаження файлу. Використовуйте великі значення тільки в тому випадку, якщо розумієте потенційний ризик впливу на вашу систему та мережу. settings_use_server_last_modified_time=Отримувати час редагування файлу з сервера settings_use_server_last_modified_time_description=Використовувати дату редагування файлу, що вказана на сервері. settings_append_extension_to_incomplete_downloads=Додавати розширення ".part" до незавершених завантажень settings_append_extension_to_incomplete_downloads_description=До незавершених завантажень буде додаватись розширення ".part". Ця можливість допоможе розпізнати недозавантажені файли та запобігти їх застосуванню. settings_use_sparse_file_allocation=Оптимізоване розподілення файлів settings_use_sparse_file_allocation_description=Ефективніше створюйте файли, особливо на SSD, за рахунок зменшення непотрібного запису даних. Це може пришвидшити завантаження та зменшити використання диска. Якщо завантаження починаються повільно або у вас спостерігається незвичайна швидкість завантаження, вимкніть цю опцію, оскільки вона може не підтримуватися на деяких пристроях. settings_ignore_ssl_certificates=Ігнорувати SSL-сертифікати settings_ignore_ssl_certificates_description=Вимикає перевірку сертифікатів SSL. Рекомендується використовувати лише тоді, коли це дійсно необхідно, оскільки це може призвести до зниження захисту вашого з'єднання. settings_global_speed_limiter=Загальне обмеження швидкості settings_global_speed_limiter_description=Загальне обмеження швидкості завантажень. settings_show_average_speed=Показувати середню швидкість settings_show_average_speed_description=Показувати середню або точну швидкість завантаження. settings_use_category_by_default=Категоризація завантажень settings_use_category_by_default_description=Автоматично переміщувати завантаження до відповідної категорії під час його додавання. settings_default_download_folder=Каталог завантажень за замовчуванням settings_default_download_folder_description=В цьому каталозі будуть розташовані всі завантаження. settings_default_download_folder_describe="{{folder}}" буде використано settings_use_proxy=Проксі-сервер settings_use_proxy_description=Використовувати проксі для завантаження файлів settings_use_proxy_describe_no_proxy=Проксі-сервер не використовується settings_use_proxy_describe_system_proxy=Буде використано системний проксі-сервер settings_use_proxy_describe_manual_proxy="{{value}}" буде використано settings_use_proxy_describe_pac_proxy=pac-файл "{{value}}" буде використано settings_track_deleted_files_on_disk=Відстежувати вилучені з диску файли settings_track_deleted_files_on_disk_description=Автоматично вилучати файли зі списку, якщо вони були переміщені чи видалені з каталогу завантаження. settings_delete_partial_file_on_download_cancellation=Видаляти недозавантажений файл при скасуванні завантаження settings_delete_partial_file_on_download_cancellation_description=Недозавантажені файли будуть видалятись після скасування завантаження. Ця функція допоможе підтримувати порядок в каталозі завантажень та зменшить зайве використання пам'яті накопичувача. Однак, наступного разу, коли ви розпочнете завантаження, воно почнеться з початку. settings_default_user_agent=User-Agent settings_default_user_agent_description=Вкажіть рядок User-Agent, щоб визначити спосіб ідентифікації запитів на серверах. Це може допомогти отримати доступ до матеріалів, що оптимізовані для деяких пристроїв, або обійти обмеження завантаження на певних вебсайтах. settings_download_size_unit=Одиниця вимірювання обсягу даних settings_download_size_unit_description=Одиниця вимірювання, що буде використовуватись для відображення розміру завантажень (обсягу даних). settings_download_speed_unit=Одиниця вимірювання швидкості завантаження settings_download_speed_unit_description=Одиниця вимірювання, що буде використовуватись для виведення швидкості завантаження. settings_theme=Тема оформлення settings_theme_description=Тема оформлення застосунку settings_default_dark_theme=Системна темна тема settings_default_dark_theme_description=Ця тема буде застосована, якщо в системі увімкнена темна тема. settings_default_light_theme=Системна світла тема settings_default_light_theme_description=Ця тема буде застосована, якщо в системі увімкнена світла тема. settings_font=Шрифт settings_font_description=За допомогою цього параметру можна змінити шрифт інтерфейсу застосунку. Зверніть увагу\: деякі шрифти можуть некоректно відображатись в застосунку. settings_ui_scale=Масштабування інтерфейсу settings_ui_scale_description=Цей параметр дозволяє налаштувати розмір елементів інтерфейсу застосунку. settings_language=Мова settings_compact_top_bar=Компактна верхня панель settings_compact_top_bar_description=Об'єднати верхню панель з заголовком, коли основне вікно має достатню ширину. settings_use_native_menu_bar=Використовувати нативну панель меню settings_use_native_menu_bar_description=Для панелі меню буде застосований системний стиль settings_use_relative_date_time=Використовувати відносну дату/час settings_use_relative_date_time_description=Використовувати відносний формат дати/часу в застосунку (наприклад, "2 дні тому" замість точної дати та часу). settings_show_icon_labels=Показувати мітки піктограм settings_show_icon_labels_description=Показувати мітки піктограм на панелі інструментів коли це можливо. settings_use_system_tray=Використовувати системний лоток settings_use_system_tray_description=Показувати іконку застосунку в системному лотку, коли застосунок запущений. settings_start_on_boot=Запускати під час завантаження settings_start_on_boot_description=Автозапуск застосунку при вході користувача в систему. settings_notification_sound=Звук сповіщення settings_notification_sound_description=Програвати звук сповіщення settings_browser_integration=Інтеграція з браузером settings_browser_integration_description=Дозволити захоплення завантажень з браузера. Для використання цієї функції необхідно встановити додаток для браузера. settings_browser_integration_server_port=Порт сервера settings_browser_integration_server_port_description=Порт для інтеграції з браузером settings_browser_integration_server_port_describe=Застосунок буде прослуховувати порт {{port}} settings_dynamic_part_creation=Створювати динамічні фрагменти settings_dynamic_part_creation_description=Створювати новий фрагмент шляхом розбиття інших, коли завантаження фрагменту завершено. Ця функція підвищую швидкість завантаження файлу. settings_show_completion_dialog=Показувати діалог завершення завантаження settings_show_completion_dialog_description=Показувати вікно "Завершення завантаження" коли завантаження файлу завершено. settings_show_download_progress_dialog=Показувати діалог прогресу завантаження settings_show_download_progress_dialog_description=Показувати вікно "Прогрес завантаження" коли завантаження розпочато. settings_per_host_settings=Налаштування веб-сайтів settings_per_host_settings_descriptions=Ці налаштування будуть автоматично застосовані до кожного нового завантаження, що було розпочато з вказаного веб-сайту. settings_download_max_concurrent_downloads=Кількість одночасних завантажень settings_download_max_concurrent_downloads_description=Максимальна кількість файлів, що можуть завантажуватись одночасно. Завантаження, що керуються чергами не рахуються. Використовуйте значення 0 для зняття обмеження. download_item_settings_speed_limit=Обмеження швидкості download_item_settings_speed_limit_description=Обмеження швидкості завантаження для поточного файлу download_item_settings_show_download_completion_dialog=Показувати діалог завершення завантаження download_item_settings_show_download_completion_dialog_description=Показувати вікно "Завершення завантаження" коли поточне завантаження завершено. download_item_settings_shutdown_on_completion=Вимкнути комп'ютер download_item_settings_shutdown_on_completion_description=Автоматично вимкнути комп'ютер після завершення цього завантаження. download_item_settings_thread_count=Кількість потоків download_item_settings_thread_count_description=Кількість потоків, котрі будуть використовуватись для завантаження цього файлу. Якщо вказано значення 0, то будуть застосовані глобальні налаштування. download_item_settings_thread_count_describe=Для цього завантаження буде використано {{count}} потоків download_item_settings_username_description=Надати ім'я користувача, якщо посилання веде на захищений ресурс download_item_settings_password_description=Надати пароль, якщо посилання веде на захищений ресурс download_item_settings_download_page=Сторінка завантаження download_item_settings_download_page_description=Сторінка, на якій було розпочато завантаження download_item_settings_file_checksum=Контрольна сума download_item_settings_file_checksum_description=Хеш-рядок, котрий використовується для перевірки, що файл завантажений правильно download_item_settings_user_agent=User-Agent download_item_settings_user_agent_description=Власний User-Agent для цього завантаження. Залиште поле пустим, якщо бажаєте використовувати значення за замовчуванням. file_checksum=Контрольна сума file_checksum_page=Перевірка контрольної суми файлу file_checksum_page_file_checksum_default_algorithm=Алгоритм file_checksum_page_file_checksum_default_algorithm_help=Алгоритм за замовчуванням використовується для розрахунку контрольних сум тих файлів, для яких контрольна сума не була вказана на початку завантаження. start=Почати calculated_checksum=Розрахована контрольна сума saved_checksum=Збережена контрольна сума checksum_algorithm=Алгоритм file_not_found=Файл не знайдено download_not_finished=Завантаження не закінчено done=Завершено waiting=Очікування matches=Збігається not_matches=Не збігається copy_to_clipboard=Копіювати до буферу обміну username=Ім'я користувача password=Пароль average_speed=Середня швидкість exact_speed=Точна швидкість unlimited=Необмежено use_global_settings=Використовувати глобальні налаштування cant_run_browser_integration=Не вдалося запустити інтеграцію з браузером cant_open_file=Не вдалося відкрити файл cant_open_folder=Не вдалося відкрити каталог # times for example 2 seconds ago relative_time_long_years={{years}} років relative_time_long_months={{months}} місяців relative_time_long_days={{days}} днів relative_time_long_hours={{hours}} годин relative_time_long_minutes={{minutes}} хвилин relative_time_long_seconds={{seconds}} секунд relative_time_short_years={{years}} р relative_time_short_months={{months}} м relative_time_short_days={{days}} д relative_time_short_hours={{hours}} год. relative_time_short_minutes={{minutes}} хв. relative_time_short_seconds={{seconds}} сек. relative_time_left={{time}} залишилось relative_time_ago={{time}} тому auto=Автоматично unspecified=Не вказано custom=Власний icon=Іконка author=Автор link=Посилання size=Розмір status=Статус parts_info_downloaded_size=Завантажено parts_info_total_size=Всього speed=Швидкість time_left=Часу залишилося date_added=Дата додавання info=Відомості download_page_downloaded_size=Завантажено download_page_download_completed=Завантаження завершено resume_support=Відновлення завантаження yes=Так no=Ні parts_info=Інформація про фрагменти disconnected=Роз'єднано receiving_data=Отримання даних connecting=Підключення warning=Попередження unsupported_resume_warning=Це завантаження не підтримує відновлення. Можливо вам доведеться почати завантаження спочатку в разі його зупинки. stop_anyway=Зупинити примусово customize_columns=Налаштувати стовпці reset=Скидання monday=Понеділок tuesday=Вівторок wednesday=Середа thursday=Четвер friday=П’ятниця saturday=Субота sunday=Неділя proxy_open_system_proxy_settings=Відкрити системні параметри проксі proxy_type=Тип проксі proxy_do_not_use_proxy_for=Не використовувати проксі для proxy_do_not_use_proxy_for_description=Список Url-адрес, які не можуть бути проксі-серверами\nВи можете використовувати символ підстановки з *\nнаприклад 192.168.1.* example.com (через пробіл) proxy_change_title=Змінити проксі change_proxy=Змінити проксі proxy_no=Без проксі proxy_system=Системний проксі proxy_manual=Ручний проксі proxy_pac=Автоналаштування проксі-сервера proxy_pac_url=URL-адреса автоматичної конфігурації проксі address=Адреса port=Порт address_and_port=Адреса та порт use_authentication=Використовувати автентифікацію warning_you_may_have_to_restart_the_download_later=Можливо, вам доведеться почати завантаження спочатку\! edit_download_title=Редагування завантаження edit_download_update_from_download_page=Оновити зі сторінки завантаження edit_download_update_from_download_page_description=Коли це вікно відкрите, ви можете перейти на сторінку завантаження файлу та натиснути кнопку завантаження. Застосунок автоматично захопить та оновить дані про завантаження, щоб ви могли зберегти їх. edit_download_saved_download_item_size_not_match=Розмір нового завантаження ({{newSize}}) не збігається з розміром вже існуючого ({{currentSize}}). translators_page_thanks=Висловлюю подяку перекладачам цього проєкту ❤️ translators=Перекладачі language=Мова translators_contribute_title=Покращити переклад translators_contribute_description=Бажаєте допомогти покращити цей проєкт ? Якщо в перекладі є помилки або ваша мова відсутня в списку, ви можете долучитись до перекладу та зробити його ще краще\! contribute=Зробити внесок meet_the_translators=Переглянути перекладачів localized_by_translators=Перекладено спільнотою confirm_exit=Підтвердження виходу confirm_exit_description=Ви впевнені, що бажаєте завершити роботу AB Download Manager? Активні завантаження будуть зупинені. update=Оновити update_updater=Майстер оновлень update_available=Доступне оновлення update_error=Помилка оновлення update_available_suggest_to_to_update=Ви можете оновитися до останньої версії, щоб насолоджуватися новими функціями та покращеннями. update_release_notes=Примітки до випуску update_check_for_update=Перевірити наявність оновлень update_checking_for_update=Перевірка оновлень update_no_update=Ви використовуєте останню версію update_check_error=Помилка під час перевірки оновлення update_app_updated_to_version_n=Застосунок оновлено до версії {{version}} create_desktop_entry=Створити ярлик (Desktop Entry) shutdown_alert=Сповіщення при вимкненні комп'ютера system_shutdown_soon=Комп'ютер скоро вимкнеться\! system_shutdown_failed=Не вдалося вимкнути систему\! system_shutdown_soon_description=Скоро буде завершено роботу системи. Якщо ви все ще використовуєте комп'ютер, вам варто зберегти свої файли або скасувати вимкнення комп'ютера. system_shutdown_reason_queue_completed=Всі завантаження в черзі завершено. system_shutdown_reason_queue_end_time_reached=Запланований час зупинки черги настав. system_shutdown_download_finished=Завантаження завершено. shutdown_now=Завершити роботу зараз settings_per_host_settings_new_host=<Новий веб-сайт> settings_per_host_settings_not_selected=Оберіть елемент або створіть його\! settings_per_host_settings_host=Веб-сайт settings_per_host_settings_host_description=Ці налаштування будуть застосовані до завантажень, що були розпочаті з вказаних веб-сайтів. В іменах веб-сайтів дозволено використовувати символи підстановки (*). Наприклад, "github.com", "*.yahoo.com"). settings_browser_in_launcher=Іконка веб-браузера в лаунчері settings_browser_in_launcher_description=Показувати піктограму веб-браузера AB DM в лаунчері (меню програм) системи. sort_by=Сортувати за welcome=Ласкаво просимо\! new_folder=Новий каталог skip=Пропустити lets_go=Поїхали\! next=Далі select_all=Виділити всі select_inside=Обрати всередині select_invert=Інвертувати обране open_settings=Відкрити налаштування back=Назад service_is_running=Служба працює initial_setup_description=Нумо все налаштуємо. initial_setup_notice=Ви можете змінити ці налаштування будь-коли. permission_granted=Дозвіл надано permission_not_granted=Дозвіл не надано permissions=Дозволи give_permission=Надати дозвіл give_storage_permission=Дозволити доступ до сховища storage_roots=Кореневі каталоги permissions_initial_title=Налаштування дозволів permissions_initial_description=Для коректної роботи застосунку необхідні деякі дозволи. На наступному екрані ви побачите для яких цілей потрібен кожен з них та зможете вирішити — приймати його чи ні. permissions_done_title=Все налаштовано\! permissions_done_description=Все готово. Усі необхідні дозволи надано - застосунок може добре працювати. permissions_manage_storage_title=Керування доступом до сховища permissions_manage_storage_reason=Цей дозвіл дає право застосунку змінювати теку завантаження, визначати повторювані завантаження більш точно, та увімкнути додаткові функції. Це необов'язково, проте рекомендується для найкращого досвіду. permission_read_write_external_storage_title=Читання та запис сховища permission_read_write_external_storage_reason=Цей дозвіл дає можливість застосунку зберігати та керувати завантаженими файлами, змінювати каталог завантажень та покращувати виявлення завантажень-дублікатів. permissions_post_notification_title=Публікація сповіщень permissions_post_notification_reason=Для керування завантаженнями, застосунку необхідно працювати у фоновому режимі. Сповіщення будуть інформувати вас та дозволять виконувати операції у фоновому режимі. permissions_ignore_battery_optimization_title=Ігнорувати оптимізацію акумулятора permissions_ignore_battery_optimization_reason=Деякі пристрої в агресивній манері обмежують фонову активність застосунків задля економії заряду акумулятору. Через це завантаження можуть бути призупинені або скасовані, коли застосунок не відкритий. Ви можете вилучити цей застосунок зі списку на Оптимізацію Батареї, щоб бути впевненими, що завантаження не будуть перервані. open_in_browser=Відкрити у веб-браузері browser=Веб-браузер browser_new_tab=Нова вкладка browser_close_tab=Закрити вкладку browser_open_in_new_tab=Відкрити у новій вкладці browser_open_in_new_background_tab=Відкрити у новій фоновій вкладці browser_no_tab_open=Немає відкритих вкладок browser_tabs=Вкладки browser_paste_and_go=Вставити та перейти browser_bookmarks=Закладки browser_add_bookmark=Додати закладку browser_edit_bookmark=Редагувати закладку browser_add_to_bookmarks=Додати до закладок browser_remove_from_bookmarks=Видалити із закладок ================================================ FILE: shared/resources/src/commonMain/resources/com/abdownloadmanager/resources/locales/vi_VN.properties ================================================ app_title=AB Download Manager confirm_auto_categorize_downloads_title=Danh mục tải về tự động confirm_auto_categorize_downloads_description=Các tập tin chưa được phân loại sẽ được sắp xếp tự động vào các danh mục liên quan. confirm_reset_to_default_categories_title=Cài đặt lại danh mục mặc định confirm_reset_to_default_categories_description=điều này sẽ XÓA tất cả danh mục và sẽ đưa trở về danh mục mặc định\! confirm_delete_download_items_title=Xác nhận xóa confirm_delete_download_items_description=Bạn có chắc là muốn xóa {{count}} tập tin? confirm_delete_download_unfinished_items_description=Bạn có chắc chắn muốn xóa {{count}} lượt tải xuống chưa hoàn tất không? confirm_delete_download_finished_and_unfinished_items_description=Bạn có chắc chắn muốn xóa {{finishedCount}} lượt tải xuống đã hoàn thành và {{unfinishedCount}} lượt tải xuống chưa hoàn thành không? also_delete_file_from_disk=Cũng xóa tập tin từ ổ cứng confirm_delete_category_item_title=Đang xóa danh mục {{name}} confirm_delete_category_item_description=Bạn có chắc là muốn xóa danh mục "{{value}}? your_download_will_not_be_deleted=Tập tin đã tải của bạn sẽ không bị xóa drag_the_file_to_another_app=Kéo tệp vào một ứng dụng khác drop_link_or_file_here=Thả đường dẫn hoặc tập tin ở đây. nothing_will_be_imported=Không có gì được nạp n_links_will_be_imported={{count}} đường dẫn sẽ được nạp n_items_selected={{count}} tập tin được chọn window_close=Đóng window_minimize=Thu nhỏ window_maximize=Phóng to window_restore=Khôi phục delete=Xóa remove=Loại bỏ cancel=Hủy close=Đóng menu=Menu more_options=Tùy chọn khác ok=OK add=Thêm paste=Dán change=Đổi edit=Sửa change_anyway=Vẫn thay đổi download=Tải xuống refresh=Làm mới settings=Cài đặt on_completion=Hoàn tất unknown=Không biết unknown_error=Lỗi không rõ download_item_not_found=Không tìm thấy tập tin đã tải name=Tên download_link=Đường dẫn tải not_finished=Chưa hoàn thành all=Tất cả finished=Hoàn thành Unfinished=Chưa hoàn thành canceled=Hủy error=Lỗi paused=Dừng downloading=Đang tải added=Thêm idle=CHỜ preparing_file=Đang chuẩn bị tập tin creating_file=Đang tạo tập tin resuming=Đang khôi phục retrying=Đang thử lại list_is_empty=Danh sách trống\! search_in_the_list=Tìm trong danh sách search=Tìm clear=Làm sạch general=Tổng quan enabled=Cho phép disabled=Vô hiệu default=Mặc định file=Tệp tasks=Tác Vụ tools=Công Cụ help=Trợ Giúp system=Hệ thống all_missing_files=Tất cả các tập tin bị mất all_finished=Tất cả đã hoàn tất all_unfinished=Tất cả chưa hoàn thành entire_list=Toàn bộ danh sách download_browser_integration=Liên kết với trình duyệt exit=Đóng show_downloads=Hiển thị mục tải new_download=Mục tải xuống mới stop_all=Dừng tất cả import_from_clipboard=Nhập từ khay nhớ tạm batch_download=Tải hàng loạt open=Mở share=Chia sẻ open_file=Mở tệp open_folder=Mở thư mục resume=Tiếp tục pause=Tạm Dừng restart_download=Khởi Động Lại Mục Tải copy=Sao chép copy_link=Sao Chép Liên Kết copy_as_curl=Sao chép dưới dạng cURL show_properties=Xem Thuộc Tính move_to_queue=Thêm Vào Hàng Chờ move_to_this_queue=Di chuyển đến hàng đợi này move_to_category=Thêm Vào Danh Mục move_to_this_category=Di chuyển đến danh mục này categories=Danh mục add_category=Thêm Danh Mục edit_category=Sửa Danh Mục delete_category=Xoá Danh Mục category_name=Tên Danh Mục category_download_location=Vị Trí Tải Của Danh Mục category_download_location_description=Khi danh mục này được chọn trong "Thêm tải xuống", hãy sử dụng thư mục này làm "Vị trí tải xuống" category_file_types=Loại Tệp Của Danh Mục category_file_types_description=Tự động đưa các loại tệp này vào danh mục này. (khi bạn thêm tệp tải xuống mới)\nPhân tách phần mở rộng tệp bằng dấu cách (ext1 ext2 ...) category_url_patterns=Mẫu liên kết category_url_patterns_description=Tự động đưa tải xuống từ các liên kết này vào danh mục này. (khi bạn thêm tải xuống mới)\nPhân tách các liên kết bằng dấu cách, bạn cũng có thể sử dụng * cho ký tự đại diện auto_categorize_downloads=Tự Động Sắp Xếp Danh Mục Tải restore_defaults=Đặt Về Mặc Định about=Giới Thiệu version_n=Phiên bản {{value}} developed_with_love_for_you=Được phát triển với ❤️ dành cho bạn donate=Đóng góp visit_the_project_website=Xem trang web của ứng dụng this_is_a_free_and_open_source_software=Đây là một phần mềm miễn phí với mã nguồn mở view_the_source_code=Xem mã nguồn third_party_libraries=Thư viện bên thứ ba powered_by_open_source_software=Được làm nên từ các phần mềm mã nguồn mở view_the_open_source_licenses=Xem các giấy phép mã nguồn mở support_and_community=Hỗ trợ & Cộng đồng telegram=Telegram channel=Kênh group=Nhóm add_download=Thêm Mục Tải add_multi_download_page_header=Chọn tập tin bạn muốn tải save_to=Lưu tại where_should_each_item_saved=Mỗi tập tin được lưu tại đâu? there_are_multiple_items_please_select_a_way_you_want_to_save_them=Có nhiều tập tin\! Vui lòng chọn cách mà bạn muốn lưu each_item_on_its_own_category=Mỗi tập tin trong danh mục của nó each_item_on_its_own_category_description=Mỗi tập tin sẽ lưu tại danh mục theo định dạng tập tin all_items_in_one_category=Tất cả tập tin trong một danh mục all_items_in_one_category_description=Tất cả tập tin sẽ được lưu tại vị trí danh mục đã chọn all_items_in_one_Location=Tất cả tập tin tại một vị trí all_items_in_one_Location_description=Tất cả tập tin sẽ được lưu tại danh mục được chọn unselected_all_items_in_specific_location_description=Tất cả các tập tin sẽ được lưu trong vị trí danh mục đã chọn no_category_selected=Không có danh mục được chọn no_categories_found=Không tìm thấy danh mục nào download_location=Vị trí tải về location=Vị trí select_queue=Chọn hàng đợi without_queue=Không đợi use_category=Sử dụng danh mục cant_write_to_this_folder=Không thể lưu tại thư mục này file_name_already_exists=Tên tập tin đã tồn tại download_already_exists=Tải xuống đã tồn tại invalid_file_name=Tên tập tin vô hiệu show_solutions=Chỉ ra giải pháp... change_solution=Đổi giải pháp select_a_solution=Chọn giải pháp select_download_strategy_description=Đường dẫn bạn cung cấp đã nằm trong danh sách tải, hãy chọn bạn muốn làm gì download_strategy_add_a_numbered_file=Thêm số vào tập tin download_strategy_add_a_numbered_file_description=Thêm chỉ mục vào cuối tên tập tin được tải download_strategy_override_existing_file=Ghi đè tập tin đã tồn tại download_strategy_override_existing_file_description=Xóa tập tin đã tồn tại và lưu lại tập tin đó download_strategy_update_download_link=Cập nhật bản tải xuống hiện có download_strategy_update_download_link_description=Cập nhật liên kết tải xuống hiện có và thông tin đăng nhập của nó download_strategy_show_downloaded_file=Chỉ tập tin đã tải download_strategy_show_downloaded_file_description=Tập tin đã tồn tại, bạn hãy chọn tải lại hoặc mở nó batch_download_link_help=Nhập đường dẫn chứa ký thự thay thế (sử dụng *) invalid_url=URL sai list_is_too_large_maximum_n_items_allowed=Danh sách quá lớn\! Cho phép tối đa {{count}} tập tin enter_range=Nhập vào dãy range_from=Từ range_to=Đến batch_download_wildcard_length=Độ dài thay thế first_link=Đường dẫn đầu tiên last_link=Đường dẫn cuối cùng open_source_software_used_in_this_app=Mở phần mềm nguồn mở sử dụng trong ứng dụng này links=Đường dẫn website=Website developers=Các tác giả source_code=Mã nguồn license=Bản quyền no_license_found=Không có bản quyền được tìm thấy organization=Tổ chức add_new_queue=Thêm hàng đợi mới queue_name=Tên hàng đợi queues=Hàng đợi stop_queue=Dừng hàng đợi start_queue=Bắt đầu hàng đợi clear_queue_items=Hàng đợi trống config=Cài Đặt items=Mục move_down=Di chuyển xuống move_up=Di chuyển lên remove_queue=Xoá Khỏi Hàng Chờ queue_name_help=Chọn tên cho hàng chờ queue_name_describe=Tên hàng chờ là {{value}} queue_max_concurrent_download=Số mục tải xuống tối đa cùng lúc queue_max_concurrent_download_description=Số tải xuống tối đa cho hàng chờ này queue_automatic_stop=Tự động dừng queue_automatic_stop_description=Tự động dừng hàng chờ khi hết mục tải queue_scheduler=Lịch trình queue_enable_scheduler=Bật lịch trình queue_active_days=Ngày hoạt động queue_active_days_description=Chọn những ngày lịch trình hoạt động queue_scheduler_enable_auto_start_time=Bật thời gian bắt đầu tự động queue_scheduler_auto_start_time=Thời gian tự bắt đầu queue_scheduler_enable_auto_stop_time=Kích hoạt tự bắt đầu queue_scheduler_auto_stop_time=Thời gian tự kết thúc queue_shutdown_on_completion=Tắt máy khi hoàn tất queue_shutdown_on_completion_description=Tự động tắt máy khi hàng đợi này hoàn thành hoặc khi đạt đến thời gian kết thúc đã lên lịch. appearance=Giao diện download_engine=Trình tải browser_integration=Liên kết với trình duyệt settings_download_max_retries_count=Số lần thử tải xuống tối đa settings_download_max_retries_count_description=Số lần tối đa ứng dụng sẽ thử lại một lần tải xuống không thành công trước khi từ bỏ settings_download_max_retries_count_describe_no_retries=Tải xuống không thành công sẽ không được thử lại settings_download_max_retries_count_describe_n_retries=Tải xuống không thành công sẽ được thử lại {{count}} lần settings_download_thread_count=Số luồng settings_download_thread_count_description=Số luồng tải xuống tối đa cho mỗi mục tải xuống settings_download_thread_count_describe=Một lượt tải xuống có thể có tới {{count}} luồng settings_download_thread_count_with_large_value_describe=Cảnh báo\: Việc thiết lập số luồng cao có thể làm tăng mức sử dụng tài nguyên hệ thống, giảm hiệu suất hoặc gây ra sự cố kết nối với máy chủ. Chỉ sử dụng giá trị cao hơn nếu bạn hiểu được tác động tiềm ẩn lên hệ thống và mạng của mình. settings_use_server_last_modified_time=Sử dụng thời gian sửa đổi cuối cùng của máy chủ settings_use_server_last_modified_time_description=Khi tải xuống tệp, hãy sử dụng thời gian sửa đổi cuối cùng của máy chủ cho tệp cục bộ settings_append_extension_to_incomplete_downloads=Thêm phần mở rộng vào Tải xuống chưa hoàn tất settings_append_extension_to_incomplete_downloads_description=Thêm phần mở rộng ".part" vào các bản tải xuống chưa hoàn tất. Điều này giúp xác định các bản tải xuống chưa hoàn tất và ngăn chặn việc vô tình mở các tệp chưa hoàn tất. settings_use_sparse_file_allocation=Phân bổ không gian tập tin thưa thớt settings_use_sparse_file_allocation_description=Tạo tệp hiệu quả hơn, đặc biệt là trên SSD, bằng cách giảm ghi dữ liệu không cần thiết. Điều này có thể tăng tốc độ bắt đầu tải xuống và giảm mức sử dụng đĩa. Nếu tải xuống bắt đầu chậm hoặc bạn gặp phải tốc độ tải xuống bất thường, hãy cân nhắc tắt tùy chọn này vì tùy chọn này có thể không được hỗ trợ đầy đủ trên một số thiết bị. settings_ignore_ssl_certificates=Bỏ qua chứng chỉ SSL settings_ignore_ssl_certificates_description=Tắt xác minh chứng chỉ SSL. Chỉ sử dụng khi cần thiết vì nó có thể khiến kết nối của bạn gặp rủi ro về bảo mật. settings_global_speed_limiter=Bộ giới hạn tốc độ chung settings_global_speed_limiter_description=Giới hạn tốc độ tải xuống chung (0 nghĩa là không giới hạn) settings_show_average_speed=Hiện tốc độ trung bình settings_show_average_speed_description=Tốc độ tải xuống trung bình hoặc chính xác settings_use_category_by_default=Sử dụng danh mục theo mặc định settings_use_category_by_default_description=Sử dụng danh mục theo mặc định khi thêm mục tải xuống. settings_default_download_folder=Thư mục tải xuống mặc định settings_default_download_folder_description=Khi bạn thêm tải xuống mới, vị trí này được sử dụng theo mặc định settings_default_download_folder_describe="{{folder}}" sẽ được sử dụng settings_use_proxy=Sử dụng Proxy settings_use_proxy_description=Sử dụng proxy để tải xuống tệp settings_use_proxy_describe_no_proxy=Không sử dụng Proxy settings_use_proxy_describe_system_proxy=Hệ thống Proxy sẽ được sử dụng settings_use_proxy_describe_manual_proxy="{{value}}" sẽ được sử dụng settings_use_proxy_describe_pac_proxy=tệp pac "{{value}}" sẽ được sử dụng settings_track_deleted_files_on_disk=Theo dõi các tập tin đã xóa trên đĩa settings_track_deleted_files_on_disk_description=Tự động xóa tệp khỏi danh sách khi chúng bị xóa hoặc di chuyển khỏi thư mục tải xuống. settings_delete_partial_file_on_download_cancellation=Xóa tập tin tải xuống dở khi hủy tải settings_delete_partial_file_on_download_cancellation_description=Khi một lượt tải xuống bị hủy, một phần tệp đã tải xuống sẽ bị xóa khỏi ổ đĩa. Điều này giúp giữ cho thư mục tải xuống của bạn sạch sẽ và giảm việc sử dụng dung lượng ổ đĩa không cần thiết. Tuy nhiên, lượt tải xuống sẽ khởi động lại từ đầu vào lần tiếp theo bạn bắt đầu. settings_default_user_agent=Tác nhân người dùng mặc định settings_default_user_agent_description=Chỉ định chuỗi Tác nhân người dùng mặc định để xác định cách các yêu cầu tự nhận diện với máy chủ. Điều này có thể giúp truy cập nội dung được tối ưu hóa cho các thiết bị cụ thể hoặc vượt qua các giới hạn tải xuống do một số trang web áp đặt. settings_download_size_unit=Đơn vị của kích thước tải xuống settings_download_size_unit_description=Đơn vị được sử dụng để hiển thị kích thước tải xuống settings_download_speed_unit=Đơn vị tốc độ tải xuống settings_download_speed_unit_description=Đơn vị được sử dụng để hiển thị tốc độ tải xuống settings_theme=Chủ đề settings_theme_description=Chọn chủ đề cho ứng dụng settings_default_dark_theme=Chủ đề tối mặc định settings_default_dark_theme_description=Áp dụng khi ứng dụng theo chủ đề hệ thống và chế độ tối đang hoạt động settings_default_light_theme=Chủ đề sáng mặc định settings_default_light_theme_description=Áp dụng khi ứng dụng theo chủ đề hệ thống và chế độ sáng đang hoạt động settings_font=Phông chữ settings_font_description=Thay đổi phông chữ được sử dụng trong giao diện ứng dụng. Một số phông chữ có thể không hiển thị chính xác trong ứng dụng. settings_ui_scale=Tỉ lệ UI settings_ui_scale_description=Điều chỉnh kích thước của các thành phần giao diện ứng dụng settings_language=Ngôn ngữ settings_compact_top_bar=Thu gọn thanh trên cùng settings_compact_top_bar_description=Gộp thanh trên cùng với thanh tiêu đề khi cửa sổ chính có đủ chiều rộng settings_use_native_menu_bar=Sử dụng thanh Menu gốc settings_use_native_menu_bar_description=Sử dụng kiểu thanh menu mặc định của hệ thống settings_use_relative_date_time=Sử dụng ngày/giờ tương đối settings_use_relative_date_time_description=Sử dụng định dạng ngày/giờ tương đối cho các ngày trong ứng dụng (ví dụ\: "2 ngày trước" thay vì ngày/giờ chính xác) settings_show_icon_labels=Hiện nhãn biểu tượng settings_show_icon_labels_description=Hiển thị nhãn dưới các biểu tượng khi có thể (như các hành động trên thanh công cụ chính) settings_use_system_tray=Sử dụng khay hệ thống settings_use_system_tray_description=Hiện biểu tượng khay hệ thống khi ứng dụng đang chạy settings_start_on_boot=Bắt đầu khi khởi động settings_start_on_boot_description=Tự khởi động ứng dụng khi người dùng đăng nhập settings_notification_sound=Âm thanh thông báo settings_notification_sound_description=Phát âm thanh khi có thông báo mới settings_browser_integration=Tích hợp trình duyệt settings_browser_integration_description=Chấp nhận tải từ trình duyệt settings_browser_integration_server_port=Cổng server settings_browser_integration_server_port_description=Cổng cho tích hợp trình duyệt settings_browser_integration_server_port_describe=Ứng dụng sẽ lắng nghe cổng {{port}} settings_dynamic_part_creation=Tạo thành phần tự động settings_dynamic_part_creation_description=Khi một phần tải xong, tạo thêm phần khác bằng cách chia nhỏ các phần khác để cải thiện tốc độ tải xuống settings_show_completion_dialog=Hiển thị hộp thoại hoàn tất tải xuống settings_show_completion_dialog_description=Tự động hiển thị hộp thoại "Hoàn tất tải xuống" khi quá trình tải xuống hoàn tất. settings_show_download_progress_dialog=Hiển thị hộp thoại tiến trình tải xuống settings_show_download_progress_dialog_description=Tự động hiển thị hộp thoại "Tiến trình tải xuống" khi quá trình tải xuống bắt đầu. settings_per_host_settings=Cài đặt cho từng máy chủ settings_per_host_settings_descriptions=Các cài đặt này sẽ tự động được áp dụng cho bất kỳ lượt tải xuống mới nào khớp với máy chủ đã chỉ định. settings_download_max_concurrent_downloads=Số lượt tải xuống đồng thời tối đa settings_download_max_concurrent_downloads_description=Số lượng tệp tối đa có thể được tải xuống cùng lúc (các lượt tải xuống được quản lý bởi hàng đợi không được tính; đặt thành 0 để không giới hạn) download_item_settings_speed_limit=Giới hạn tốc độ download_item_settings_speed_limit_description=Giới hạn tốc độ tải xuống cho mục này download_item_settings_show_download_completion_dialog=Hiển thị hộp thoại hoàn tất tải xuống download_item_settings_show_download_completion_dialog_description=Tự động hiển thị hộp thoại "Hoàn tất tải xuống" khi quá trình tải xuống hoàn tất. download_item_settings_shutdown_on_completion=Tắt máy khi hoàn tất download_item_settings_shutdown_on_completion_description=Tự động tắt máy khi quá trình tải xuống này hoàn tất. download_item_settings_thread_count=Số luồng download_item_settings_thread_count_description=Đã sử dụng bao nhiêu luồng để tải xuống mục tải xuống này (mặc định là 0) download_item_settings_thread_count_describe={{count}} luồng cho tải xuống này download_item_settings_username_description=Cung cấp tên người dùng nếu liên kết là tài nguyên được bảo vệ download_item_settings_password_description=Cung cấp mật khẩu nếu liên kết là tài nguyên được bảo vệ download_item_settings_download_page=Trang tải xuống download_item_settings_download_page_description=Trang web nơi tải xuống này được bắt đầu download_item_settings_file_checksum=Tệp Checksum download_item_settings_file_checksum_description=Chuỗi băm có thể được sử dụng để kiểm tra xem tệp có được tải xuống đúng cách hay không download_item_settings_user_agent=User-Agent download_item_settings_user_agent_description=User-Agent tùy chỉnh cho mục này (để trống để sử dụng mặc định) file_checksum=Tệp Checksum file_checksum_page=Kiểm tra tập tin Checksum file_checksum_page_file_checksum_default_algorithm=Thuật toán mặc định file_checksum_page_file_checksum_default_algorithm_help=Thuật toán mặc định được sử dụng để tính toán tổng kiểm tra tệp khi chúng không được cung cấp. start=Bắt đầu calculated_checksum=Đã tính toán Checksum saved_checksum=Đã lưu Checksum checksum_algorithm=Thuật toán file_not_found=Không tìm thấy tập tin download_not_finished=Tải xuống chưa hoàn tất done=Xong waiting=Đang chờ matches=Khớp not_matches=Không khớp copy_to_clipboard=Sao chép vào bảng tạm username=Tên đăng nhập password=Mật khẩu average_speed=Tốc độ trung bình exact_speed=Tốc độ chính xác unlimited=Không giới hạn use_global_settings=Sử dụng cài đặt chung cant_run_browser_integration=Không thể chạy tích hợp trình duyệt cant_open_file=Không thể mở tập tin cant_open_folder=Không thể mở thư mục # times for example 2 seconds ago relative_time_long_years={{years}} năm relative_time_long_months={{months}} tháng relative_time_long_days={{days}} ngày relative_time_long_hours={{hours}} giờ relative_time_long_minutes={{minutes}} phút relative_time_long_seconds={{seconds}} giây relative_time_short_years={{years}} n relative_time_short_months={{months}} th relative_time_short_days={{days}} ng relative_time_short_hours={{hours}} h relative_time_short_minutes={{minutes}} p relative_time_short_seconds={{seconds}} s relative_time_left={{time}} trái relative_time_ago={{time}} trước auto=Tự động unspecified=Không xác định custom=Tuỳ chỉnh icon=Biểu tượng author=Tác giả link=Liên kết size=Kích thước status=Trạng thái parts_info_downloaded_size=Đã tải xuống parts_info_total_size=Tổng speed=Tốc độ time_left=Thời gian còn lại date_added=Ngày thêm vào info=Thông tin download_page_downloaded_size=Đã tải xuống download_page_download_completed=Tải xuống hoàn tất resume_support=Hỗ trợ khôi phục yes=Có no=Không parts_info=Thông tin các phần disconnected=Ngắt kết nối receiving_data=Đang nhận dữ liệu connecting=Đang kết nối warning=Cảnh báo unsupported_resume_warning=Tải xuống này không hỗ trợ khôi phục\! Bạn có thể phải KHỞI ĐỘNG LẠI sau trong danh sách tải xuống stop_anyway=Dừng luôn customize_columns=Tuỳ chỉnh cột reset=Đặt lại monday=Thứ hai tuesday=Thứ ba wednesday=Thứ tư thursday=Thứ năm friday=Thứ sáu saturday=Thứ bảy sunday=Chủ nhật proxy_open_system_proxy_settings=Mở cài đặt Proxy hệ thống proxy_type=Loại Proxy proxy_do_not_use_proxy_for=Không sử dụng proxy cho proxy_do_not_use_proxy_for_description=Danh sách các liên kết có thể không sử dụng được proxy\nBạn có thể sử dụng ký tự đại diện với *\nví dụ 192.168.1.* example.com (phân cách bằng dấu cách) proxy_change_title=Đổi Proxy change_proxy=Đổi Proxy proxy_no=Không Proxy proxy_system=Proxy hệ thống proxy_manual=Proxy thủ công proxy_pac=Cấu hình tự động Proxy proxy_pac_url=Liên kết cấu hình Proxy tự động address=Địa chỉ port=Cổng address_and_port=Địa chỉ & Cổng use_authentication=Sử dụng xác thực warning_you_may_have_to_restart_the_download_later=Bạn có thể phải khởi động lại quá trình tải xuống sau\! edit_download_title=Chỉnh sửa tải xuống edit_download_update_from_download_page=Cập nhật từ Trang tải xuống edit_download_update_from_download_page_description=Khi cửa sổ này mở ra, bạn có thể vào Trang tải xuống và nhấp vào nút tải xuống. Ứng dụng sẽ chụp và cập nhật thông tin xác thực tải xuống mới để bạn có thể lưu chúng. edit_download_saved_download_item_size_not_match=Mục tải xuống đã lưu có kích thước là {{currentSize}}, không khớp với kích thước mới là {{newSize}}. translators_page_thanks=Cảm ơn những người đã giúp dịch dự án này ❤️ translators=Người dịch language=Ngôn ngữ translators_contribute_title=Cải thiện bản dịch translators_contribute_description=Bạn có muốn giúp cải thiện dự án này không? Nếu ngôn ngữ của bạn không được liệt kê hoặc cần một số điều chỉnh, bạn có thể đóng góp bản dịch của mình và làm cho nó tốt hơn\! contribute=Đóng góp meet_the_translators=Gặp gỡ các biên dịch viên localized_by_translators=Bản địa hóa bởi biên dịch viên confirm_exit=Xác nhận thoát confirm_exit_description=Bạn có chắc chắn muốn thoát khỏi AB Download Manager không?\nCác lượt tải xuống/hàng đợi đang hoạt động sẽ bị dừng\! update=Cập nhật update_updater=Trình cập nhật update_available=Đã có bản cập nhật update_error=Cập nhật lỗi update_available_suggest_to_to_update=Bạn có thể cập nhật lên phiên bản mới nhất để tận hưởng các tính năng mới, cải tiến và nâng cao hiệu suất. update_release_notes=Thông tin phiên bản update_check_for_update=Kiểm tra cập nhật update_checking_for_update=Đang kiểm tra cập nhật update_no_update=Bạn đang sử dụng phiên bản mới nhất update_check_error=Lỗi khi kiểm tra bản cập nhật update_app_updated_to_version_n=Ứng dụng đã được cập nhật lên phiên bản {{version}} create_desktop_entry=Tạo mục nhập trên Desktop shutdown_alert=Cảnh báo tắt máy system_shutdown_soon=Máy sắp tắt\! system_shutdown_failed=Tắt máy không thành công\! system_shutdown_soon_description=Máy sẽ sớm tắt. Nếu bạn vẫn đang sử dụng máy tính, vui lòng lưu công việc của bạn hoặc hủy bỏ việc tắt máy. system_shutdown_reason_queue_completed=Tất cả các lượt tải xuống trong hàng đợi đã hoàn tất. system_shutdown_reason_queue_end_time_reached=Đã đến thời gian kết thúc dự kiến cho hàng đợi tải xuống. system_shutdown_download_finished=Đã tải xong. shutdown_now=Tắt máy ngay settings_per_host_settings_new_host= settings_per_host_settings_not_selected=Tạo hoặc chọn một mục mới trước\! settings_per_host_settings_host=Máy chủ settings_per_host_settings_host_description=Các cài đặt này sẽ được áp dụng cho các lượt tải xuống khớp với tên máy chủ này. Hỗ trợ ký tự đại diện (*). (ví dụ\: example.com, *.example.com — chỉ sử dụng một). settings_browser_in_launcher=Biểu tượng trình duyệt trong Trình khởi chạy settings_browser_in_launcher_description=Hiện hoặc ẩn biểu tượng trình duyệt trong trình khởi chạy (danh sách ứng dụng). sort_by=Sắp xếp theo welcome=Chào mừng new_folder=Thư mục mới skip=Bỏ qua lets_go=Đi nào next=Tiếp select_all=Chọn tất cả select_inside=Chọn bên trong select_invert=Chọn đảo ngược open_settings=Mở cài đặt back=Quay lại service_is_running=Dịch vụ đang chạy initial_setup_description=Hãy cùng thiết lập nào initial_setup_notice=Bạn có thể thay đổi các cài đặt này bất cứ lúc nào sau này permission_granted=Đã được cấp quyền permission_not_granted=Chưa cấp phép quyền permissions=Quyền give_permission=Cấp quyền give_storage_permission=Cho phép truy cập bộ nhớ storage_roots=Thư mục gốc lưu trữ permissions_initial_title=Thiết lập quyền permissions_initial_description=Để hoạt động bình thường, ứng dụng cần một vài quyền. Trên màn hình tiếp theo, bạn sẽ thấy mỗi quyền được sử dụng cho mục đích gì và bạn có thể quyết định cho phép hoặc bỏ qua quyền nào. permissions_done_title=Bạn đã sẵn sàng permissions_done_description=Mọi thứ đã sẵn sàng. Tất cả các quyền cần thiết đã được cấp và ứng dụng đã sẵn sàng hoạt động. permissions_manage_storage_title=Quản lý quyền truy cập lưu trữ permissions_manage_storage_reason=Quyền này cho phép ứng dụng thay đổi thư mục tải xuống, phát hiện các tệp tải xuống trùng lặp chính xác hơn và kích hoạt một số tính năng bổ sung. Quyền này là tùy chọn, nhưng được khuyến nghị để có trải nghiệm tốt nhất. permission_read_write_external_storage_title=Lưu trữ đọc và ghi permission_read_write_external_storage_reason=Quyền này cho phép ứng dụng lưu và quản lý các tệp đã tải xuống, thay đổi vị trí tải xuống và cải thiện khả năng phát hiện các tệp tải xuống trùng lặp. permissions_post_notification_title=Gửi thông báo permissions_post_notification_reason=Ứng dụng cần chạy ngầm để quản lý các lượt tải xuống. Thông báo được sử dụng để thông báo cho bạn và cho phép ứng dụng hoạt động trong nền. permissions_ignore_battery_optimization_title=Bỏ qua tối ưu hóa pin permissions_ignore_battery_optimization_reason=Một số thiết bị hạn chế mạnh mẽ các hoạt động nền để tiết kiệm pin, điều này có thể tạm dừng hoặc dừng quá trình tải xuống khi ứng dụng không được mở. Bạn có thể tùy chọn loại trừ ứng dụng khỏi tính năng tối ưu hóa pin để đảm bảo quá trình tải xuống tiếp tục diễn ra không bị gián đoạn open_in_browser=Mở bằng trình duyệt browser=Trình duyệt browser_new_tab=Thẻ mới browser_close_tab=Đóng thẻ browser_open_in_new_tab=Mở trong thẻ mới browser_open_in_new_background_tab=Mở trong thẻ nền mới browser_no_tab_open=Không có thẻ nào đang mở browser_tabs=Thẻ browser_paste_and_go=Dán và truy cập browser_bookmarks=Dấu trang browser_add_bookmark=Thêm dấu trang browser_edit_bookmark=Chỉnh sửa dấu trang browser_add_to_bookmarks=Thêm vào dấu trang browser_remove_from_bookmarks=Xoá khỏi dấu trang ================================================ FILE: shared/resources/src/commonMain/resources/com/abdownloadmanager/resources/locales/zh_CN.properties ================================================ app_title=AB 下载管理器 confirm_auto_categorize_downloads_title=自动分类下载项 confirm_auto_categorize_downloads_description=所有未分类的下载项将自动归入相应分类。 confirm_reset_to_default_categories_title=重置为默认分类 confirm_reset_to_default_categories_description=这将删除所有自定义分类并恢复默认分类! confirm_delete_download_items_title=确认删除 confirm_delete_download_items_description=您确定要删除这 {{count}} 个下载项吗? confirm_delete_download_unfinished_items_description=您确定要删除 {{count}} 个未完成的下载项吗? confirm_delete_download_finished_and_unfinished_items_description=您确定要删除 {{finishedCount}} 个已完成和 {{unfinishedCount}} 个未完成的下载项吗? also_delete_file_from_disk=同时从硬盘中删除文件 confirm_delete_category_item_title=删除 {{name}} 分类 confirm_delete_category_item_description=您确定要删除 “{{value}}” 分类吗? your_download_will_not_be_deleted=您的下载项将被保留 drag_the_file_to_another_app=将文件拖动到另一个应用程序 drop_link_or_file_here=将链接或文件拖到此处 nothing_will_be_imported=没有可导入的项目 n_links_will_be_imported={{count}} 个链接将被导入 n_items_selected=已选中 {{count}} 个下载项 window_close=关闭 window_minimize=最小化 window_maximize=最大化 window_restore=还原 delete=删除 remove=移除 cancel=取消 close=关闭 menu=菜单 more_options=更多选项 ok=确定 add=添加 paste=粘贴 change=修改 edit=编辑 change_anyway=继续更改 download=下载 refresh=刷新 settings=设置 on_completion=完成后 unknown=未知 unknown_error=未知错误 download_item_not_found=找不到下载项 name=名称 download_link=下载链接 not_finished=未完成 all=全部 finished=已完成 Unfinished=未完成 canceled=已取消 error=错误 paused=已暂停 downloading=正在下载 added=已添加 idle=空闲 preparing_file=正在准备文件 creating_file=正在创建文件 resuming=正在恢复 retrying=正在重试 list_is_empty=列表为空! search_in_the_list=在列表中搜索 search=搜索 clear=清空 general=常规 enabled=启用 disabled=禁用 default=默认 file=文件 tasks=任务 tools=工具 help=帮助 system=系统设置 all_missing_files=所有缺失的文件 all_finished=全部完成 all_unfinished=全部未完成 entire_list=完整列表 download_browser_integration=下载浏览器插件 exit=退出 show_downloads=查看下载项 new_download=新建下载 stop_all=全部停止 import_from_clipboard=从剪切板导入 batch_download=批量下载 open=打开 share=分享 open_file=打开文件 open_folder=打开文件夹 resume=恢复 pause=暂停 restart_download=重新下载 copy=复制 copy_link=复制链接 copy_as_curl=复制为 cURL show_properties=查看属性 move_to_queue=移动到队列 move_to_this_queue=移动到此队列 move_to_category=移动到分类 move_to_this_category=移动到此分类 categories=分类 add_category=添加分类 edit_category=编辑分类 delete_category=删除分类 category_name=分类名称 category_download_location=分类下载位置 category_download_location_description=在 “添加下载” 时选择此分类后,将使用此目录作为 “下载位置” category_file_types=分类文件类型 category_file_types_description=添加新下载项时,将自动把这些类型的文件归入此分类\n请用空格分隔文件扩展名(例如:ext1 ext2 ...) category_url_patterns=URL 匹配规则 category_url_patterns_description=自动将来自这些 URL 的下载放入此类别。(当您添加新下载时)\n用空格分隔 URL,您也可以使用 * 作为通配符 auto_categorize_downloads=自动分类下载项 restore_defaults=恢复默认设置 about=关于 version_n=版本 {{value}} developed_with_love_for_you=用❤️为您开发 donate=捐赠 visit_the_project_website=访问项目官网 this_is_a_free_and_open_source_software=这是一个免费的开源软件 view_the_source_code=查看源代码 third_party_libraries=依赖 powered_by_open_source_software=由开源软件驱动 view_the_open_source_licenses=查看开源协议 support_and_community=支持 & 社区 telegram=Telegram channel=频道 group=群组 add_download=添加下载 add_multi_download_page_header=选择您想要下载的文件 save_to=保存到 where_should_each_item_saved=每个文件应该保存到哪里? there_are_multiple_items_please_select_a_way_you_want_to_save_them=发现了多个文件!请选择一个保存它们的地方 each_item_on_its_own_category=每个文件都有属于自己的分类 each_item_on_its_own_category_description=所有的文件都会基于它们的文件类型被保存到对应的分类下 all_items_in_one_category=所有文件都保存在同一分类 all_items_in_one_category_description=所有的文件都会被保存到选定的分类位置 all_items_in_one_Location=所有文件都保存在同一位置 all_items_in_one_Location_description=所有的文件都会被保存到选定的目录 unselected_all_items_in_specific_location_description=所有文件将保存在选定的分类位置 no_category_selected=未选择分类 no_categories_found=未找到分类 download_location=下载位置 location=位置 select_queue=选择队列 without_queue=不使用队列 use_category=分类到 cant_write_to_this_folder=无法写入该目录 file_name_already_exists=文件名已存在 download_already_exists=已存在下载项 invalid_file_name=文件名无效 show_solutions=查看解决方案... change_solution=更改解决方案 select_a_solution=选择一个解决方案 select_download_strategy_description=您提供的下载链接已经在下载列表里面了,请选择您想做的操作 download_strategy_add_a_numbered_file=在文件后添加编号 download_strategy_add_a_numbered_file_description=在文件名末尾添加编号 download_strategy_override_existing_file=覆盖现有文件 download_strategy_override_existing_file_description=移除现有下载项并开始下载 download_strategy_update_download_link=更新现有的下载 download_strategy_update_download_link_description=更新现有的下载链接及其凭据 download_strategy_show_downloaded_file=显示已下载的文件 download_strategy_show_downloaded_file_description=显示已经存在的下载项以便您恢复下载或打开它 batch_download_link_help=输入包含通配符的链接 (使用*) invalid_url=无效的URL list_is_too_large_maximum_n_items_allowed=批量下载的文件数太多啦!最大支持数量为 {{count}} enter_range=输入范围 range_from=从 range_to=到 batch_download_wildcard_length=通配符长度 first_link=首个链接 last_link=末尾链接 open_source_software_used_in_this_app=此App使用到的开源软件 links=链接 website=网站 developers=开发者 source_code=源代码 license=许可证 no_license_found=找不到许可证 organization=组织 add_new_queue=添加新的队列 queue_name=队列名称 queues=队列 stop_queue=停止队列 start_queue=开始队列 clear_queue_items=清空队列 config=配置 items=项目 move_down=下移 move_up=上移 remove_queue=移除队列 queue_name_help=为队列指定名称 queue_name_describe=队列名称为 {{value}} queue_max_concurrent_download=下载最大并发数 queue_max_concurrent_download_description=此队列最大同时下载个数 queue_automatic_stop=自动停止 queue_automatic_stop_description=当队列中没有下载项时自动停止 queue_scheduler=计划任务 queue_enable_scheduler=启用计划任务 queue_active_days=每周运行日 queue_active_days_description=此计划任务应该在哪些日子启动? queue_scheduler_enable_auto_start_time=启用自动开始时间 queue_scheduler_auto_start_time=自动开始时间 queue_scheduler_enable_auto_stop_time=启用自动停止计划 queue_scheduler_auto_stop_time=自动停止时间 queue_shutdown_on_completion=完成后关机 queue_shutdown_on_completion_description=当此队列完成或达到计划结束时间时,自动关机。 appearance=外观 download_engine=下载引擎 browser_integration=浏览器插件集成 settings_download_max_retries_count=最大下载重试次数 settings_download_max_retries_count_description=应用在放弃之前重新尝试失败下载的最大次数 settings_download_max_retries_count_describe_no_retries=下载失败不会重试 settings_download_max_retries_count_describe_n_retries=下载失败将重试 {{count}} 次 settings_download_thread_count=线程数目 settings_download_thread_count_description=每个下载项的最大线程数目 settings_download_thread_count_describe=每个下载项最高可达 {{count}} 个线程 settings_download_thread_count_with_large_value_describe=警告:设置较高的线程数可能会增加系统资源的使用,降低性能,或导致与服务器的连接问题。只有在了解其对系统和网络潜在影响的情况下,才应使用更高的值。 settings_use_server_last_modified_time=使用服务器提供的最后修改时间 settings_use_server_last_modified_time_description=下载文件时,为本地文件使用服务器提供的最后修改时间 settings_append_extension_to_incomplete_downloads=将扩展名添加到未完成的下载 settings_append_extension_to_incomplete_downloads_description=将 “.part” 扩展名附加到未完成的下载文件。这有助于识别未完成的下载,并防止意外打开不完整的文件。 settings_use_sparse_file_allocation=稀疏文件空间分配 settings_use_sparse_file_allocation_description=使用稀疏文件分配方式,通过减少不必要的数据写入来提高文件创建效率(尤其是在SSD上)。这可以加快下载启动速度并减少硬盘占用。如果发现下载启动较慢或出现异常的下载速度,建议禁用此选项,因为某些设备可能不完全支持此功能。 settings_ignore_ssl_certificates=忽略 SSL 证书 settings_ignore_ssl_certificates_description=禁用 SSL 证书验证。仅在必要时使用,因为这可能会使您的连接面临安全风险。 settings_global_speed_limiter=全局速度限制 settings_global_speed_limiter_description=全局下载速度限制 (0 表示无限制) settings_show_average_speed=显示平均速度 settings_show_average_speed_description=下载速度 settings_use_category_by_default=默认使用分类 settings_use_category_by_default_description=添加下载时默认使用分类。 settings_default_download_folder=默认下载文件夹 settings_default_download_folder_description=当您添加新的下载项时,默认使用此位置保存文件 settings_default_download_folder_describe=将使用 {{folder}} settings_use_proxy=使用代理 settings_use_proxy_description=为下载使用代理 settings_use_proxy_describe_no_proxy=不使用代理 settings_use_proxy_describe_system_proxy=使用系统代理 settings_use_proxy_describe_manual_proxy=将使用 {{value}} settings_use_proxy_describe_pac_proxy=将使用 pac 文件 “{{value}}” settings_track_deleted_files_on_disk=跟踪磁盘上的已删除文件 settings_track_deleted_files_on_disk_description=当文件从下载目录中删除或移动时,自动将其从列表中移除。 settings_delete_partial_file_on_download_cancellation=下载取消时删除分块文件 settings_delete_partial_file_on_download_cancellation_description=当下载被取消时,分块文件将从磁盘中删除。这有助于保持下载文件夹的整洁,并减少不必要的磁盘空间占用。然而,下次重新开始下载时,下载将从头开始。 settings_default_user_agent=默认的用户代理 settings_default_user_agent_description=指定默认的用户代理字符串,以定义请求如何向服务器标识。这有助于访问为特定设备优化的内容或绕过某些网站施加的下载限制。 settings_download_size_unit=下载大小单位 settings_download_size_unit_description=用于显示下载大小的单位 settings_download_speed_unit=下载速度单位 settings_download_speed_unit_description=用于显示下载速度的单位 settings_theme=主题 settings_theme_description=选择应用主题 settings_default_dark_theme=默认深色主题 settings_default_dark_theme_description=当应用跟随系统主题且使用深色模式时 settings_default_light_theme=默认浅色主题 settings_default_light_theme_description=当应用跟随系统主题且使用浅色模式时 settings_font=字体 settings_font_description=更改应用界面中使用的字体,某些字体可能无法在应用中正确显示。 settings_ui_scale=用户界面缩放 settings_ui_scale_description=调整应用界面元素的大小 settings_language=语言 settings_compact_top_bar=紧凑型顶栏 settings_compact_top_bar_description=当主窗口宽度足够时,合并顶栏和标题栏 settings_use_native_menu_bar=使用原生菜单栏 settings_use_native_menu_bar_description=使用系统默认的菜单栏样式 settings_use_relative_date_time=使用相对日期/时间 settings_use_relative_date_time_description=在应用中使用相对日期/时间格式 (例如 “2天前” 而不是具体的日期/时间) settings_show_icon_labels=显示图标标签 settings_show_icon_labels_description=尽可能在图标下方显示标签(例如首页工具栏操作) settings_use_system_tray=使用系统托盘 settings_use_system_tray_description=当应用程序运行时显示系统托盘图标 settings_start_on_boot=开机自启 settings_start_on_boot_description=在用户登录时自动启动 settings_notification_sound=通知声音 settings_notification_sound_description=通知时播放声音 settings_browser_integration=浏览器插件集成 settings_browser_integration_description=接受来自浏览器的下载 settings_browser_integration_server_port=服务器端口 settings_browser_integration_server_port_description=浏览器插件使用的端口 settings_browser_integration_server_port_describe=App 将监听端口 {{port}} settings_dynamic_part_creation=动态分段 settings_dynamic_part_creation_description=当一段下载完成后,通过拆分其他段来创建另一个下载段,以提高下载速度 settings_show_completion_dialog=显示下载完成对话框 settings_show_completion_dialog_description=下载完成时自动显示 “下载完成” 对话框。 settings_show_download_progress_dialog=显示下载进度对话框 settings_show_download_progress_dialog_description=开始下载时自动显示 “下载进度” 对话框。 settings_per_host_settings=分主机设置 settings_per_host_settings_descriptions=这些设置将自动应用于所有符合指定主机的新下载项。 settings_download_max_concurrent_downloads=最大并发下载数 settings_download_max_concurrent_downloads_description=同时下载文件数上限(队列管理的下载不计入;设置为0表示无限制) download_item_settings_speed_limit=速度限制 download_item_settings_speed_limit_description=为此下载项限制下载速度 download_item_settings_show_download_completion_dialog=显示下载完成对话框 download_item_settings_show_download_completion_dialog_description=此下载项完成时自动显示 “下载完成” 对话框。 download_item_settings_shutdown_on_completion=完成后关机 download_item_settings_shutdown_on_completion_description=下载完成后自动关机。 download_item_settings_thread_count=线程数 download_item_settings_thread_count_description=此下载项使用多少个线程 (0代表使用全局设置) download_item_settings_thread_count_describe=为此下载项使用 {{count}} 线程 download_item_settings_username_description=若链接指向受保护的资源,请提供用户名 download_item_settings_password_description=若链接指向受保护的资源,请提供密码 download_item_settings_download_page=下载页 download_item_settings_download_page_description=此下载项开始下载时所处的网页 download_item_settings_file_checksum=文件校验和 download_item_settings_file_checksum_description=用于检查文件是否正确下载的哈希字符串 download_item_settings_user_agent=用户代理 download_item_settings_user_agent_description=对此物品使用自定义代理 (留空以使用预设代理) file_checksum=文件校验和 file_checksum_page=文件校验和检查器 file_checksum_page_file_checksum_default_algorithm=默认算法 file_checksum_page_file_checksum_default_algorithm_help=未提供时用于计算文件校验和的默认算法。 start=开始 calculated_checksum=计算出的校验和 saved_checksum=保存的校验和 checksum_algorithm=算法 file_not_found=找不到文件 download_not_finished=下载未完成 done=已完成 waiting=等待中 matches=匹配 not_matches=不匹配 copy_to_clipboard=复制到剪贴板 username=用户名 password=密码 average_speed=平均速度 exact_speed=精确速度 unlimited=无限制 use_global_settings=使用全局设置 cant_run_browser_integration=无法运行浏览器插件集成 cant_open_file=无法打开文件 cant_open_folder=无法打开目录 # times for example 2 seconds ago relative_time_long_years={{years}} 年 relative_time_long_months={{months}} 月 relative_time_long_days={{days}} 天 relative_time_long_hours={{hours}} 小时 relative_time_long_minutes={{minutes}} 分钟 relative_time_long_seconds={{seconds}} 秒 relative_time_short_years={{years}} 年 relative_time_short_months={{months}} 月 relative_time_short_days={{days}} 天 relative_time_short_hours={{hours}} 小时 relative_time_short_minutes={{minutes}} 分 relative_time_short_seconds={{seconds}} 秒 relative_time_left=剩余 {{time}} relative_time_ago={{time}} 前 auto=自动 unspecified=未指定 custom=自定义 icon=图标 author=作者 link=链接 size=大小 status=状态 parts_info_downloaded_size=已下载 parts_info_total_size=总大小 speed=速度 time_left=剩余时间 date_added=加入时间 info=信息 download_page_downloaded_size=已下载 download_page_download_completed=下载已完成 resume_support=断点续传 yes=是 no=否 parts_info=分段信息 disconnected=断开连接 receiving_data=接收数据 connecting=发送GET请求 warning=警告 unsupported_resume_warning=此下载项不支持断点续传!您可能需要在下载列表中重新下载此文件 stop_anyway=强制停止 customize_columns=自定义列 reset=重置 monday=星期一 tuesday=星期二 wednesday=星期三 thursday=星期四 friday=星期五 saturday=星期六 sunday=星期日 proxy_open_system_proxy_settings=使用系统代理设置 proxy_type=代理类型 proxy_do_not_use_proxy_for=对以下网址不使用代理 proxy_do_not_use_proxy_for_description=一个不应被代理的网址列表\n您可以使用通配符 *\n比如 192.168.1.* example.com (两个网址用空格隔开) proxy_change_title=更改代理 change_proxy=更改代理 proxy_no=无代理 proxy_system=系统代理 proxy_manual=手动设置代理 proxy_pac=自动代理配置(PAC) proxy_pac_url=自动代理配置(PAC) URL address=地址 port=端口 address_and_port=地址 & 端口 use_authentication=使用身份验证 warning_you_may_have_to_restart_the_download_later=您可能需要稍后重新开始下载! edit_download_title=编辑下载 edit_download_update_from_download_page=从下载页更新 edit_download_update_from_download_page_description=当此窗口打开时,您可以转到下载页面并单击下载按钮。本应用将捕获并更新新的下载凭据,以便您保存它们。 edit_download_saved_download_item_size_not_match=已保存的下载项的大小为 {{currentSize}},与新的大小 {{newSize}} 不匹配。 translators_page_thanks=感谢那些帮助翻译这个项目的人❤️ translators=翻译人员 language=语言 translators_contribute_title=改进翻译 translators_contribute_description=想要帮助改进这个项目吗?如果您使用的语言尚未被收录,或者现有翻译需要改进,欢迎贡献您的翻译,让这个项目变得更好! contribute=贡献 meet_the_translators=查看翻译人员 localized_by_translators=由翻译人员提供本地化 confirm_exit=确认退出 confirm_exit_description=您确定要退出 AB 下载管理器吗?\n正在进行的下载和队列都将被停止! update=更新 update_updater=更新程序 update_available=有可用的更新 update_error=更新失败 update_available_suggest_to_to_update=您可以更新到最新版本,以享受新功能、增强功能和性能提升。 update_release_notes=更新日志 update_check_for_update=检查更新 update_checking_for_update=正在检查更新 update_no_update=您正在使用最新版本 update_check_error=检查更新时出错 update_app_updated_to_version_n=应用已更新至版本 {{version}} create_desktop_entry=创建桌面条目 shutdown_alert=关机警告 system_shutdown_soon=系统将很快关机! system_shutdown_failed=系统关机失败! system_shutdown_soon_description=系统即将关机。如果您仍在使用计算机,请保存您的工作或取消关机。 system_shutdown_reason_queue_completed=队列中的所有下载已完成。 system_shutdown_reason_queue_end_time_reached=下载队列的预定结束时间已到。 system_shutdown_download_finished=下载完毕。 shutdown_now=立即关机 settings_per_host_settings_new_host=<新主机> settings_per_host_settings_not_selected=首先创建或选择一个新项目 ! settings_per_host_settings_host=主机名 settings_per_host_settings_host_description=这些设置将应用于匹配此主机名的下载。支持通配符(*) (例如,example.com,*.example.com — 仅使用一个)。 settings_browser_in_launcher=启动器里的浏览器图标 settings_browser_in_launcher_description=是否在启动器中显示浏览器图标 (应用列表)。 sort_by=排序方式 welcome=欢迎 new_folder=新建文件夹 skip=跳过 lets_go=开始 next=下一步 select_all=全选 select_inside=在内部选择 select_invert=反选 open_settings=打开设置 back=返回 service_is_running=服务运行中 initial_setup_description=让我们开始设置 initial_setup_notice=您可以稍后随时修改这些设置 permission_granted=已授予权限 permission_not_granted=未授予权限 permissions=权限 give_permission=允许权限 give_storage_permission=允许访问存储 storage_roots=存储根目录 permissions_initial_title=权限设置 permissions_initial_description=为了正常运行,应用需要一些权限。在下一屏幕上,您将看到每个权限的用途,并可以决定是允许还是跳过。 permissions_done_title=一切就绪 permissions_done_description=一切就绪。所有必要的权限已获得,应用可以正常使用。 permissions_manage_storage_title=管理存储访问权限 permissions_manage_storage_reason=此权限允许应用更改下载文件夹,更准确地检测重复下载,并启用一些额外功能。虽然是可选的,但为了获得最佳体验,建议启用。 permission_read_write_external_storage_title=读写存储设备 permission_read_write_external_storage_reason=此权限允许应用保存和管理下载的文件、修改下载位置,并改善重复下载检测。 permissions_post_notification_title=发布通知 permissions_post_notification_reason=应用需要在后台运行以管理下载。通知用于通知您,并允许后台操作。 permissions_ignore_battery_optimization_title=忽略电池优化 permissions_ignore_battery_optimization_reason=一些设备为了节省电池会积极限制后台活动,这可能会在应用未打开时暂停或停止下载。您可以选择将应用排除在电池优化之外,以确保下载不中断 open_in_browser=在浏览器中打开 browser=浏览器 browser_new_tab=新标签 browser_close_tab=关闭标签 browser_open_in_new_tab=在新标签中打开 browser_open_in_new_background_tab=在后台打开新标签页 browser_no_tab_open=没有打开的标签页 browser_tabs=标签页 browser_paste_and_go=粘贴并转到 browser_bookmarks=收藏夹 browser_add_bookmark=添加收藏 browser_edit_bookmark=编辑收藏 browser_add_to_bookmarks=添加收藏 browser_remove_from_bookmarks=从收藏中移除 ================================================ FILE: shared/resources/src/commonMain/resources/com/abdownloadmanager/resources/locales/zh_TW.properties ================================================ app_title=AB 下載管理員 confirm_auto_categorize_downloads_title=自動分類下載項目 confirm_auto_categorize_downloads_description=未分類的下載項目都將自動歸類 confirm_reset_to_default_categories_title=重設為預設類別 confirm_reset_to_default_categories_description=這將刪除所有分類並復原預設分類! confirm_delete_download_items_title=確認刪除 confirm_delete_download_items_description=您確定要刪除這 {{count}} 個下載項目嗎? confirm_delete_download_unfinished_items_description=您確定要刪除 {{count}} 個未完成的下載檔案嗎? confirm_delete_download_finished_and_unfinished_items_description=您確定要刪除 {{finishedCount}} 個已完成和 {{unfinishedCount}} 個未完成的下載檔案嗎? also_delete_file_from_disk=同時從磁碟中刪除檔案 confirm_delete_category_item_title=確定要移除 {{name}} 類別嗎 confirm_delete_category_item_description=你確定要刪除 {{value}} 類別嗎? your_download_will_not_be_deleted=將保留您的下載項目 drag_the_file_to_another_app=將檔案拖曳到另一個應用程式 drop_link_or_file_here=將連結或檔案拖曳至此 nothing_will_be_imported=沒有可匯入的項目 n_links_will_be_imported={{count}} 個連結將被匯入 n_items_selected=已選取 {{count}} 個下載項目 window_close=關閉 window_minimize=最小化 window_maximize=最大化 window_restore=還原 delete=刪除 remove=移除 cancel=取消 close=關閉 menu=選單 more_options=更多選項 ok=確定 add=新增 paste=貼上 change=修改 edit=編輯 change_anyway=仍要變更 download=下載 refresh=重新整理 settings=設定 on_completion=完成 unknown=未知 unknown_error=未知錯誤 download_item_not_found=找不到下載項目 name=名稱 download_link=下載連結 not_finished=未完成 all=全部 finished=已完成 Unfinished=未完成 canceled=已取消 error=錯誤 paused=已暫停 downloading=下載中 added=已添加 idle=閒置中 preparing_file=正在準備檔案 creating_file=正在建立檔案 resuming=正在復原 retrying=正在重試 list_is_empty=清單為空! search_in_the_list=在清單中搜尋 search=搜尋 clear=清空 general=一般 enabled=啟用 disabled=已停用 default=預設 file=檔案 tasks=任務 tools=工具 help=說明 system=系統 all_missing_files=所有遺失的檔案 all_finished=全部完成 all_unfinished=全部未完成 entire_list=完整清單 download_browser_integration=下載瀏覽器整合 exit=結束 show_downloads=顯示下載 new_download=新增下載 stop_all=全部停止 import_from_clipboard=從剪貼簿匯入 batch_download=批次下載 open=開啟 share=分享 open_file=開啟檔案 open_folder=開啟資料夾 resume=繼續下載 pause=暫停 restart_download=重新下載 copy=複製 copy_link=複製連結 copy_as_curl=複製為 cURL show_properties=檢視內容 move_to_queue=移動到佇列 move_to_this_queue=移動到此佇列 move_to_category=移動到類別 move_to_this_category=移動到此類別 categories=類別 add_category=添加類別 edit_category=編輯類別 delete_category=刪除類別 category_name=類別名稱 category_download_location=類別下載位置 category_download_location_description=在「新增下載」中選擇此類別時,將此目錄用作「下載位置」 category_file_types=類別檔案類型 category_file_types_description=自動將這些檔案類型放入此類別。(新增下載時)用空格分隔檔案副檔名(ext1 ext2 ...) category_url_patterns=URL 模式 category_url_patterns_description=自動將從這些 URL 下載的檔案歸類到此類別(新增下載時)。URL 之間以空白鍵隔開,可使用 * 萬用字元 auto_categorize_downloads=自動分類下載項目 restore_defaults=復原為預設值 about=關於 version_n=版本 {{value}} developed_with_love_for_you=用❤️為您開發 donate=贊助 visit_the_project_website=瀏覽專案官網 this_is_a_free_and_open_source_software=這是一個開源免費的軟體 view_the_source_code=查看原始碼 third_party_libraries=第三方函式庫 powered_by_open_source_software=由開源軟體驅動 view_the_open_source_licenses=查看開源協議 support_and_community=支援 & 社群 telegram=Telegram channel=頻道 group=群組 add_download=添加下載 add_multi_download_page_header=選擇您想要下載的檔案 save_to=儲存到 where_should_each_item_saved=每個檔案應該儲存到哪裡? there_are_multiple_items_please_select_a_way_you_want_to_save_them=發現了多個檔案!請選擇一個儲存的方法 each_item_on_its_own_category=每個檔案都有屬於自己的類別 each_item_on_its_own_category_description=所有的檔案都會基於它們的檔案類型被儲存到對應的類別下 all_items_in_one_category=所有檔案都儲存在同一類別 all_items_in_one_category_description=所有的檔案都會被儲存到選擇的類別位置 all_items_in_one_Location=所有檔案都儲存在同一位置 all_items_in_one_Location_description=所有的檔案都會被儲存到選擇的目錄 unselected_all_items_in_specific_location_description=所有檔案都會被儲存到選擇的類別位置 no_category_selected=未選擇類別 no_categories_found=未找到類別 download_location=下載位置 location=位置 select_queue=選擇佇列 without_queue=不使用佇列 use_category=使用分類 cant_write_to_this_folder=無法寫入此目錄 file_name_already_exists=檔案名稱已存在 download_already_exists=已存在下載 invalid_file_name=無效的檔案名稱 show_solutions=查看解決方案... change_solution=變更解決方案 select_a_solution=選擇一個解決方案 select_download_strategy_description=您提供的下載連結已經在下載清單中了,請選擇您想做的操作 download_strategy_add_a_numbered_file=在檔案後加入編號 download_strategy_add_a_numbered_file_description=在下載檔案名稱的結尾加上編號 download_strategy_override_existing_file=覆蓋現有檔案 download_strategy_override_existing_file_description=移除現有下載項目並開始下載 download_strategy_update_download_link=更新已有的下載 download_strategy_update_download_link_description=更新現有的下載連結及其憑證 download_strategy_show_downloaded_file=顯示已下載的檔案 download_strategy_show_downloaded_file_description=顯示已經存在的下載項目以便您復原下載或開啟它 batch_download_link_help=輸入包含萬用字元的連結 (使用 *) invalid_url=無效的網址 list_is_too_large_maximum_n_items_allowed=批次下載的檔案數量太多啦!最多支援 {{count}} 個 enter_range=輸入範圍 range_from=從 range_to=到 batch_download_wildcard_length=萬用字元長度 first_link=首個連結 last_link=末尾連結 open_source_software_used_in_this_app=這個應用程式使用的其他開源軟體 links=連結 website=網站 developers=開發人員 source_code=原始碼 license=授權條款 no_license_found=找不到授權條款 organization=組織 add_new_queue=新增佇列 queue_name=佇列名稱 queues=佇列 stop_queue=停止佇列 start_queue=開始佇列 clear_queue_items=佇列無任務 config=設定 items=項目 move_down=下移 move_up=上移 remove_queue=移除佇列 queue_name_help=指定名稱給此佇列 queue_name_describe=佇列名稱為 {{value}} queue_max_concurrent_download=最大同時下載數量 queue_max_concurrent_download_description=此佇列最大同時下載數量 queue_automatic_stop=自動停止 queue_automatic_stop_description=當佇列中沒有下載項目時自動停止 queue_scheduler=排程器 queue_enable_scheduler=啟用排程器 queue_active_days=每週執行日 queue_active_days_description=這個排程器會在哪幾天運作? queue_scheduler_enable_auto_start_time=啟用自動開始時間 queue_scheduler_auto_start_time=自動開始時間 queue_scheduler_enable_auto_stop_time=啟用自動停止計劃 queue_scheduler_auto_stop_time=自動停止時間 queue_shutdown_on_completion=完成後關閉系統 queue_shutdown_on_completion_description=系統將在當前佇列完成時或達到預定結束時間自動關閉。 appearance=外觀 download_engine=下載引擎 browser_integration=瀏覽器擴充功能整合 settings_download_max_retries_count=最大下載重試次數 settings_download_max_retries_count_description=應用程式放棄失敗下載前,會重試的最大次數 settings_download_max_retries_count_describe_no_retries=下載失敗將不會重試 settings_download_max_retries_count_describe_n_retries=下載失敗時將會重試{{count}}次 (數) settings_download_thread_count=執行緒數量 settings_download_thread_count_description=每個下載項目的最大執行緒數目 settings_download_thread_count_describe=每個下載項目最高可達 {{count}} 個執行緒 settings_download_thread_count_with_large_value_describe=警告:設定過高的執行緒數量可能會增加系統資源使用量、降低效能,或導致與伺服器的連線問題。請僅在您了解對系統和網路的潛在影響時,才使用較高的數值。 settings_use_server_last_modified_time=使用伺服器提供的最後修改時間 settings_use_server_last_modified_time_description=下載檔案時,為本機檔案使用伺服器提供的最後修改時間 settings_append_extension_to_incomplete_downloads=在未完成下載加上副檔名 settings_append_extension_to_incomplete_downloads_description=在未完成下載加上「.part」副檔名.。這將有助識別未完成的下載並防止意外開啟不完整的檔案。 settings_use_sparse_file_allocation=稀疏檔案配置 settings_use_sparse_file_allocation_description=透過減少不必要的資料寫入,更有效率地建立檔案,尤其是在 SSD 上。這可以加快下載開始速度並減少磁碟使用量。如果下載開始速度緩慢或遇到不正常的下載速度,請考慮停用此選項,因為它可能在某些裝置上未完全支援。 settings_ignore_ssl_certificates=忽略 SSL 憑證 settings_ignore_ssl_certificates_description=停用 SSL 憑證驗證。僅在必要時使用,因爲這可能會使連結面臨安全風險。 settings_global_speed_limiter=全域速度限制 settings_global_speed_limiter_description=全域下載速度限制(0 表示無限制) settings_show_average_speed=顯示平均速度 settings_show_average_speed_description=平均下載速度或準確度 settings_use_category_by_default=使用預設類別 settings_use_category_by_default_description=新增下載時使用預設類別。 settings_default_download_folder=預設下載資料夾 settings_default_download_folder_description=當你新增下載項目時,預設會使用這個位置。 settings_default_download_folder_describe=將使用 {{folder}} settings_use_proxy=使用代理 settings_use_proxy_description=為下載使用代理 settings_use_proxy_describe_no_proxy=不使用代理 settings_use_proxy_describe_system_proxy=使用系統代理 settings_use_proxy_describe_manual_proxy=將使用 {{value}} settings_use_proxy_describe_pac_proxy=將使用 pac 檔案「{{value}}」 settings_track_deleted_files_on_disk=追蹤磁碟中已刪除的檔案 settings_track_deleted_files_on_disk_description=當檔案從下載目錄中被刪除或移動時,自動從清單中移除。 settings_delete_partial_file_on_download_cancellation=下載取消時,刪除部分檔案 settings_delete_partial_file_on_download_cancellation_description=當下載取消時,部分已下載的檔案將從磁碟中刪除。這有助於保持您的下載資料夾整潔,並減少不必要的磁碟空間使用量。然而,下次您啟動下載時,下載將會重新從頭開始。 settings_default_user_agent=預設的用戶代理 settings_default_user_agent_description=指定預設的用戶代理字符串,以定義請求如何向服務器標識。這有助於訪問為特定裝置優化的內容或繞過某些網站施加的下載限制。 settings_download_size_unit=下載大小單位 settings_download_size_unit_description=用於顯示下載大小的單位 settings_download_speed_unit=下載速度單位 settings_download_speed_unit_description=用來顯示下載速度的單位 settings_theme=主題 settings_theme_description=選擇應用程式主題 settings_default_dark_theme=預設深色模式 settings_default_dark_theme_description=此主題適用於系統主題和深色模式已啟用時 settings_default_light_theme=預設淺色主題 settings_default_light_theme_description=當應用程式遵循系統主題並啟用淺色模式時,將會生效 settings_font=字型 settings_font_description=變更字型來套用在此應用程式的介面。部分字型可能無法在此應用程式中正確顯示。 settings_ui_scale=使用者介面縮放 settings_ui_scale_description=調整應用程式介面元件的大小 settings_language=語言 settings_compact_top_bar=緊湊型頂欄 settings_compact_top_bar_description=當主視窗寬度足夠時,合併頂欄和標題欄 settings_use_native_menu_bar=使用原生選單列 settings_use_native_menu_bar_description=使用系統預設選單列風格 settings_use_relative_date_time=使用相對日期/時間 settings_use_relative_date_time_description=請使用相對日期/時間格式顯示應用程式中的日期(例如:“2 天前”而不是具體的日期和時間) settings_show_icon_labels=顯示圖示標籤 settings_show_icon_labels_description=盡可能在圖示下方顯示標籤(例如首頁工具列操作) settings_use_system_tray=使用系統匣 settings_use_system_tray_description=當應用程式執行時顯示系統匣圖示 settings_start_on_boot=開機時啟動 settings_start_on_boot_description=在使用者登入時自動啟動 settings_notification_sound=通知聲音 settings_notification_sound_description=通知時播放聲音 settings_browser_integration=瀏覽器擴充功能設定 settings_browser_integration_description=接受來自瀏覽器的下載 settings_browser_integration_server_port=伺服器連接埠 settings_browser_integration_server_port_description=瀏覽器擴充功能使用的連接埠 settings_browser_integration_server_port_describe=App 將監聽埠 {{port}} settings_dynamic_part_creation=動態分段 settings_dynamic_part_creation_description=當一段下載完成後,透過分割其他段來建立另一個下載段,以提高下載速度 settings_show_completion_dialog=完成下載時顯示提示 settings_show_completion_dialog_description=下載完成後,自動顯示「下載完成」對話方塊。 settings_show_download_progress_dialog=顯示下載進度 settings_show_download_progress_dialog_description=下載開始後,自動顯示「下載進度」對話方塊。 settings_per_host_settings=每個主機的設定 settings_per_host_settings_descriptions=這些設定會自動套用到任何符合指定主機的新下載。 settings_download_max_concurrent_downloads=最大並發下載量 settings_download_max_concurrent_downloads_description=同時下載的最大數量(佇列管理的下載不計入在內;設定 0 為無限制) download_item_settings_speed_limit=速度限制 download_item_settings_speed_limit_description=為此下載項目限制下載速度 download_item_settings_show_download_completion_dialog=完成時顯示提示 download_item_settings_show_download_completion_dialog_description=當這個下載完成後,自動顯示「下載完成」對話方塊。 download_item_settings_shutdown_on_completion=完成時關機 download_item_settings_shutdown_on_completion_description=在下載完成時自動關閉系統。 download_item_settings_thread_count=執行緒數 download_item_settings_thread_count_description=下載此下載項目時使用的執行緒數量(0 表示使用預設值) download_item_settings_thread_count_describe=為此下載項目使用 {{count}} 執行緒 download_item_settings_username_description=若連結指向受保護的資源,請提供使用者名稱 download_item_settings_password_description=若連結指向受保護的資源,請提供密碼 download_item_settings_download_page=下載頁面 download_item_settings_download_page_description=此下載項目開始下載時所處的網頁 download_item_settings_file_checksum=檔案校驗和 download_item_settings_file_checksum_description=一個可以用來檢查檔案是否正確下載的哈希字串 download_item_settings_user_agent=User-Agent download_item_settings_user_agent_description=對此物品使用自定義代理 (留空以使用預設代理) file_checksum=檔案校驗和 file_checksum_page=檔案校驗和檢查器 file_checksum_page_file_checksum_default_algorithm=預設演算法 file_checksum_page_file_checksum_default_algorithm_help=當未提供檔案校驗和時,用來計算檔案校驗和的預設演算法。 start=開始 calculated_checksum=計算出的校驗和 saved_checksum=儲存的校驗和 checksum_algorithm=演算法 file_not_found=找不到檔案 download_not_finished=下載未完成 done=完成 waiting=等待中 matches=符合 not_matches=不符合 copy_to_clipboard=複製到剪貼簿 username=使用者名稱 password=密碼 average_speed=平均速度 exact_speed=精確速度 unlimited=無限制 use_global_settings=使用全域設定 cant_run_browser_integration=無法執行瀏覽器擴充功能整合 cant_open_file=無法打開檔案 cant_open_folder=無法打開資料夾 # times for example 2 seconds ago relative_time_long_years={{years}} 年 relative_time_long_months={{months}} 月 relative_time_long_days={{days}} 日 relative_time_long_hours={{hours}} 時 relative_time_long_minutes={{minutes}} 分 relative_time_long_seconds={{seconds}} 秒 relative_time_short_years={{years}} 年 relative_time_short_months={{months}} 月 relative_time_short_days={{days}} 日 relative_time_short_hours={{hours}} 小時 relative_time_short_minutes={{minutes}} 分 relative_time_short_seconds={{seconds}} 秒 relative_time_left=剩餘 {{time}} relative_time_ago={{time}} 前 auto=自動 unspecified=未指定 custom=自訂 icon=圖示 author=作者 link=連結 size=大小 status=狀態 parts_info_downloaded_size=已下載 parts_info_total_size=總大小 speed=速度 time_left=剩餘時間 date_added=加入時間 info=資訊 download_page_downloaded_size=已下載 download_page_download_completed=下載完成 resume_support=斷點續傳 yes=是 no=否 parts_info=分段資訊 disconnected=已中斷連線 receiving_data=正在接收資料 connecting=連線中 warning=警告 unsupported_resume_warning=此下載項目不支援斷點續傳!你可能需要在下載清單中重新下載此檔案 stop_anyway=強制停止 customize_columns=自訂列 reset=重設 monday=星期一 tuesday=星期二 wednesday=星期三 thursday=星期四 friday=星期五 saturday=星期六 sunday=星期日 proxy_open_system_proxy_settings=開啟系統代理設定 proxy_type=代理類型 proxy_do_not_use_proxy_for=不使用代理的網址 proxy_do_not_use_proxy_for_description=一個可能不會被代理的 Url 列表\n你可以使用 * 作為萬用字元\n例如 192.168.1.* example.com (用空格分隔) proxy_change_title=變更代理 change_proxy=變更代理 proxy_no=無代理 proxy_system=系統代理 proxy_manual=手動代理 proxy_pac=代理伺服器自動設定 proxy_pac_url=代理伺服器自動設定網址 address=位址 port=埠 address_and_port=位址與埠 use_authentication=使用驗證 warning_you_may_have_to_restart_the_download_later=你可能要稍後再重新開始下載! edit_download_title=編輯下載 edit_download_update_from_download_page=從下載頁更新 edit_download_update_from_download_page_description=當此視窗出現時,你可以至下載頁面點選下載。應用程式會自動擷取並更新新的下載資訊以便您儲存。 edit_download_saved_download_item_size_not_match=已儲存的下載項目的大小為 {{currentSize}},與新的大小 {{newSize}} 不相符。 translators_page_thanks=感謝協助翻譯該專案的人 ❤️ translators=譯者 language=語言 translators_contribute_title=幫助改進翻譯 translators_contribute_description=不支援你的語言或有需要改進的地方?不妨一起協助翻譯這個專案,讓這個專案變得越來越好! contribute=貢獻 meet_the_translators=查看翻譯人員 localized_by_translators=由翻譯人員在地化 confirm_exit=確認結束 confirm_exit_description=您確定要結束 AB Download Manager 嗎?\n正在進行的下載/佇列將會停止! update=更新 update_updater=更新程式 update_available=有可用更新 update_error=更新錯誤 update_available_suggest_to_to_update=您可以更新至最新版本,以享有新功能、增強功能和效能提升。 update_release_notes=版本說明 update_check_for_update=檢查更新 update_checking_for_update=正在檢查更新 update_no_update=你正在使用最新版本 update_check_error=檢查更新時發生錯誤 update_app_updated_to_version_n=應用程式已更新至 {{version}} create_desktop_entry=建立桌面項目 shutdown_alert=關機警報 system_shutdown_soon=系統即將關閉! system_shutdown_failed=系統關機異常! system_shutdown_soon_description=系統即將關閉。如果您繼續使用電腦,請儲存您的資料或取消關機。 system_shutdown_reason_queue_completed=所有下載任務已處理完畢。 system_shutdown_reason_queue_end_time_reached=下載任務排隊結束時間已到達。 system_shutdown_download_finished=下載已完成。 shutdown_now=現在關機 settings_per_host_settings_new_host= settings_per_host_settings_not_selected=請先建立或選取一個新項目! settings_per_host_settings_host=主機 settings_per_host_settings_host_description=這些設定將套用於符合此主機名稱的下載。支援萬用字元(*)(例如,example.com,*.example.com — 只使用一個)。 settings_browser_in_launcher=在啟動器中的瀏覽器圖示 settings_browser_in_launcher_description=在啟動器(應用程式列表)中顯示或隱藏瀏覽器圖示。 sort_by=排序方式 welcome=歡迎 new_folder=新增資料夾 skip=略過 lets_go=我們走吧 next=下一步 select_all=選擇全部 select_inside=選取內部 select_invert=反向選取 open_settings=開啟設定 back=返回 service_is_running=服務正在運行 initial_setup_description=讓我們開始設定吧 initial_setup_notice=您可以隨時變更這些設定 permission_granted=權限已授予 permission_not_granted=未授予權限 permissions=權限 give_permission=允許權限 give_storage_permission=允許儲存空間存取權限 storage_roots=儲存根目錄 permissions_initial_title=權限設定 permissions_initial_description=為了正常運行,該應用程式需要一些權限。在下一個畫面上,您將看到每項權限的用途,您可以決定允許哪些權限,或跳過哪些權限。 permissions_done_title=一切就緒 permissions_done_description=一切準備就緒。所有必需的權限都已授予,應用程式可以正常運作了。 permissions_manage_storage_title=管理儲存空間存取權限 permissions_manage_storage_reason=此權限允許應用程式變更下載資料夾、更準確地偵測重複下載並啟用一些額外功能。此權限為可選,但為了獲得最佳體驗,建議授予。 permission_read_write_external_storage_title=讀取與寫入儲存 permission_read_write_external_storage_reason=此權限允許應用程式保存和管理下載的檔案、更改下載位置並改善重複下載檢測。 permissions_post_notification_title=發布通知 permissions_post_notification_reason=該應用程式需要在背景運行以管理下載。通知功能用於讓您隨時了解最新資訊並允許背景運行。 permissions_ignore_battery_optimization_title=忽略電池最佳化 permissions_ignore_battery_optimization_reason=有些設備為了省電會嚴格限制後台活動,這可能會導致應用程式未開啟時下載暫停或停止。您可以選擇將應用程式從電池優化中排除,以確保下載不間斷地進行 open_in_browser=以瀏覽器開啟 browser=瀏覽器 browser_new_tab=新標籤 browser_close_tab=關閉標籤 browser_open_in_new_tab=在新分頁中開啟 browser_open_in_new_background_tab=在新背景分頁中開啟 browser_no_tab_open=未開啟任何分頁 browser_tabs=分頁 browser_paste_and_go=貼上並前往 browser_bookmarks=書籤 browser_add_bookmark=新增書籤 browser_edit_bookmark=編輯書籤 browser_add_to_bookmarks=新增至書籤 browser_remove_from_bookmarks=從書籤中移除 ================================================ FILE: shared/updater/build.gradle.kts ================================================ import org.gradle.kotlin.dsl.implementation import org.jetbrains.kotlin.gradle.dsl.JvmTarget plugins { id(MyPlugins.kotlinMultiplatform) id(Plugins.Android.library) id(Plugins.Kotlin.serialization) } kotlin { jvm("desktop") androidTarget("android") { compilerOptions { jvmTarget.set(JvmTarget.JVM_21) } } sourceSets { commonMain.dependencies { implementation(libs.kotlin.serialization.json) api(libs.okhttp.okhttp) api(libs.kotlin.coroutines.core) implementation(project(":shared:utils")) implementation(libs.semver) implementation("ir.amirab.util:platform:1") } val desktopMain by getting desktopMain.dependencies { implementation(libs.jna.platform) } } } android { namespace = "com.abdownloadmanager.updater" compileSdk = 36 defaultConfig { minSdk = 26 } } ================================================ FILE: shared/updater/src/androidMain/kotlin/AndroidDirectLinkUpdateApplier.kt ================================================ import com.abdownloadmanager.InstallableArch import com.abdownloadmanager.updateapplier.BaseUpdateApplier import com.abdownloadmanager.updateapplier.UpdateDownloader import com.abdownloadmanager.updateapplier.UpdateInstaller import com.abdownloadmanager.updateapplier.UpdatePreparer import com.abdownloadmanager.updatechecker.UpdateInfo import com.abdownloadmanager.updatechecker.UpdateSource /** * this update applier works for direct downloads! */ class AndroidDirectLinkUpdateApplier( private val updateDownloader: UpdateDownloader, ) : BaseUpdateApplier() { override fun updateSupported(): Boolean { return true } override fun getUpdatePreparer(): UpdatePreparer { return updateDownloader } override fun getBestDownloadSource(updateInfo: UpdateInfo): UpdateSource { val downloadableSources = updateInfo.updateSource.filterIsInstance() .sortedBy { // universal downloads have bigger size so we put them last it.installableArch !is InstallableArch.Universal } val downloadSource = downloadableSources.find { isApk(it.name) } return requireNotNull(downloadSource) { "Can't find proper download link for your platform! Please update it manually" } } override fun getUpdateInstaller(preparedUpdate: UpdatePreparer.PreparedUpdate): UpdateInstaller { return ApkInstaller((preparedUpdate as UpdateDownloader.PreparedUpdateFile).file) } fun isApk(name: String): Boolean { return name.endsWith(".apk") } } ================================================ FILE: shared/updater/src/androidMain/kotlin/ApkInstaller.kt ================================================ import com.abdownloadmanager.updateapplier.UpdateInstaller import ir.amirab.util.osfileutil.FileUtils import java.io.File class ApkInstaller( private val apkFile: File, ) : UpdateInstaller { override fun installUpdate() { FileUtils.openFile(apkFile) } } ================================================ FILE: shared/updater/src/commonMain/kotlin/com/abdownloadmanager/ArtifactUtil.kt ================================================ package com.abdownloadmanager import io.github.z4kn4fein.semver.Version import ir.amirab.util.platform.Arch import ir.amirab.util.platform.Platform import kotlin.contracts.ExperimentalContracts import kotlin.contracts.contract data class AppArtifactInfo( val version: Version, val platform: Platform, val arch: InstallableArch, ) sealed interface InstallableArch { fun isCompatible(arch: Arch): Boolean data object Universal : InstallableArch { override fun isCompatible(arch: Arch): Boolean { return true } private val possibleNames = listOf( "universal", null, ) fun fromString(arch: String?): InstallableArch? { return if (arch?.lowercase() in possibleNames) { InstallableArch.Universal } else { null } } } data class SomeArch(val arch: Arch) : InstallableArch { override fun isCompatible(arch: Arch): Boolean { return this.arch == arch } companion object { fun fromString(arch: String?): InstallableArch? { return arch ?.let(Arch::fromString) ?.let(::SomeArch) } } } companion object { fun fromString(archName: String?): InstallableArch? { return listOf( Universal::fromString, SomeArch::fromString, ).firstNotNullOf { it(archName) } } } } @OptIn(ExperimentalContracts::class) fun InstallableArch.isUniversal(): Boolean { contract { returns(true) implies (this@isUniversal is InstallableArch.Universal) } return this is InstallableArch.Universal } object ArtifactUtil { val artifactRegex = "(?[a-zA-Z]+)_(?(\\d+\\.\\d+\\.\\d+))_(?[a-zA-Z]+)_(?[a-zA-Z0-9]+)\\.(?.+)".toRegex() fun getArtifactInfo(name: String): AppArtifactInfo? { val values = artifactRegex.find(name)?.groups ?: return null val version = runCatching { values.get("version")?.value } .getOrNull() ?.let(Version::parse) ?: return null val platform = runCatching { values.get("platform")?.value } .getOrNull() ?.let(Platform::fromString) ?: return null val arch = runCatching { values.get("arch")?.value } .getOrNull() ?.let(InstallableArch::fromString) ?: return null return AppArtifactInfo( version = version, platform = platform, arch = arch, ) } } ================================================ FILE: shared/updater/src/commonMain/kotlin/com/abdownloadmanager/UpdateDownloadLocationProvider.kt ================================================ package com.abdownloadmanager import java.io.File fun interface UpdateDownloadLocationProvider { fun getSaveLocation(): File } ================================================ FILE: shared/updater/src/commonMain/kotlin/com/abdownloadmanager/UpdateManager.kt ================================================ package com.abdownloadmanager import com.abdownloadmanager.updateapplier.UpdateApplier import com.abdownloadmanager.updatechecker.UpdateChecker import com.abdownloadmanager.updatechecker.UpdateInfo import ir.amirab.util.AppVersionTracker import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.update sealed interface UpdateCheckStatus { data object IDLE : UpdateCheckStatus data object NoUpdate : UpdateCheckStatus data object NewUpdate : UpdateCheckStatus data class Error(val e: Throwable) : UpdateCheckStatus data object Checking : UpdateCheckStatus } class UpdateManager( private val updateChecker: UpdateChecker, private val updateApplier: UpdateApplier, private val appVersionTracker: AppVersionTracker, ) { private var _newVersionData: MutableStateFlow = MutableStateFlow(null) val newVersionData = _newVersionData.asStateFlow() private val _updateCheckStatus: MutableStateFlow = MutableStateFlow(UpdateCheckStatus.IDLE) val updateCheckStatus = _updateCheckStatus.asStateFlow() suspend fun cleanDownloadedFiles() { runCatching { updateApplier.cleanup() }.onFailure { it.printStackTrace() } } fun isUpdateSupported(): Boolean { return updateApplier.updateSupported() } suspend fun checkForUpdate(): UpdateInfo? { val newUpdateCheck = try { _updateCheckStatus.update { UpdateCheckStatus.Checking } val checkedData = updateChecker.check() _updateCheckStatus.value = if (checkedData == null) { UpdateCheckStatus.NoUpdate } else { UpdateCheckStatus.NewUpdate } checkedData } catch (e: Exception) { _updateCheckStatus.update { UpdateCheckStatus.Error(e) } null } _newVersionData.update { newUpdateCheck } return newUpdateCheck } suspend fun update() { _newVersionData.value?.let { if (updateApplier.updateSupported()) { updateApplier.applyUpdate(it) } } } // TODO add onAfter update installed // ... } ================================================ FILE: shared/updater/src/commonMain/kotlin/com/abdownloadmanager/github/githubapi.kt ================================================ package com.abdownloadmanager.github import ir.amirab.util.await import kotlinx.serialization.SerialName import kotlinx.serialization.Serializable import kotlinx.serialization.json.Json import okhttp3.OkHttpClient import okhttp3.Request @Serializable data class Asset( @SerialName("name") val name: String, @SerialName("browser_download_url") val downloadLink: String, ) @Serializable data class Release( @SerialName("tag_name") val version: String, @SerialName("body") val body: String? = null, @SerialName("assets") val assets: List, ) class GithubApi( private val owner: String, private val repo: String, private val client: OkHttpClient, ) { val json = Json { ignoreUnknownKeys = true } suspend fun getLatestReleases(): Release { val response = client.newCall( Request.Builder() .url("https://api.github.com/repos/${owner}/${repo}/releases/latest") .build() ).await() response.use { if (!response.isSuccessful) { error(response.message) } val release = json.decodeFromString( response.body!!.string() ) return release } } } ================================================ FILE: shared/updater/src/commonMain/kotlin/com/abdownloadmanager/updateapplier/BaseUpdateApplier.kt ================================================ package com.abdownloadmanager.updateapplier import com.abdownloadmanager.updatechecker.UpdateInfo import com.abdownloadmanager.updatechecker.UpdateSource abstract class BaseUpdateApplier : UpdateApplier { abstract override fun updateSupported(): Boolean abstract fun getUpdatePreparer(): UpdatePreparer override suspend fun applyUpdate( updateInfo: UpdateInfo, ) { if (!updateSupported()) { return } validateAppStateOnApplyUpdate() //it is only check for same instance // if I faced to multiple update (when user press "update" many times) // I have to cancel this suspension job and create a new instance instead if (preparing) { return } preparing = true val downloadSource = getBestDownloadSource(updateInfo) val updatePreparer = getUpdatePreparer() val preparedUpdate = try { updatePreparer.prepareUpdate(downloadSource) } catch (e: Exception) { preparing = false throw e } if (!preparedUpdate.isValid()) { preparing = false return } val updateInstaller = getUpdateInstaller(preparedUpdate) // updateDownloader.removeUpdate(updateInfo) try { updateInstaller.installUpdate() } catch (e: Exception) { throw RuntimeException( buildString { appendLine("can't start installation") e.localizedMessage?.let(this::append) }, e, ) } } protected var preparing: Boolean = false protected fun extension(name: String): String { return name.substringAfterLast('.', "") } override suspend fun cleanup() { getUpdatePreparer().disposeAllUpdates() } abstract fun getBestDownloadSource(updateInfo: UpdateInfo): UpdateSource abstract fun getUpdateInstaller(preparedUpdate: UpdatePreparer.PreparedUpdate): UpdateInstaller open fun validateAppStateOnApplyUpdate() {} } ================================================ FILE: shared/updater/src/commonMain/kotlin/com/abdownloadmanager/updateapplier/UpdateApplier.kt ================================================ package com.abdownloadmanager.updateapplier import com.abdownloadmanager.updatechecker.UpdateInfo interface UpdateApplier { fun updateSupported(): Boolean suspend fun applyUpdate(updateInfo: UpdateInfo) suspend fun cleanup() } ================================================ FILE: shared/updater/src/commonMain/kotlin/com/abdownloadmanager/updateapplier/UpdateDownloader.kt ================================================ package com.abdownloadmanager.updateapplier import com.abdownloadmanager.updatechecker.UpdateSource import java.io.File interface UpdatePreparer { interface PreparedUpdate { fun isValid(): Boolean } suspend fun prepareUpdate(source: UpdateSource): PreparedUpdate suspend fun disposeUpdate(updateSource: UpdateSource) suspend fun disposeAllUpdates() fun accept(updateSource: UpdateSource): Boolean } abstract class UpdateDownloader : UpdatePreparer { data class PreparedUpdateFile( val file: File ) : UpdatePreparer.PreparedUpdate { override fun isValid(): Boolean { return file.exists() } } override fun accept(updateSource: UpdateSource): Boolean { return updateSource is UpdateSource.DirectDownloadLink } override suspend fun prepareUpdate(source: UpdateSource): UpdatePreparer.PreparedUpdate { return PreparedUpdateFile( downloadUpdateFile(source as UpdateSource.DirectDownloadLink) ) } override suspend fun disposeUpdate(updateSource: UpdateSource) { removeUpdateFiles(updateSource as UpdateSource.DirectDownloadLink) } override suspend fun disposeAllUpdates() { return removeAllUpdateFiles() } abstract suspend fun downloadUpdateFile(updateDirectDownloadLink: UpdateSource.DirectDownloadLink): File abstract suspend fun removeUpdateFiles(updateDirectDownloadLink: UpdateSource.DirectDownloadLink) abstract suspend fun removeAllUpdateFiles() } ================================================ FILE: shared/updater/src/commonMain/kotlin/com/abdownloadmanager/updateapplier/UpdateInstaller.kt ================================================ package com.abdownloadmanager.updateapplier interface UpdateInstaller { fun installUpdate() } ================================================ FILE: shared/updater/src/commonMain/kotlin/com/abdownloadmanager/updatechecker/DummyUpdateChecker.kt ================================================ package com.abdownloadmanager.updatechecker import com.abdownloadmanager.InstallableArch import io.github.z4kn4fein.semver.Version import ir.amirab.util.platform.Arch import ir.amirab.util.platform.Platform import kotlinx.coroutines.delay class DummyUpdateChecker(currentVersion: Version) : UpdateChecker(currentVersion) { override suspend fun getMyPlatformLatestVersion(): UpdateInfo { val newVersion = currentVersion.copy( major = currentVersion.minor + 1, preRelease = null, buildMetadata = null, ) delay(1000) // error("Something wrong") return UpdateInfo( version = newVersion, platform = Platform.getCurrentPlatform(), arch = Arch.getCurrentArch(), updateSource = listOf( UpdateSource.DirectDownloadLink( link = "http://127.0.0.1:8080/ABDownloadManager_1.4.4_windows_x64.zip", name = "ABDownloadManager_1.4.4_windows_x64.zip", hash = "md5:0123456789abcdef", installableArch = InstallableArch.fromString("x64") ) ), changeLog = """ 1. there is an improve on download engine. 2. fix known bugs. """.trimIndent() ) } } ================================================ FILE: shared/updater/src/commonMain/kotlin/com/abdownloadmanager/updatechecker/GithubUpdateChecker.kt ================================================ package com.abdownloadmanager.updatechecker import com.abdownloadmanager.github.GithubApi import com.abdownloadmanager.ArtifactUtil import io.github.z4kn4fein.semver.Version import ir.amirab.util.platform.Arch import ir.amirab.util.platform.Platform class GithubUpdateChecker( currentVersion: Version, private val githubApi: GithubApi, ) : UpdateChecker(currentVersion) { override suspend fun getMyPlatformLatestVersion(): UpdateInfo { return getLatestVersionsForThisDevice() } private suspend fun getLatestVersionsForThisDevice(): UpdateInfo { val release = githubApi.getLatestReleases() val currentPlatform = Platform.getCurrentPlatform() val currentArch = Arch.getCurrentArch() val updateSources = mutableListOf() var foundVersion: Version? = null var initializedVersionFromAssetNames = false for (asset in release.assets) { val v = ArtifactUtil.getArtifactInfo(asset.name) ?: continue if (v.platform != currentPlatform) continue // universal builds should be installed on any arch if (!v.arch.isCompatible(currentArch)) continue if (!initializedVersionFromAssetNames) { foundVersion = v.version initializedVersionFromAssetNames = true } val isHashFile = asset.name.endsWith(".md5") if (isHashFile) { // nothing for now! } else { updateSources.add( UpdateSource.DirectDownloadLink( link = asset.downloadLink, name = asset.name, hash = null, installableArch = v.arch, ) ) } } return UpdateInfo( version = foundVersion ?: Version.parse(release.version.substring("v".length)), platform = currentPlatform, arch = currentArch, changeLog = release.body ?: "", updateSource = updateSources ) } } ================================================ FILE: shared/updater/src/commonMain/kotlin/com/abdownloadmanager/updatechecker/UpdateChecker.kt ================================================ package com.abdownloadmanager.updatechecker import io.github.z4kn4fein.semver.Version abstract class UpdateChecker( protected val currentVersion: Version, ) { abstract suspend fun getMyPlatformLatestVersion(): UpdateInfo suspend fun check(): UpdateInfo? { val latest = getMyPlatformLatestVersion() require(latest.updateSource.isNotEmpty()) { "There is no release for this platform" } return latest.takeIf { it.version > currentVersion } } } ================================================ FILE: shared/updater/src/commonMain/kotlin/com/abdownloadmanager/updatechecker/UpdateInfo.kt ================================================ package com.abdownloadmanager.updatechecker import com.abdownloadmanager.InstallableArch import io.github.z4kn4fein.semver.Version import ir.amirab.util.platform.Arch import ir.amirab.util.platform.Platform data class UpdateInfo( val version: Version, val platform: Platform, val arch: Arch, val updateSource: List, val changeLog: String, ) sealed interface UpdateSource { data class DirectDownloadLink( val link: String, val name: String, val hash: String?, val installableArch: InstallableArch?, ) : UpdateSource } ================================================ FILE: shared/updater/src/desktopMain/kotlin/com/abdownloadmanager/updateapplier/DesktopDirectLinkUpdateApplier.kt ================================================ package com.abdownloadmanager.updateapplier import com.abdownloadmanager.updatechecker.UpdateInfo import com.abdownloadmanager.updatechecker.UpdateSource import ir.amirab.util.platform.Platform import ir.amirab.util.platform.isMac import java.io.File class DesktopDirectLinkUpdateApplier( private val installationFolder: String?, private val appName: String, private val updateFolder: String, private val logDir: String, private val updatePreparer: UpdateDownloader, ) : BaseUpdateApplier() { override fun getUpdatePreparer(): UpdatePreparer { return updatePreparer } override fun updateSupported(): Boolean { val installationFolder = installationFolder ?: return false return File(installationFolder).canWrite() } override fun validateAppStateOnApplyUpdate() { requireNotNull(installationFolder) { "update applier can only apply update if installation folder is not null" } } override fun getBestDownloadSource(updateInfo: UpdateInfo): UpdateSource { val downloadableSources = updateInfo.updateSource.filterIsInstance() var downloadSource = downloadableSources.find { isArchiveFile(it.name) } if (Platform.getCurrentPlatform() == Platform.Desktop.Windows) { val exeDirectDownloadLink = downloadableSources.find { isExeFile(it.name) } if (isAppInstalledWithNSIS() && exeDirectDownloadLink != null) { downloadSource = exeDirectDownloadLink } } return requireNotNull(downloadSource) { "Can't find proper download link for your platform! Please update it manually" } } override fun getUpdateInstaller(preparedUpdate: UpdatePreparer.PreparedUpdate): UpdateInstaller { requireNotNull(preparedUpdate is UpdateDownloader.PreparedUpdateFile) val downloadedFile = (preparedUpdate as UpdateDownloader.PreparedUpdateFile).file return when { isArchiveFile(downloadedFile.name) -> { val appFolderInArchive = when { Platform.isMac() -> "$appName.app" else -> appName } UpdateInstallerFromArchiveFile( archiveFile = downloadedFile, installationFolder = installationFolder!!, // validated appFolderInArchive = appFolderInArchive, folderToExtractUpdate = File(updateFolder).resolve("extracted"), logDir = logDir, ) } isExeFile(downloadedFile.name) -> { UpdateInstallerByWindowsExecutable(downloadedFile) } else -> { // should not happen btw error("can't install ${extension(downloadedFile.name)} format automatically! please update it manually!") } } } private fun isAppInstalledWithNSIS(): Boolean { return File(installationFolder, "uninstall.exe").exists() } private fun isArchiveFile(name: String): Boolean { return name.endsWith(".tar.gz") || name.endsWith(".zip") } private fun isExeFile(name: String): Boolean { return name.endsWith(".exe") } } ================================================ FILE: shared/updater/src/desktopMain/kotlin/com/abdownloadmanager/updateapplier/UpdateInstallerByWindowsExecutable.kt ================================================ package com.abdownloadmanager.updateapplier import java.io.File class UpdateInstallerByWindowsExecutable( private val executable: File, ) : UpdateInstaller { override fun installUpdate() { val file = executable.absolutePath ProcessBuilder() .command("cmd", "/c", file, "/S") .start() } } ================================================ FILE: shared/updater/src/desktopMain/kotlin/com/abdownloadmanager/updateapplier/UpdateInstallerFromArchiveFile.kt ================================================ package com.abdownloadmanager.updateapplier import ir.amirab.util.platform.Platform import okio.FileSystem import okio.Path.Companion.toPath import okio.buffer import okio.use import java.io.File import java.util.zip.ZipEntry import java.util.zip.ZipInputStream /** * the duty of the script is * it accepts [folderToExtractUpdate], [installationFolder] * 1. stop the app * 2. remove the installed app files * 3. copy [folderToExtractUpdate] into [installationFolder] * 4. remove [folderToExtractUpdate] * 5. start the app again */ class UpdateInstallerFromArchiveFile( private val archiveFile: File, private val installationFolder: String, private val folderToExtractUpdate: File, private val appFolderInArchive: String, private val logDir: String, ) : UpdateInstaller { private fun getScriptPath(logFile: String): String { val platform = Platform.getCurrentPlatform() val updaterPath = "com/abdownloadmanager/updater" val scriptForPlatform = when (platform) { Platform.Desktop.Linux -> "$updaterPath/updater_linux.sh" Platform.Desktop.MacOS -> "$updaterPath/updater_macos.sh" Platform.Desktop.Windows -> "$updaterPath/updater_windows.bat" else -> error("script for this platform not found") }.toPath() extractTo(archiveFile, folderToExtractUpdate) val updateFolder = folderToExtractUpdate.resolve(appFolderInArchive) require(updateFolder.exists()) { "Can't find required files for this update please update it manually" } val scriptExtension = scriptForPlatform.toString().substringAfterLast('.', "") val scriptContent = FileSystem.RESOURCES.source(scriptForPlatform).buffer().use { it.readUtf8() } val scriptPathInTempFolder = FileSystem.SYSTEM_TEMPORARY_DIRECTORY.resolve( "abdm-updater.$scriptExtension" ) scriptPathInTempFolder.toFile().writeText(scriptContent) val scriptContentFile = scriptPathInTempFolder.toString() val commandToRun = when (platform) { Platform.Desktop.Linux -> execInBash( scriptPath = scriptContentFile, updateFolder = updateFolder.path, installationFolder = installationFolder, logFile = logFile, ) Platform.Desktop.MacOS -> execInBash( scriptPath = scriptContentFile, updateFolder = updateFolder.path, installationFolder = installationFolder, logFile = logFile, ) Platform.Desktop.Windows -> execInCMD( scriptPath = scriptContentFile, updateFolder = updateFolder.path, installationFolder = installationFolder, logFile = logFile, ) else -> error("platform $platform not supported") } val scriptToRun = FileSystem.SYSTEM_TEMPORARY_DIRECTORY.resolve("abdm-updater.run.$scriptExtension") scriptToRun.toFile().writeText(commandToRun) return scriptToRun.toString() } private fun executeScript() { val logFile = File(logDir, "update_log.txt") .apply { parentFile.mkdirs() }.path val scriptPath = getScriptPath(logFile) val command = when (val p = Platform.getCurrentPlatform()) { Platform.Desktop.Linux -> arrayOf("bash", scriptPath) Platform.Desktop.MacOS -> arrayOf("bash", scriptPath) Platform.Desktop.Windows -> arrayOf("cmd", "/c", scriptPath) else -> error("platform: $p not supported for updating by script") } // println("execute script $command") ProcessBuilder() .command(*command) .apply { // in linux if I don't remove it the program won't restart environment().remove("_JPACKAGE_LAUNCHER") } .start() } private fun execInCMD( scriptPath: String, updateFolder: String, installationFolder: String, logFile: String, ): String { return """ cmd /c ""$scriptPath" "$updateFolder" "$installationFolder" > "$logFile" 2>&1" """.trimIndent() } private fun execInBash( scriptPath: String, updateFolder: String, installationFolder: String, logFile: String, ): String { return """ bash "$scriptPath" "$updateFolder" "$installationFolder" > "$logFile" 2>&1 & """.trimIndent() } override fun installUpdate() { executeScript() } } private fun extractTo(archiveFile: File, destinationFolder: File) { val name = archiveFile.name require(!destinationFolder.isFile) { "destination folder is a file!" } destinationFolder.mkdirs() require(destinationFolder.isDirectory) { "destination folder is not created!" } when { name.endsWith(".zip") -> extractZip(archiveFile, destinationFolder) name.endsWith("tar.gz") -> extractTarGzUsingTar(archiveFile, destinationFolder) else -> error("archive file not detected for this file name: $name") } } private fun extractZip(zipFile: File, outputDirPath: File) { ZipInputStream(zipFile.inputStream()).use { zis -> var entry: ZipEntry? = null while (true) { entry = zis.nextEntry if (entry == null) break val outputFile = outputDirPath.resolve(entry.name) if (entry.isDirectory) { outputFile.mkdirs() } else { outputFile.parentFile.mkdirs() outputFile.outputStream().use { fileOutputStream -> zis.copyTo(fileOutputStream) } } } } } private fun extractTarGzUsingTar(tarGzFilePath: File, outputDirPath: File) { val tarCommand = listOf("tar", "-xzvf", tarGzFilePath.path, "-C", outputDirPath.path) try { val process = ProcessBuilder(tarCommand) .start() val exitCode = process.waitFor() if (exitCode == 0) { println("Extraction completed successfully.") } else { println("Error during extraction. Exit code: $exitCode") } } catch (e: Exception) { println("Failed to execute tar command: ${e.message}") } } ================================================ FILE: shared/updater/src/desktopMain/resources/com/abdownloadmanager/updater/updater_linux.sh ================================================ APP_NAME="ABDownloadManager" awaitTermination(){ local processName="${1:?}" local count=0 while true; do local pids=$(pidof "$processName") if [ -z "$pids" ]; then break fi if [ $count -eq 10 ]; then echo "timeout waiting for $processName to terminate" break fi echo "waiting for $processName to terminate" sleep 1 done } stopApp(){ echo "stopping the app" local pids=$(pidof "$APP_NAME") if [ -z "$pids" ]; then echo "no process found with name $APP_NAME" return fi kill -9 "$pids" awaitTermination "$APP_NAME" if [ $? -ne 0 ]; then echo "failed to stop $APP_NAME" return 1 fi echo "process $APP_NAME stopped" } removeCurrentInstallation(){ local installationFolder="${1:?}" filesToRemove=( "bin" "lib" ) echo "removing current installation" for filesToRemove in "${filesToRemove[@]}" ; do echo "executing rm -rf \"$installationFolder/$filesToRemove\"" rm -rf "$installationFolder/$filesToRemove" done } copyUpdateToInstallationFolder(){ local updateFile="$1" local installationFolder="${2:?"installationFolder not passed"}" echo "copying update files to installation folder" echo "executing: cp -a \"$updateFile/.\" $installationFolder" cp -a "$updateFile/." "$installationFolder" } removeUpdateFiles(){ local updateFile="$1" echo "removing update folder" echo "executing: rm -rf \"$updateFile\"" rm -rf "$updateFile" } executablePath(){ local installationFolder="${1:?}" echo "$installationFolder/bin/$APP_NAME" } executeProgram(){ local installationFolder=$1 local path=$(executablePath "$installationFolder") echo "starting $APP_NAME..." echo "executing: \"$path\"" "$path" } main(){ local updateFile="$1" local installationFolder="$2" stopApp "$installationFolder" if [ $? -ne 0 ]; then echo "returning back to program" executeProgram "$installationFolder" exit 1 fi removeCurrentInstallation "$installationFolder" copyUpdateToInstallationFolder "$updateFile" "$installationFolder" removeUpdateFiles "$updateFile" executeProgram "$installationFolder" } main "$@" ================================================ FILE: shared/updater/src/desktopMain/resources/com/abdownloadmanager/updater/updater_macos.sh ================================================ APP_NAME="ABDownloadManager" awaitTermination(){ local processName="${1:?}" local count=0 while true; do local pids=$(pgrep -x "$processName") if [ -z "$pids" ]; then break fi if [ $count -eq 10 ]; then echo "timeout waiting for $processName to terminate" break fi echo "waiting for $processName to terminate" sleep 1 count=$((count + 1)) done } stopApp(){ echo "stopping the app" local pids=$(pgrep -x "$APP_NAME") if [ -z "$pids" ]; then echo "no process found with name $APP_NAME" return fi kill -9 $pids awaitTermination "$APP_NAME" if [ $? -ne 0 ]; then echo "failed to stop $APP_NAME" return 1 fi echo "process $APP_NAME stopped" } removeCurrentInstallation(){ local installationFolder="${1:?}" echo "removing current installation" echo "executing rm -rf \"$installationFolder\"" rm -rf "$installationFolder" } copyUpdateToInstallationFolder(){ local updateFile="$1" local installationFolder="${2:?}" echo "copying update files to installation folder" echo "executing: cp -Rp \"$updateFile\" \"$installationFolder\"" cp -Rp "$updateFile" "$installationFolder" } removeUpdateFiles(){ local updateFile="$1" echo "removing update folder" echo "executing: rm -rf \"$updateFile\"" rm -rf "$updateFile" } executeProgram(){ local installationFolder=$1 echo "starting $APP_NAME..." echo "executing: open \"$installationFolder\"" open "$installationFolder" } main(){ local updateFile="$1" local installationFolder="$2" stopApp "$APP_NAME" if [ $? -ne 0 ]; then echo "returning back to program" executeProgram "$installationFolder" exit 1 fi removeCurrentInstallation "$installationFolder" copyUpdateToInstallationFolder "$updateFile" "$installationFolder" removeUpdateFiles "$updateFile" executeProgram "$installationFolder" } main "$@" ================================================ FILE: shared/updater/src/desktopMain/resources/com/abdownloadmanager/updater/updater_windows.bat ================================================ @echo off set APP_NAME=ABDownloadManager call :main "%1" "%2" goto :eof :stopApp echo execute: taskkill /IM %APP_NAME%.exe /F taskkill /IM %APP_NAME%.exe /F call :wait_for_termination echo %APP_NAME% is terminated goto :eof :wait_for_termination echo checking for termination of %APP_NAME% tasklist /FI "IMAGENAME eq %APP_NAME%.exe" | find /I "%APP_NAME%.exe" >nul 2>&1 if errorlevel 1 ( goto :eof ) else ( ping 127.0.0.1 -n 2 >nul 2>&1 goto wait_for_termination ) :removeCurrentInstallation setlocal set installationFolder=%~1 set filesToRemove=("app" "runtime" "ABDownloadManager.exe" "ABDownloadManager.ico") for %%f in %filesToRemove% do ( if exist %installationFolder%\%%f ( if exist %installationFolder%\%%f\* ( echo executing rmdir /S /Q %installationFolder%\%%f rmdir /S /Q "%installationFolder%\%%f" ) else ( echo executing del /F /Q %installationFolder%\%%f del /F /Q "%installationFolder%\%%f" ) ) ) endlocal goto :eof :copyUpdateToInstallationFolder setlocal set updateFile=%1 set installationFolder=%2 echo executing: xcopy /E /I /Y %updateFile% %installationFolder% xcopy /E /I /Y %updateFile% %installationFolder% endlocal goto :eof :removeUpdateFolder setlocal set updateFolder=%1 echo executing rmdir /S /Q "%updateFolder%" rmdir /S /Q "%updateFolder%" endlocal goto :eof :executeProgram setlocal set installationFolder=%~1 set code=%2 set message=%3 echo executing %installationFolder%\%APP_NAME%.exe start "" %installationFolder%\%APP_NAME%.exe endlocal goto :eof :main setlocal set updateFile=%1 set installationFolder=%2 call :stopApp call :removeCurrentInstallation %installationFolder% call :copyUpdateToInstallationFolder %updateFile% %installationFolder% call :removeUpdateFolder %updateFile% call :executeProgram %installationFolder% endlocal goto :eof ================================================ FILE: shared/utils/build.gradle.kts ================================================ import org.jetbrains.kotlin.gradle.dsl.JvmTarget plugins { id(MyPlugins.kotlinMultiplatform) id(Plugins.Kotlin.serialization) id(Plugins.Android.library) } kotlin { jvm("desktop") androidTarget("android") { compilerOptions { jvmTarget.set(JvmTarget.JVM_21) } } sourceSets { commonMain.dependencies { implementation(libs.kotlin.serialization.json) api(libs.okio.okio) api(libs.okhttp.okhttp) api(libs.kotlin.coroutines.core) api(libs.kotlin.datetime) api(libs.semver) api(libs.arrow.optics) api("ir.amirab.util:platform:1") } val desktopMain by getting desktopMain.dependencies { api(libs.jna.platform) } androidMain.dependencies { implementation(libs.koin.core) implementation(libs.androidx.core.ktx) } } } android { compileSdk = 36 namespace = "ir.amirab.util" defaultConfig { minSdk = 26 } } ================================================ FILE: shared/utils/src/androidMain/kotlin/ir/amirab/util/openUrl.android.kt ================================================ package ir.amirab.util import android.content.Context import android.content.Intent import android.net.Uri import org.koin.core.component.KoinComponent import org.koin.core.component.inject actual object URLOpener : KoinComponent { val context: Context by inject() actual fun openUrl(url: String) { val intent = Intent( Intent.ACTION_VIEW, ) intent.data = Uri.parse(url) intent.flags = Intent.FLAG_ACTIVITY_NEW_TASK runCatching { context.startActivity(intent) } } } ================================================ FILE: shared/utils/src/androidMain/kotlin/ir/amirab/util/osfileutil/AndroidFileUtil.kt ================================================ package ir.amirab.util.osfileutil import android.content.Context import android.content.Intent import android.net.Uri import android.os.Build import android.provider.Settings import android.util.Log import android.webkit.MimeTypeMap import android.widget.Toast import androidx.core.content.FileProvider import org.koin.core.component.KoinComponent import org.koin.core.component.inject import java.io.File class AndroidFileUtil : FileUtilsBase(), KoinComponent { val context: Context by inject() override fun openFileInternal(file: File): Boolean { val mimeType = MimeTypeMap .getSingleton() .getMimeTypeFromExtension(file.extension.lowercase()) ?: "*/*" val uri = FileProvider.getUriForFile(context, "${context.packageName}.provider", file) val intent = Intent(Intent.ACTION_VIEW).apply { setDataAndType(uri, mimeType) addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION) } return runCatching { context.startActivity(intent) true } .onFailure { it.printStackTrace() (it.localizedMessage ?: it::class.qualifiedName)?.let { message -> Toast.makeText(context, message, Toast.LENGTH_SHORT).show() } } .getOrElse { false } } override fun openFolderOfFileInternal(file: File): Boolean { return file.parentFile?.let { openFolderInternal(it) } ?: false } override fun openFolderInternal(folder: File): Boolean { throw UnsupportedOperationException( "Android doesn't support open folder" ) } override fun isRemovableStorage(path: String): Boolean { return false } } ================================================ FILE: shared/utils/src/androidMain/kotlin/ir/amirab/util/osfileutil/FileUtils.android.kt ================================================ package ir.amirab.util.osfileutil actual fun getPlatformFileUtil(): FileUtils { return AndroidFileUtil() } ================================================ FILE: shared/utils/src/commonMain/kotlin/ir/amirab/SelectionUtil.kt ================================================ package ir.amirab object SelectionUtil { inline fun invertSelection( selectionList: List, all: List, getId: (T) -> ID ): List { return all .filterNot { getId(it) in selectionList } .map { getId(it) } } inline fun toggleSelectInside( selectionList: List, fullSortedList: List, getId: (T) -> ID, ): List? { val selectionSet = selectionList.toSet() val startIndex = fullSortedList.indexOfFirst { getId(it) in selectionSet } val endIndex = fullSortedList.indexOfLast { getId(it) in selectionSet } if (startIndex == -1 || endIndex == -1) { return null } val startItem = getId(fullSortedList[startIndex]) val endItem = getId(fullSortedList[endIndex]) return if ((endIndex - startIndex + 1) == selectionSet.size) { listOf(startItem, endItem) } else { selectInside( sortedList = fullSortedList, startItem = startItem, endItem = endItem, getID = getId, ) } } // ONLY PASS SORTED LIST! inline fun getARangeOfItems( sortedList: List, id: (Item) -> ID, fromItem: ID, toItem: ID, ): List { return sortedList.map(id).dropWhile { it != fromItem && it != toItem }.dropLastWhile { it != fromItem && it != toItem } } inline fun selectInside( sortedList: List, startItem: ID, endItem: ID, getID: (T) -> ID ): List { val ids: List = getARangeOfItems( sortedList = sortedList, id = getID, fromItem = startItem, toItem = endItem, ) return ids } } ================================================ FILE: shared/utils/src/commonMain/kotlin/ir/amirab/util/AppVersionTracker.kt ================================================ package ir.amirab.util import io.github.z4kn4fein.semver.Version class AppVersionTracker( val previousVersion: () -> Version?, val currentVersion: Version, ) { fun isNewInstall(): Boolean { return previousVersion() == null } fun isUpgraded(): Boolean { val previousVersion = previousVersion() ?: return false return previousVersion < currentVersion } fun isDowngraded(): Boolean { val previousVersion = previousVersion() ?: return false return previousVersion > currentVersion } fun isNewOrUpdated() = isNewInstall() || isUpgraded() } ================================================ FILE: shared/utils/src/commonMain/kotlin/ir/amirab/util/CallAwait.kt ================================================ package ir.amirab.util import kotlinx.coroutines.suspendCancellableCoroutine import okhttp3.Call import okhttp3.Callback import okhttp3.Response import java.io.IOException import kotlin.coroutines.resume import kotlin.coroutines.resumeWithException suspend fun Call.await(): Response { return suspendCancellableCoroutine { continuation -> continuation.invokeOnCancellation { try { cancel() } catch (_: Throwable) { } } enqueue(object : Callback { override fun onResponse(call: Call, response: Response) { continuation.resume(response) } override fun onFailure(call: Call, e: IOException) { // Don't bother with resuming the continuation if it is already cancelled. if (continuation.isCancelled) return continuation.resumeWithException(e) } }) } } ================================================ FILE: shared/utils/src/commonMain/kotlin/ir/amirab/util/CollectionUtils.kt ================================================ package ir.amirab.util fun MutableList.swap( index: Int, toPosition: Int ): MutableList = apply { val p = set(toPosition, this[index]) set(index, p) } fun List.swapped(index: Int, toPosition: Int): List { val l = toMutableList() l.swap(index, toPosition) return l.toList() } fun Set.swapped(a: T, b: T): Set { val l = toMutableList() val indexA = indexOf(a) val indexB = indexOf(b) val tmp = l.set(indexB, l[indexA]) l.set(indexA, tmp) return l.toList().toSet() } fun List.shifted(index: Int, delta: Int): List { val indices = indices require(index in indices) val newPosition = index + delta require(newPosition in indices) val l = toMutableList() l.add(newPosition, l.removeAt(index)) return l.toList() } fun MutableList.shift(index: Int, delta: Int): List { val indices = indices require(index in indices) val newPosition = index + delta require(newPosition in indices) add(newPosition, removeAt(index)) return this } fun MutableList.shiftToLast(index: Int): List { return shift(index, lastIndex - index) } fun MutableList.shiftToFirst(index: Int): List { return shift(index, -index) } ================================================ FILE: shared/utils/src/commonMain/kotlin/ir/amirab/util/Exec.kt ================================================ package ir.amirab.util import java.util.concurrent.TimeUnit /** * this helper function is here to execute a command and waits for the process to finish and return the result based on exit code * @param command the command * @param waitFor maximum time allowed process finish ( in milliseconds ) * @return `true` when process exits with `0` exit code, `false` if the process fails with non-zero exit code or execution time exceeds the [waitFor] */ fun execAndWait( command: Array, waitFor: Long = 2_000, ): Boolean { return runCatching { val p = Runtime.getRuntime().exec(command) val exited = p.waitFor(waitFor, TimeUnit.MILLISECONDS) if (exited) { p.exitValue() == 0 } else { false } }.getOrElse { false } } ================================================ FILE: shared/utils/src/commonMain/kotlin/ir/amirab/util/FileExtensions.kt ================================================ package ir.amirab.util import okio.FileSystem import okio.IOException import okio.Path.Companion.toOkioPath import java.io.File fun File.toUpUntil( condition: (File) -> Boolean ): File? { var file: File? = this while (true) { if (file == null) { return null } if (condition(file)) { return file } file = file.parentFile } } fun File.tryAtomicMove(destination: File) { val target = destination.toOkioPath() val source = toOkioPath() try { // this should replace existing target in java.nio file system // however if on some target we have to use java.io we should delete the file first FileSystem.SYSTEM.atomicMove(source, target) } catch (e: IOException) { if (!e.message.orEmpty().contains("atomic move")) { throw e } FileSystem.SYSTEM.delete(target, false) FileSystem.SYSTEM.copy(source, target) FileSystem.SYSTEM.delete(source) } } ================================================ FILE: shared/utils/src/commonMain/kotlin/ir/amirab/util/FileNameValidator.kt ================================================ package ir.amirab.util import java.io.File object FileNameValidator { fun isValidFileName(name: String): Boolean { if (name.isEmpty()) return false return runCatching { File(name).canonicalFile }.getOrNull()?.let { it.name == name } ?: false } } ================================================ FILE: shared/utils/src/commonMain/kotlin/ir/amirab/util/FilenameDecoder.kt ================================================ package ir.amirab.util import java.nio.charset.Charset /** * this is very similar to URLDecoder however it doesn't replace "+" with " " * RFC 5987 */ object FilenameDecoder { fun decode( encoded: String, charset: Charset = Charsets.UTF_8, ): String { var strIndex = 0 val stringBuilder = StringBuilder() // we only initiate it when we visit % var bytes: ByteArray? = null while (strIndex < encoded.length) { var ch = encoded[strIndex] if (ch == '%') { var byteIndex = 0 if (bytes == null) { // maximum required size bytes = ByteArray((encoded.length - strIndex) / 3) } while (true) { if ((strIndex + 2) >= encoded.length) { throw IllegalArgumentException("Incomplete percent encoding at position $strIndex") } bytes[byteIndex++] = Integer.parseInt( encoded, // after % take two chars strIndex + 1, strIndex + 3, 16 ).toByte() strIndex += 3 // %ab (3 chars) if (strIndex < encoded.length) { ch = encoded[strIndex] if (ch == '%') { continue } } break } stringBuilder.append( String(bytes, 0, byteIndex, charset) ) } else { stringBuilder.append(ch) strIndex++ } } val modified = bytes != null return if (modified) { stringBuilder.toString() } else { encoded } } } ================================================ FILE: shared/utils/src/commonMain/kotlin/ir/amirab/util/GuardedEntry.kt ================================================ package ir.amirab.util import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.first import kotlinx.coroutines.sync.Mutex import kotlinx.coroutines.sync.withLock interface BaseGuardedEntry { suspend fun awaitDone() fun isDone(): Boolean } interface GuardedEntry : BaseGuardedEntry { fun action(block: () -> T): T? } interface SuspendGuardedEntry : BaseGuardedEntry { suspend fun action(block: suspend () -> T): T? } private abstract class BaseGuardedEntryImpl : BaseGuardedEntry { private val _isBooted = MutableStateFlow(false) protected fun setIsDone() { _isBooted.value = true } override fun isDone(): Boolean { return _isBooted.value } override suspend fun awaitDone() { if (isDone()) return _isBooted.first { it } } } private class GuardedActionImpl : BaseGuardedEntryImpl(), GuardedEntry { private val mutex = Any() override fun action(block: () -> T): T? { if (isDone()) { return null } return synchronized(mutex) { if (isDone()) { return null } val result = block() setIsDone() result } } } private class SuspendGuardedActionImpl : BaseGuardedEntryImpl(), SuspendGuardedEntry { private val mutex = Mutex() override suspend fun action(block: suspend () -> T): T? { if (isDone()) { return null } return mutex.withLock { if (isDone()) { return null } val result = block() setIsDone() result } } } /** prevent multiple threads call something. for example some object might require booting once. and calling boot again can lead to undefined behavior ```kt val entry = guardedEntry() thread { entry.action { print("1") } } thread { entry.action { print("2") } } ``` only one of these prints will be printed! */ fun guardedEntry(): GuardedEntry = GuardedActionImpl() fun suspendGuardedEntry(): SuspendGuardedEntry = SuspendGuardedActionImpl() ================================================ FILE: shared/utils/src/commonMain/kotlin/ir/amirab/util/HttpUrlUtils.kt ================================================ package ir.amirab.util import okhttp3.HttpUrl import okhttp3.HttpUrl.Companion.toHttpUrl object HttpUrlUtils { fun createURL(url: String): HttpUrl { return url.toHttpUrl() } fun isValidUrl(link: String): Boolean { return runCatching { createURL(link) }.isSuccess } fun extractNameFromLink(link: String): String? { return runCatching { createURL(link) }.map { url -> val foundName = url.pathSegments .lastOrNull { it.isNotBlank() } ?.let { kotlin.runCatching { FilenameDecoder.decode(it, Charsets.UTF_8) }.getOrNull() } if (foundName != null) { return@map foundName } url.host.replace('.', '_') } .getOrNull() } fun getHost(url: String): String? { return kotlin.runCatching { createURL(url).host }.getOrNull() } } ================================================ FILE: shared/utils/src/commonMain/kotlin/ir/amirab/util/IfThen.kt ================================================ @file:OptIn(ExperimentalContracts::class) package ir.amirab.util import kotlin.contracts.ExperimentalContracts import kotlin.contracts.ExperimentalExtendedContracts import kotlin.contracts.InvocationKind import kotlin.contracts.contract @OptIn(ExperimentalExtendedContracts::class) inline fun T.ifThen(condition: Boolean, block: T.() -> Base): Base { contract { callsInPlace(block, InvocationKind.AT_MOST_ONCE) condition holdsIn block } return if (condition) { this.block() } else { this } } ================================================ FILE: shared/utils/src/commonMain/kotlin/ir/amirab/util/NullCheck.kt ================================================ @file:OptIn(ExperimentalContracts::class) @file:Suppress("NOTHING_TO_INLINE") package ir.amirab.util import kotlin.contracts.ExperimentalContracts import kotlin.contracts.contract inline fun isNull(value: T): Boolean { contract { returns(true) implies (value == null) returns(false) implies (value != null) } return value == null } inline fun isNotNull(value: T): Boolean { contract { returns(true) implies (value != null) returns(false) implies (value == null) } return value != null } ================================================ FILE: shared/utils/src/commonMain/kotlin/ir/amirab/util/OkioUtils.kt ================================================ package ir.amirab.util import okio.FileSystem import okio.Path import okio.Path.Companion.toPath import java.nio.file.FileAlreadyExistsException import java.nio.file.NotDirectoryException import kotlin.io.path.createDirectories import kotlin.io.path.isDirectory fun Path.writeText( text: String, fileSystem: FileSystem = FileSystem.SYSTEM ) { fileSystem.write(this, false) { writeUtf8(text) } } fun Path.readText( fileSystem: FileSystem = FileSystem.SYSTEM ): String { return fileSystem.read(this) { readUtf8() } } fun Path.exists( fileSystem: FileSystem = FileSystem.SYSTEM ): Boolean { return fileSystem.exists(this) } fun Path.isFile( fileSystem: FileSystem = FileSystem.SYSTEM ): Boolean { return fileSystem.metadataOrNull(this)?.isRegularFile == true } fun Path.isDirectory( fileSystem: FileSystem = FileSystem.SYSTEM ): Boolean { return fileSystem.metadataOrNull(this)?.isDirectory == true } fun Path.toAbsolute( fileSystem: FileSystem = FileSystem.SYSTEM ): Path { return ifThen(!isAbsolute) { fileSystem.canonicalize("".toPath()) / this } } fun Path.listFiles(fileSystem: FileSystem = FileSystem.SYSTEM): List { return fileSystem.list(this) } fun Path.listFilesOrNull(fileSystem: FileSystem = FileSystem.SYSTEM): List? { return fileSystem.listOrNull(this) } fun Path.pathString(): String = toString() fun Path.createDirectories( fileSystem: FileSystem = FileSystem.SYSTEM ) { fileSystem.createDirectories( dir = this, mustCreate = false ) } fun Path.createParentDirectories( fileSystem: FileSystem = FileSystem.SYSTEM ) { parent ?.takeIf { !it.isDirectory(fileSystem) } ?.createDirectories(fileSystem) } fun Path.deleteIfExists( fileSystem: FileSystem = FileSystem.SYSTEM ) { fileSystem.delete(this, false) } fun Path.startsWith(other: Path) = normalized().run { other.normalized().let { normalizedOther -> normalizedOther.segments.size <= segments.size && segments .slice(0 until normalizedOther.segments.size) .filterIndexed { index, s -> normalizedOther.segments[index] != s } .isEmpty() } } ================================================ FILE: shared/utils/src/commonMain/kotlin/ir/amirab/util/PathValidator.kt ================================================ package ir.amirab.util import ir.amirab.util.osfileutil.FileUtils import java.io.File object PathValidator { fun canWriteToThisPath(path: String): Boolean { return FileUtils.canWriteInThisFolder(path) } fun isValidPath(path: String): Boolean { if (path.isEmpty()) return false return runCatching { File(path).canonicalFile true }.getOrElse { false } } } ================================================ FILE: shared/utils/src/commonMain/kotlin/ir/amirab/util/StringUtil.kt ================================================ package ir.amirab.util fun wildcardMatch( pattern: String, input: String, ): Boolean { return pattern .split("*") .joinToString(".*") { Regex.escape(it) } .toRegex(RegexOption.IGNORE_CASE) .containsMatchIn(input) } ================================================ FILE: shared/utils/src/commonMain/kotlin/ir/amirab/util/ValueHolder.kt ================================================ package ir.amirab.util import kotlin.reflect.KProperty data class ValueHolder( var value: T ) @Suppress("NOTHING_TO_INLINE") inline operator fun ValueHolder.setValue(thisObj: Any?, property: KProperty<*>, value: T) { this.value = value } @Suppress("NOTHING_TO_INLINE") inline operator fun ValueHolder.getValue(thisObj: Any?, property: KProperty<*>): T = value ================================================ FILE: shared/utils/src/commonMain/kotlin/ir/amirab/util/coroutines/CombineFlows.kt ================================================ @file:Suppress("UNCHECKED_CAST") package ir.amirab.util.coroutines import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.combine fun combine( flow: Flow, flow2: Flow, flow3: Flow, flow4: Flow, flow5: Flow, flow6: Flow, transform: suspend (T1, T2, T3, T4, T5, T6) -> R ): Flow = combine(flow, flow2, flow3, flow4, flow5, flow6) { args: Array<*> -> transform( args[0] as T1, args[1] as T2, args[2] as T3, args[3] as T4, args[4] as T5, args[5] as T6, ) } fun combine( flow: Flow, flow2: Flow, flow3: Flow, flow4: Flow, flow5: Flow, flow6: Flow, flow7: Flow, transform: suspend (T1, T2, T3, T4, T5, T6, T7) -> R ): Flow = combine(flow, flow2, flow3, flow4, flow5, flow6, flow7) { args: Array<*> -> transform( args[0] as T1, args[1] as T2, args[2] as T3, args[3] as T4, args[4] as T5, args[5] as T6, args[6] as T7, ) } ================================================ FILE: shared/utils/src/commonMain/kotlin/ir/amirab/util/coroutines/CoroutineUtils.kt ================================================ package ir.amirab.util.coroutines import kotlinx.coroutines.CompletableDeferred import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Deferred import kotlinx.coroutines.async import kotlinx.coroutines.launch import kotlin.coroutines.cancellation.CancellationException /** * a launch will be used for a suspend task and a deferred * the difference with [async] is that exceptions will be thrown immediately without calling [Deferred.await] */ fun CoroutineScope.launchWithDeferred( block: suspend CoroutineScope.() -> T ): Deferred { val deferred = CompletableDeferred() val job = launch { try { deferred.complete(block()) } catch (e: Exception) { deferred.completeExceptionally(e) throw e } } // cancell the job if caller request cancellation deferred.invokeOnCompletion { if (it is CancellationException) { job.cancel(it) } } return deferred } ================================================ FILE: shared/utils/src/commonMain/kotlin/ir/amirab/util/coroutines/debounce.kt ================================================ package ir.amirab.util.coroutines import ir.amirab.util.ValueHolder import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Job import kotlinx.coroutines.delay import kotlinx.coroutines.launch fun CoroutineScope.debounce( fn: () -> Unit, delayMillis: Long, ): () -> Unit { var lastRun: Job? = null return { lastRun?.cancel() lastRun = launch { delay(delayMillis) fn.invoke() } } } fun CoroutineScope.debounce( fn: (T) -> Unit, delayMillis: Long, previousValueMerge: ((previous: T, current: T) -> T)? = null, ): (T) -> Unit { var lastRun: Job? = null val previousValueHolder = ValueHolder(null as T?) return { v -> val previousValue = previousValueHolder.value val param = if (previousValueMerge != null && previousValue != null) { previousValueMerge(previousValue, v) } else { v } previousValueHolder.value = v lastRun?.cancel() lastRun = launch { delay(delayMillis) fn.invoke(param) } } } ================================================ FILE: shared/utils/src/commonMain/kotlin/ir/amirab/util/datasize/BaseSize.kt ================================================ package ir.amirab.util.datasize sealed class BaseSize( val size: Long, ) { abstract fun longString(): String fun scaleInto(baseSize: BaseSize): Double { return when { baseSize == this -> 1.0 else -> size / baseSize.size.toDouble() } } data object Bits : BaseSize(1) { override fun toString(): String { return "b" } override fun longString(): String { return "Bits" } } data object Bytes : BaseSize(8) { override fun toString(): String { return "B" } override fun longString(): String { return "Bytes" } } } ================================================ FILE: shared/utils/src/commonMain/kotlin/ir/amirab/util/datasize/CommonSizeConvertConfigs.kt ================================================ package ir.amirab.util.datasize object CommonSizeConvertConfigs { val BinaryBytes get() = ConvertSizeConfig( baseSize = BaseSize.Bytes, factors = SizeFactors.BinarySizeFactors, ) val BinaryBits get() = ConvertSizeConfig( baseSize = BaseSize.Bits, factors = SizeFactors.BinarySizeFactors, ) val DecimalBytes get() = ConvertSizeConfig( baseSize = BaseSize.Bytes, factors = SizeFactors.DecimalSizeFactors, ) val DecimalBits get() = ConvertSizeConfig( baseSize = BaseSize.Bits, factors = SizeFactors.DecimalSizeFactors, ) } ================================================ FILE: shared/utils/src/commonMain/kotlin/ir/amirab/util/datasize/CommonSizeUnits.kt ================================================ package ir.amirab.util.datasize object CommonSizeUnits { val BinaryBytes = SizeUnit( factorValue = SizeFactors.FactorValue.None, baseSize = BaseSize.Bytes, factors = SizeFactors.BinarySizeFactors, ) val BinaryBits = SizeUnit( factorValue = SizeFactors.FactorValue.None, baseSize = BaseSize.Bits, factors = SizeFactors.BinarySizeFactors, ) } ================================================ FILE: shared/utils/src/commonMain/kotlin/ir/amirab/util/datasize/ConvertSizeConfig.kt ================================================ package ir.amirab.util.datasize data class ConvertSizeConfig( val baseSize: BaseSize, val factors: SizeFactors, // default to auto val acceptedFactors: List = SizeFactors.FactorValue.entries, ) ================================================ FILE: shared/utils/src/commonMain/kotlin/ir/amirab/util/datasize/SizeConverter.kt ================================================ package ir.amirab.util.datasize object SizeConverter { fun sizeToBytes( sizeWithUnit: SizeWithUnit, ): Long { return convert( sizeWithUnit, CommonSizeConvertConfigs .BinaryBytes .fixedFactor(SizeFactors.FactorValue.None) ).value.toLong() } fun bytesToSize( bytes: Long, target: ConvertSizeConfig, ): SizeWithUnit { return convert( SizeWithUnit( bytes.toDouble(), CommonSizeUnits.BinaryBytes, ), target ) } fun convert( src: SizeWithUnit, target: ConvertSizeConfig, ): SizeWithUnit { val valueWithoutFactor = src.unit.factors.removeFactor( src.value, src.unit.factorValue ) val valueWithBaseSize = valueWithoutFactor * src.unit.baseSize.scaleInto(target.baseSize) val factorValue = target.factors.bestFactor( valueWithBaseSize.toLong(), target.acceptedFactors, ) val finalValue = target.factors.withFactor(valueWithBaseSize, factorValue) return SizeWithUnit( value = finalValue, SizeUnit( factorValue = factorValue, factors = target.factors, baseSize = target.baseSize, ) ) } } ================================================ FILE: shared/utils/src/commonMain/kotlin/ir/amirab/util/datasize/SizeFactors.kt ================================================ package ir.amirab.util.datasize import kotlin.math.absoluteValue import kotlin.math.pow sealed class SizeFactors( val baseValue: Long, ) { enum class FactorValue { None, Kilo, Mega, Giga, Tera, // Peta, // Exa, } operator fun get(factorValue: FactorValue): Long { return getFactorSize(factorValue) } private fun getFactorSize(factorValue: FactorValue): Long { return factors[factorValue.ordinal] } private val factors = FactorValue.entries.map { baseValue.toDouble().pow(it.ordinal).toLong() } fun bestFactor( value: Long, acceptedFactors: List = FactorValue.entries, ): FactorValue { require(acceptedFactors.isNotEmpty()) { "acceptedFactors must not be empty" } // we need lowest if (value == 0L) { return acceptedFactors.first() } // no other choice if (acceptedFactors.size == 1) return acceptedFactors.first() // find in range val inRange = acceptedFactors.lastOrNull { getFactorSize(it) <= value } if (inRange != null) { return inRange } // find rearrest return acceptedFactors.minBy { (value - getFactorSize(it)).absoluteValue } } fun removeFactor(value: Double, factorValue: FactorValue): Long { return (value * getFactorSize(factorValue)).toLong() } fun withFactor(value: Double, factorValue: FactorValue): Double { if (factorValue == FactorValue.None) return value return value / getFactorSize(factorValue) } abstract fun toString(factorValue: FactorValue): String abstract fun toLongString(factorValue: FactorValue): String data object DecimalSizeFactors : SizeFactors(baseValue = 1000) { override fun toString(factorValue: FactorValue): String { return when (factorValue) { FactorValue.None -> "" FactorValue.Kilo -> "K" FactorValue.Mega -> "M" FactorValue.Giga -> "G" FactorValue.Tera -> "T" // FactorValue.Peta -> "P" // FactorValue.Exa -> "E" } } override fun toLongString(factorValue: FactorValue): String { return when (factorValue) { FactorValue.None -> "" FactorValue.Kilo -> "Kilo" FactorValue.Mega -> "Mega" FactorValue.Giga -> "Giga" FactorValue.Tera -> "Tera" // FactorValue.Peta -> "Peta" // FactorValue.Exa -> "Exa" } } } data object BinarySizeFactors : SizeFactors(baseValue = 1024) { override fun toString(factorValue: FactorValue): String { return when (factorValue) { FactorValue.None -> "" FactorValue.Kilo -> "Ki" FactorValue.Mega -> "Mi" FactorValue.Giga -> "Gi" FactorValue.Tera -> "Ti" // FactorValue.Peta -> "Pi" // FactorValue.Exa -> "Ei" } } override fun toLongString(factorValue: FactorValue): String { return when (factorValue) { FactorValue.None -> "" FactorValue.Kilo -> "Kibi" FactorValue.Mega -> "Mebi" FactorValue.Giga -> "Gibi" FactorValue.Tera -> "Tebi" // FactorValue.Peta -> "Pebi" // FactorValue.Exa -> "Exbi" } } } } ================================================ FILE: shared/utils/src/commonMain/kotlin/ir/amirab/util/datasize/SizeUnit.kt ================================================ package ir.amirab.util.datasize data class SizeUnit( val factorValue: SizeFactors.FactorValue = SizeFactors.FactorValue.None, val baseSize: BaseSize, val factors: SizeFactors, ) { override fun toString(): String { val factor = factors.toString(factorValue) return "$factor$baseSize" } } ================================================ FILE: shared/utils/src/commonMain/kotlin/ir/amirab/util/datasize/SizeWithUnit.kt ================================================ package ir.amirab.util.datasize import java.text.DecimalFormat import java.text.DecimalFormatSymbols import java.text.NumberFormat import java.util.* data class SizeWithUnit( val value: Double, val unit: SizeUnit, ) { fun toString(format: NumberFormat?): String { val formattedValue = formatedValue(format) return "$formattedValue $unit" } fun formatedValue(format: NumberFormat? = DefaultFormat) = format ?.format(value) ?: value.toString() override fun toString(): String { return toString(DefaultFormat) } companion object { val DefaultFormat = DecimalFormat( "#.##", DecimalFormatSymbols( Locale.US, ) ) val SmallFormat = DecimalFormat( "#.#", DecimalFormatSymbols( Locale.US, ) ) } } ================================================ FILE: shared/utils/src/commonMain/kotlin/ir/amirab/util/datasize/extensions.kt ================================================ package ir.amirab.util.datasize fun SizeUnit.asConverterConfig( acceptedFactors: List = listOf( factorValue ), ): ConvertSizeConfig { return ConvertSizeConfig( factors = factors, baseSize = baseSize, acceptedFactors = acceptedFactors ) } fun ConvertSizeConfig.bits() = copy( baseSize = BaseSize.Bits ) fun ConvertSizeConfig.bytes() = copy( baseSize = BaseSize.Bytes ) fun ConvertSizeConfig.decimal() = copy( factors = SizeFactors.DecimalSizeFactors ) fun ConvertSizeConfig.binary() = copy( factors = SizeFactors.BinarySizeFactors ) fun ConvertSizeConfig.autoSelectFactors() = copy( acceptedFactors = SizeFactors.FactorValue.entries ) fun ConvertSizeConfig.fixedFactor(factorValue: SizeFactors.FactorValue) = copy( acceptedFactors = listOf(factorValue) ) ================================================ FILE: shared/utils/src/commonMain/kotlin/ir/amirab/util/enumValueOrNull.kt ================================================ package ir.amirab.util inline fun > String.enumValueOrNull(): T? { return runCatching { enumValueOf(this) }.getOrNull() } ================================================ FILE: shared/utils/src/commonMain/kotlin/ir/amirab/util/flow/FlowOperators.kt ================================================ @file:Suppress("UNCHECKED_CAST", "unused") package ir.amirab.util.flow import kotlinx.coroutines.* import kotlinx.coroutines.channels.Channel import kotlinx.coroutines.flow.* import java.util.* import kotlin.time.Duration private val NULL = Any() /** * this is like simple but emits last emission * after last period */ fun Flow.rest(time: Long, emitLastEmissionWithoutRest: Boolean = false): Flow { return channelFlow { var upStreamFinished = false var lastValue: Any? = NULL suspend fun pushValue() { val value = lastValue if (lastValue !== NULL) { lastValue = NULL send(value as T) } } val ticker = launch { while (isActive) { delay(time) pushValue() if (upStreamFinished) { break } } close() } launch { collect { lastValue = it } if (emitLastEmissionWithoutRest) { pushValue() ticker.cancel() } upStreamFinished = true } } } fun Flow.concurrentMap( capacity: Int = Channel.BUFFERED, transformBlock: suspend (T) -> R, ): Flow { return flow { coroutineScope { map { async(start = CoroutineStart.LAZY) { transformBlock( it ) } } .buffer(capacity) .map { it.start() it.await() } .let { emitAll(it) } } } } fun Flow.throttle(waitMillis: Int) = flow { coroutineScope { val context = coroutineContext var nextTime = 0L var delayPost: Deferred? = null collect { val current = System.currentTimeMillis() if (nextTime < current) { nextTime = current + waitMillis emit(it) delayPost?.cancel() } else { val delayNext = nextTime delayPost?.cancel() delayPost = async(Dispatchers.Default) { delay(nextTime - current) if (delayNext == nextTime) { nextTime = System.currentTimeMillis() + waitMillis withContext(context) { emit(it) } } } } } } } fun Flow.rateLimit(limit: Long, per: Duration): Flow { return rateLimit(limit, per.inWholeMilliseconds) } fun Flow.rateLimit(limit: Long, per: Long) = flow { coroutineScope { val context = coroutineContext var lastStartTime = System.currentTimeMillis() var remainingInDuration = limit val items = LinkedList() var isDone = false launch(context) { collect { items.add(it) } isDone = true } launch(Dispatchers.Default) { while (isActive) { yield() if (remainingInDuration > 0) { val removeFirst = items.removeFirstOrNull() if (removeFirst != null) { withContext(context) { emit(removeFirst) } remainingInDuration-- } else { if (isDone) { break } } } else { val waitUntil = lastStartTime + per delay(waitUntil - System.currentTimeMillis()) lastStartTime = System.currentTimeMillis() remainingInDuration = limit } } } } } fun interval(time: Long, initialValue: T, newValue: (T) -> T): Flow { var value = initialValue return interval(time) .map { value.apply { value = newValue(this) } } } fun interval(time: Long, timeOut: Long = time) = flow { if (timeOut > 0) { delay(timeOut) } emit(Unit) while (true) { delay(time) emit(Unit) } } fun Flow.saved(count: Int): Flow> { require(count >= 0) return when (count) { 0 -> emptyFlow() else -> scan>( listOf() ) { l, v -> if (l.size < count) { l.plus(v) } else { l.drop(1).plus(v) } }.drop(1) // scan emits an initial value (emptyList) } } fun Flow>.pad(capacity: Int, fillAfter: Boolean) = map { actual -> val size = actual.size if (capacity > size) { val pad = List(capacity - size) { null } if (fillAfter) actual + pad else pad + actual } else actual } fun Flow.takeFirstEmitInEvery(millis: Long) = flow { var lastEmitTime = 0L collect { val now = System.currentTimeMillis() if (now - lastEmitTime >= millis) { lastEmitTime = now emit(it) } } } fun Flow.chunked(count: Int): Flow> = flow { val list = mutableListOf() collect { if (list.size == count) { emit(list.toList()) list.clear() } else { list.add(it) } } if (list.isNotEmpty()) { emit(list) } } fun Flow.onEachLatest(block: suspend (T) -> Unit) = transformLatest { block(it) emit(it) } fun Flow.withPrevious( transform: (previous: T?, current: T) -> R, ): Flow { return saved(2) .pad(2, false) .map { val previous = it[0] val current = it[1] as T transform(previous, current) } } fun Flow.withPrevious(): Flow> = withPrevious { previous, current -> previous to current } ================================================ FILE: shared/utils/src/commonMain/kotlin/ir/amirab/util/flow/FlowUtils.kt ================================================ package ir.amirab.util.flow import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.flow.* fun Flow.withStartEmit(): Flow { return flow { emit(Unit) emitAll(this@withStartEmit) } } fun createMutableStateFlowFromFlow( flow: Flow, initialValue: T, updater: (T) -> Unit, scope: CoroutineScope, ): MutableStateFlow { val downStream = MutableStateFlow(initialValue) flow.onEach { newFromUpStream -> downStream.update { newFromUpStream } }.launchIn(scope) downStream.onEach { updater(it) }.launchIn(scope) return downStream } fun createMutableStateFlowFromStateFlow( flow: StateFlow, updater: suspend (T) -> Unit, scope: CoroutineScope, ): MutableStateFlow { val downStream = MutableStateFlow(flow.value) flow.onEach { newFromUpStream -> downStream.update { newFromUpStream } }.launchIn(scope) downStream.onEach { updater(it) }.launchIn(scope) return downStream } ================================================ FILE: shared/utils/src/commonMain/kotlin/ir/amirab/util/flow/StateFlowUtil.kt ================================================ package ir.amirab.util.flow import arrow.optics.Lens import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.coroutineScope import kotlinx.coroutines.flow.* class DerivedStateFlow( private val getValue: () -> T, private val flow: Flow, ) : StateFlow { override val replayCache: List get() = listOf(value) override val value: T get() = getValue() override suspend fun collect(collector: FlowCollector): Nothing { coroutineScope { flow .distinctUntilChanged() .stateIn(this) .collect(collector) } } } @PublishedApi internal class TwoWayDerivedStateFlow( private val upStream: MutableStateFlow, private val map: (T) -> R, private val unMap: (R) -> T, ) : MutableStateFlow { override var value: R get() { return map(upStream.value) } set(v) { upStream.value = unMap(v) } override val replayCache: List get() = listOf(value) private val _sc = MutableStateFlow(0) override val subscriptionCount: StateFlow get() = _sc.asStateFlow() private val _mappedStream = upStream.mapStateFlow(map) override suspend fun collect(collector: FlowCollector): Nothing { try { _sc.update { it + 1 } _mappedStream.collect(collector) } finally { _sc.update { it - 1 } } } override fun compareAndSet(expect: R, update: R): Boolean { return upStream.compareAndSet( expect = unMap(expect), update = unMap(update), ) } @ExperimentalCoroutinesApi override fun resetReplayCache() { upStream.resetReplayCache() } override fun tryEmit(value: R): Boolean { this.value = value return true } override suspend fun emit(value: R) { this.value = value } } /** * NOTE : * DON"T USE MutableStateFlow::update * If I use the map and unmap does not return equally it will cause to infinite loop * */ fun MutableStateFlow.mapTwoWayStateFlow( map: (T) -> R, unMap: T.(R) -> T, ): MutableStateFlow { return TwoWayDerivedStateFlow( upStream = this, map = map, unMap = { unMap(value, it) }, ) } fun MutableStateFlow.mapTwoWayStateFlow( lens: Lens ): MutableStateFlow { return TwoWayDerivedStateFlow( upStream = this, map = lens::get, unMap = { lens.set(value, it) }, ) } fun StateFlow.mapStateFlow( transform: (T) -> R ): StateFlow { return DerivedStateFlow( getValue = { transform(value) }, flow = this.map(transform) ) } fun combineStateFlows( a: StateFlow, b: StateFlow, transform: (a: T1, b: T2) -> R ): StateFlow { return DerivedStateFlow( getValue = { transform(a.value, b.value) }, flow = combine(a, b) { a, b -> transform(a, b) } ) } fun combineStateFlows( a: StateFlow, b: StateFlow, c: StateFlow, transform: (a: T1, b: T2, c: T3) -> R ): StateFlow { return DerivedStateFlow( getValue = { transform(a.value, b.value, c.value) }, flow = combine(a, b, c) { a, b, c -> transform(a, b, c) } ) } fun combineStateFlows( a: StateFlow, b: StateFlow, c: StateFlow, d: StateFlow, transform: (a: T1, b: T2, c: T3, d: T4) -> R ): StateFlow { return DerivedStateFlow( getValue = { transform(a.value, b.value, c.value, d.value) }, flow = combine(a, b, c, d) { a, b, c, d -> transform(a, b, c, d) } ) } fun combineStateFlows( a: StateFlow, b: StateFlow, c: StateFlow, d: StateFlow, e: StateFlow, transform: (a: T1, b: T2, c: T3, d: T4, e: T5) -> R ): StateFlow { return DerivedStateFlow( getValue = { transform(a.value, b.value, c.value, d.value, e.value) }, flow = combine(a, b, c, d, e) { a, b, c, d, e -> transform(a, b, c, d, e) } ) } fun combineStateFlows( a: StateFlow, b: StateFlow, c: StateFlow, d: StateFlow, e: StateFlow, f: StateFlow, transform: (a: T1, b: T2, c: T3, d: T4, e: T5, f: T6) -> R ): StateFlow { return DerivedStateFlow( getValue = { transform(a.value, b.value, c.value, d.value, e.value, f.value) }, flow = combine(a, b, c, d, e, f) { array -> @Suppress("UNCHECKED_CAST") transform(array[0] as T1, array[1] as T2, array[2] as T3, array[3] as T4, array[4] as T5, array[5] as T6) } ) } fun combineStateFlows( a: StateFlow, b: StateFlow, c: StateFlow, d: StateFlow, e: StateFlow, f: StateFlow, g: StateFlow, transform: (a: T1, b: T2, c: T3, d: T4, e: T5, f: T6, g: T7) -> R ): StateFlow { return DerivedStateFlow( getValue = { transform(a.value, b.value, c.value, d.value, e.value, f.value, g.value) }, flow = combine(a, b, c, d, e, f, g) { array -> @Suppress("UNCHECKED_CAST") transform( array[0] as T1, array[1] as T2, array[2] as T3, array[3] as T4, array[4] as T5, array[5] as T6, array[6] as T7, ) } ) } inline fun combineStateFlows( flows: Iterable>, noinline transform: (list: Array) -> R ): StateFlow { return DerivedStateFlow( getValue = { transform( flows .map { it.value } .toTypedArray() ) }, flow = combine(flows) { transform(it) } ) } inline fun combineStateFlows( vararg flows: StateFlow, noinline transform: (list: Array) -> R ): StateFlow { return combineStateFlows(listOf(*flows), transform) } ================================================ FILE: shared/utils/src/commonMain/kotlin/ir/amirab/util/lock.kt ================================================ @file:OptIn(ExperimentalContracts::class) package ir.amirab.util import arrow.core.Either import arrow.core.left import arrow.core.right import kotlinx.coroutines.sync.Mutex import kotlin.contracts.ExperimentalContracts import kotlin.contracts.InvocationKind import kotlin.contracts.contract object MutexIsLocked inline fun Mutex.tryLocked(block: () -> T): Either { contract { callsInPlace(block, InvocationKind.AT_MOST_ONCE) } if (tryLock()) { try { return block().right() } finally { unlock() } } return MutexIsLocked.left() } ================================================ FILE: shared/utils/src/commonMain/kotlin/ir/amirab/util/openUrl.kt ================================================ package ir.amirab.util expect object URLOpener { fun openUrl(url: String) } ================================================ FILE: shared/utils/src/commonMain/kotlin/ir/amirab/util/osfileutil/FileUtils.kt ================================================ package ir.amirab.util.osfileutil import java.io.File interface FileUtils { fun openFile(file: File): Boolean fun openFolderOfFile(file: File): Boolean fun openFolder(folder: File): Boolean fun canWriteInThisFolder(folder: String): Boolean fun isRemovableStorage(path: String): Boolean companion object : FileUtils by getPlatformFileUtil() } expect fun getPlatformFileUtil(): FileUtils ================================================ FILE: shared/utils/src/commonMain/kotlin/ir/amirab/util/osfileutil/FileUtilsBase.kt ================================================ package ir.amirab.util.osfileutil import java.io.File import java.io.FileNotFoundException abstract class FileUtilsBase : FileUtils { override fun openFile(file: File): Boolean { return openFileInternal( file = preparedFile(file) ) } override fun openFolderOfFile(file: File): Boolean { return openFolderOfFileInternal( file = preparedFile(file) ) } override fun openFolder(folder: File): Boolean { return openFolderInternal( folder = preparedFile(folder) ) } override fun canWriteInThisFolder(folder: String): Boolean { return runCatching { File(folder).canUseThisAsFolder() }.getOrElse { false } } private fun File.canUseThisAsFolder(): Boolean { var current: File? = this while (true) { if (current == null) break if (current.exists()) { return current.isDirectory } current = current.parentFile } return false } private fun preparedFile(file: File): File { val file = file.canonicalFile.absoluteFile if (!file.exists()) { throw FileNotFoundException("$file not found") } return file } protected abstract fun openFileInternal(file: File): Boolean protected abstract fun openFolderOfFileInternal(file: File): Boolean protected abstract fun openFolderInternal(folder: File): Boolean } ================================================ FILE: shared/utils/src/desktopMain/kotlin/ir/amirab/util/openUrl.desktop.kt ================================================ package ir.amirab.util import java.awt.Desktop import java.net.URI actual object URLOpener { actual fun openUrl(url: String) { runCatching { val desktop = Desktop.getDesktop() if (desktop.isSupported(Desktop.Action.BROWSE)) { desktop.browse(URI(url)) } } } } ================================================ FILE: shared/utils/src/desktopMain/kotlin/ir/amirab/util/osfileutil/DesktopFileUtils.kt ================================================ package ir.amirab.util.osfileutil import kotlin.io.path.Path import kotlin.io.path.absolute import kotlin.io.path.fileStore abstract class DesktopFileUtils : FileUtilsBase() { override fun isRemovableStorage(path: String): Boolean { runCatching { val store = Path(path).absolute().fileStore() if (store.supportsFileAttributeView("basic")) { val isRemovable = store.getAttribute("volume:isRemovable") if (isRemovable is Boolean) { return isRemovable } } }.onFailure { it.printStackTrace() } return false } } ================================================ FILE: shared/utils/src/desktopMain/kotlin/ir/amirab/util/osfileutil/FileUtils.desktop.kt ================================================ package ir.amirab.util.osfileutil import ir.amirab.util.platform.Platform import ir.amirab.util.platform.asDesktop actual fun getPlatformFileUtil(): FileUtils { return when (Platform.asDesktop()) { Platform.Desktop.Windows -> WindowsFileUtils() Platform.Desktop.Linux -> LinuxFileUtils() Platform.Desktop.MacOS -> MacOsFileUtils() } } ================================================ FILE: shared/utils/src/desktopMain/kotlin/ir/amirab/util/osfileutil/JVMFileUtils.kt ================================================ package ir.amirab.util.osfileutil import java.awt.Desktop import java.io.File /** * it uses the jvm default. */ internal class JVMFileUtils : DesktopFileUtils() { override fun openFileInternal(file: File): Boolean { runCatching { Desktop.getDesktop().open(file) return true } return false } override fun openFolderOfFileInternal(file: File): Boolean { runCatching { Desktop.getDesktop().browseFileDirectory(file) return true } return false } override fun openFolderInternal(folder: File): Boolean { kotlin.runCatching { Desktop.getDesktop().open(folder) return true } return false } } ================================================ FILE: shared/utils/src/desktopMain/kotlin/ir/amirab/util/osfileutil/LinuxFileUtils.kt ================================================ package ir.amirab.util.osfileutil import ir.amirab.util.execAndWait import java.io.File import java.net.URLEncoder internal class LinuxFileUtils : DesktopFileUtils() { override fun openFileInternal(file: File): Boolean { return execAndWait(arrayOf("xdg-open", file.path)) } override fun openFolderOfFileInternal(file: File): Boolean { val uri = "file://" + encodePath(file.path) val dbusSendResult = execAndWait( arrayOf( "dbus-send", "--print-reply", "--dest=org.freedesktop.FileManager1", "/org/freedesktop/FileManager1", "org.freedesktop.FileManager1.ShowItems", "array:string:$uri", "string:" ) ) if (dbusSendResult) { return true } val xdgOpenResult = execAndWait( arrayOf("xdg-open", file.parent) ) return xdgOpenResult } override fun openFolderInternal(folder: File): Boolean { return execAndWait(arrayOf("xdg-open", folder.parent)) } private fun encodePath(path: String): String { return path .split('/') .joinToString("/") { URLEncoder .encode(it, Charsets.UTF_8) .replace("+", "%20") } } } ================================================ FILE: shared/utils/src/desktopMain/kotlin/ir/amirab/util/osfileutil/MacOsFileUtils.kt ================================================ package ir.amirab.util.osfileutil import ir.amirab.util.execAndWait import java.io.File internal class MacOsFileUtils : DesktopFileUtils() { override fun openFileInternal(file: File): Boolean { return execAndWait(arrayOf("open", file.path)) } override fun openFolderOfFileInternal(file: File): Boolean { return execAndWait(arrayOf("open", "-R", file.path)) } override fun openFolderInternal(folder: File): Boolean { return execAndWait(arrayOf("open", folder.path)) } } ================================================ FILE: shared/utils/src/desktopMain/kotlin/ir/amirab/util/osfileutil/WindowsFileUtils.kt ================================================ package ir.amirab.util.osfileutil import com.sun.jna.Native import com.sun.jna.Pointer import com.sun.jna.platform.win32.* import com.sun.jna.win32.StdCallLibrary import com.sun.jna.win32.W32APIOptions import ir.amirab.util.execAndWait import java.io.File import kotlin.io.path.Path import kotlin.io.path.absolute internal class WindowsFileUtils : DesktopFileUtils() { override fun openFileInternal(file: File): Boolean { return execAndWait(arrayOf("cmd", "/c", "start", "/B", "", file.path.quoted())) } override fun openFolderOfFileInternal(file: File): Boolean { val nativeSuccess = showFileInFolderViaNative(file.path) if (nativeSuccess) { return true } //fallback to use explorer return execAndWait(arrayOf("cmd", "/c", "explorer.exe", "/select,", file.path.quoted())) } override fun openFolderInternal(folder: File): Boolean { val nativeSuccess = openFolderViaNative(folder.path) if (nativeSuccess) { return true } //fallback to use explorer return execAndWait(arrayOf("cmd", "/c", "explorer.exe", folder.path.quoted())) } override fun isRemovableStorage(path: String): Boolean { return try { isRemovableStorageViaNative(path) } catch (e: Exception) { e.printStackTrace() super.isRemovableStorage(path) } } private fun isRemovableStorageViaNative(path: String): Boolean { val rootPath = Path(path).absolute().root.toString() val driveType = Kernel32.INSTANCE.GetDriveType(rootPath) return driveType == WinBase.DRIVE_REMOVABLE } private fun showFileInFolderViaNative( file: String, ): Boolean { try { Ole32.INSTANCE.CoInitializeEx(null, Ole32.COINIT_APARTMENTTHREADED) val path = Shell32Ex.INSTANCE.ILCreateFromPath(File(file).parent) val selectedFiles = arrayOf(Shell32Ex.INSTANCE.ILCreateFromPath(file)) val cidl = WinDef.UINT(selectedFiles.size.toLong()) try { val res = Shell32Ex.INSTANCE.SHOpenFolderAndSelectItems( pIdlFolder = path, cIdl = cidl, apIdl = selectedFiles, dwFlags = WinDef.DWORD(0) ) return WinError.S_OK == res } finally { Shell32Ex.INSTANCE.ILFree(path) selectedFiles.forEach { Shell32Ex.INSTANCE.ILFree(it) } } } catch (e: Exception) { e.printStackTrace() return false } finally { Ole32.INSTANCE.CoUninitialize() } } private fun openFolderViaNative(folder: String): Boolean { try { val result = Shell32.INSTANCE.ShellExecute( null, "explore", folder, null, null, WinUser.SW_NORMAL, ).toInt() return result > 32 } catch (e: Exception) { e.printStackTrace() return false } } private fun String.quoted() = "\"$this\"" } private interface Shell32Ex : StdCallLibrary { fun ILCreateFromPath(path: String?): Pointer? fun ILFree(pIdl: Pointer?) fun SHOpenFolderAndSelectItems( pIdlFolder: Pointer?, cIdl: WinDef.UINT?, apIdl: Array?, dwFlags: WinDef.DWORD?, ): WinNT.HRESULT? companion object { val INSTANCE: Shell32Ex = Native.load("shell32", Shell32Ex::class.java, W32APIOptions.DEFAULT_OPTIONS) } }